History

Merge pull request #53 from RhysSullivan/cleanup/remove-legacy-source-adapters

Rhys Sullivan committed 7 months ago

Commit 67590a22 parents 8f11ac4 57dc21e380 files · +48873 −47928Browse files

Unified · Split

380 files
  1. .executor/executor.jsonc +7 −58
  2. .gitignore +2 −1
  3. .mcp.json +13 −0
  4. .oxlintrc.jsonc +0 −9
  5. ARCHITECTURE.md +0 −411
  6. README.md +15 −8
  7. apps/docs/developer/adapters-and-formats.mdx +0 −98
  8. apps/docs/developer/cli.mdx +0 −62
  9. apps/docs/developer/codemode.mdx +0 −69
  10. apps/docs/developer/core-model.mdx +0 −129
  11. apps/docs/developer/credentials-and-auth.mdx +0 −113
  12. apps/docs/developer/extending-executor.mdx +0 −83
  13. apps/docs/developer/import-fidelity.mdx +0 −132
  14. apps/docs/developer/mcp.mdx +0 −51
  15. apps/docs/developer/overview.mdx +0 −108
  16. apps/docs/developer/persistence-and-migrations.mdx +0 −86
  17. apps/docs/developer/tool-catalog-and-execution.mdx +0 −91
  18. apps/docs/docs.json +2 −37
  19. apps/docs/introduction.mdx +2 −12
  20. apps/executor/package.json +4 −1
  21. apps/executor/src/cli/dev.ts +0 −231
  22. apps/executor/src/cli/main.ts +57 −125
  23. apps/executor/src/cli/pending-interaction-output.test.ts +0 −113
  24. apps/executor/src/cli/pending-interaction-output.ts +0 −4
  25. apps/executor/src/cli/runtime-paths.test.ts +47 −0
  26. apps/executor/src/cli/runtime-paths.ts +16 −1
  27. apps/executor/src/distribution/artifact.ts +0 −6
  28. apps/executor/src/server/server.real-ingestion.test.ts +0 −262
  29. apps/web/package.json +5 −0
  30. apps/web/src/components/local-mcp-install-card.tsx +21 −4
  31. apps/web/src/components/shell.tsx +76 −18
  32. apps/web/src/components/source-favicon.tsx +13 −61
  33. apps/web/src/components/source-not-found-state.tsx +2 −1
  34. apps/web/src/components/source-plugins-reset-state.tsx +27 −0
  35. apps/web/src/lib/source-favicon.ts +0 −123
  36. apps/web/src/main.tsx +82 −85
  37. apps/web/src/plugins/index.ts +83 −0
  38. apps/web/src/plugins/pages.tsx +259 −0
  39. apps/web/src/plugins/source-presets.ts +657 −0
  40. apps/web/src/views/add-source.tsx +0 −2534
  41. apps/web/src/views/home.tsx +63 −34
  42. apps/web/src/views/json-form.ts +0 −74
  43. apps/web/src/views/secrets.tsx +42 −22
  44. apps/web/src/views/source-detail.tsx +0 −1172
  45. apps/web/src/views/source-editor.tsx +0 −1919
  46. apps/web/src/views/source-templates.ts +0 −358
  47. apps/web/vite.config.ts +18 −0
  48. bun.lock +276 −92
  49. examples/sqlite-sdk-consumer/src/sqlite-backend.ts +7 −466
  50. knip.json +0 −3
  51. opencode.json +11 −4
  52. package.json +1 −0
  53. packages/auth/mcp-oauth/tsconfig.json +0 −19
  54. packages/auth/oauth2/tsconfig.json +0 −19
  55. packages/clients/react/package.json +12 −3
  56. packages/clients/react/src/core/api-atoms.ts +191 −0
  57. packages/clients/react/src/core/base-url.ts +12 −0
  58. packages/clients/react/src/core/http-client.ts +71 −0
  59. packages/clients/react/src/core/loadable.ts +61 −0
  60. packages/clients/react/src/core/reactivity.ts +71 −0
  61. packages/clients/react/src/core/types.ts +16 −0
  62. packages/clients/react/src/core/workspace.ts +62 −0
  63. packages/clients/react/src/hooks/executions.ts +46 −0
  64. packages/clients/react/src/hooks/local.ts +21 −0
  65. packages/clients/react/src/hooks/mutations.ts +44 −0
  66. packages/clients/react/src/hooks/secrets.ts +85 −0
  67. packages/clients/react/src/hooks/sources.ts +156 −0
  68. packages/clients/react/src/index.test.tsx +105 −381
  69. packages/clients/react/src/index.ts +65 −1420
  70. packages/clients/react/src/plugins/components/badge.tsx +44 −0
  71. packages/clients/react/src/plugins/components/code-block.tsx +104 −0
  72. packages/clients/react/src/plugins/components/document-panel.tsx +30 −0
  73. packages/clients/react/src/plugins/components/icons.tsx +90 −0
  74. packages/clients/react/src/plugins/components/loadable.tsx +51 −0
  75. packages/clients/react/src/plugins/components/markdown.tsx +41 −0
  76. packages/clients/react/src/plugins/components/source-tool-explorer-search.ts +24 −0
  77. packages/clients/react/src/plugins/components/source-tool-explorer.tsx +122 −0
  78. packages/clients/react/src/plugins/components/source-tool-workbench.tsx +755 −0
  79. packages/clients/react/src/plugins/index.ts +106 −0
  80. packages/clients/react/src/plugins/lib/cn.ts +6 −0
  81. packages/clients/react/src/plugins/lib/shiki.ts +211 −0
  82. packages/clients/react/src/plugins/paths.ts +105 −0
  83. packages/clients/react/src/plugins/plugin-route-context.tsx +55 −0
  84. packages/clients/react/src/plugins/registry.ts +75 −0
  85. packages/clients/react/src/plugins/route-context.tsx +64 −0
  86. packages/clients/react/src/plugins/types.ts +58 −0
  87. packages/clients/react/src/provider.tsx +6 −0
  88. packages/clients/react/src/source-plugins/index.ts +1 −0
  89. packages/dev/kitchen-sink/package.json +0 −1
  90. packages/dev/kitchen-sink/src/source-runtime.test.ts +0 −901
  91. packages/hosts/mcp/package.json +1 −1
  92. packages/hosts/mcp/src/index.ts +5 −7
  93. packages/kernel/core/src/tool-map.ts +3 −3
  94. packages/kernel/core/src/types.ts +6 −6
  95. packages/kernel/ir/src/catalog.test.ts +50 −14
  96. packages/kernel/ir/src/catalog.ts +28 −4
  97. packages/kernel/ir/src/model.ts +2 −2
  98. packages/platform/api/package.json +2 −4
  99. packages/platform/api/src/api.ts +57 −5
  100. packages/platform/api/src/client.ts +0 −196
  101. packages/platform/api/src/effect.ts +7 −4
  102. packages/platform/api/src/executions/api.ts +11 −0
  103. packages/platform/api/src/executions/http.ts +5 −0
  104. packages/platform/api/src/executor-context.ts +0 −2
  105. packages/platform/api/src/http.ts +32 −12
  106. packages/platform/api/src/index.ts +8 −34
  107. packages/platform/api/src/oauth/api.ts +0 −103
  108. packages/platform/api/src/oauth/http.ts +0 −321
  109. packages/platform/api/src/payload-schemas.test.ts +0 −100
  110. packages/platform/api/src/plugins.ts +23 −0
  111. packages/platform/api/src/sources/api.ts +0 −161
  112. packages/platform/api/src/sources/http.ts +1 −1147
  113. packages/platform/internal/src/index.ts +0 −1
  114. packages/platform/internal/src/tools/executor-admin.ts +0 −11
  115. packages/platform/sdk-file/package.json +0 −2
  116. packages/platform/sdk-file/src/capture-release-workspace-fixture.ts +8 −22
  117. packages/platform/sdk-file/src/config.test.ts +2 −8
  118. packages/platform/sdk-file/src/config.ts +2 −3
  119. packages/platform/sdk-file/src/executor-state-store.test.ts +0 −166
  120. packages/platform/sdk-file/src/executor-state-store.ts +9 −634
  121. packages/platform/sdk-file/src/index.ts +27 −24
  122. packages/platform/sdk-file/src/release-upgrade-fixtures.ts +33 −13
  123. packages/platform/sdk-file/src/secret-material-providers.ts +6 −8
  124. packages/platform/sdk-file/src/source-artifacts.test.ts +0 −358
  125. packages/platform/sdk-file/src/source-artifacts.ts +1 −3
  126. packages/platform/sdk-file/src/source-type-declarations.test.ts +0 −956
  127. packages/platform/sdk-file/src/source-type-declarations.ts +1 −1
  128. packages/platform/sdk-file/src/tools.ts +1 −3
  129. packages/platform/sdk-file/src/workspace-state.ts +5 −5
  130. packages/platform/sdk/package.json +1 −7
  131. packages/platform/sdk/src/backend.ts +1 −71
  132. packages/platform/sdk/src/contracts.ts +0 −1
  133. packages/platform/sdk/src/executor-effect.ts +177 −289
  134. packages/platform/sdk/src/executor.test.ts +0 −446
  135. packages/platform/sdk/src/executor.ts +119 −63
  136. packages/platform/sdk/src/index.ts +1 −0
  137. packages/platform/sdk/src/local/operations.ts +2 −382
  138. packages/platform/sdk/src/local/secrets.ts +0 −1
  139. packages/platform/sdk/src/operations.ts +0 −1
  140. packages/platform/sdk/src/plugins.ts +543 −0
  141. packages/platform/sdk/src/policies/operations.ts +1 −1
  142. packages/platform/sdk/src/runtime/auth/auth-artifacts.ts +0 −514
  143. packages/platform/sdk/src/runtime/auth/auth-leases.ts +0 −507
  144. packages/platform/sdk/src/runtime/auth/mcp-auth-provider.ts +0 −262
  145. packages/platform/sdk/src/runtime/auth/mcp-oauth.ts +0 −1
  146. packages/platform/sdk/src/runtime/auth/oauth-loopback.test.ts +0 −110
  147. packages/platform/sdk/src/runtime/auth/oauth-loopback.ts +0 −106
  148. packages/platform/sdk/src/runtime/auth/oauth2-pkce.ts +0 −1
  149. packages/platform/sdk/src/runtime/auth/provider-grant-lifecycle.ts +0 −94
  150. packages/platform/sdk/src/runtime/auth/source-auth-material.test.ts +0 −268
  151. packages/platform/sdk/src/runtime/auth/source-auth-material.ts +0 −176
  152. packages/platform/sdk/src/runtime/catalog/source/reconcile.ts +2 −6
  153. packages/platform/sdk/src/runtime/catalog/source/runtime.test.ts +0 −1160
  154. packages/platform/sdk/src/runtime/catalog/source/runtime.ts +13 −17
  155. packages/platform/sdk/src/runtime/catalog/source/snapshot.test.ts +0 −1371
  156. packages/platform/sdk/src/runtime/catalog/source/sync.ts +21 −117
  157. packages/platform/sdk/src/runtime/execution/http.test.ts +42 −1
  158. packages/platform/sdk/src/runtime/execution/ir-execution.ts +6 −11
  159. packages/platform/sdk/src/runtime/execution/live.ts +6 −11
  160. packages/platform/sdk/src/runtime/execution/mcp-resume.test.ts +0 −411
  161. packages/platform/sdk/src/runtime/execution/runtime-config.test.ts +1 −1
  162. packages/platform/sdk/src/runtime/execution/scope/authorization.ts +6 −1
  163. packages/platform/sdk/src/runtime/execution/scope/environment.ts +27 −48
  164. packages/platform/sdk/src/runtime/execution/scope/source-catalog.test.ts +9 −5
  165. packages/platform/sdk/src/runtime/execution/scope/tool-invoker.ts +43 −81
  166. packages/platform/sdk/src/runtime/execution/service.ts +8 −0
  167. packages/platform/sdk/src/runtime/execution/test-http-client.ts +2 −2
  168. packages/platform/sdk/src/runtime/executor-runtime.test.ts +0 −2390
  169. packages/platform/sdk/src/runtime/executor-state-store.ts +3 −120
  170. packages/platform/sdk/src/runtime/index.ts +30 −79
  171. packages/platform/sdk/src/runtime/source-artifacts.ts +0 −10
  172. packages/platform/sdk/src/runtime/sources/executor-tools.ts +206 −466
  173. packages/platform/sdk/src/runtime/sources/graphql-tools.ts +0 −1
  174. packages/platform/sdk/src/runtime/sources/openapi-type-contract.test.ts +293 −0
  175. packages/platform/sdk/src/runtime/sources/source-adapter-fixture-matrix.test.ts +0 −1469
  176. packages/platform/sdk/src/runtime/sources/source-adapters/google-discovery.test.ts +0 −139
  177. packages/platform/sdk/src/runtime/sources/source-adapters/google-discovery.ts +0 −1
  178. packages/platform/sdk/src/runtime/sources/source-adapters/graphql.test.ts +0 −157
  179. packages/platform/sdk/src/runtime/sources/source-adapters/graphql.ts +0 −1
  180. packages/platform/sdk/src/runtime/sources/source-adapters/index.ts +0 −52
  181. packages/platform/sdk/src/runtime/sources/source-adapters/internal.ts +0 −131
  182. packages/platform/sdk/src/runtime/sources/source-adapters/mcp.test.ts +0 −1082
  183. packages/platform/sdk/src/runtime/sources/source-adapters/mcp.ts +0 −1
  184. packages/platform/sdk/src/runtime/sources/source-auth-service.ts +0 −3815
  185. packages/platform/sdk/src/runtime/sources/source-credential-interactions.ts +0 −34
  186. packages/platform/sdk/src/runtime/sources/source-definitions.test.ts +0 −632
  187. packages/platform/sdk/src/runtime/sources/source-definitions.ts +46 −430
  188. packages/platform/sdk/src/runtime/sources/source-discovery.test.ts +0 −278
  189. packages/platform/sdk/src/runtime/sources/source-inspection.test.ts +1 −1
  190. packages/platform/sdk/src/runtime/sources/source-plugins/index.ts +29 −0
  191. packages/platform/sdk/src/runtime/sources/source-store.test.ts +0 −96
  192. packages/platform/sdk/src/runtime/sources/source-store.ts +10 −18
  193. packages/platform/sdk/src/runtime/sources/source-store/auth.ts +0 −141
  194. packages/platform/sdk/src/runtime/sources/source-store/config.ts +2 −143
  195. packages/platform/sdk/src/runtime/sources/source-store/lifecycle.ts +4 −165
  196. packages/platform/sdk/src/runtime/sources/source-store/records.ts +17 −127
  197. packages/platform/sdk/src/schema/enums.ts +3 −38
  198. packages/platform/sdk/src/schema/ids.ts +0 −26
  199. packages/platform/sdk/src/schema/index.ts +1 −7
  200. packages/platform/sdk/src/schema/models/auth-artifact.ts +0 −375
  201. packages/platform/sdk/src/schema/models/auth-lease.ts +0 −54
  202. packages/platform/sdk/src/schema/models/local-config.ts +2 −73
  203. packages/platform/sdk/src/schema/models/provider-auth-grant.ts +0 −37
  204. packages/platform/sdk/src/schema/models/scope-oauth-client.ts +0 −26
  205. packages/platform/sdk/src/schema/models/secret-ref.ts +8 −0
  206. packages/platform/sdk/src/schema/models/source-auth-session.ts +0 −159
  207. packages/platform/sdk/src/schema/models/source-catalog.ts +4 −8
  208. packages/platform/sdk/src/schema/models/source-discovery.ts +0 −16
  209. packages/platform/sdk/src/schema/models/source-oauth-client.ts +0 −48
  210. packages/platform/sdk/src/schema/models/source.ts +0 −100
  211. packages/platform/sdk/src/sources/contracts.ts +0 −236
  212. packages/platform/sdk/src/sources/discovery.ts +0 −56
  213. packages/platform/sdk/src/sources/inspection.ts +2 −2
  214. packages/platform/sdk/src/sources/operations.test.ts +157 −0
  215. packages/platform/sdk/src/sources/operations.ts +161 −117
  216. packages/platform/sdk/vitest.config.ts +20 −0
  217. packages/platform/server/package.json +22 −4
  218. packages/platform/server/src/env.test.ts +1 −1
  219. packages/platform/server/src/google-discovery-oauth-session-storage.ts +40 −0
  220. packages/platform/server/src/google-discovery-source-storage.ts +43 −0
  221. packages/platform/server/src/graphql-source-storage.ts +43 −0
  222. packages/platform/server/src/index.test.ts +474 −500
  223. packages/platform/server/src/index.ts +70 −14
  224. packages/platform/server/src/json-file-storage.ts +79 −0
  225. packages/platform/server/src/mcp-oauth-session-storage.ts +40 −0
  226. packages/platform/server/src/mcp-source-storage.ts +43 −0
  227. packages/platform/server/src/openapi-source-storage.ts +92 −0
  228. packages/sources/builtins/package.json +0 −26
  229. packages/sources/builtins/src/index.ts +0 −12
  230. packages/sources/core/package.json +0 −1
  231. packages/sources/core/src/catalog-fragment.ts +3 −3
  232. packages/sources/core/src/catalog-shared.ts +8 −25
  233. packages/sources/core/src/composition.ts +0 −88
  234. packages/sources/core/src/discovery-models.ts +1 −4
  235. packages/sources/core/src/discovery.ts +1 −4
  236. packages/sources/core/src/index.ts +0 −2
  237. packages/sources/core/src/registry.ts +0 −44
  238. packages/sources/core/src/shared.ts +0 −253
  239. packages/sources/core/src/source-models.ts +1 −98
  240. packages/sources/core/src/types.ts +3 −152
  241. packages/sources/google-discovery/CHANGELOG.md +0 −7
  242. packages/sources/google-discovery/src/adapter.test.ts +0 −202
  243. packages/sources/google-discovery/src/adapter.ts +0 −708
  244. packages/sources/google-discovery/src/index.ts +0 −34
  245. packages/sources/google-discovery/src/local-config.ts +0 −11
  246. packages/sources/graphql/src/adapter.ts +0 −539
  247. packages/sources/graphql/src/index.ts +0 −13
  248. packages/sources/graphql/src/local-config.ts +0 −7
  249. packages/sources/mcp/CHANGELOG.md +0 −8
  250. packages/sources/mcp/src/adapter.ts +0 −627
  251. packages/sources/mcp/src/index.ts +0 −39
  252. packages/sources/mcp/src/local-config.ts +0 −17
  253. packages/sources/mcp/vitest.config.ts +0 −7
  254. packages/sources/openapi/CHANGELOG.md +0 −7
  255. packages/sources/openapi/openapi-extractor-rs/.gitignore +0 −1
  256. packages/sources/openapi/openapi-extractor-rs/Cargo.lock +0 −445
  257. packages/sources/openapi/openapi-extractor-rs/Cargo.toml +0 −17
  258. packages/sources/openapi/openapi-extractor-rs/build-wasm.sh +0 −20
  259. packages/sources/openapi/openapi-extractor-rs/src/lib.rs +0 −1016
  260. packages/sources/openapi/openapi-extractor-rs/src/main.rs +0 −58
  261. packages/sources/openapi/src/adapter.ts +0 −684
  262. packages/sources/openapi/src/discovery.ts +0 −487
  263. packages/sources/openapi/src/document.test.ts +0 −52
  264. packages/sources/openapi/src/document.ts +0 −49
  265. packages/sources/openapi/src/extraction.real-specs.test.ts +0 −126
  266. packages/sources/openapi/src/extraction.ts +0 −893
  267. packages/sources/openapi/src/extractor-wasm.ts +0 −69
  268. packages/sources/openapi/src/index.ts +0 −100
  269. packages/sources/openapi/src/local-config.ts +0 −8
  270. packages/sources/openapi/src/openapi-extractor-wasm/openapi_extractor.d.ts +0 −39
  271. packages/sources/openapi/src/openapi-extractor-wasm/openapi_extractor.js +0 −230
  272. packages/sources/openapi/src/openapi-extractor-wasm/openapi_extractor_bg.wasm +0 −0
  273. packages/sources/openapi/src/openapi-extractor-wasm/openapi_extractor_bg.wasm.d.ts +0 −10
  274. packages/sources/openapi/src/real-spec-coverage.test.ts +0 −351
  275. packages/sources/openapi/src/schema-refs.ts +0 −131
  276. packages/sources/openapi/src/tool-presentation.test.ts +0 −360
  277. packages/sources/openapi/src/tool-presentation.ts +0 −333
  278. packages/sources/openapi/src/tools.test.ts +0 −1316
  279. packages/sources/openapi/src/tools.ts +0 −625
  280. packages/sources/openapi/vitest.config.ts +0 −7
  281. plugins/execution-history/react/components.tsx +301 −0
  282. plugins/execution-history/react/index.ts +29 −0
  283. plugins/execution-history/react/package.json +25 −0
  284. plugins/execution-history/react/tsconfig.json +17 −0
  285. plugins/google-discovery/http/index.ts +356 −0
  286. plugins/google-discovery/http/package.json +26 −0
  287. plugins/google-discovery/http/tsconfig.json +1 −1
  288. plugins/google-discovery/react/components.tsx +1461 −0
  289. plugins/google-discovery/react/icons.ts +44 −0
  290. plugins/google-discovery/react/index.ts +35 −0
  291. plugins/google-discovery/react/package.json +28 −0
  292. plugins/google-discovery/react/tsconfig.json +17 −0
  293. plugins/google-discovery/sdk/catalog.ts +3 −2
  294. plugins/google-discovery/sdk/discovery.ts +1 −1
  295. plugins/google-discovery/sdk/document.ts +0 −0
  296. plugins/google-discovery/sdk/index.ts +1011 −0
  297. plugins/google-discovery/sdk/oauth2.ts +29 −9
  298. plugins/google-discovery/sdk/package.json +8 −6
  299. plugins/google-discovery/sdk/tools.test.ts +4 −2
  300. plugins/google-discovery/sdk/tools.ts +10 −5
  301. plugins/google-discovery/sdk/tsconfig.json +1 −1
  302. plugins/google-discovery/sdk/types.ts +2 −1
  303. plugins/google-discovery/shared/index.ts +223 −0
  304. plugins/google-discovery/shared/package.json +7 −5
  305. plugins/google-discovery/shared/tsconfig.json +1 −1
  306. plugins/graphql/http/index.ts +180 −0
  307. plugins/graphql/http/package.json +26 −0
  308. plugins/graphql/http/tsconfig.json +1 −1
  309. plugins/graphql/react/components.tsx +840 −0
  310. plugins/graphql/react/index.ts +36 −0
  311. plugins/graphql/react/package.json +27 −0
  312. plugins/graphql/react/tsconfig.json +17 −0
  313. plugins/graphql/sdk/catalog.ts +2 −2
  314. plugins/graphql/sdk/discovery.ts +11 −3
  315. plugins/graphql/sdk/graphql-tools.test.ts +0 −0
  316. plugins/graphql/sdk/graphql-tools.ts +4 −4
  317. plugins/graphql/sdk/index.ts +542 −0
  318. plugins/graphql/sdk/package.json +32 −0
  319. plugins/graphql/sdk/provider-data.ts +0 −0
  320. plugins/graphql/sdk/tsconfig.json +15 −0
  321. plugins/graphql/shared/index.ts +66 −0
  322. plugins/graphql/shared/package.json +5 −3
  323. plugins/graphql/shared/tsconfig.json +15 −0
  324. plugins/mcp/http/index.ts +333 −0
  325. plugins/mcp/http/package.json +26 −0
  326. plugins/mcp/http/tsconfig.json +1 −1
  327. plugins/mcp/react/components.tsx +1285 −0
  328. plugins/mcp/react/index.ts +30 −0
  329. plugins/mcp/react/json.ts +56 −0
  330. plugins/mcp/react/package.json +27 −0
  331. plugins/mcp/react/transport.ts +0 −0
  332. plugins/mcp/react/tsconfig.json +17 −0
  333. plugins/mcp/sdk/catalog.ts +2 −2
  334. plugins/mcp/sdk/connection-pool.ts +0 −0
  335. plugins/mcp/sdk/connection.ts +0 −0
  336. plugins/mcp/sdk/discovery.ts +3 −3
  337. plugins/mcp/sdk/elicitation-bridge.ts +0 −0
  338. plugins/mcp/sdk/elicitation-form.test.ts +0 −0
  339. plugins/mcp/sdk/elicitation.url.test.ts +9 −7
  340. plugins/mcp/sdk/index.ts +991 −0
  341. plugins/mcp/sdk/manifest.ts +0 −0
  342. plugins/mcp/sdk/oauth-effect-errors.ts +0 −0
  343. plugins/mcp/sdk/oauth.ts +4 −3
  344. plugins/mcp/sdk/package.json +11 −7
  345. plugins/mcp/sdk/tools.test.ts +1 −2
  346. plugins/mcp/sdk/tools.ts +0 −0
  347. plugins/mcp/sdk/tsconfig.json +2 −2
  348. plugins/mcp/shared/index.ts +174 −0
  349. plugins/mcp/shared/package.json +5 −4
  350. plugins/mcp/shared/tsconfig.json +15 −0
  351. plugins/openapi/http/index.ts +223 −0
  352. plugins/openapi/http/package.json +26 −0
  353. plugins/openapi/http/tsconfig.json +15 −0
  354. plugins/openapi/react/components.tsx +996 −0
  355. plugins/openapi/react/index.ts +29 −0
  356. plugins/openapi/react/package.json +27 −0
  357. plugins/openapi/react/tsconfig.json +17 −0
  358. plugins/openapi/sdk/catalog.ts +196 −263
  359. plugins/openapi/sdk/definitions.ts +53 −78
  360. plugins/openapi/sdk/document.test.ts +32 −0
  361. plugins/openapi/sdk/document.ts +32 −0
  362. plugins/openapi/sdk/extraction.ts +1525 −0
  363. plugins/openapi/sdk/fixtures/openapi-external/components/parameters/widgetId.yaml +6 −0
  364. plugins/openapi/sdk/fixtures/openapi-external/components/schemas/widget.yaml +14 −0
  365. plugins/openapi/sdk/fixtures/openapi-external/operations/widgets.get.yaml +17 −0
  366. plugins/openapi/sdk/fixtures/openapi-external/root.yaml +18 −0
  367. plugins/openapi/sdk/fixtures/resend-openapi.yaml +3715 −0
  368. plugins/openapi/sdk/fixtures/slack-openapi.json +23500 −0
  369. plugins/openapi/sdk/http-serialization.ts +193 −241
  370. plugins/openapi/sdk/index.ts +741 −0
  371. plugins/openapi/sdk/package.json +10 −10
  372. plugins/openapi/sdk/refresh.test.ts +216 −0
  373. plugins/openapi/sdk/smoke.test.ts +119 −0
  374. plugins/openapi/sdk/test-harness.ts +164 −0
  375. plugins/openapi/sdk/tsconfig.json +13 −0
  376. plugins/openapi/sdk/types.ts +102 −110
  377. plugins/openapi/shared/index.ts +276 −0
  378. plugins/openapi/shared/package.json +7 −5
  379. plugins/openapi/shared/tsconfig.json +15 −0
  380. ralph.ts +22 −0
.executor/executor.jsonc+7−58
@@ -1,64 +1,13 @@
11 {
22 "sources": {
3− "github": {
4− "kind": "openapi",
5− "name": "GitHub",
6− "connection": {
7− "endpoint": "https://api.github.com"
8− },
9− "binding": {
10− "specUrl": "https://example.com/github-openapi.yaml",
11− "defaultHeaders": null
12− }
3+ "github-v3-rest-api": {
4+ "name": "GitHub v3 REST API",
5+ "kind": "openapi"
136 },
14− "google-gmail": {
15− "name": "Gmail",
16− "namespace": "google.gmail",
17− "connection": {
18− "endpoint": "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"
19− },
20− "kind": "google_discovery",
21− "binding": {
22− "service": "gmail",
23− "version": "v1",
24− "discoveryUrl": "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest",
25− "defaultHeaders": null,
26− "scopes": [
27− "scope:gmail.readonly"
28− ]
29− }
30− },
31− "google-calendar": {
32− "name": "Google Calendar",
33− "namespace": "google.calendar",
34− "connection": {
35− "endpoint": "https://calendar-json.googleapis.com/$discovery/rest?version=v3"
36− },
37− "kind": "google_discovery",
38− "binding": {
39− "service": "calendar",
40− "version": "v3",
41− "discoveryUrl": "https://calendar-json.googleapis.com/$discovery/rest?version=v3",
42− "defaultHeaders": null,
43− "scopes": [
44− "scope:calendar.readonly"
45− ]
46− }
47− },
48− "google-drive": {
49− "name": "Drive",
50− "namespace": "google.drive",
51− "connection": {
52− "endpoint": "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest"
53− },
54− "kind": "google_discovery",
55− "binding": {
56− "service": "drive",
57− "version": "v3",
58− "discoveryUrl": "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest",
59− "defaultHeaders": null,
60− "scopes": []
61− }
7+ "axiom-mcp": {
8+ "kind": "mcp",
9+ "name": "Axiom MCP",
10+ "namespace": "axiom.mcp"
6211 }
6312 }
6413 }
.gitignore+2−1
@@ -83,4 +83,5 @@ sources/composio-scrape/data/ · 82 unmodified lines
8383 personal-notes/
8484 .tmp/
8585 executor/.env.executor-push
86−ignored/
86+ignored/
87+agent-notes/
.mcp.json+13−0
@@ -0,0 +1,13 @@
1+{
2+ "mcpServers": {
3+ "executor-stdio": {
4+ "command": "bun",
5+ "args": [
6+ "run",
7+ "executor",
8+ "mcp",
9+ "--stdio"
10+ ]
11+ }
12+ }
13+}
.oxlintrc.jsonc+0−9
@@ -79,14 +79,5 @@ · 78 unmodified lines
7979 "no-unsafe-finally": "off",
8080 },
8181 },
82− {
83− // Generated wasm glue is vendored into the repo and not hand-maintained.
84− "files": [
85− "packages/sources/openapi/src/openapi-extractor-wasm/openapi_extractor.js",
86− ],
87− "rules": {
88− "no-unused-vars": "off",
89− },
90− },
9182 ],
9283 }
ARCHITECTURE.md+0−411
@@ -1,411 +0,0 @@
1−# Architecture
2−
3−This document explains the active v3 architecture at a high level.
4−
5−If `README.md` answers "what is this product and how do I use it?", this file answers "what are the major moving parts and how do they fit together?"
6−
7−## One-line view
8−
9−`executor` is a local daemonized control plane that turns connected sources into a workspace tool catalog and then runs TypeScript against that catalog, pausing for user interaction whenever a tool or auth flow needs it.
10−
11−## System shape
12−
13−```text
14− +-----------------------+
15− | CLI / executor |
16− | apps/executor |
17− +-----------+-----------+
18− |
19− | HTTP
20− v
21−+-------------+ +---------+---------+ +----------------+
22−| Browser UI |-->| local server |<--| MCP clients |
23−| apps/web | | packages/platform | | via /mcp |
24−| | | /server | | |
25−+-------------+ +---------+---------+ +----------------+
26− |
27− | provides runtime layer
28− v
29− +---------+---------+
30− | control plane |
31− | packages/platform/ |
32− | control-plane |
33− +----+-----+----+----+
34− | | |
35− persistence --+ | +-- source auth / discovery / inspection
36− |
37− +-- execution environment resolver
38− |
39− +-- live execution manager
40− v
41− +---------+---------+
42− | QuickJS sandboxed |
43− | executor runtime |
44− | packages/kernel/ |
45− | runtime-quickjs |
46− | default executor |
47− +--------------------+
48−```
49−
50−## Design goals reflected in the code
51−
52−The current architecture optimizes for a few specific ideas:
53−
54−- local-first operation with one daemon instead of many disconnected tools
55−- one shared runtime for CLI, browser UI, and MCP access
56−- schema-rich tool usage instead of raw HTTP from prompts
57−- reusable source connections that become workspace-scoped tools
58−- human-in-the-loop execution that can pause and resume cleanly
59−- adapters for multiple source kinds without hardwiring product logic to one protocol
60−
61−## Major components
62−
63−### `apps/executor`: installed CLI and daemon manager
64−
65−This is the main user entrypoint.
66−
67−Responsibilities:
68−
69−- exposes commands such as `up`, `down`, `status`, `doctor`, `call`, and `resume`
70−- ensures the local daemon is running before execution
71−- submits TypeScript executions to the local control plane
72−- handles paused interaction flows, including opening browser URLs when needed
73−- includes a few dev-only seed helpers for demo sources
74−
75−Conceptually, the CLI is not the business logic. It is a thin user-facing shell over the local runtime.
76−
77−### `packages/platform/server`: one local process for API, MCP, and UI
78−
79−This package hosts the actual local server.
80−
81−Responsibilities:
82−
83−- creates the control-plane runtime
84−- mounts the control-plane HTTP API at `/v1`
85−- mounts the `executor` MCP endpoint at `/mcp`
86−- serves the web UI assets for normal browser routes
87−- writes PID metadata for daemon lifecycle management
88−
89−This is an important architectural choice: the API and UI are served by the same local process, so the product behaves like one install rather than a pile of separate services.
90−
91−### `packages/platform/sdk`: product core
92−
93−This is the center of the system.
94−
95−It contains the runtime layer, persistence integration, and the business logic for:
96−
97−- local installation bootstrap
98−- accounts, organizations, and workspaces
99−- source discovery and connection
100−- source auth and credential flows
101−- source inspection and tool indexing
102−- secret and credential handling
103−- execution creation, resumption, and state tracking
104−- policy-aware tool invocation
105−
106−If you want to understand the behavior of the product, this is the most important package.
107−
108−### `packages/kernel/runtime-quickjs`: default code execution runtime
109−
110−This package provides the TypeScript execution environment used by the local product.
111−
112−At a high level it receives:
113−
114−- an executor implementation
115−- a tool catalog
116−- a tool invoker
117−
118−and runs user-authored code against that environment.
119−
120−The default runtime executes code inside a QuickJS WebAssembly sandbox so tool calls stay proxied through the control plane.
121−
122−The workspace can override that in `.executor/executor.jsonc` with `runtime: "quickjs" | "ses" | "deno"`.
123−
124−### Adapter packages
125−
126−Several packages exist to turn external systems into callable tools:
127−
128−- `packages/kernel/core`: shared tool abstractions, discovery, schemas, and system tools
129−- `packages/sources/mcp`: MCP tool loading and invocation
130−- `packages/sources/openapi`: OpenAPI extraction, manifests, and tool generation
131−- `packages/hosts/mcp`: exposes the local runtime itself as an MCP server
132−- `packages/clients/react`: React hooks and client state wrappers for the local UI
133−
134−These packages are what let the control plane treat multiple source kinds as one logical tool catalog.
135−
136−## Runtime model
137−
138−### Local installation bootstrap
139−
140−On first startup, the control plane provisions a local installation automatically.
141−
142−That bootstrap creates:
143−
144−- one local account
145−- one personal organization
146−- one default workspace
147−- one local installation record that points at them
148−
149−This means the product can work out of the box as a local single-user system without requiring an external identity or tenant setup step.
150−
151−### Single daemon, shared state
152−
153−All main entrypoints talk to the same local daemon:
154−
155−- CLI commands call the local HTTP API
156−- the web app calls the same local HTTP API
157−- MCP hosts talk to the `/mcp` handler exposed by that same process
158−
159−Because of that, these surfaces share:
160−
161−- the same workspace
162−- the same connected sources
163−- the same secrets and credentials
164−- the same execution history and interaction state
165−
166−## Persistence model
167−
168−The persistence layer is local-file-backed.
169−
170−Default behavior:
171−
172−- workspace config and state are stored in local files
173−
174−Optional behavior:
175−
176−- future cloud backends can plug in behind Effect service boundaries
177−
178−At a high level, the local control plane stores these domains:
179−
180−- installation identity: local installation identity for the workspace
181−- source state: sources, auth sessions, source credential bindings
182−- tool model: tool artifacts and related metadata extracted from sources
183−- secret state: credentials and secret materials
184−- execution state: executions and execution interactions
185−- governance state: policies
186−
187−This is why `executor` can reconnect sources once, inspect them later, and run multiple executions over time without rebuilding everything from scratch on every prompt.
188−
189−## Source lifecycle
190−
191−The source lifecycle is one of the defining architectural paths in the system.
192−
193−### 1. Discovery
194−
195−The Add Source flow starts with URL discovery.
196−
197−The discovery service probes a URL and tries, in order, to determine whether it looks like:
198−
199−- an OpenAPI source
200−- a GraphQL source
201−- an MCP source
202−- or an unknown endpoint
203−
204−Discovery also produces metadata such as:
205−
206−- inferred source kind
207−- confidence level
208−- probable auth style
209−- optional spec URL
210−- warnings
211−- potential namespace and transport hints
212−
213−### 2. Connection and auth
214−
215−Once a source is identified, the auth service drives the connection flow.
216−
217−Depending on the source, that may involve:
218−
219−- connecting immediately with no auth
220−- asking for a bearer token or other credential material
221−- creating an OAuth session and handing the user into a browser flow
222−- persisting auth bindings back to the source
223−
224−The important architecture point is that source connection is not just a CRUD write. It is a runtime workflow that can branch into interactive auth.
225−
226−### 3. Tool indexing
227−
228−After a source is connected, `executor` materializes a workspace-visible tool model.
229−
230−The exact extraction path depends on the source kind:
231−
232−- OpenAPI sources are transformed from the OpenAPI document into tool manifests and typed operations
233−- GraphQL sources are introspected into callable query and mutation tools
234−- MCP sources are represented through persisted tool artifacts and runtime invocation metadata
235−
236−This indexed representation is what powers search, inspection, and execution without re-parsing everything in the UI.
237−
238−### 4. Inspection
239−
240−The source inspection service reconstructs a rich inspection bundle for the UI.
241−
242−That bundle includes:
243−
244−- source metadata
245−- namespace and pipeline kind
246−- tool summaries
247−- tool detail payloads
248−- raw document text when available
249−- manifest and definitions JSON when available
250−
251−The UI uses this to show both a tree view of tools and a search-oriented discovery view.
252−
253−## Execution architecture
254−
255−Execution is the other defining path in the system.
256−
257−### 1. Create execution
258−
259−An execution begins when the CLI, API, or MCP bridge submits TypeScript code.
260−
261−The control plane creates an execution record with status such as:
262−
263−- `pending`
264−- `running`
265−- `waiting_for_interaction`
266−- `completed`
267−- `failed`
268−
269−### 2. Resolve the workspace execution environment
270−
271−Before code runs, the runtime builds the execution environment for the current workspace.
272−
273−That environment combines:
274−
275−- built-in system tools from `codemode-core`
276−- `executor` internal tools such as `executor.sources.add`
277−- persisted tools generated from connected OpenAPI, GraphQL, and MCP sources
278−
279−The resolver returns three things:
280−
281−- the SES code executor
282−- the workspace tool catalog
283−- the tool invoker that actually dispatches calls
284−
285−This resolver is the composition point where the whole product becomes one callable tool surface.
286−
287−### 3. Run TypeScript
288−
289−The code then runs inside the local SES sandbox runtime.
290−
291−The intended calling pattern is:
292−
293−1. discover a tool by intent
294−2. inspect its schema if needed
295−3. call the selected `tools.*` path
296−
297−The runtime is deliberately opinionated here: the product is built around tool calls, not ad hoc `fetch` requests from user code.
298−
299−### 4. Invoke tools through the control plane
300−
301−When code calls a tool, the control plane decides how to handle it.
302−
303−Possible paths include:
304−
305−- built-in system tool invocation
306−- internal `executor` tool invocation
307−- OpenAPI tool invocation with resolved auth headers
308−- GraphQL tool invocation with resolved auth headers
309−- MCP tool invocation through the MCP connector
310−
311−This is also the point where policy checks and source auth resolution happen.
312−
313−### 5. Persist results and surface interactions
314−
315−The execution service updates persistent execution state as the run progresses.
316−
317−If a tool call or auth flow needs user interaction, the live execution manager:
318−
319−- creates an execution interaction record
320−- marks the execution as `waiting_for_interaction`
321−- stores enough payload to resume later
322−- waits for a structured elicitation response
323−
324−Once the response arrives, the manager moves the execution back to `running` and the code continues.
325−
326−## Human interaction model
327−
328−A major architectural feature of `executor` is that interactions are first-class runtime state.
329−
330−This is handled by the live execution manager and the execution interaction tables.
331−
332−That gives the product a clean pause/resume loop instead of forcing every host to improvise its own half-finished approval flow.
333−
334−Interaction types include:
335−
336−- URL-based flows, such as opening a secure credential page or OAuth URL
337−- form-like elicitation where a host can provide a structured response
338−
339−This same idea is used both for direct CLI execution and for the MCP-facing `execute` and `resume` tools.
340−
341−## API and MCP surfaces
342−
343−### HTTP API
344−
345−The control-plane HTTP API is mounted under `/v1`.
346−
347−Major groups include:
348−
349−- local installation and secret management
350−- OAuth
351−- organizations and memberships
352−- workspaces
353−- sources
354−- policies
355−- executions
356−
357−The web UI is a client of this API.
358−
359−### MCP bridge
360−
361−`packages/hosts/mcp` exposes the local runtime as an MCP server.
362−
363−The bridge registers two main tools:
364−
365−- `execute`: run TypeScript against the local runtime
366−- `resume`: continue a paused execution
367−
368−When the MCP host supports managed elicitation, the bridge can drive the interaction loop directly through MCP instead of requiring the caller to implement custom resume handling.
369−
370−## Frontend architecture
371−
372−The web app lives in `apps/web` and is a React app built with Vite.
373−
374−At a high level:
375−
376−- routes are managed with TanStack Router
377−- data access is wrapped by `@executor/react`
378−- the UI is focused on sources, source inspection, and secrets
379−- production assets are served by the local server
380−- development runs Vite separately while still embedding the same backend behavior
381−
382−The frontend is intentionally thin. It is mostly a presentation layer over the control-plane API.
383−
384−## Why the architecture is shaped this way
385−
386−The system is trying to solve a specific product problem:
387−
388−- give agents a better way to use tools than pasting giant manifests into prompts
389−- keep auth, credentials, and user interactions in a durable runtime
390−- let many protocols look like one workspace tool surface
391−- make CLI, UI, and MCP hosts all operate on the same local state
392−
393−That is why the architecture keeps converging on the same central idea:
394−
395−`executor` is not just a CLI and not just an MCP server. It is a local runtime that owns source connection, tool indexing, execution, and interaction state.
396−
397−## Current boundaries
398−
399−A few practical boundaries are worth calling out:
400−
401−- the active implementation is local-first and single-daemon
402−- the current web app is React/Vite, not the older architectures mentioned in planning notes
403−- `legacy/` and `legacy2/` are historical context, not the active runtime
404−- policy infrastructure exists in the core, but the central product loop today is source connection plus execution
405−
406−## Read next
407−
408−- `README.md` for the product view and usage guidance
409−- `apps/executor/src/cli/main.ts` for the CLI surface
410−- `packages/platform/server/src/index.ts` for how the local server is assembled
411−- `packages/platform/sdk/src/runtime/` for the core runtime flows
README.md+15−8
@@ -229,24 +229,33 @@ Examples: · 228 unmodified lines
229229
230230 ```ts
231231 return await tools.executor.sources.add({
232− endpoint: "https://example.com/mcp",
232+ kind: "mcp",
233233 name: "Example",
234− namespace: "example",
234+ endpoint: "https://example.com/mcp",
235+ transport: "auto",
236+ queryParams: null,
237+ headers: null,
238+ command: null,
239+ args: null,
240+ env: null,
241+ cwd: null,
242+ auth: { kind: "none" },
235243 });
236244 ```
237245
238246 ```ts
239247 return await tools.executor.sources.add({
240248 kind: "openapi",
241− endpoint: "https://api.github.com",
249+ name: "GitHub",
242250 specUrl:
243251 "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json",
244− name: "GitHub",
245− namespace: "github",
252+ baseUrl: null,
253+ auth: { kind: "none" },
246254 });
247255 ```
248256
249−For HTTP-style sources, `executor` can drive the credential flow for you.
257+`executor.sources.add(...)` accepts the final plugin-specific source config.
258+Browser OAuth and popup-driven auth flows live on the plugin-owned HTTP and web surfaces.
250259
251260 ## How execution works
252261
@@ -382,5 +391,3 @@ This repository is explicitly on its third major architecture iteration. · 129 unmodified lines
382391 - `apps/` and `packages/` are the active implementation
383392 - `legacy/` is the original codebase
384393 - `legacy2/` is the second generation
385−
386−If you want the system view instead of the product view, read [`ARCHITECTURE.md`](./ARCHITECTURE.md).
apps/docs/developer/adapters-and-formats.mdx+0−98
@@ -1,98 +0,0 @@
1−---
2−title: "Adapters And Formats"
3−description: "How executor supports multiple integration types without hardwiring the core to one protocol."
4−---
5−
6−Adapters are the main extensibility boundary in executor.
7−
8−They let executor support multiple integration styles without turning the control plane into a long chain of provider-specific branches.
9−
10−## What an adapter does
11−
12−An adapter owns the format-specific or protocol-specific behavior for a source.
13−
14−That includes:
15−
16−- connect payload shape
17−- binding config serialization and decoding
18−- source validation
19−- source config extraction
20−- materialization
21−- manifest parsing
22−- operation description
23−- invocation
24−
25−The control plane owns the generic lifecycle around that.
26−
27−## Why adapters exist
28−
29−Executor needs to support integrations that do not all look the same at the source boundary:
30−
31−- OpenAPI specs
32−- GraphQL APIs
33−- MCP servers
34−- future Google Discovery imports
35−- future Postman collections
36−- future snippet bundles
37−
38−If the core storage model were shaped around one of those directly, every new integration type would cause another redesign.
39−
40−Adapters prevent that.
41−
42−## Adapter vs format
43−
44−It is important not to confuse the adapter with the import format.
45−
46−Examples:
47−
48−- OpenAPI and GraphQL are different adapters even though both import HTTP-shaped API descriptions
49−- MCP is its own adapter because it has distinct session, discovery, and resume semantics
50−
51−This lets executor share a thin catalog model without pretending every source has the same runtime behavior.
52−
53−## What gets stored
54−
55−Adapters do **not** get to invent arbitrary top-level storage models.
56−
57−They compile into the same canonical layers:
58−
59−- recipe
60−- revision
61−- raw documents
62−- schema bundles
63−- operations
64−
65−That means the control plane can stay generic while adapters remain flexible.
66−
67−## Why binding config is adapter-owned
68−
69−Adapter-owned binding config is one of the most important anti-churn decisions in the codebase.
70−
71−Instead of adding more and more provider columns to the source model, the adapter carries its own binding payload and version.
72−
73−That makes it much cheaper to evolve formats without reopening the core schema for every field addition.
74−
75−## Why provider-specific detail still exists
76−
77−Executor does still store some provider-shaped detail in operation metadata and bundle/document kinds.
78−
79−That is intentional, but the direction is:
80−
81−- keep the generic IR thin
82−- keep provider-specific detail in adapter code or typed provider metadata
83−
84−The goal is not to erase differences between formats. The goal is to stop those differences from dominating the core architecture.
85−
86−For the current fidelity tradeoffs and known lossy areas across adapters, see [Import Fidelity](/developer/import-fidelity).
87−
88−## What a good new adapter should do
89−
90−A good new adapter should:
91−
92−- compile into the canonical recipe/revision/operation model
93−- use adapter-owned binding config
94−- avoid requiring new generic tables or provider columns unless truly necessary
95−- expose stable tool paths and search text
96−- make auth needs explicit through the existing auth slot model
97−
98−If an integration needs a new primitive outside the source/tool model entirely, that is a sign it may not belong as just another adapter.
apps/docs/developer/cli.mdx+0−62
@@ -1,62 +0,0 @@
1−---
2−title: "CLI"
3−description: "How the executor CLI fits into the system and why it stays thin."
4−---
5−
6−The CLI lives in `apps/executor`.
7−
8−Its job is to be the installed user-facing shell around the local runtime, not the place where product logic is implemented.
9−
10−The main interactive entrypoints are foreground session commands such as `executor web` and `executor mcp`, plus `executor mcp --stdio` for local process-spawned MCP integrations.
11−
12−## Responsibilities
13−
14−The CLI is responsible for:
15−
16−- starting foreground web and MCP sessions
17−- optionally starting and stopping the detached local daemon for compatibility
18−- checking daemon health and installation state
19−- submitting executions
20−- resuming paused interactions
21−- packaging and distribution concerns for the installed binary
22−
23−## What it should not own
24−
25−The CLI should not own:
26−
27−- source persistence rules
28−- auth workflows
29−- tool discovery
30−- execution state machines
31−- tool invocation behavior
32−
33−Those belong in the control plane.
34−
35−## Why this boundary matters
36−
37−This split is intentional.
38−
39−It means the browser UI, CLI, and local MCP entrypoint all talk to the same product core instead of each carrying their own business logic. That keeps behavior consistent across entrypoints and avoids reimplementing source, credential, and execution rules in multiple places.
40−
41−## How it talks to the rest of the system
42−
43−At a high level, the CLI:
44−
45−1. starts a foreground local session or ensures the detached daemon is available
46−2. calls the local HTTP API exposed by `packages/platform/server`
47−3. hands off real work to `packages/platform/sdk` and `packages/platform/api`
48−
49−That makes the CLI a stable shell over a shared local runtime rather than a second product core.
50−
51−## Why this is flexible
52−
53−Because the CLI is thin, new product capabilities usually do not require redesigning the CLI architecture.
54−
55−Most new work lands in:
56−
57−- control-plane runtime logic
58−- source adapters
59−- codemode packages
60−- server/API surfaces
61−
62−The CLI mostly needs new commands or command wiring on top of those primitives.
apps/docs/developer/codemode.mdx+0−69
@@ -1,69 +0,0 @@
1−---
2−title: "Code Mode"
3−description: "The shared tool abstractions and adapter packages that connect sources to execution."
4−---
5−
6−The codemode packages are the bridge between imported integrations and executable tools.
7−
8−They provide the shared tool abstractions that both the control plane and runtime can depend on.
9−
10−## Core role
11−
12−The codemode layer is where executor turns "some external thing" into a discoverable and invokable tool surface.
13−
14−That is why it exists as a separate layer instead of being buried directly inside each product surface.
15−
16−## Main packages
17−
18−### `packages/kernel/core`
19−
20−This package holds the common tool model:
21−
22−- tool definitions
23−- tool catalogs
24−- tool invokers
25−- discovery helpers
26−- schema-aware tool contracts
27−- shared system-tool concepts
28−
29−This is the main abstraction boundary that other layers build on.
30−
31−### `packages/sources/openapi`
32−
33−This package handles OpenAPI-specific extraction and tool generation.
34−
35−Its job is to understand OpenAPI and compile it into the shared codemode tool model, not to own workspace persistence or auth lifecycle.
36−
37−### `packages/sources/mcp`
38−
39−This package handles MCP-specific tool discovery and invocation behavior and adapts remote MCP tools into the same shared tool model.
40−
41−## Why this layer exists
42−
43−Without the codemode layer, the control plane would have to understand every source format and execution detail directly.
44−
45−Instead, executor keeps the core model broad and pushes format-specific transformation logic into focused packages.
46−
47−That is what makes the system extensible:
48−
49−- new adapters can target the same internal tool model
50−- the runtime can execute against one logical tool surface
51−- the same abstractions can be reused outside the control plane, including SDK-style consumers
52−
53−## How it fits with the control plane
54−
55−The control plane owns:
56−
57−- bindings
58−- credentials
59−- revisions and materialization
60−- tool indexing
61−- policy and invocation lifecycle
62−
63−The codemode layer owns:
64−
65−- how external formats become tools
66−- how those tools are described
67−- how format-specific invocation helpers are implemented
68−
69−That boundary is what keeps the architecture adapter-based instead of turning the control plane into one large protocol-specific switch statement.
apps/docs/developer/core-model.mdx+0−129
@@ -1,129 +0,0 @@
1−---
2−title: "Core Model"
3−description: "The main data and runtime concepts in executor today."
4−---
5−
6−The most important thing to understand about executor is that it does **not** model the system as one flat `source` row.
7−
8−The current model has a few separate concepts that each exist for a different reason.
9−
10−## The primary concepts
11−
12−### Source binding
13−
14−A source is the workspace-local installation record.
15−
16−It answers:
17−
18−- what is this integration called in this workspace?
19−- is it enabled?
20−- what namespace should its tools use?
21−- what install-local config does it have?
22−
23−This is why a source carries local concerns like name, namespace, enabled state, binding config, and status.
24−
25−### Recipe
26−
27−A recipe is the shareable integration identity.
28−
29−It answers:
30−
31−- what integration definition is this?
32−- what adapter produced it?
33−- what broad catalog kind does it belong to?
34−
35−A recipe is meant to converge across workspaces when the underlying integration definition is the same.
36−
37−### Revision / materialization
38−
39−A recipe revision is the compiled result of an import.
40−
41−It answers:
42−
43−- what source config produced this compiled shape?
44−- what manifest, documents, schema bundles, and operations belong to it?
45−
46−This is the durable execution artifact layer.
47−
48−### Operation IR
49−
50−Operations are the canonical callable tool index.
51−
52−They answer:
53−
54−- what tools exist?
55−- how should they be searched and described?
56−- how does invocation find the right persisted operation quickly?
57−
58−The operation layer exists because raw upstream documents are not a good direct query surface for search, policy, and invocation lookup.
59−
60−### Credential
61−
62−Credentials are private auth bindings.
63−
64−They answer:
65−
66−- who can authenticate for this binding?
67−- for which slot?
68−- using what auth kind and secret refs?
69−
70−Credentials are actor-scoped by default because two users in the same workspace may connect different identities to the same source.
71−
72−## Why the split matters
73−
74−This split fixes a few old problems:
75−
76−- shareable definition is no longer confused with workspace-local installation
77−- auth is no longer implicitly one-token-per-source
78−- materialized tool content is no longer stored directly under one workspace source
79−- provider-specific import details do not need to become universal top-level columns
80−
81−## Binding config
82−
83−Source-specific connection config now lives in adapter-owned binding payloads:
84−
85−- `bindingVersion`
86−- `binding`
87−
88−That means the core model does not need built-in fields forever for every provider-specific connection knob.
89−
90−Examples:
91−
92−- OpenAPI binding can carry `specUrl` and `defaultHeaders`
93−- MCP binding can carry `transport`, `queryParams`, and `headers`
94−- internal bindings can be empty
95−
96−The adapter owns serialization, decoding, defaults, and validation for that payload.
97−
98−## Catalog kinds vs adapters
99−
100−Executor separates broad catalog kinds from adapter keys.
101−
102−Catalog kinds:
103−
104−- `imported`
105−- `internal`
106−
107−Adapters are more specific:
108−
109−- `openapi`
110−- `graphql`
111−- `mcp`
112−- `google_discovery`
113−- future `postman_collection`
114−- future `snippet_bundle`
115−
116−The important distinction is that execution now belongs to adapters. The shared catalog stays broad and stable, while connect/auth/invoke behavior is described through adapter strategies instead of one top-level runtime family enum.
117−
118−## Why this is the right shape
119−
120−The current model is designed so that new support usually means:
121−
122−- add or evolve an adapter
123−- compile into the same recipe/revision/operation layers
124−
125−and not:
126−
127−- redesign how source persistence fundamentally works
128−
129−That is the main goal of the current architecture.
apps/docs/developer/credentials-and-auth.mdx+0−113
@@ -1,113 +0,0 @@
1−---
2−title: "Credentials And Auth"
3−description: "How executor models auth, interactive flows, and actor-scoped credential ownership."
4−---
5−
6−Auth in executor is not treated as a side detail on the source row.
7−
8−It is modeled as its own layer because auth has different ownership and lifecycle semantics from source bindings and recipe materialization.
9−
10−## Auth ownership
11−
12−Credentials are scoped to:
13−
14−- workspace
15−- source binding
16−- actor
17−- slot
18−
19−This is important because different users in the same workspace may connect different identities to the same source.
20−
21−## Auth slots
22−
23−Executor distinguishes where auth is used.
24−
25−Important current slots:
26−
27−- `runtime`
28−- `import`
29−
30−Why this matters:
31−
32−- some integrations use the same auth for both
33−- some need auth only to fetch or generate the materialization
34−- some may eventually need setup/bootstrap-specific auth
35−
36−This is why source auth is more than a single token field.
37−
38−## Static credentials
39−
40−The common case today is still static auth backed by secret references.
41−
42−Examples:
43−
44−- bearer token
45−- OAuth access token
46−- OAuth refresh token
47−
48−The control plane stores secret refs, not raw secret values inline on the source or credential rows.
49−
50−## Interactive auth sessions
51−
52−Interactive auth, such as OAuth flows, is modeled separately from stored credentials.
53−
54−That separation exists because:
55−
56−- a pending auth flow has state and lifecycle of its own
57−- it may be tied to an interaction or execution
58−- it is not the same thing as a resolved credential
59−
60−This is why executor has auth sessions and credentials as separate concepts.
61−
62−## Shareable config vs private state
63−
64−Project config under `.executor/executor.jsonc` is intended to stay portable and committable.
65−
66−Private auth state does not belong in the workspace tree:
67−
68−- actor-scoped credentials
69−- OAuth sessions
70−- refresh leases
71−- secret material
72−
73−That state lives in the local control-plane store under the user's home-scoped executor state instead of the project `.executor/state` directory.
74−
75−## Why actor scope matters
76−
77−The old one-token-per-source assumption does not hold once:
78−
79−- multiple users share a workspace
80−- each user connects their own Google account, GitHub account, or other external identity
81−
82−Actor-scoped ownership is the durable fix for that.
83−
84−## The next auth primitive
85−
86−One important extension area is dynamic credentials.
87−
88−Static secret refs are not always enough for integrations that:
89−
90−- derive short-lived auth by calling login or refresh endpoints
91−- need app-specific runtime headers
92−- require token renewal logic
93−
94−The likely next auth-layer primitive is an auth provider or auth material resolver:
95−
96−- configured with secret refs and stable inputs
97−- produces runtime auth material on demand
98−- caches it with expiry metadata
99−
100−That is broader than sources and should be reusable by future source adapters, tool providers, and code bundles.
101−
102−## Why auth is separated from recipes
103−
104−Recipes are shareable integration definitions.
105−Credentials are private installation-specific state.
106−
107−That separation is important both for correctness and for shareability:
108−
109−- a recipe can be portable
110−- a source binding can be local
111−- credentials stay private
112−
113−This is the main reason auth is not modeled as part of the shareable recipe layer.
apps/docs/developer/extending-executor.mdx+0−83
@@ -1,83 +0,0 @@
1−---
2−title: "Extending Executor"
3−description: "How to add new source support without breaking the current architecture."
4−---
5−
6−The best way to extend executor is to respect the current boundaries instead of stretching the core model.
7−
8−## First question: is it really a source?
9−
10−Not every new integration should be modeled as a source.
11−
12−Use the source model when the thing is:
13−
14−- a persistent external system
15−- with install-local configuration
16−- with auth state
17−- that materializes into callable tools
18−
19−If the new thing is really:
20−
21−- a local tool library
22−- a package bundle
23−- a browser automation runtime
24−
25−then it may need a different top-level primitive instead of being shoved into the source model.
26−
27−## If it is a source, add an adapter
28−
29−The happy path for a new integration is:
30−
31−1. define the adapter key, catalog kind, and strategies
32−2. define the connect and binding config shape
33−3. materialize into documents, schema bundles, and operations
34−4. implement invocation
35−5. add tests against the canonical model
36−
37−The adapter should absorb provider-specific logic so the core does not have to.
38−
39−## What a new adapter should not do
40−
41−Avoid these patterns:
42−
43−- adding new provider-specific columns to generic tables because one adapter wants them
44−- storing expanded presentation output as canonical truth
45−- bypassing the operation layer and invoking directly from raw documents
46−- treating auth as provider-specific ad hoc fields on the source row
47−
48−Those are the patterns that lead back to churn.
49−
50−## The standard for a good extension
51−
52−A good new adapter should make the core system feel boring.
53−
54−That means:
55−
56−- persistence still looks like recipe/revision/operations
57−- auth still flows through the same credential/session primitives
58−- discovery and inspection still work through the same catalog ideas
59−- execution still delegates through the same tool and adapter boundaries
60−
61−If a new adapter needs deep new behavior in all of those places, that is usually a sign it may be a new primitive, not just a new adapter.
62−
63−## What feels like the next natural extensions
64−
65−The current architecture is well-suited to future additions like:
66−
67−- Google Discovery imports
68−- Postman collection imports
69−- snippet bundles
70−- richer auth material providers
71−
72−Those should mostly be adapter and auth-layer work, not another persistence redesign.
73−
74−## Why this is the best setup
75−
76−The current setup is not trying to be abstract for its own sake.
77−It is trying to keep the codebase in a state where:
78−
79−- core concepts stay stable
80−- new support lands as extensions
81−- source support does not require philosophy rewrites every few weeks
82−
83−That is the real reason the system is shaped around adapters, recipes, revisions, operations, and actor-scoped credentials.
apps/docs/developer/import-fidelity.mdx+0−132
@@ -1,132 +0,0 @@
1−---
2−title: "Import Fidelity"
3−description: "What executor preserves, normalizes, and currently loses when importing external formats."
4−---
5−
6−Executor should normalize syntax, not meaning.
7−
8−That means the import pipeline can simplify surface differences between OpenAPI, GraphQL, Google Discovery, and MCP, but it should not silently erase semantics that change execution, trust, or user understanding.
9−
10−The practical shape is:
11−
12−- keep the raw source document or manifest
13−- keep adapter-native metadata that does not fit the common model cleanly
14−- promote execution-relevant semantics into first-class IR
15−
16−## The fidelity buckets
17−
18−This audit uses four buckets:
19−
20−- `Raw source`: the original imported document or manifest
21−- `Adapter/native`: typed provider data or native blobs attached during import
22−- `IR`: first-class catalog symbols and capability metadata
23−- `UI today`: what current inspection surfaces directly
24−
25−Current inspection is still narrow. It mostly shows projected schemas plus `tool.executable.native[0]`, not all preserved native blobs or capability-native metadata.
26−
27−## OpenAPI
28−
29−| Source detail | Raw source | Adapter/native | IR | UI today | Notes |
30−| --- | --- | --- | --- | --- | --- |
31−| Operation auth requirements and security schemes | Yes | No | No | No | Operation `security` is not promoted; imported capabilities currently use `auth: none`. |
32−| Exact response variants, status codes, and headers | Yes | Partial | No | Partial | Import chooses one preferred response, then projects one synthetic `2XX` response set. |
33−| Parameter serialization (`style`, `explode`, `allowReserved`, content-based params) | Yes | Mostly no | No | No | The IR can model these, but the OpenAPI importer currently only takes parameter `schema`. |
34−| Multiple request and response media types | Yes | Partial | Partial | Partial | Import picks a preferred content entry for typing; response docs keep content types only for the chosen response. |
35−| `servers` defaults | Yes | No | No | No | The IR can represent scope server defaults, but OpenAPI import does not populate them. |
36−| Rich schema semantics beyond the importer subset | Yes | Partial | Partial | Partial | Unsupported JSON Schema shapes fall back to `unknown` plus native preservation. |
37−
38−## GraphQL
39−
40−| Source detail | Raw source | Adapter/native | IR | UI today | Notes |
41−| --- | --- | --- | --- | --- | --- |
42−| Subscription root fields as tools | Yes | Partial | No | No | The manifest records `subscriptionTypeName`, but tool generation only materializes query and mutation fields. |
43−| Full field result shapes for generated field tools | Yes | Partial | Partial | Partial | Field-tool outputs are heuristic projections, not full result types. |
44−| Union and deep nested selections | Yes | Partial | Partial | Partial | Unions degrade to `__typename`; nested selection stops after depth 2 in generated field tools. |
45−| Arbitrary caller-selected field projections | Yes | Partial | No | No | Generated field tools are compiled with fixed selections; the raw `request` tool is the escape hatch. |
46−| Deprecation reasons | Yes | Partial | Partial | No | GraphQL schemas emit `deprecated: true` plus `x-deprecationReason`, but the generic schema importer only keeps `deprecated` as first-class shape metadata. |
47−
48−GraphQL is a special case: the main loss is not the raw source being discarded, but the generated field-tool projection being intentionally narrower than the schema. That tradeoff is reasonable for discoverability, but only if the system continues to preserve and expose the raw request path.
49−
50−## Google Discovery
51−
52−| Source detail | Raw source | Adapter/native | IR | UI today | Notes |
53−| --- | --- | --- | --- | --- | --- |
54−| OAuth scope descriptions | Yes | Partial | No | No | Document-level scope descriptions are extracted, but the IR security scheme rewrites scopes to `{ scope: scope }`. |
55−| Rich media upload metadata | Yes | Partial | Partial | Partial | Upload support is collapsed to booleans such as `supportsMediaUpload` and `supportsMediaDownload`. |
56−| Service-level metadata (`title`, `description`, `batchPath`, `documentationLink`) | Yes | Partial | No | No | The extracted manifest has these fields, but per-operation provider data does not carry them into the catalog. |
57−| Parameter descriptions, repeated-ness, enums, defaults | Yes | Yes | Mostly yes | Mostly yes | This area is comparatively good. |
58−| Request and response schemas | Yes | Yes | Yes | Yes | Core schema structure is preserved reasonably well. |
59−
60−Google Discovery is less lossy than OpenAPI on request and response typing, but it still loses useful service metadata and the human meaning attached to OAuth scopes.
61−
62−## MCP
63−
64−| Source detail | Raw source | Adapter/native | IR | UI today | Notes |
65−| --- | --- | --- | --- | --- | --- |
66−| Non-tool MCP resources such as prompts or resources | No | No | No | No | The adapter only discovers `listTools()`. Anything outside the tool list never enters import. |
67−| Extra tool metadata beyond `name`, `description`, `inputSchema` or `parameters`, and `outputSchema` | Partial | Partial | No | No | The manifest schema keeps only a narrow subset of listed tool fields. |
68−| Tool-level safety or effect hints, if present | Partial | No | No | No | Imported MCP operations are currently hardcoded to `effect: action`. |
69−| Tool input and output schemas | Yes | Yes | Yes | Yes | This is the strongest part of the current MCP import. |
70−| Server-level metadata outside the tool list | No | No | No | No | The stored MCP "document" is a synthesized tool manifest, not a richer upstream server description. |
71−
72−MCP is the most structurally lossy adapter today because the discovery step itself only imports tools and already narrows their metadata aggressively.
73−
74−## Cross-cutting UI caveat
75−
76−Even when data is preserved natively, the current inspection UI often does not show it directly.
77−
78−Today inspection primarily exposes:
79−
80−- projected call and result schemas
81−- capability summary text
82−- the first executable native blob
83−
84−That means "preserved in native data" and "visible to a user" are still different states.
85−
86−## What should be first-class in IR
87−
88−These semantics are worth promoting into the common model because they affect execution or trust:
89−
90−- auth and security requirements described by the source format
91−- response and status variants
92−- HTTP parameter serialization
93−- upload, download, and streaming traits
94−- GraphQL selection and subscription semantics
95−- source-provided safety or idempotency hints
96−
97−By contrast, many provider-specific labels, service metadata fields, and raw source extras are fine to keep in adapter-native metadata as long as they remain inspectable.
98−
99−## Recommended priority order
100−
101−### P0
102−
103−- OpenAPI auth and security requirements
104−- OpenAPI response variants and status distinctions
105−- Google Discovery scope descriptions
106−- MCP discovery breadth: decide whether executor intends to import only tools or broader MCP surface area
107−
108−### P1
109−
110−- OpenAPI parameter serialization
111−- GraphQL subscription modeling
112−- GraphQL field-tool projection metadata so the fixed-selection behavior is explicit
113−- Google Discovery service metadata surfacing
114−- MCP tool metadata preservation beyond the current narrow manifest
115−
116−### P2
117−
118−- Better UI fallback to native/provider metadata
119−- Richer diagnostics for intentionally lossy normalization
120−- A fixture-backed audit matrix test per adapter
121−
122−## The IR boundary
123−
124−The right boundary is:
125−
126−- `raw source` remains the source of truth
127−- `adapter-native metadata` keeps source-specific details close to their format
128−- `IR` stays unified, but it must be lossless for downstream semantics
129−
130−If executor keeps that line clear, a standardized representation is a strength.
131−
132−If executor lets semantically important details live only in hidden native blobs, the standardized representation becomes misleading.
apps/docs/developer/mcp.mdx+0−51
@@ -1,51 +0,0 @@
1−---
2−title: "MCP"
3−description: "How executor uses MCP both as an adapter and as an external interface."
4−---
5−
6−MCP shows up in executor in two different ways.
7−
8−## 1. Executor can connect to MCP servers
9−
10−This is the source side of MCP.
11−
12−When a user connects an MCP endpoint, executor treats it as an adapter with MCP-specific connection, discovery, and invocation behavior. The control plane persists the binding and credential state, while MCP-specific execution behavior lives behind the adapter and codemode layers.
13−
14−This is why MCP is modeled as its own adapter instead of being flattened into a generic HTTP execution model.
15−
16−## 2. Executor can expose itself as an MCP server
17−
18−This is the outward-facing side of MCP.
19−
20−`packages/hosts/mcp` and the `/mcp` route in `packages/platform/server` let external MCP clients talk to the local executor runtime.
21−
22−That means external MCP hosts can:
23−
24−- discover executor tools
25−- trigger executions
26−- resume paused interactions
27−- work against the same local workspace state as the CLI and browser UI
28−
29−## Why the distinction matters
30−
31−These two roles are related, but they are not the same thing:
32−
33−- inbound MCP means executor is acting as a client to a remote MCP server
34−- outbound MCP means executor is acting as a server for external MCP clients
35−
36−Keeping that distinction clear avoids collapsing very different responsibilities into one vague "MCP integration" concept.
37−
38−## Where the logic lives
39−
40−Broadly:
41−
42−- control plane owns workspace state, bindings, credentials, and execution lifecycle
43−- `packages/sources/mcp` owns MCP tool loading and invocation helpers
44−- `packages/hosts/mcp` owns the local MCP server surface
45−- `packages/platform/server` mounts the `/mcp` handler
46−
47−## Why MCP stays flexible in this model
48−
49−Because MCP keeps its own adapter/runtime behavior, executor can support protocol-specific features such as sessions, elicitation, and resume without bending the generic imported-tool model to fit them.
50−
51−That is the main reason MCP is a first-class category in the architecture.
apps/docs/developer/overview.mdx+0−108
@@ -1,108 +0,0 @@
1−---
2−title: "Control Plane Overview"
3−description: "How the control plane is structured today and how the major pieces fit together."
4−---
5−
6−Executor is built around one central idea:
7−
8−- turn connected systems into a durable, discoverable tool catalog
9−- run TypeScript against that catalog inside a controlled runtime
10−- keep connection state, auth, execution, and inspection in one local control plane
11−
12−## One-line architecture
13−
14−Executor is a local control plane that:
15−
16−1. stores connected integrations as sources
17−2. materializes those sources into callable tools
18−3. exposes those tools to the runtime, the UI, and MCP clients
19−4. resolves auth and policy at invocation time
20−
21−## Major packages
22−
23−### `apps/executor`
24−
25−The CLI and daemon manager.
26−
27−Responsibilities:
28−
29−- start and stop the local daemon
30−- submit executions
31−- resume paused interactions
32−- seed and inspect sources in development flows
33−
34−### `packages/platform/server`
35−
36−The local server shell around the control plane.
37−
38−Responsibilities:
39−
40−- mount the HTTP API at `/v1`
41−- mount the local MCP server at `/mcp`
42−- serve the web UI
43−
44−### `packages/platform/sdk`
45−
46−The product core.
47−
48−Responsibilities:
49−
50−- source connection and lifecycle
51−- auth and credential state
52−- persistence
53−- tool discovery and inspection
54−- execution management
55−- policy-aware invocation
56−
57−### Runtime and codemode packages
58−
59−These packages provide the execution and tool abstractions:
60−
61−- `packages/kernel/runtime-ses`
62−- `packages/kernel/core`
63−- `packages/sources/openapi`
64−- `packages/sources/mcp`
65−- `packages/clients/react`
66−
67−## The mental model
68−
69−The current architecture is easiest to understand if you think of it as an adapter-based compiler pipeline:
70−
71−- adapters ingest external formats and protocols
72−- the control plane stores a canonical internal representation
73−- the runtime executes against that representation
74−
75−That is why executor has strong internal concepts like:
76−
77−- source bindings
78−- recipes and revisions
79−- materialized operations
80−- schema bundles
81−- actor-scoped credentials
82−
83−Those are not UI concepts. They are the durable internal model that lets executor support multiple integration styles without rewriting the core every time.
84−
85−## What is intentionally centralized
86−
87−Executor intentionally centralizes a few things that many products split across multiple services:
88−
89−- local identity and workspace bootstrap
90−- source connection state
91−- secret material references
92−- tool inspection data
93−- execution history
94−- interaction/resume state
95−
96−This is why the UI, CLI, and local MCP entrypoint can all behave like one system instead of separate tools bolted together.
97−
98−## Why the architecture is shaped this way
99−
100−The current implementation optimizes for:
101−
102−- local-first operation
103−- strong schema-aware tool usage
104−- reusable connected integrations
105−- minimal provider-specific logic in the control plane core
106−- extensibility through adapters instead of one-off execution paths
107−
108−The rest of the developer docs explain how those goals show up in the current model.
apps/docs/developer/persistence-and-migrations.mdx+0−86
@@ -1,86 +0,0 @@
1−---
2−title: "Persistence And Migrations"
3−description: "How executor stores source state today and how migration logic is intended to work."
4−---
5−
6−Executor persistence is designed around durable runtime state, not just CRUD storage.
7−
8−That means the database stores:
9−
10−- installation and workspace identity
11−- source bindings
12−- recipes and revisions
13−- raw documents
14−- schema bundles
15−- operations
16−- credentials and auth sessions
17−- executions and interactions
18−
19−## Why the storage is layered
20−
21−The storage model is layered because these concerns have different reuse and ownership semantics.
22−
23−Examples:
24−
25−- raw documents are provenance
26−- revisions are compiled materializations
27−- operations are the hot-path query surface
28−- source bindings are workspace-local
29−- credentials are private and actor-scoped
30−
31−Trying to collapse those into one table or one document shape causes churn quickly.
32−
33−## What is normalized vs serialized
34−
35−Executor intentionally uses both relational columns and serialized JSON.
36−
37−Normalized:
38−
39−- ownership
40−- ids and references
41−- search and lookup fields
42−- status and visibility
43−- auth/session ownership
44−
45−Serialized:
46−
47−- adapter-owned binding config
48−- source config blobs
49−- provider metadata
50−- some session payloads
51−
52−This is deliberate.
53−The rule is to normalize what the core system queries and enforces, and serialize what belongs to adapter-specific contracts.
54−
55−## Migrations philosophy
56−
57−The intended migration model is:
58−
59−- SQL migrations move structural schema into the new shape
60−- one-shot code migrations repair or rebuild data that SQL cannot derive sanely
61−- steady-state runtime paths should not carry compatibility branches forever
62−
63−That last point matters.
64−Compatibility logic belongs in upgrade code, not in normal reads and writes.
65−
66−## What is still acceptable in migration code
67−
68−One-shot rebuilds are still acceptable when:
69−
70−- the old data shape is structurally valid but not yet in the best compiled form
71−- the rebuild can be derived from already-stored documents
72−- the logic is ledgered and run once
73−
74−What is not acceptable:
75−
76−- provider-specific legacy fallbacks in steady-state runtime logic
77−- carrying old columns indefinitely because one path still reads them
78−
79−## Why this supports extensibility
80−
81−This persistence shape is meant to let new integrations land in one of two ways:
82−
83−- add a new adapter that compiles into the same source model
84−- add a genuinely new top-level primitive if the thing is not really a source
85−
86−That is how executor avoids turning every new integration into another schema redesign.
apps/docs/developer/tool-catalog-and-execution.mdx+0−91
@@ -1,91 +0,0 @@
1−---
2−title: "Tool Catalog And Execution"
3−description: "How source materialization becomes callable tools and how invocation works."
4−---
5−
6−Executor turns connected integrations into a workspace tool catalog.
7−
8−That catalog is the thing the runtime actually executes against.
9−
10−The execution sandbox is selected from `.executor/executor.jsonc`:
11−
12−```jsonc
13−{
14− "runtime": "quickjs"
15−}
16−```
17−
18−Supported values are `quickjs` (default), `ses`, and `deno`.
19−
20−## How tools are produced
21−
22−At a high level:
23−
24−1. an adapter materializes a source
25−2. the materialization writes documents, schema bundles, and operations
26−3. the control plane loads those persisted operations into a workspace tool index
27−4. the runtime invokes one tool at a time through the control plane
28−
29−This means executor does not rebuild every integration from scratch on every execution.
30−
31−## Why operations exist as a separate layer
32−
33−The operation layer exists to support:
34−
35−- search
36−- inspection
37−- policy
38−- fast tool lookup
39−- tool description without loading huge raw documents every time
40−
41−Raw upstream documents are still kept, but they are not the best hot-path query surface.
42−
43−## Schema transport
44−
45−Executor uses compact operation-root schemas plus shared schema bundles.
46−
47−This is better than storing expanded per-tool schema blobs because it avoids:
48−
49−- massive duplicate schema payloads
50−- slow formatting and rendering
51−- treating presentation output as canonical storage
52−
53−The result is:
54−
55−- smaller payloads
56−- more reusable schema graphs
57−- cleaner tool detail responses
58−
59−## Tool catalog shape
60−
61−The tool catalog is built from:
62−
63−- persisted source tools
64−- built-in executor tools
65−
66−That lets executor present one unified discovery and invocation surface even though the underlying tools come from different places.
67−
68−## Invocation flow
69−
70−At a high level, invocation works like this:
71−
72−1. resolve the persisted operation by tool path
73−2. run policy and approval checks
74−3. resolve auth material for the correct slot
75−4. load the manifest and schema bundle needed for the call
76−5. delegate actual invocation to the adapter
77−
78−This is the key point:
79−
80−- the control plane orchestrates
81−- the adapter performs the protocol-specific call
82−
83−## Why this is flexible
84−
85−This architecture is flexible because it keeps a stable middle:
86−
87−- the runtime only needs tools and invokers
88−- the control plane only needs the canonical operation and auth model
89−- adapters can vary widely at the source boundary without changing the runtime contract
90−
91−That is what makes it possible to add more source types without constantly rewriting execution logic.
apps/docs/docs.json+2−37
@@ -11,50 +11,15 @@ · 10 unmodified lines
1111 "navigation": {
1212 "tabs": [
1313 {
14− "tab": "Product",
14+ "tab": "Docs",
1515 "groups": [
1616 {
17− "group": "Introduction",
17+ "group": "Status",
1818 "pages": [
1919 "introduction"
2020 ]
2121 }
2222 ]
23− },
24− {
25− "tab": "Developer",
26− "groups": [
27− {
28− "group": "Control Plane",
29− "pages": [
30− "developer/overview",
31− "developer/core-model",
32− "developer/adapters-and-formats",
33− "developer/credentials-and-auth",
34− "developer/tool-catalog-and-execution",
35− "developer/persistence-and-migrations",
36− "developer/extending-executor"
37− ]
38− },
39− {
40− "group": "CLI",
41− "pages": [
42− "developer/cli"
43− ]
44− },
45− {
46− "group": "MCP",
47− "pages": [
48− "developer/mcp"
49− ]
50− },
51− {
52− "group": "Code Mode",
53− "pages": [
54− "developer/codemode"
55− ]
56− }
57− ]
5823 }
5924 ]
6025 },
apps/docs/introduction.mdx+2−12
@@ -5,16 +5,6 @@ description: "A local-first execution environment for AI agents." · 4 unmodified lines
55
66 `executor` is a local-first execution environment for AI agents. It gives an agent a TypeScript runtime, a discoverable tool catalog, and a single local place to connect external systems such as MCP servers, OpenAPI APIs, and GraphQL APIs.
77
8−This docs site is split into two documentation surfaces:
8+The docs set has been intentionally removed for now while the plugin architecture is still shifting.
99
10−- product-facing docs for how to install and use executor
11−- developer-facing docs for how executor is built and how to extend it
12−
13−The product-facing section will come later. The current docs start with the developer-facing architecture and implementation model.
14−
15−If you are trying to understand the current system shape, start with:
16−
17−- [Control Plane](/developer/overview)
18−- [CLI](/developer/cli)
19−- [MCP](/developer/mcp)
20−- [Code Mode](/developer/codemode)
10+Use the repository source directly if you need current implementation details.
apps/executor/package.json+4−1
@@ -38,8 +38,11 @@ · 37 unmodified lines
3838 "@effect/platform-node": "catalog:",
3939 "@executor/codemode-core": "workspace:*",
4040 "@executor/executor-mcp": "workspace:*",
41+ "@executor/plugin-google-discovery-sdk": "workspace:*",
42+ "@executor/plugin-graphql-sdk": "workspace:*",
43+ "@executor/plugin-mcp-sdk": "workspace:*",
44+ "@executor/plugin-openapi-sdk": "workspace:*",
4145 "@executor/platform-api": "workspace:*",
42− "@executor/platform-internal": "workspace:*",
4346 "@executor/platform-sdk": "workspace:*",
4447 "@executor/platform-sdk-file": "workspace:*",
4548 "@executor/server": "workspace:*",
apps/executor/src/cli/dev.ts+0−231
@@ -1,231 +0,0 @@
1−import type { ExecutorApiEffectClient as ExecutorApiClient } from "@executor/platform-api/effect";
2−import { ScopeIdSchema } from "@executor/platform-sdk/schema";
3−import * as Effect from "effect/Effect";
4−
5−const readBindingString = (binding: Record<string, unknown>, key: string): string | null =>
6− typeof binding[key] === "string" ? String(binding[key]) : null;
7−
8−type SeedDemoMcpSourceInput = {
9− client: ExecutorApiClient;
10− workspaceId: string;
11− endpoint: string;
12− name: string;
13− namespace: string;
14−};
15−
16−type SeedDemoMcpSourceResult =
17− | {
18− action: "noop";
19− sourceId: string;
20− workspaceId: string;
21− endpoint: string;
22− }
23− | {
24− action: "updated" | "created";
25− sourceId: string;
26− workspaceId: string;
27− endpoint: string;
28− };
29−
30−type SeedGithubOpenApiSourceInput = {
31− client: ExecutorApiClient;
32− workspaceId: string;
33− endpoint: string;
34− specUrl: string;
35− name: string;
36− namespace: string;
37− credentialEnvVar?: string;
38−};
39−
40−export const seedDemoMcpSourceInWorkspace = (
41− input: SeedDemoMcpSourceInput,
42−): Effect.Effect<SeedDemoMcpSourceResult, unknown, never> =>
43− Effect.gen(function* () {
44− const workspaceId = ScopeIdSchema.make(input.workspaceId);
45−
46− const existing = yield* input.client.sources.list({
47− path: {
48− workspaceId,
49− },
50− });
51−
52− const existingByName = existing.find(
53− (source) => source.kind === "mcp" && source.name === input.name,
54− );
55−
56− const expected = {
57− endpoint: input.endpoint,
58− namespace: input.namespace,
59− transport: "streamable-http" as const,
60− };
61−
62− if (
63− existingByName !== undefined
64− && existingByName.endpoint === expected.endpoint
65− && existingByName.namespace === expected.namespace
66− && readBindingString(existingByName.binding, "transport") === expected.transport
67− && existingByName.auth.kind === "none"
68− ) {
69− return {
70− action: "noop",
71− sourceId: existingByName.id,
72− workspaceId: input.workspaceId,
73− endpoint: existingByName.endpoint,
74− };
75− }
76−
77− if (existingByName !== undefined) {
78− const updated = yield* input.client.sources.update({
79− path: {
80− workspaceId,
81− sourceId: existingByName.id,
82− },
83− payload: {
84− endpoint: input.endpoint,
85− status: "connected",
86− enabled: true,
87− namespace: input.namespace,
88− binding: {
89− transport: "streamable-http",
90− queryParams: null,
91− headers: null,
92− },
93− auth: {
94− kind: "none",
95− },
96− },
97− });
98−
99− return {
100− action: "updated",
101− sourceId: updated.id,
102− workspaceId: input.workspaceId,
103− endpoint: updated.endpoint,
104− };
105− }
106−
107− const created = yield* input.client.sources.create({
108− path: {
109− workspaceId,
110− },
111− payload: {
112− name: input.name,
113− kind: "mcp",
114− endpoint: input.endpoint,
115− status: "connected",
116− enabled: true,
117− namespace: input.namespace,
118− binding: {
119− transport: "streamable-http",
120− queryParams: null,
121− headers: null,
122− },
123− auth: {
124− kind: "none",
125− },
126− },
127− });
128−
129− return {
130− action: "created",
131− sourceId: created.id,
132− workspaceId: input.workspaceId,
133− endpoint: created.endpoint,
134− };
135− });
136−
137−export const seedGithubOpenApiSourceInWorkspace = (
138− input: SeedGithubOpenApiSourceInput,
139−): Effect.Effect<SeedDemoMcpSourceResult, unknown, never> =>
140− Effect.gen(function* () {
141− const workspaceId = ScopeIdSchema.make(input.workspaceId);
142−
143− const existing = yield* input.client.sources.list({
144− path: {
145− workspaceId,
146− },
147− });
148−
149− const existingByName = existing.find(
150− (source) => source.kind === "openapi" && source.name === input.name,
151− );
152−
153− const auth = {
154− kind: "bearer" as const,
155− headerName: "Authorization",
156− prefix: "Bearer ",
157− token: {
158− providerId: "env",
159− handle: input.credentialEnvVar ?? "GITHUB_TOKEN",
160− },
161− };
162−
163− if (
164− existingByName !== undefined
165− && existingByName.endpoint === input.endpoint
166− && existingByName.namespace === input.namespace
167− && readBindingString(existingByName.binding, "specUrl") === input.specUrl
168− && JSON.stringify(existingByName.binding.defaultHeaders ?? null) === JSON.stringify(null)
169− && JSON.stringify(existingByName.auth) === JSON.stringify(auth)
170− ) {
171− return {
172− action: "noop",
173− sourceId: existingByName.id,
174− workspaceId: input.workspaceId,
175− endpoint: existingByName.endpoint,
176− };
177− }
178−
179− if (existingByName !== undefined) {
180− const updated = yield* input.client.sources.update({
181− path: {
182− workspaceId,
183− sourceId: existingByName.id,
184− },
185− payload: {
186− endpoint: input.endpoint,
187− status: "connected",
188− enabled: true,
189− namespace: input.namespace,
190− binding: {
191− specUrl: input.specUrl,
192− defaultHeaders: null,
193− },
194− auth,
195− },
196− });
197−
198− return {
199− action: "updated",
200− sourceId: updated.id,
201− workspaceId: input.workspaceId,
202− endpoint: updated.endpoint,
203− };
204− }
205−
206− const created = yield* input.client.sources.create({
207− path: {
208− workspaceId,
209− },
210− payload: {
211− name: input.name,
212− kind: "openapi",
213− endpoint: input.endpoint,
214− status: "connected",
215− enabled: true,
216− namespace: input.namespace,
217− binding: {
218− specUrl: input.specUrl,
219− defaultHeaders: null,
220− },
221− auth,
222− },
223− });
224−
225− return {
226− action: "created",
227− sourceId: created.id,
228− workspaceId: input.workspaceId,
229− endpoint: created.endpoint,
230− };
231− });
apps/executor/src/cli/main.ts+57−125
@@ -14,9 +14,20 @@ import { · 13 unmodified lines
1414 type ExecutorApiEffectClient as ExecutorApiClient,
1515 } from "@executor/platform-api/effect";
1616 import { runExecutorMcpStdioServer } from "@executor/executor-mcp";
17−import { createWorkspaceExecutorAdminToolMap } from "@executor/platform-internal";
1817 import {
19− EXECUTOR_SOURCES_ADD_HELP_LINES,
18+ googleDiscoverySdkPlugin,
19+} from "@executor/plugin-google-discovery-sdk";
20+import {
21+ graphqlSdkPlugin,
22+} from "@executor/plugin-graphql-sdk";
23+import {
24+ mcpSdkPlugin,
25+} from "@executor/plugin-mcp-sdk";
26+import {
27+ openApiSdkPlugin,
28+} from "@executor/plugin-openapi-sdk";
29+import {
30+ getExecutorSourcesAddHelpLines,
2031 RuntimeExecutionResolverService,
2132 } from "@executor/platform-sdk/runtime";
2233 import {
@@ -37,10 +48,15 @@ import * as Option from "effect/Option"; · 14 unmodified lines
3748 import * as Cause from "effect/Cause";
3849
3950 import {
51+ createFileGoogleDiscoveryOAuthSessionStorage,
52+ createFileGoogleDiscoverySourceStorage,
53+ createFileGraphqlSourceStorage,
54+ createFileMcpOAuthSessionStorage,
55+ createFileMcpSourceStorage,
56+ createFileOpenApiSourceStorage,
4057 createLocalExecutorServer,
4158 DEFAULT_SERVER_BASE_URL,
4259 DEFAULT_SERVER_HOST,
43− DEFAULT_LOCAL_DATA_DIR,
4460 DEFAULT_SERVER_LOG_FILE,
4561 DEFAULT_SERVER_PID_FILE,
4662 DEFAULT_SERVER_PORT,
@@ -49,10 +65,7 @@ import { · 2 unmodified lines
4965 runLocalExecutorServer,
5066 } from "@executor/server";
5167 import {
52− seedDemoMcpSourceInWorkspace,
53− seedGithubOpenApiSourceInWorkspace,
54−} from "./dev";
55−import {
68+ resolveCliLocalDataDir,
5669 resolveRuntimeWebAssetsDir,
5770 resolveSelfCommand,
5871 } from "./runtime-paths";
@@ -74,7 +87,6 @@ import { · 15 unmodified lines
7487
7588 const toError = (cause: unknown): Error =>
7689 cause instanceof Error ? cause : new Error(String(cause));
77−
7890 const sleep = (ms: number) =>
7991 Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms)));
8092
@@ -166,6 +178,7 @@ const getBootstrapClient = (baseUrl: string = DEFAULT_SERVER_BASE_URL) => · 85 unmodified lines
166178 const decodeExecutionId = Schema.decodeUnknown(ExecutionIdSchema);
167179 const require = createRequire(import.meta.url);
168180 const CLI_NAME = "executor";
181+const CLI_LOCAL_DATA_DIR = resolveCliLocalDataDir();
169182 const CLI_VERSION = (() => {
170183 const candidatePaths = [
171184 "../package.json",
@@ -217,13 +230,13 @@ const buildWorkflowText = (namespaces: readonly string[] = []): string => · 45 unmodified lines
217230 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',
218231 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",
219232 "3) Call selected tools.<path>(input).",
220− '4) To connect a source, call tools.executor.sources.add(...) for MCP, OpenAPI, or GraphQL APIs.',
221− ...EXECUTOR_SOURCES_ADD_HELP_LINES,
233+ "4) Use source plugins to inspect or add API sources.",
234+ ...getExecutorSourcesAddHelpLines(),
222235 "5) If execution pauses for interaction, resume it with `executor resume --execution-id ...`.",
223236 "Do not use fetch; use tools.* only.",
224237 ].join("\n");
225238
226−const DEFAULT_RUN_WORKFLOW = buildWorkflowText();
239+const getDefaultRunWorkflow = () => buildWorkflowText();
227240
228241 const indentBlock = (value: string, prefix: string = " "): string =>
229242 value
@@ -268,7 +281,7 @@ const buildRunWorkflowText = ( · 38 unmodified lines
268281 catalog?: ToolCatalog,
269282 ): Effect.Effect<string, Error, never> => {
270283 if (!catalog) {
271− return Effect.succeed(DEFAULT_RUN_WORKFLOW);
284+ return Effect.succeed(getDefaultRunWorkflow());
272285 }
273286
274287 return catalog.listNamespaces({ limit: 200 }).pipe(
@@ -287,9 +300,36 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 12 unmodified lines
287300 Effect.acquireUseRelease(
288301 createExecutorEffect({
289302 backend: createLocalExecutorBackend({
290− localDataDir: DEFAULT_LOCAL_DATA_DIR,
303+ localDataDir: CLI_LOCAL_DATA_DIR,
291304 }),
292− createInternalToolMap: createWorkspaceExecutorAdminToolMap,
305+ plugins: [
306+ graphqlSdkPlugin({
307+ storage: createFileGraphqlSourceStorage({
308+ rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/graphql/sources`,
309+ }),
310+ }),
311+ googleDiscoverySdkPlugin({
312+ storage: createFileGoogleDiscoverySourceStorage({
313+ rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/google-discovery/sources`,
314+ }),
315+ oauthSessions: createFileGoogleDiscoveryOAuthSessionStorage({
316+ rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/google-discovery/oauth-sessions`,
317+ }),
318+ }),
319+ mcpSdkPlugin({
320+ storage: createFileMcpSourceStorage({
321+ rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/mcp/sources`,
322+ }),
323+ oauthSessions: createFileMcpOAuthSessionStorage({
324+ rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/mcp/oauth-sessions`,
325+ }),
326+ }),
327+ openApiSdkPlugin({
328+ storage: createFileOpenApiSourceStorage({
329+ rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/openapi/sources`,
330+ }),
331+ }),
332+ ] as const,
293333 }).pipe(Effect.mapError(toError)),
294334 (executor) =>
295335 Effect.gen(function* () {
@@ -312,7 +352,7 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 16 unmodified lines
312352 Effect.catchAllCause((cause) =>
313353 Effect.succeed(
314354 [
315− DEFAULT_RUN_WORKFLOW,
355+ getDefaultRunWorkflow(),
316356 "",
317357 formatCatalogUnavailableMessage(cause),
318358 ].join("\n"),
@@ -389,8 +429,6 @@ const printCallHelp = (workflow: string) => · 70 unmodified lines
389429 "",
390430 ' executor call \'const matches = await tools.discover({ query: "github issues", limit: 5 }); return matches;\'',
391431 ' executor call \'const matches = await tools.discover({ query: "repo details", limit: 1 }); const path = matches.bestPath; return await tools.describe.tool({ path, includeSchemas: true });\'',
392− ' executor call \'return await tools.executor.sources.add({ endpoint: "https://example.com/mcp", name: "Example", namespace: "example" });\'',
393− ' executor call \'return await tools.executor.sources.add({ kind: "openapi", endpoint: "https://api.github.com", specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json", name: "GitHub", namespace: "github" });\'',
394432 " cat script.ts | executor call --stdin",
395433 " executor call --file script.ts",
396434 " executor call --no-open --file script.ts",
@@ -418,7 +456,6 @@ const getLocalAuthedClient = (baseUrl: string = DEFAULT_SERVER_BASE_URL) => · 21 unmodified lines
418456 const installation = yield* bootstrapClient.local.installation({});
419457 const client = yield* createExecutorApiClient({
420458 baseUrl,
421− accountId: installation.actorScopeId,
422459 });
423460
424461 return {
@@ -440,7 +477,7 @@ const getDefaultServerOptions = (port: number = DEFAULT_SERVER_PORT) => { · 15 unmodified lines
440477 return {
441478 host: DEFAULT_SERVER_HOST,
442479 port,
443− localDataDir: DEFAULT_LOCAL_DATA_DIR,
480+ localDataDir: CLI_LOCAL_DATA_DIR,
444481 pidFile: DEFAULT_SERVER_PID_FILE,
445482 ui: assetsDir ? { assetsDir } : undefined,
446483 };
@@ -602,7 +639,7 @@ const getServerStatus = ( · 155 unmodified lines
602639 pid,
603640 pidRunning,
604641 logFile,
605− localDataDir: DEFAULT_LOCAL_DATA_DIR,
642+ localDataDir: CLI_LOCAL_DATA_DIR,
606643 webAssetsDir: resolveRuntimeWebAssetsDir(),
607644 installation,
608645 } satisfies LocalServerStatus;
@@ -1036,54 +1073,6 @@ const printExecution = (envelope: ExecutionEnvelope) => · 427 unmodified lines
10361073 }));
10371074 });
10381075
1039−const seedDemoMcpSource = (input: {
1040− baseUrl: string;
1041− endpoint: string;
1042− name: string;
1043− namespace: string;
1044−}) =>
1045− Effect.gen(function* () {
1046− yield* ensureServer(input.baseUrl);
1047− const { installation, client } = yield* getLocalAuthedClient(input.baseUrl);
1048− const result = yield* seedDemoMcpSourceInWorkspace({
1049− client,
1050− workspaceId: installation.scopeId,
1051− endpoint: input.endpoint,
1052− name: input.name,
1053− namespace: input.namespace,
1054− });
1055−
1056− yield* Effect.sync(() => {
1057− console.log(JSON.stringify(result));
1058− });
1059− });
1060−
1061−const seedGithubOpenApiSource = (input: {
1062− baseUrl: string;
1063− endpoint: string;
1064− specUrl: string;
1065− name: string;
1066− namespace: string;
1067− credentialEnvVar?: string;
1068−}) =>
1069− Effect.gen(function* () {
1070− yield* ensureServer(input.baseUrl);
1071− const { installation, client } = yield* getLocalAuthedClient(input.baseUrl);
1072− const result = yield* seedGithubOpenApiSourceInWorkspace({
1073− client,
1074− workspaceId: installation.scopeId,
1075− endpoint: input.endpoint,
1076− specUrl: input.specUrl,
1077− name: input.name,
1078− namespace: input.namespace,
1079− credentialEnvVar: input.credentialEnvVar,
1080− });
1081−
1082− yield* Effect.sync(() => {
1083− console.log(JSON.stringify(result));
1084− });
1085− });
1086−
10871076 const driveExecution = (input: {
10881077 client: ExecutorApiClient;
10891078 workspaceId: ExecutionEnvelope["execution"]["scopeId"];
@@ -1359,64 +1348,7 @@ const resumeCommand = Command.make( · 269 unmodified lines
13591348 }),
13601349 ).pipe(Command.withDescription("Resume a paused execution"));
13611350
1362−const devSeedMcpDemoCommand = Command.make(
1363− "seed-mcp-demo",
1364− {
1365− baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_SERVER_BASE_URL)),
1366− endpoint: Options.text("endpoint").pipe(
1367− Options.withDefault("http://127.0.0.1:58506/mcp"),
1368− ),
1369− name: Options.text("name").pipe(Options.withDefault("Demo")),
1370− namespace: Options.text("namespace").pipe(Options.withDefault("demo")),
1371− },
1372− ({ baseUrl, endpoint, name, namespace }) =>
1373− seedDemoMcpSource({
1374− baseUrl,
1375− endpoint,
1376− name,
1377− namespace,
1378− }),
1379−).pipe(
1380− Command.withDescription(
1381− "Seed the localhost MCP elicitation demo source into the default workspace",
1382− ),
1383−);
1384−
1385−const devSeedGithubCommand = Command.make(
1386− "seed-github",
1387− {
1388− baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_SERVER_BASE_URL)),
1389− endpoint: Options.text("endpoint").pipe(
1390− Options.withDefault("https://api.github.com"),
1391− ),
1392− specUrl: Options.text("spec-url").pipe(
1393− Options.withDefault(
1394− "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json",
1395− ),
1396− ),
1397− name: Options.text("name").pipe(Options.withDefault("GitHub")),
1398− namespace: Options.text("namespace").pipe(Options.withDefault("github")),
1399− credentialEnvVar: Options.text("credential-env-var").pipe(
1400− Options.withDefault("GITHUB_TOKEN"),
1401− ),
1402− },
1403− ({ baseUrl, endpoint, specUrl, name, namespace, credentialEnvVar }) =>
1404− seedGithubOpenApiSource({
1405− baseUrl,
1406− endpoint,
1407− specUrl,
1408− name,
1409− namespace,
1410− credentialEnvVar,
1411− }),
1412−).pipe(
1413− Command.withDescription(
1414− "Seed a GitHub OpenAPI source into the default workspace",
1415− ),
1416−);
1417−
14181351 const devCommand = Command.make("dev").pipe(
1419− Command.withSubcommands([devSeedMcpDemoCommand, devSeedGithubCommand] as const),
14201352 Command.withDescription("Development helpers"),
14211353 );
14221354
apps/executor/src/cli/pending-interaction-output.test.ts+0−113
@@ -1,113 +0,0 @@
1−import { describe, expect, it } from "@effect/vitest";
2−import type { ExecutionInteraction } from "@executor/platform-sdk/schema";
3−
4−import {
5− buildPausedExecutionOutput,
6− parseInteractionPayload,
7−} from "./pending-interaction-output";
8−
9−const makeInteraction = (patch: Partial<ExecutionInteraction> = {}): ExecutionInteraction => ({
10− id: "exec_123:tool_execution_gate:call_1" as never,
11− executionId: "exec_123" as never,
12− status: "pending",
13− kind: "form",
14− purpose: "tool_execution_gate",
15− payloadJson: JSON.stringify({
16− elicitation: {
17− mode: "form",
18− message: "Allow DELETE /v2/domains/{domain}/records/{recordId}?",
19− requestedSchema: {
20− type: "object",
21− properties: {
22− approve: {
23− type: "boolean",
24− description: "Whether to approve the tool call",
25− },
26− },
27− required: ["approve"],
28− additionalProperties: false,
29− },
30− },
31− }),
32− responseJson: null,
33− responsePrivateJson: null,
34− createdAt: 1,
35− updatedAt: 1,
36− ...patch,
37−});
38−
39−describe("pending-interaction-output", () => {
40− it("parses requested schema from interaction payload", () => {
41− const parsed = parseInteractionPayload(makeInteraction());
42−
43− expect(parsed).not.toBeNull();
44− expect(parsed?.mode).toBe("form");
45− expect(parsed?.requestedSchema).toEqual({
46− type: "object",
47− properties: {
48− approve: {
49− type: "boolean",
50− description: "Whether to approve the tool call",
51− },
52− },
53− required: ["approve"],
54− additionalProperties: false,
55− });
56− });
57−
58− it("builds a structured paused execution instruction with full resume command", () => {
59− const output = buildPausedExecutionOutput({
60− executionId: "exec_123",
61− interaction: makeInteraction(),
62− baseUrl: "http://127.0.0.1:8788",
63− shouldOpenUrls: false,
64− });
65−
66− expect(output.resumeCommand).toBe(
67− "executor resume --execution-id exec_123 --base-url http://127.0.0.1:8788 --no-open",
68− );
69− expect(output.interaction.requestedSchema).toEqual({
70− type: "object",
71− properties: {
72− approve: {
73− type: "boolean",
74− description: "Whether to approve the tool call",
75− },
76− },
77− required: ["approve"],
78− additionalProperties: false,
79− });
80− expect(output.instruction).toContain("requires approval");
81− expect(output.instruction).toContain("Allow DELETE /v2/domains/{domain}/records/{recordId}?");
82− expect(output.instruction).toContain(output.resumeCommand);
83− expect(output.instruction).toContain("interaction.requestedSchema");
84− });
85−
86− it("describes URL interactions with the next command to run", () => {
87− const output = buildPausedExecutionOutput({
88− executionId: "exec_456",
89− interaction: makeInteraction({
90− id: "exec_456:source_connect_oauth2:call_1" as never,
91− executionId: "exec_456" as never,
92− kind: "url",
93− purpose: "source_connect_oauth2",
94− payloadJson: JSON.stringify({
95− elicitation: {
96− mode: "url",
97− message: "Finish connecting Vercel",
98− url: "https://vercel.com/oauth/start",
99− },
100− }),
101− }),
102− baseUrl: "http://127.0.0.1:8788",
103− shouldOpenUrls: true,
104− });
105−
106− expect(output.interaction.mode).toBe("url");
107− expect(output.interaction.url).toBe("https://vercel.com/oauth/start");
108− expect(output.instruction).toContain("https://vercel.com/oauth/start");
109− expect(output.instruction).toContain(
110− "executor resume --execution-id exec_456 --base-url http://127.0.0.1:8788",
111− );
112− });
113−});
apps/executor/src/cli/pending-interaction-output.ts+0−4
@@ -55,10 +55,6 @@ const describePauseReason = (interaction: ExecutionInteraction): string => { · 54 unmodified lines
5555 switch (interaction.purpose) {
5656 case "tool_execution_gate":
5757 return "this tool call requires approval before executor can continue";
58− case "source_connect_oauth2":
59− return "executor needs browser authentication to finish connecting the source";
60− case "source_connect_secret":
61− return "executor needs credentials to finish connecting the source";
6258 default:
6359 return "executor needs additional input before it can continue";
6460 }
apps/executor/src/cli/runtime-paths.test.ts+47−0
@@ -0,0 +1,47 @@
1+import { join } from "node:path";
2+import { describe, expect, it } from "@effect/vitest";
3+
4+import {
5+ DEFAULT_EXECUTOR_DATA_DIR,
6+ DEFAULT_LOCAL_DATA_DIR,
7+} from "@executor/server";
8+
9+import {
10+ resolveCliLocalDataDir,
11+} from "./runtime-paths";
12+
13+describe("runtime-paths", () => {
14+ it("uses the web dev local data dir for source-launched CLI runs", () => {
15+ const originalArgv1 = process.argv[1];
16+
17+ try {
18+ process.argv[1] = "/tmp/executor.ts";
19+
20+ expect(resolveCliLocalDataDir()).toBe(
21+ join(DEFAULT_EXECUTOR_DATA_DIR, "control-plane-web-dev"),
22+ );
23+ } finally {
24+ if (originalArgv1 === undefined) {
25+ delete process.argv[1];
26+ } else {
27+ process.argv[1] = originalArgv1;
28+ }
29+ }
30+ });
31+
32+ it("keeps the default local data dir for bundled launches", () => {
33+ const originalArgv1 = process.argv[1];
34+
35+ try {
36+ process.argv[1] = "/tmp/executor";
37+
38+ expect(resolveCliLocalDataDir()).toBe(DEFAULT_LOCAL_DATA_DIR);
39+ } finally {
40+ if (originalArgv1 === undefined) {
41+ delete process.argv[1];
42+ } else {
43+ process.argv[1] = originalArgv1;
44+ }
45+ }
46+ });
47+});
apps/executor/src/cli/runtime-paths.ts+16−1
@@ -1,8 +1,11 @@
11 import { existsSync } from "node:fs";
2−import { dirname, extname, resolve } from "node:path";
2+import { dirname, extname, join, resolve } from "node:path";
33 import { fileURLToPath } from "node:url";
44
55 import {
6+ DEFAULT_EXECUTOR_DATA_DIR,
7+ DEFAULT_LOCAL_DATA_DIR,
8+ EXECUTOR_LOCAL_DATA_DIR_ENV,
69 EXECUTOR_WEB_ASSETS_DIR_ENV,
710 } from "@executor/server";
811
@@ -36,6 +39,8 @@ const getSourceEntrypoint = (): string | null => { · 27 unmodified lines
3639 : null;
3740 };
3841
42+const isSourceCliLaunch = (): boolean => getSourceEntrypoint() !== null;
43+
3944 const resolveBundledNodeLauncher = (): string | null => {
4045 const candidate = resolve(sourceDir, "executor.js");
4146 return existsSync(candidate) ? candidate : null;
@@ -76,6 +81,16 @@ export const resolveSelfCommand = (args: readonly string[]): readonly string[] = · 34 unmodified lines
7681 : [process.execPath, sourceEntrypoint, ...args];
7782 };
7883
84+export const resolveCliLocalDataDir = (): string => {
85+ if (trim(process.env[EXECUTOR_LOCAL_DATA_DIR_ENV])) {
86+ return DEFAULT_LOCAL_DATA_DIR;
87+ }
88+
89+ return isSourceCliLaunch()
90+ ? join(DEFAULT_EXECUTOR_DATA_DIR, "control-plane-web-dev")
91+ : DEFAULT_LOCAL_DATA_DIR;
92+};
93+
7994 export const resolveRuntimeWebAssetsDir = (): string | null => {
8095 const explicit = resolveIfExists(process.env[EXECUTOR_WEB_ASSETS_DIR_ENV]);
8196 if (explicit) {
apps/executor/src/distribution/artifact.ts+0−6
@@ -187,12 +187,6 @@ export const buildDistributionPackage = async ( · 186 unmodified lines
187187
188188 await cp(webDistDir, webDir, { recursive: true });
189189 await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm"));
190−
191− await mkdir(join(binDir, "openapi-extractor-wasm"), { recursive: true });
192− await cp(
193− join(repoRoot, "packages/sources/openapi/src/openapi-extractor-wasm/openapi_extractor_bg.wasm"),
194− join(binDir, "openapi-extractor-wasm/openapi_extractor_bg.wasm"),
195− );
196190 await cp(
197191 join(repoRoot, "packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs"),
198192 join(binDir, "deno-subprocess-worker.mjs"),
apps/executor/src/server/server.real-ingestion.test.ts+0−262
@@ -1,262 +0,0 @@
1−import { FileSystem } from "@effect/platform";
2−import { NodeFileSystem } from "@effect/platform-node";
3−import { describe, expect, it } from "@effect/vitest";
4−import * as Effect from "effect/Effect";
5−
6−import {
7− createExecutorApiEffectClient as createExecutorApiClient,
8−} from "@executor/platform-api/effect";
9−import { type SourceDiscoveryKind } from "@executor/platform-sdk/schema";
10−import { createLocalExecutorServer } from "@executor/server";
11−
12−const REAL_VERCEL_SPEC_URL = "https://openapi.vercel.sh";
13−const REAL_VERCEL_API_ENDPOINT = "https://api.vercel.com";
14−const REAL_LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql";
15−const REAL_DEEPWIKI_MCP_ENDPOINT = "https://mcp.deepwiki.com/mcp";
16−
17−const makeTempWorkspaceRoot = () =>
18− FileSystem.FileSystem.pipe(
19− Effect.flatMap((fs) => fs.makeTempDirectory({ prefix: "executor-real-ingestion-" })),
20− Effect.provide(NodeFileSystem.layer),
21− );
22−
23−const createIsolatedLocalExecutorServer = () =>
24− Effect.gen(function* () {
25− const workspaceRoot = yield* makeTempWorkspaceRoot();
26− return yield* createLocalExecutorServer({
27− port: 0,
28− localDataDir: ":memory:",
29− workspaceRoot,
30− });
31− });
32−
33−const createApiClientHarness = () =>
34− Effect.gen(function* () {
35− const server = yield* createIsolatedLocalExecutorServer();
36− const bootstrapClient = yield* createExecutorApiClient({
37− baseUrl: server.baseUrl,
38− });
39− const installation = yield* bootstrapClient.local.installation({});
40− const client = yield* createExecutorApiClient({
41− baseUrl: server.baseUrl,
42− accountId: installation.actorScopeId,
43− });
44−
45− return {
46− installation,
47− client,
48− };
49− });
50−
51−const canonicalUrl = (value: string): string =>
52− new URL(value).toString();
53−
54−const expectDiscoveredKind = <T extends { detectedKind: SourceDiscoveryKind }>(
55− result: T,
56− expectedKind: SourceDiscoveryKind,
57−) => {
58− expect(result.detectedKind).toBe(expectedKind);
59−};
60−
61−describe("local-executor-server real ingestion", () => {
62− it.live("discovers and ingests the real Vercel OpenAPI source through the API client", () =>
63− Effect.scoped(Effect.gen(function* () {
64− const { installation, client } = yield* createApiClientHarness();
65−
66− const discovered = yield* client.sources.discover({
67− payload: {
68− url: REAL_VERCEL_SPEC_URL,
69− },
70− });
71−
72− expectDiscoveredKind(discovered, "openapi");
73− expect(canonicalUrl(discovered.endpoint)).toBe(canonicalUrl(REAL_VERCEL_API_ENDPOINT));
74− expect(canonicalUrl(discovered.specUrl ?? "")).toBe(canonicalUrl(REAL_VERCEL_SPEC_URL));
75−
76− const connected = yield* client.sources.connect({
77− path: {
78− workspaceId: installation.scopeId,
79− },
80− payload: {
81− kind: "openapi",
82− name: "Vercel",
83− namespace: "vercel",
84− endpoint: REAL_VERCEL_API_ENDPOINT,
85− specUrl: REAL_VERCEL_SPEC_URL,
86− auth: {
87− kind: "none",
88− },
89− },
90− });
91−
92− expect(connected.kind).toBe("connected");
93− if (connected.kind !== "connected") {
94− throw new Error(`Expected connected result, received ${connected.kind}`);
95− }
96−
97− const inspection = yield* client.sources.inspection({
98− path: {
99− workspaceId: installation.scopeId,
100− sourceId: connected.source.id,
101− },
102− });
103−
104− expect(inspection.namespace).toBe("vercel");
105− expect(inspection.toolCount).toBeGreaterThan(250);
106−
107− const discoveredTools = yield* client.sources.inspectionDiscover({
108− path: {
109− workspaceId: installation.scopeId,
110− sourceId: connected.source.id,
111− },
112− payload: {
113− query: "list user events",
114− limit: 5,
115− },
116− });
117−
118− expect(discoveredTools.bestPath).toBeTruthy();
119− expect(discoveredTools.total).toBeGreaterThan(0);
120− })),
121− 15_000,
122− );
123−
124− it.live("discovers and ingests the real Linear GraphQL source through the API client", () =>
125− Effect.scoped(Effect.gen(function* () {
126− const { installation, client } = yield* createApiClientHarness();
127−
128− const discovered = yield* client.sources.discover({
129− payload: {
130− url: REAL_LINEAR_GRAPHQL_ENDPOINT,
131− },
132− });
133−
134− expectDiscoveredKind(discovered, "graphql");
135− expect(canonicalUrl(discovered.endpoint)).toBe(canonicalUrl(REAL_LINEAR_GRAPHQL_ENDPOINT));
136− expect(discovered.specUrl).toBeNull();
137−
138− const connected = yield* client.sources.connect({
139− path: {
140− workspaceId: installation.scopeId,
141− },
142− payload: {
143− kind: "graphql",
144− name: "Linear",
145− namespace: "linear",
146− endpoint: REAL_LINEAR_GRAPHQL_ENDPOINT,
147− auth: {
148− kind: "none",
149− },
150− },
151− });
152−
153− expect(connected.kind).toBe("connected");
154− if (connected.kind !== "connected") {
155− throw new Error(`Expected connected result, received ${connected.kind}`);
156− }
157− expect(connected.source.status).toBe("connected");
158− expect(connected.source.namespace).toBe("linear");
159−
160− const inspection = yield* client.sources.inspection({
161− path: {
162− workspaceId: installation.scopeId,
163− sourceId: connected.source.id,
164− },
165− });
166−
167− expect(inspection.source.kind).toBe("graphql");
168− expect(inspection.toolCount).toBeGreaterThan(100);
169− expect(
170− inspection.tools.every((tool) => tool.inputTypePreview === undefined),
171− ).toBe(true);
172− expect(
173− inspection.tools.every((tool) => tool.outputTypePreview === undefined),
174− ).toBe(true);
175−
176− const toolDetail = yield* client.sources.inspectionTool({
177− path: {
178− workspaceId: installation.scopeId,
179− sourceId: connected.source.id,
180− toolPath: "linear.administrableTeams",
181− },
182− });
183−
184− expect(toolDetail.summary.path).toBe("linear.administrableTeams");
185− expect(toolDetail.summary.inputTypePreview).toBeUndefined();
186− expect(toolDetail.summary.outputTypePreview).toBeUndefined();
187− expect(toolDetail.contract.input.typeDeclaration).toContain(
188− "type LinearAdministrableTeamsCall = {",
189− );
190− expect(toolDetail.contract.input.schemaJson).toContain("\"$defs\"");
191−
192− const storedSource = yield* client.sources.get({
193− path: {
194− workspaceId: installation.scopeId,
195− sourceId: connected.source.id,
196− },
197− });
198−
199− expect(storedSource.kind).toBe("graphql");
200− expect(storedSource.status).toBe("connected");
201− expect(storedSource.endpoint).toBe(REAL_LINEAR_GRAPHQL_ENDPOINT);
202− })),
203− 15_000,
204− );
205−
206− it.live("discovers and ingests a real public MCP source through the API client", () =>
207− Effect.scoped(Effect.gen(function* () {
208− const { installation, client } = yield* createApiClientHarness();
209−
210− const discovered = yield* client.sources.discover({
211− payload: {
212− url: REAL_DEEPWIKI_MCP_ENDPOINT,
213− },
214− });
215−
216− expectDiscoveredKind(discovered, "mcp");
217− expect(canonicalUrl(discovered.endpoint)).toBe(canonicalUrl(REAL_DEEPWIKI_MCP_ENDPOINT));
218−
219− const connected = yield* client.sources.connect({
220− path: {
221− workspaceId: installation.scopeId,
222− },
223− payload: {
224− kind: "mcp",
225− name: "DeepWiki",
226− namespace: "deepwiki",
227− endpoint: REAL_DEEPWIKI_MCP_ENDPOINT,
228− },
229− });
230−
231− expect(connected.kind).toBe("connected");
232− if (connected.kind !== "connected") {
233− throw new Error(`Expected connected result, received ${connected.kind}`);
234− }
235−
236− const inspection = yield* client.sources.inspection({
237− path: {
238− workspaceId: installation.scopeId,
239− sourceId: connected.source.id,
240− },
241− });
242−
243− expect(inspection.namespace).toBe("deepwiki");
244− expect(inspection.toolCount).toBeGreaterThan(0);
245−
246− const discoveredTools = yield* client.sources.inspectionDiscover({
247− path: {
248− workspaceId: installation.scopeId,
249− sourceId: connected.source.id,
250− },
251− payload: {
252− query: "repository",
253− limit: 5,
254− },
255− });
256−
257− expect(discoveredTools.bestPath).toBeTruthy();
258− expect(discoveredTools.total).toBeGreaterThan(0);
259− })),
260− 15_000,
261− );
262−});
apps/web/package.json+5−0
@@ -9,6 +9,11 @@ · 8 unmodified lines
99 "start": "NODE_ENV=production bun src/server.ts"
1010 },
1111 "dependencies": {
12+ "@executor/plugin-execution-history-react": "workspace:*",
13+ "@executor/plugin-google-discovery-react": "workspace:*",
14+ "@executor/plugin-graphql-react": "workspace:*",
15+ "@executor/plugin-mcp-react": "workspace:*",
16+ "@executor/plugin-openapi-react": "workspace:*",
1217 "@executor/react": "workspace:*",
1318 "@executor/server": "workspace:*",
1419 "@shikijs/langs": "^4.0.1",
apps/web/src/components/local-mcp-install-card.tsx+21−4
@@ -4,6 +4,8 @@ import { cn } from "../lib/utils"; · 3 unmodified lines
44
55 type LocalMcpInstallMode = "stdio" | "http";
66
7+const isDevBuild = import.meta.env.DEV;
8+
79 export function LocalMcpInstallCard(props: {
810 title?: string;
911 description?: string;
@@ -17,7 +19,9 @@ export function LocalMcpInstallCard(props: { · 7 unmodified lines
1719 }, []);
1820
1921 const command = mode === "stdio"
20− ? 'npx add-mcp "executor mcp --stdio" --name "executor-stdio"'
22+ ? isDevBuild
23+ ? 'npx add-mcp "bun run executor mcp --stdio" --name "executor-stdio"'
24+ : 'npx add-mcp "executor mcp --stdio" --name "executor-stdio"'
2125 : origin
2226 ? `npx add-mcp "${origin}/mcp" --transport http --name "executor"`
2327 : 'npx add-mcp "<this-server>/mcp" --transport http --name "executor"';
@@ -57,9 +61,22 @@ export function LocalMcpInstallCard(props: { · 33 unmodified lines
5761 </div>
5862 <CodeBlock code={command} lang="bash" className="rounded-xl border border-border bg-background/70" />
5963 {mode === "stdio" ? (
60− <p className="mt-3 text-[12px] text-muted-foreground">
61− Requires the `executor` CLI to be available on your PATH. Uses a distinct MCP name to avoid colliding with an existing remote `executor` entry.
62− </p>
64+ <div className="mt-3 space-y-1 text-[12px] text-muted-foreground">
65+ {!isDevBuild ? (
66+ <p>
67+ Requires the `executor` CLI on your PATH. Uses a distinct MCP name to avoid colliding with an existing remote `executor` entry.
68+ </p>
69+ ) : (
70+ <>
71+ <p>
72+ Uses the repo-local dev CLI: <code>bun run executor mcp --stdio</code>.
73+ </p>
74+ <p>
75+ Run the `add-mcp` command from the repository root, or set your MCP client working directory to this repo before using the saved entry.
76+ </p>
77+ </>
78+ )}
79+ </div>
6380 ) : null}
6481 </section>
6582 );
apps/web/src/components/shell.tsx+76−18
@@ -1,10 +1,15 @@
1−import { Link, Outlet, useMatchRoute } from "@tanstack/react-router";
1+import { Link, Outlet, useLocation, useMatchRoute } from "@tanstack/react-router";
22 import { useCallback, useEffect, useState } from "react";
33 import { useSources, type Source } from "@executor/react";
4+import { sourcePluginsIndexPath } from "@executor/react/plugins";
45 import { cn } from "../lib/utils";
56 import { IconPlus, IconCopy, IconCheck } from "./icons";
67 import { LoadableBlock } from "./loadable";
78 import { SourceFavicon } from "./source-favicon";
9+import {
10+ getSourceFrontendPaths,
11+ registeredFrontendPluginNavRoutes,
12+} from "../plugins";
813
914 // ── Status dot color ─────────────────────────────────────────────────────
1015
@@ -217,10 +222,18 @@ function UpdateCard(props: { latestVersion: string; channel: UpdateChannel }) { · 206 unmodified lines
217222 // ── AppShell ─────────────────────────────────────────────────────────────
218223 export function AppShell() {
219224 const sources = useSources();
225+ const location = useLocation();
220226 const matchRoute = useMatchRoute();
221227 const isHome = matchRoute({ to: "/" });
222228 const isSecrets = matchRoute({ to: "/secrets" });
223229 const { latestVersion, updateAvailable, channel } = useLatestVersion(VITE_APP_VERSION);
230+ const mainPluginNavItems = registeredFrontendPluginNavRoutes.filter(
231+ ({ route }) => (route.nav?.section ?? "main") === "main",
232+ );
233+ const sourcePluginNavItems = registeredFrontendPluginNavRoutes.filter(
234+ ({ route }) => route.nav?.section === "sources",
235+ );
236+
224237 return (
225238 <div className="flex h-screen overflow-hidden">
226239 {/* Sidebar */}
@@ -241,13 +254,21 @@ export function AppShell() { · 14 unmodified lines
241254 <nav className="flex flex-1 flex-col p-2 overflow-y-auto">
242255 <NavItem to="/" label="Dashboard" active={!!isHome} />
243256 <NavItem to="/secrets" label="Secrets" active={!!isSecrets} />
257+ {mainPluginNavItems.map(({ plugin, route, to }) => (
258+ <NavItem
259+ key={`${plugin.key}:${route.key}`}
260+ to={to}
261+ label={route.nav?.label ?? route.key}
262+ active={location.pathname === to || location.pathname.startsWith(`${to}/`)}
263+ />
264+ ))}
244265
245266 {/* Sources */}
246267 <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50">
247268 <div className="flex items-center justify-between gap-2">
248269 <span>Sources</span>
249270 <Link
250− to="/sources/add"
271+ to={sourcePluginsIndexPath}
251272 className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-[10px] font-medium normal-case tracking-normal text-primary transition-colors hover:bg-sidebar-active hover:text-foreground"
252273 >
253274 <IconPlus className="size-3" />
@@ -255,16 +276,39 @@ export function AppShell() { · 1 unmodified line
255276 </Link>
256277 </div>
257278 </div>
279+ {sourcePluginNavItems.length > 0 && (
280+ <div className="mb-2 flex flex-col gap-px">
281+ {sourcePluginNavItems.map(({ plugin, route, to }) => (
282+ <NavItem
283+ key={`${plugin.key}:${route.key}`}
284+ to={to}
285+ label={route.nav?.label ?? route.key}
286+ active={
287+ location.pathname === to
288+ || location.pathname.startsWith(`${to}/`)
289+ }
290+ />
291+ ))}
292+ </div>
293+ )}
258294 <LoadableBlock loadable={sources} loading="Loading...">
259295 {(items) =>
260− items.length === 0 ? (
296+ !Array.isArray(items) ? (
297+ <div className="px-2.5 py-2 text-[11px] leading-relaxed text-destructive">
298+ Sources returned an unexpected payload.
299+ </div>
300+ ) : items.length === 0 ? (
261301 <div className="px-2.5 py-2 text-[11px] leading-relaxed text-muted-foreground/40">
262302 No sources yet
263303 </div>
264304 ) : (
265305 <div className="flex flex-col gap-px">
266306 {items.map((source) => (
267− <SourceItem key={source.id} source={source} matchRoute={matchRoute} />
307+ <SourceItem
308+ key={source.id}
309+ pathname={location.pathname}
310+ source={source}
311+ />
268312 ))}
269313 </div>
270314 )
@@ -294,7 +338,7 @@ export function AppShell() { · 23 unmodified lines
294338 </aside>
295339
296340 {/* Main content */}
297− <main className="flex flex-1 flex-col min-w-0 overflow-hidden">
341+ <main className="flex min-h-0 flex-1 flex-col min-w-0 overflow-hidden">
298342 <Outlet />
299343 </main>
300344 </div>
@@ -304,20 +348,34 @@ export function AppShell() { · 3 unmodified lines
304348 // ── SourceItem ───────────────────────────────────────────────────────────
305349
306350 function SourceItem(props: {
351+ pathname: string;
307352 source: Source;
308− matchRoute: ReturnType<typeof useMatchRoute>;
309353 }) {
310− const { source, matchRoute } = props;
311− const active = matchRoute({
312− to: "/sources/$sourceId",
313− params: { sourceId: source.id },
314− fuzzy: true,
315− });
354+ const paths = getSourceFrontendPaths(props.source.kind);
355+
356+ if (!paths) {
357+ return (
358+ <div className="group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] text-sidebar-foreground/60">
359+ <div className="flex size-3 shrink-0 items-center justify-center text-muted-foreground/50">
360+ <SourceFavicon source={props.source} className="size-3" />
361+ </div>
362+ <span className="flex-1 truncate">{props.source.name}</span>
363+ <span
364+ className={cn("size-1.5 shrink-0 rounded-full", statusColor[props.source.status] ?? "bg-muted-foreground/30")}
365+ title={props.source.status}
366+ />
367+ </div>
368+ );
369+ }
370+
371+ const detailPath = paths.detail(props.source.id);
372+ const active =
373+ props.pathname === detailPath
374+ || props.pathname.startsWith(`${detailPath}/`);
316375
317376 return (
318377 <Link
319− to="/sources/$sourceId"
320− params={{ sourceId: source.id }}
378+ to={detailPath}
321379 search={{ tab: "model" }}
322380 className={cn(
323381 "group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors",
@@ -327,12 +385,12 @@ function SourceItem(props: { · 3 unmodified lines
327385 )}
328386 >
329387 <div className="flex size-3 shrink-0 items-center justify-center text-muted-foreground/50">
330− <SourceFavicon endpoint={source.endpoint} kind={source.kind} className="size-3" />
388+ <SourceFavicon source={props.source} className="size-3" />
331389 </div>
332− <span className="flex-1 truncate">{source.name}</span>
390+ <span className="flex-1 truncate">{props.source.name}</span>
333391 <span
334− className={cn("size-1.5 shrink-0 rounded-full", statusColor[source.status] ?? "bg-muted-foreground/30")}
335− title={source.status}
392+ className={cn("size-1.5 shrink-0 rounded-full", statusColor[props.source.status] ?? "bg-muted-foreground/30")}
393+ title={props.source.status}
336394 />
337395 </Link>
338396 );
apps/web/src/components/source-favicon.tsx+13−61
@@ -1,34 +1,25 @@
1−import { useMemo, useState } from "react";
1+import { useState } from "react";
22 import type { Source } from "@executor/react";
3−import { getGoogleProductIconUrl, getSourceFaviconUrl } from "../lib/source-favicon";
3+import { getSourceFrontendIconUrl } from "../plugins";
44 import { cn } from "../lib/utils";
55
66 type SourceKind = Source["kind"] | string;
77
88 export function SourceFavicon({
9− endpoint,
10− kind,
9+ source,
1110 className,
1211 size = 16,
1312 }: {
14− endpoint?: string | null;
15− kind: SourceKind;
13+ source: Source;
1614 className?: string;
1715 size?: number;
1816 }) {
19− const faviconUrl = useMemo(() => {
20− // For google_discovery, prefer the real product icon
21− if (kind === "google_discovery") {
22− return getGoogleProductIconUrl(endpoint) ?? getSourceFaviconUrl(endpoint);
23− }
24− return getSourceFaviconUrl(endpoint);
25− }, [endpoint, kind]);
26−
17+ const faviconUrl = getSourceFrontendIconUrl(source);
2718 const [failedUrl, setFailedUrl] = useState<string | null>(null);
2819 const isFailed = Boolean(faviconUrl && failedUrl === faviconUrl);
2920
3021 if (!faviconUrl || isFailed) {
31− return <DefaultSourceIcon kind={kind} className={className} />;
22+ return <DefaultSourceIcon kind={source.kind} className={className} />;
3223 }
3324
3425 return (
@@ -47,7 +38,7 @@ export function SourceFavicon({ · 12 unmodified lines
4738 }
4839
4940 export function DefaultSourceIcon({
50− kind,
41+ kind: _kind,
5142 className,
5243 }: {
5344 kind: SourceKind;
@@ -55,49 +46,10 @@ export function DefaultSourceIcon({ · 1 unmodified line
5546 }) {
5647 const base = cn("shrink-0", className);
5748
58− switch (kind) {
59− case "mcp":
60− return (
61− <svg viewBox="0 0 16 16" fill="none" className={base}>
62− <rect x="2" y="3" width="12" height="10" rx="1.5" stroke="currentColor" strokeWidth="1.2" />
63− <path d="M5 7h1M5 9h4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
64− </svg>
65− );
66− case "graphql":
67− return (
68− <svg viewBox="0 0 16 16" fill="none" className={base}>
69− <path d="M8 2L14 5.5V10.5L8 14L2 10.5V5.5L8 2Z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
70− <circle cx="8" cy="8" r="1.5" fill="currentColor" opacity="0.5" />
71− </svg>
72− );
73− case "openapi":
74− return (
75− <svg viewBox="0 0 16 16" fill="none" className={base}>
76− <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" />
77− <path d="M5 6h6M5 8h4M5 10h5" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" />
78− </svg>
79− );
80− case "google_discovery":
81− return (
82− <svg viewBox="0 0 16 16" className={base}>
83− <path d="M8 3.2a4.8 4.8 0 00-3.39 8.2l1.02-1.02A3.36 3.36 0 018 4.64a3.33 3.33 0 012.34.96l1.03-1.03A4.78 4.78 0 008 3.2z" fill="#EA4335" />
84− <path d="M12.8 8c0-.37-.04-.72-.1-1.06H8v2.12h2.7a2.4 2.4 0 01-1 1.52l1.02 1.02A4.8 4.8 0 0012.8 8z" fill="#4285F4" />
85− <path d="M5.63 9.38A3.36 3.36 0 014.64 8c0-.5.11-.97.3-1.4L3.92 5.58A4.78 4.78 0 003.2 8c0 .88.26 1.7.72 2.4l1.02-1.02z" fill="#FBBC05" />
86− <path d="M8 12.8c1.2 0 2.27-.4 3.1-1.1l-1.02-1.02c-.54.38-1.24.6-2.08.6a3.36 3.36 0 01-3.07-2.28l-1.02 1.02A4.8 4.8 0 008 12.8z" fill="#34A853" />
87− </svg>
88− );
89− case "internal":
90− return (
91− <svg viewBox="0 0 16 16" fill="none" className={base}>
92− <path d="M8 2v12M4 6l4-4 4 4M4 10l4 4 4-4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
93− </svg>
94− );
95− default:
96− return (
97− <svg viewBox="0 0 16 16" fill="none" className={base}>
98− <rect x="2.5" y="2.5" width="11" height="11" rx="2" stroke="currentColor" strokeWidth="1.2" />
99− <path d="M5 6h6M5 8h4M5 10h3" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" />
100− </svg>
101− );
102− }
49+ return (
50+ <svg viewBox="0 0 16 16" fill="none" className={base}>
51+ <rect x="2.5" y="2.5" width="11" height="11" rx="2" stroke="currentColor" strokeWidth="1.2" />
52+ <path d="M5 6h6M5 8h4M5 10h3" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" />
53+ </svg>
54+ );
10355 }
apps/web/src/components/source-not-found-state.tsx+2−1
@@ -1,4 +1,5 @@
11 import { Link } from "@tanstack/react-router";
2+import { sourcePluginsIndexPath } from "@executor/react/plugins";
23
34 import { IconEmpty } from "./icons";
45 import { cn } from "../lib/utils";
@@ -23,7 +24,7 @@ export function SourceNotFoundState() { · 18 unmodified lines
2324 Back to dashboard
2425 </Link>
2526 <Link
26− to="/sources/add"
27+ to={sourcePluginsIndexPath}
2728 className={cn(
2829 "inline-flex h-7 items-center justify-center rounded-md bg-primary px-2.5 text-xs font-medium text-primary-foreground transition-all",
2930 "hover:bg-primary/90",
apps/web/src/components/source-plugins-reset-state.tsx+27−0
@@ -0,0 +1,27 @@
1+import type { ReactNode } from "react";
2+
3+type SourcePluginsResetStateProps = {
4+ title: string;
5+ message: string;
6+ action?: ReactNode;
7+};
8+
9+export function SourcePluginsResetState(
10+ input: SourcePluginsResetStateProps,
11+) {
12+ return (
13+ <div className="min-h-0 flex-1 overflow-y-auto max-w-[300px]">
14+ <div className="mx-auto max-w-3xl px-6 py-10 lg:px-10 lg:py-14">
15+ <div className="rounded-2xl border border-border bg-card p-8">
16+ <h2 className="font-display text-xl tracking-tight text-foreground">
17+ {input.title}
18+ </h2>
19+ <p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
20+ {input.message}
21+ </p>
22+ {input.action ? <div className="mt-5">{input.action}</div> : null}
23+ </div>
24+ </div>
25+ </div>
26+ );
27+}
apps/web/src/lib/source-favicon.ts+0−123
@@ -1,123 +0,0 @@
1−import { parse as parseDomain } from "tldts";
2−
3−const RAW_HOSTS = new Set([
4− "raw.githubusercontent.com",
5− "cdn.jsdelivr.net",
6− "unpkg.com",
7− "raw.github.com",
8−]);
9−
10−// ---------------------------------------------------------------------------
11−// Google product icon URLs (official CDN assets)
12−// ---------------------------------------------------------------------------
13−
14−const GOOGLE_SERVICE_ICONS: Record<string, string> = {
15− calendar: "https://ssl.gstatic.com/calendar/images/dynamiclogo_2020q4/calendar_31_2x.png",
16− drive: "https://ssl.gstatic.com/images/branding/product/2x/drive_2020q4_48dp.png",
17− gmail: "https://ssl.gstatic.com/ui/v1/icons/mail/rfr/gmail.ico",
18− docs: "https://ssl.gstatic.com/docs/documents/images/kix-favicon7.ico",
19− sheets: "https://ssl.gstatic.com/docs/spreadsheets/favicon3.ico",
20− slides: "https://ssl.gstatic.com/docs/presentations/images/favicon5.ico",
21− forms: "https://ssl.gstatic.com/docs/forms/device_home/android_192.png",
22− searchconsole: "https://ssl.gstatic.com/search-console/scfe/search_console-64.png",
23− people: "https://ssl.gstatic.com/images/branding/product/2x/contacts_2022_48dp.png",
24− tasks: "https://ssl.gstatic.com/tasks/images/favicon.ico",
25− chat: "https://ssl.gstatic.com/chat/favicon/favicon_v2.ico",
26− keep: "https://ssl.gstatic.com/keep/icon_2020q4v2_128.png",
27− classroom: "https://ssl.gstatic.com/classroom/favicon.png",
28− admin: "https://ssl.gstatic.com/images/branding/product/2x/admin_2020q4_48dp.png",
29− script: "https://ssl.gstatic.com/script/images/favicon.ico",
30− bigquery: "https://ssl.gstatic.com/bqui1/favicon.ico",
31− cloudresourcemanager: "https://www.gstatic.com/devrel-devsite/prod/v0e0f589edd85502a40d78d7d0825db8ea5ef3b99b1571571945f0f3f764ff61b/cloud/images/favicons/onecloud/favicon.ico",
32− youtube: "https://www.youtube.com/s/desktop/a94e1818/img/favicon_32x32.png",
33−};
34−
35−/**
36− * For google_discovery endpoints, extract the service name and return
37− * the official product icon URL if we have one.
38− */
39−const getGoogleServiceIconUrl = (endpoint: string | null | undefined): string | null => {
40− if (!endpoint) return null;
41− try {
42− const url = new URL(endpoint);
43− const host = url.hostname;
44−
45− // Direct service subdomain: "calendar-json.googleapis.com", "gmail.googleapis.com"
46− if (host.endsWith(".googleapis.com")) {
47− const sub = host.replace(/\.googleapis\.com$/, "").replace(/-json$/, "");
48− if (GOOGLE_SERVICE_ICONS[sub]) return GOOGLE_SERVICE_ICONS[sub];
49− }
50−
51− // Discovery URL pattern: /discovery/v1/apis/{service}/{version}/rest
52− const discoveryMatch = url.pathname.match(/\/apis\/([^/]+)\//);
53− if (discoveryMatch?.[1] && GOOGLE_SERVICE_ICONS[discoveryMatch[1]]) {
54− return GOOGLE_SERVICE_ICONS[discoveryMatch[1]];
55− }
56−
57− // $discovery/rest pattern on service subdomains is already handled above
58− } catch {
59− // fall through
60− }
61− return null;
62−};
63−
64−// ---------------------------------------------------------------------------
65−// General favicon resolution
66−// ---------------------------------------------------------------------------
67−
68−const parseUrl = (value: string | null | undefined): URL | null => {
69− if (!value) {
70− return null;
71− }
72−
73− try {
74− return new URL(value);
75− } catch {
76− return null;
77− }
78−};
79−
80−const normalizeSeedUrl = (value: string | null | undefined): string | null => {
81− const parsed = parseUrl(value);
82− if (!parsed) {
83− return null;
84− }
85−
86− if (!RAW_HOSTS.has(parsed.hostname)) {
87− return parsed.origin;
88− }
89−
90− const segments = parsed.pathname
91− .split("/")
92− .map((segment) => segment.trim())
93− .filter((segment) => segment.length > 0);
94−
95− for (const segment of segments) {
96− const withoutExtension = segment.replace(/\.(ya?ml|json)$/i, "");
97− if (/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(withoutExtension)) {
98− return `https://${withoutExtension}`;
99− }
100− }
101−
102− return parsed.origin;
103−};
104−
105−export const getGoogleProductIconUrl = getGoogleServiceIconUrl;
106−
107−export const getSourceFaviconUrl = (
108− value: string | null | undefined,
109−): string | null => {
110− const seedUrl = normalizeSeedUrl(value);
111− if (!seedUrl) {
112− return null;
113− }
114−
115− try {
116− const hostname = new URL(seedUrl).hostname;
117− const parsed = parseDomain(hostname);
118− const domain = parsed.domain ?? hostname;
119− return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=32`;
120− } catch {
121− return null;
122− }
123−};
apps/web/src/main.tsx+82−85
@@ -1,4 +1,3 @@
1−import * as React from "react";
21 import {
32 RouterProvider,
43 createRootRoute,
@@ -7,31 +6,25 @@ import { · 2 unmodified lines
76 useNavigate,
87 } from "@tanstack/react-router";
98 import { ExecutorReactProvider } from "@executor/react";
9+import {
10+ createExecutorPluginPaths,
11+ sourcePluginsIndexPath,
12+ type ExecutorPluginNavigation,
13+ type FrontendPluginRouteSearch,
14+} from "@executor/react/plugins";
1015
1116 import "./globals.css";
1217
1318 import { AppShell } from "./components/shell";
19+import {
20+ registeredFrontendPluginRoutes,
21+} from "./plugins";
22+import {
23+ ExecutorPluginRoutePage,
24+ SourcePluginsIndexPage,
25+} from "./plugins/pages";
1426 import { HomePage } from "./views/home";
15−import { EditSourcePage, NewSourcePage } from "./views/source-editor";
16−import { SourceDetailPage } from "./views/source-detail";
1727 import { SecretsPage } from "./views/secrets";
18−import { AddSourcePage } from "./views/add-source";
19−
20−// ---------------------------------------------------------------------------
21−// Route search schema
22−// ---------------------------------------------------------------------------
23−
24−type SourceRouteSearch = {
25− tab: "model" | "discover";
26− tool?: string;
27− query?: string;
28−};
29−
30−const sourceTabs = ["model", "discover"] as const;
31−
32−// ---------------------------------------------------------------------------
33−// Routes
34−// ---------------------------------------------------------------------------
3528
3629 const rootRoute = createRootRoute({
3730 component: AppShell,
@@ -43,36 +36,10 @@ const homeRoute = createRoute({ · 5 unmodified lines
4336 component: HomePage,
4437 });
4538
46−const newSourceRoute = createRoute({
47− getParentRoute: () => rootRoute,
48− path: "/sources/new",
49− component: NewSourcePage,
50−});
51−
52−const addSourceRoute = createRoute({
39+const sourcePluginsIndexRoute = createRoute({
5340 getParentRoute: () => rootRoute,
54− path: "/sources/add",
55− component: AddSourcePage,
56−});
57−
58−const sourceRoute = createRoute({
59− getParentRoute: () => rootRoute,
60− path: "/sources/$sourceId",
61− validateSearch: (search: Record<string, unknown>): SourceRouteSearch => ({
62− tab:
63− typeof search.tab === "string" && sourceTabs.includes(search.tab as SourceRouteSearch["tab"])
64− ? (search.tab as SourceRouteSearch["tab"])
65− : "model",
66− tool: typeof search.tool === "string" && search.tool.length > 0 ? search.tool : undefined,
67− query: typeof search.query === "string" ? search.query : undefined,
68− }),
69− component: SourceDetailPageWrapper,
70−});
71−
72−const editSourceRoute = createRoute({
73− getParentRoute: () => rootRoute,
74− path: "/sources/$sourceId/edit",
75− component: EditSourcePageWrapper,
41+ path: sourcePluginsIndexPath,
42+ component: SourcePluginsIndexPage,
7643 });
7744
7845 const secretsRoute = createRoute({
@@ -81,35 +48,73 @@ const secretsRoute = createRoute({ · 2 unmodified lines
8148 component: SecretsPage,
8249 });
8350
51+const createExecutorPluginNavigation = (
52+ pluginKey: string,
53+ input: {
54+ navigateTo: (
55+ to: string,
56+ search?: FrontendPluginRouteSearch,
57+ ) => void | Promise<void>;
58+ updateSearch?: (
59+ search: FrontendPluginRouteSearch,
60+ ) => void | Promise<void>;
61+ },
62+): ExecutorPluginNavigation => {
63+ const paths = createExecutorPluginPaths(pluginKey);
64+
65+ return {
66+ paths,
67+ home: () => input.navigateTo("/"),
68+ route: (path = "", search) => input.navigateTo(paths.route(path), search),
69+ updateSearch: (search) => input.updateSearch?.(search),
70+ };
71+};
8472
85−function SourceDetailPageWrapper() {
86− const { sourceId } = sourceRoute.useParams();
87− const search = sourceRoute.useSearch();
88− const navigate = useNavigate({ from: sourceRoute.fullPath });
89−
90− return (
91− <SourceDetailPage
92− sourceId={sourceId}
93− search={search}
94− navigate={navigate as any}
95− />
96− );
97−}
98−
99−function EditSourcePageWrapper() {
100− const { sourceId } = editSourceRoute.useParams();
101− return <EditSourcePage sourceId={sourceId} />;
102−}
103−
104−// ---------------------------------------------------------------------------
105−// Router
106−// ---------------------------------------------------------------------------
73+const frontendPluginRoutes = registeredFrontendPluginRoutes.map(({ plugin, route }) => {
74+ const paths = createExecutorPluginPaths(plugin.key);
75+
76+ const PluginRouteComponent = () => {
77+ const params = pluginRoute.useParams() as Record<string, string | undefined>;
78+ const search = pluginRoute.useSearch();
79+ const navigate = useNavigate();
80+ const navigateFromRoute = useNavigate({ from: pluginRoute.fullPath });
81+ const navigation = createExecutorPluginNavigation(plugin.key, {
82+ navigateTo: (to, nextSearch) =>
83+ nextSearch === undefined ? navigate({ to }) : navigate({ to, search: nextSearch }),
84+ updateSearch: (nextSearch) => navigateFromRoute({ search: nextSearch }),
85+ });
86+
87+ return (
88+ <ExecutorPluginRoutePage
89+ pluginKey={plugin.key}
90+ routeKey={route.key}
91+ params={params}
92+ search={search}
93+ navigation={navigation}
94+ />
95+ );
96+ };
97+
98+ const pluginRoute = createRoute({
99+ getParentRoute: () => rootRoute,
100+ path: paths.routePattern(route.path ?? ""),
101+ component: PluginRouteComponent,
102+ });
103+
104+ return pluginRoute;
105+});
107106
108−const routeTree = rootRoute.addChildren([homeRoute, newSourceRoute, addSourceRoute, sourceRoute, editSourceRoute, secretsRoute]);
107+const routeTree = rootRoute.addChildren([
108+ homeRoute,
109+ secretsRoute,
110+ sourcePluginsIndexRoute,
111+ ...frontendPluginRoutes,
112+]);
109113
110114 const router = createRouter({
111115 routeTree,
112116 defaultPreload: "intent",
117+ defaultPreloadStaleTime: 0,
113118 });
114119
115120 declare module "@tanstack/react-router" {
@@ -118,16 +123,8 @@ declare module "@tanstack/react-router" { · 2 unmodified lines
118123 }
119124 }
120125
121−// ---------------------------------------------------------------------------
122−// App
123−// ---------------------------------------------------------------------------
124−
125−export function App() {
126− return (
127− <React.StrictMode>
128− <ExecutorReactProvider>
129− <RouterProvider router={router} />
130− </ExecutorReactProvider>
131− </React.StrictMode>
132− );
133−}
126+export const App = () => (
127+ <ExecutorReactProvider>
128+ <RouterProvider router={router} />
129+ </ExecutorReactProvider>
130+);
apps/web/src/plugins/index.ts+83−0
@@ -0,0 +1,83 @@
1+import type {
2+ Source,
3+} from "@executor/react";
4+import {
5+ ExecutionHistoryReactPlugin,
6+} from "@executor/plugin-execution-history-react";
7+import {
8+ GoogleDiscoveryReactPlugin,
9+ getGoogleDiscoveryIconUrl,
10+} from "@executor/plugin-google-discovery-react";
11+import {
12+ GraphqlReactPlugin,
13+} from "@executor/plugin-graphql-react";
14+import {
15+ McpReactPlugin,
16+} from "@executor/plugin-mcp-react";
17+import {
18+ OpenApiReactPlugin,
19+} from "@executor/plugin-openapi-react";
20+import {
21+ createExecutorPluginPaths,
22+ createSourcePluginPaths,
23+ registerExecutorFrontendPlugins,
24+ type ExecutorFrontendPlugin,
25+} from "@executor/react/plugins";
26+
27+const frontendPlugins = [
28+ ExecutionHistoryReactPlugin,
29+ McpReactPlugin,
30+ GraphqlReactPlugin,
31+ GoogleDiscoveryReactPlugin,
32+ OpenApiReactPlugin,
33+] as const satisfies readonly ExecutorFrontendPlugin[];
34+
35+const frontendPluginRegistry = registerExecutorFrontendPlugins(frontendPlugins);
36+
37+const hasRouteKey = (
38+ plugin: ExecutorFrontendPlugin,
39+ routeKey: string,
40+): boolean =>
41+ (plugin.routes ?? []).some((route) => route.key === routeKey);
42+
43+const isSourceFrontendPlugin = (
44+ plugin: ExecutorFrontendPlugin,
45+): boolean =>
46+ hasRouteKey(plugin, "add")
47+ && hasRouteKey(plugin, "detail");
48+
49+export const registeredFrontendPlugins = frontendPluginRegistry.plugins;
50+export const registeredFrontendPluginRoutes = frontendPluginRegistry.routes;
51+export const registeredFrontendPluginNavRoutes =
52+ registeredFrontendPluginRoutes
53+ .filter(({ route }) => route.nav !== undefined)
54+ .map(({ plugin, route }) => ({
55+ plugin,
56+ route,
57+ to: createExecutorPluginPaths(plugin.key).route(route.path ?? ""),
58+ }));
59+export const registeredSourceFrontendPlugins =
60+ registeredFrontendPlugins.filter(isSourceFrontendPlugin);
61+
62+export const getFrontendPlugin = (key: string) =>
63+ frontendPluginRegistry.getPlugin(key);
64+
65+export const getFrontendPluginRoute = (
66+ pluginKey: string,
67+ routeKey: string,
68+) => frontendPluginRegistry.getRoute(pluginKey, routeKey);
69+
70+export const getSourceFrontendPlugin = (kind: string) => {
71+ const plugin = getFrontendPlugin(kind);
72+ return plugin && isSourceFrontendPlugin(plugin) ? plugin : null;
73+};
74+
75+export const getSourceFrontendPaths = (kind: string) => {
76+ const plugin = getSourceFrontendPlugin(kind);
77+ return plugin ? createSourcePluginPaths(plugin.key) : null;
78+};
79+
80+export const getSourceFrontendIconUrl = (source: Source) =>
81+ source.kind === "google-discovery"
82+ ? getGoogleDiscoveryIconUrl(source)
83+ : null;
apps/web/src/plugins/pages.tsx+259−0
@@ -0,0 +1,259 @@
1+import { useState, type FormEvent } from "react";
2+import { Link, useNavigate } from "@tanstack/react-router";
3+import {
4+ ExecutorPluginRouteProvider,
5+ createSourcePluginPaths,
6+ type ExecutorPluginNavigation,
7+ type FrontendPluginRouteDefinition,
8+ type FrontendPluginRouteParams,
9+ type FrontendPluginRouteSearch,
10+} from "@executor/react/plugins";
11+
12+import { DefaultSourceIcon } from "../components/source-favicon";
13+import { LocalMcpInstallCard } from "../components/local-mcp-install-card";
14+import { SourcePluginsResetState } from "../components/source-plugins-reset-state";
15+import {
16+ getFrontendPlugin,
17+ getFrontendPluginRoute,
18+ registeredSourceFrontendPlugins,
19+} from "./index";
20+import {
21+ buildSourcePresetSearch,
22+ resolveQuickSourceInput,
23+ sourcePresets,
24+} from "./source-presets";
25+
26+const FrontendPluginUnavailableState = () => (
27+ <SourcePluginsResetState
28+ title="Plugins unavailable"
29+ message="No plugins are available in this build."
30+ />
31+);
32+
33+const SourcePluginUnavailableState = () => (
34+ <SourcePluginsResetState
35+ title="No source plugins available"
36+ message="No source plugins are available in this build."
37+ />
38+);
39+
40+const SourceQuickEntry = () => {
41+ const navigate = useNavigate();
42+ const [value, setValue] = useState("");
43+ const [error, setError] = useState<string | null>(null);
44+
45+ const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
46+ event.preventDefault();
47+
48+ const resolved = resolveQuickSourceInput(value);
49+ if (resolved === null) {
50+ setError(
51+ "Could not recognize that input. Try a GraphQL endpoint, MCP URL, Google Discovery URL, OpenAPI spec URL, or a local MCP command.",
52+ );
53+ return;
54+ }
55+
56+ setError(null);
57+ void navigate({
58+ to: createSourcePluginPaths(resolved.pluginKey).add,
59+ search: resolved.search,
60+ });
61+ };
62+
63+ return (
64+ <div className="rounded-2xl border border-border bg-card p-6">
65+ <h2 className="font-display text-lg font-semibold tracking-tight text-foreground">
66+ Quick add
67+ </h2>
68+ <p className="mt-1 text-sm leading-6 text-muted-foreground">
69+ Paste a URL or command and we'll detect the right plugin automatically.
70+ </p>
71+
72+ <form onSubmit={handleSubmit} className="mt-4">
73+ <div className="flex flex-col gap-3 sm:flex-row">
74+ <input
75+ value={value}
76+ onChange={(event) => setValue(event.target.value)}
77+ placeholder="https://api.github.com/graphql or npx -y chrome-devtools-mcp@latest"
78+ className="h-10 flex-1 rounded-lg border border-input bg-background px-3.5 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground/45 focus:border-ring focus:ring-1 focus:ring-ring/25"
79+ />
80+ <button
81+ type="submit"
82+ className="inline-flex h-10 items-center justify-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90"
83+ >
84+ Add source
85+ </button>
86+ </div>
87+
88+ {error && (
89+ <div className="mt-3 rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-sm text-destructive">
90+ {error}
91+ </div>
92+ )}
93+ </form>
94+ </div>
95+ );
96+};
97+
98+export function ExecutorPluginRoutePage(input: {
99+ pluginKey: string;
100+ routeKey: FrontendPluginRouteDefinition["key"];
101+ params: FrontendPluginRouteParams;
102+ search: FrontendPluginRouteSearch;
103+ navigation: ExecutorPluginNavigation;
104+}) {
105+ const plugin = getFrontendPlugin(input.pluginKey);
106+ if (plugin === null) {
107+ return <FrontendPluginUnavailableState />;
108+ }
109+
110+ const routeEntry = getFrontendPluginRoute(input.pluginKey, input.routeKey);
111+ if (routeEntry === null) {
112+ return (
113+ <SourcePluginsResetState
114+ title="Route unavailable"
115+ message={`The route "${input.routeKey}" is not available for this plugin.`}
116+ />
117+ );
118+ }
119+
120+ const PluginPage = routeEntry.route.component;
121+
122+ return (
123+ <ExecutorPluginRouteProvider
124+ value={{
125+ plugin,
126+ route: routeEntry.route,
127+ params: input.params,
128+ search: input.search,
129+ navigation: input.navigation,
130+ }}
131+ >
132+ <div className="flex h-full min-h-0 flex-col overflow-hidden">
133+ <PluginPage />
134+ </div>
135+ </ExecutorPluginRouteProvider>
136+ );
137+}
138+
139+export function SourcePluginsIndexPage() {
140+ if (registeredSourceFrontendPlugins.length === 0) {
141+ return <SourcePluginUnavailableState />;
142+ }
143+
144+ const hasMcpPlugin = registeredSourceFrontendPlugins.some(
145+ (plugin) => plugin.key === "mcp",
146+ );
147+
148+ const presetsByPlugin = registeredSourceFrontendPlugins
149+ .map((plugin) => ({
150+ plugin,
151+ presets: sourcePresets.filter((preset) => preset.pluginKey === plugin.key),
152+ }))
153+ .filter((entry) => entry.presets.length > 0);
154+
155+ return (
156+ <div className="min-h-0 flex-1 overflow-y-auto">
157+ <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14">
158+ <div className="mb-8">
159+ <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl">
160+ Add source
161+ </h1>
162+ <p className="mt-1.5 text-[14px] text-muted-foreground">
163+ Connect a new tool provider to this workspace.
164+ </p>
165+ </div>
166+
167+ <SourceQuickEntry />
168+
169+ {hasMcpPlugin && (
170+ <LocalMcpInstallCard
171+ className="mt-8"
172+ title="Install this executor as MCP"
173+ description="Prefer a one-command setup? Install this local executor server into your MCP client, or add an external MCP source below."
174+ />
175+ )}
176+
177+ {presetsByPlugin.length > 0 && (
178+ <div className="mt-10">
179+ <h2 className="text-lg font-semibold text-foreground">Popular sources</h2>
180+ <p className="mt-1 text-sm leading-6 text-muted-foreground">
181+ One-click presets for common APIs and tools.
182+ </p>
183+
184+ <div className="mt-5 space-y-8">
185+ {presetsByPlugin.map(({ plugin, presets }) => {
186+ const paths = createSourcePluginPaths(plugin.key);
187+
188+ return (
189+ <section key={plugin.key}>
190+ <h3 className="mb-3 text-sm font-semibold text-foreground">
191+ {plugin.displayName ?? plugin.key}
192+ </h3>
193+
194+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
195+ {presets.map((preset) => (
196+ <Link
197+ key={preset.id}
198+ to={paths.add}
199+ search={buildSourcePresetSearch(preset)}
200+ className="rounded-xl border border-border bg-card px-4 py-3 text-left transition-colors hover:border-primary/25 hover:bg-card/90"
201+ >
202+ <div className="mb-2 flex items-center gap-2.5">
203+ <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
204+ <DefaultSourceIcon kind={preset.kind} className="size-4" />
205+ </div>
206+ <div className="min-w-0">
207+ <div className="truncate text-sm font-semibold text-foreground">
208+ {preset.name}
209+ </div>
210+ <div className="mt-0.5 text-[11px] text-muted-foreground">
211+ {preset.kind}
212+ </div>
213+ </div>
214+ </div>
215+
216+ <p className="text-sm leading-6 text-muted-foreground">
217+ {preset.summary}
218+ </p>
219+ </Link>
220+ ))}
221+ </div>
222+ </section>
223+ );
224+ })}
225+ </div>
226+ </div>
227+ )}
228+
229+ <div className="mt-10">
230+ <h2 className="text-lg font-semibold text-foreground">All source plugins</h2>
231+ <p className="mt-1 text-sm leading-6 text-muted-foreground">
232+ Start from a clean configuration page for any installed source plugin.
233+ </p>
234+
235+ <div className="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
236+ {registeredSourceFrontendPlugins.map((plugin) => {
237+ const paths = createSourcePluginPaths(plugin.key);
238+
239+ return (
240+ <Link
241+ key={plugin.key}
242+ to={paths.add}
243+ className="rounded-xl border border-border bg-card px-4 py-3 text-left transition-colors hover:border-primary/25 hover:bg-card/90"
244+ >
245+ <div className="mb-2 text-sm font-semibold text-foreground">
246+ {plugin.displayName ?? plugin.key}
247+ </div>
248+ <p className="text-sm leading-6 text-muted-foreground">
249+ {plugin.description ?? "Configure a new source for this plugin."}
250+ </p>
251+ </Link>
252+ );
253+ })}
254+ </div>
255+ </div>
256+ </div>
257+ </div>
258+ );
259+}
apps/web/src/plugins/source-presets.ts+657−0
@@ -0,0 +1,657 @@
1+const GOOGLE_DISCOVERY_PLUGIN_KEY = "google-discovery" as const;
2+const GOOGLE_DISCOVERY_SOURCE_KIND = "google_discovery" as const;
3+
4+export type SourcePreset =
5+ | {
6+ id: string;
7+ pluginKey: "mcp";
8+ kind: "mcp";
9+ name: string;
10+ summary: string;
11+ previewUrl: string;
12+ endpoint?: string;
13+ transport?: "auto" | "streamable-http" | "sse" | "stdio";
14+ command?: string;
15+ args?: string[];
16+ env?: Record<string, string>;
17+ cwd?: string;
18+ }
19+ | {
20+ id: string;
21+ pluginKey: "openapi";
22+ kind: "openapi";
23+ name: string;
24+ summary: string;
25+ previewUrl: string;
26+ baseUrl: string;
27+ specUrl: string;
28+ }
29+ | {
30+ id: string;
31+ pluginKey: "graphql";
32+ kind: "graphql";
33+ name: string;
34+ summary: string;
35+ previewUrl: string;
36+ endpoint: string;
37+ }
38+ | {
39+ id: string;
40+ pluginKey: typeof GOOGLE_DISCOVERY_PLUGIN_KEY;
41+ kind: typeof GOOGLE_DISCOVERY_SOURCE_KIND;
42+ name: string;
43+ summary: string;
44+ previewUrl: string;
45+ service: string;
46+ version: string;
47+ discoveryUrl: string;
48+ };
49+
50+const googleDiscoveryPreset = (input: {
51+ id: string;
52+ name: string;
53+ summary: string;
54+ service: string;
55+ version: string;
56+ discoveryUrl: string;
57+}): SourcePreset => ({
58+ id: input.id,
59+ pluginKey: GOOGLE_DISCOVERY_PLUGIN_KEY,
60+ kind: GOOGLE_DISCOVERY_SOURCE_KIND,
61+ name: input.name,
62+ summary: input.summary,
63+ previewUrl: input.discoveryUrl,
64+ service: input.service,
65+ version: input.version,
66+ discoveryUrl: input.discoveryUrl,
67+});
68+
69+export const sourcePresets: ReadonlyArray<SourcePreset> = [
70+ {
71+ id: "deepwiki-mcp",
72+ pluginKey: "mcp",
73+ kind: "mcp",
74+ name: "DeepWiki MCP",
75+ summary: "Repository docs and knowledge graphs via MCP.",
76+ previewUrl: "https://mcp.deepwiki.com/mcp",
77+ endpoint: "https://mcp.deepwiki.com/mcp",
78+ transport: "auto",
79+ },
80+ {
81+ id: "axiom-mcp",
82+ pluginKey: "mcp",
83+ kind: "mcp",
84+ name: "Axiom MCP",
85+ summary: "Query, stream, and analyze logs, traces, and event data.",
86+ previewUrl: "https://mcp.axiom.co/mcp",
87+ endpoint: "https://mcp.axiom.co/mcp",
88+ transport: "auto",
89+ },
90+ {
91+ id: "neon-mcp",
92+ pluginKey: "mcp",
93+ kind: "mcp",
94+ name: "Neon MCP",
95+ summary: "Manage Postgres databases, branches, and queries via MCP.",
96+ previewUrl: "https://mcp.neon.tech/mcp",
97+ endpoint: "https://mcp.neon.tech/mcp",
98+ transport: "auto",
99+ },
100+ {
101+ id: "chrome-devtools-mcp",
102+ pluginKey: "mcp",
103+ kind: "mcp",
104+ name: "Chrome DevTools MCP",
105+ summary: "Debug a live Chrome browser session over a local MCP stdio transport.",
106+ previewUrl: "npx -y chrome-devtools-mcp@latest",
107+ transport: "stdio",
108+ command: "npx",
109+ args: ["-y", "chrome-devtools-mcp@latest"],
110+ },
111+ {
112+ id: "neon-api",
113+ pluginKey: "openapi",
114+ kind: "openapi",
115+ name: "Neon API",
116+ summary: "Projects, branches, endpoints, databases, and API keys.",
117+ previewUrl: "https://console.neon.tech/api/v2",
118+ baseUrl: "https://console.neon.tech/api/v2",
119+ specUrl: "https://neon.com/api_spec/release/v2.json",
120+ },
121+ {
122+ id: "github-rest",
123+ pluginKey: "openapi",
124+ kind: "openapi",
125+ name: "GitHub REST API",
126+ summary: "Repos, issues, pull requests, actions, and org settings.",
127+ previewUrl: "https://api.github.com",
128+ baseUrl: "https://api.github.com",
129+ specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml",
130+ },
131+ {
132+ id: "github-graphql",
133+ pluginKey: "graphql",
134+ kind: "graphql",
135+ name: "GitHub GraphQL",
136+ summary: "Issues, pull requests, discussions, and repository objects via GraphQL.",
137+ previewUrl: "https://api.github.com/graphql",
138+ endpoint: "https://api.github.com/graphql",
139+ },
140+ {
141+ id: "gitlab-graphql",
142+ pluginKey: "graphql",
143+ kind: "graphql",
144+ name: "GitLab GraphQL",
145+ summary: "Projects, merge requests, issues, CI pipelines, and users.",
146+ previewUrl: "https://gitlab.com/api/graphql",
147+ endpoint: "https://gitlab.com/api/graphql",
148+ },
149+ {
150+ id: "openai-api",
151+ pluginKey: "openapi",
152+ kind: "openapi",
153+ name: "OpenAI API",
154+ summary: "Models, files, responses, and fine-tuning.",
155+ previewUrl: "https://api.openai.com/v1",
156+ baseUrl: "https://api.openai.com/v1",
157+ specUrl: "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml",
158+ },
159+ {
160+ id: "vercel-api",
161+ pluginKey: "openapi",
162+ kind: "openapi",
163+ name: "Vercel API",
164+ summary: "Deployments, projects, domains, and environments.",
165+ previewUrl: "https://api.vercel.com",
166+ baseUrl: "https://api.vercel.com",
167+ specUrl: "https://openapi.vercel.sh",
168+ },
169+ {
170+ id: "stripe-api",
171+ pluginKey: "openapi",
172+ kind: "openapi",
173+ name: "Stripe API",
174+ summary: "Payments, billing, subscriptions, and invoices.",
175+ previewUrl: "https://api.stripe.com",
176+ baseUrl: "https://api.stripe.com",
177+ specUrl: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json",
178+ },
179+ {
180+ id: "linear-graphql",
181+ pluginKey: "graphql",
182+ kind: "graphql",
183+ name: "Linear GraphQL",
184+ summary: "Issues, teams, cycles, and projects.",
185+ previewUrl: "https://api.linear.app/graphql",
186+ endpoint: "https://api.linear.app/graphql",
187+ },
188+ {
189+ id: "monday-graphql",
190+ pluginKey: "graphql",
191+ kind: "graphql",
192+ name: "Monday GraphQL",
193+ summary: "Boards, items, updates, users, and workspace metadata.",
194+ previewUrl: "https://api.monday.com/v2",
195+ endpoint: "https://api.monday.com/v2",
196+ },
197+ {
198+ id: "anilist-graphql",
199+ pluginKey: "graphql",
200+ kind: "graphql",
201+ name: "AniList GraphQL",
202+ summary: "Anime, manga, characters, media lists, and recommendations.",
203+ previewUrl: "https://graphql.anilist.co",
204+ endpoint: "https://graphql.anilist.co",
205+ },
206+ googleDiscoveryPreset({
207+ id: "google-calendar",
208+ name: "Google Calendar",
209+ summary: "Calendars, events, ACLs, and scheduling workflows.",
210+ service: "calendar",
211+ version: "v3",
212+ discoveryUrl: "https://calendar-json.googleapis.com/$discovery/rest?version=v3",
213+ }),
214+ googleDiscoveryPreset({
215+ id: "google-drive",
216+ name: "Google Drive",
217+ summary: "Files, folders, permissions, comments, and shared drives.",
218+ service: "drive",
219+ version: "v3",
220+ discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest",
221+ }),
222+ googleDiscoveryPreset({
223+ id: "google-gmail",
224+ name: "Gmail",
225+ summary: "Messages, threads, labels, drafts, and mailbox automation.",
226+ service: "gmail",
227+ version: "v1",
228+ discoveryUrl: "https://gmail.googleapis.com/$discovery/rest?version=v1",
229+ }),
230+ googleDiscoveryPreset({
231+ id: "google-docs",
232+ name: "Google Docs",
233+ summary: "Documents, structural edits, text ranges, and formatting.",
234+ service: "docs",
235+ version: "v1",
236+ discoveryUrl: "https://docs.googleapis.com/$discovery/rest?version=v1",
237+ }),
238+ googleDiscoveryPreset({
239+ id: "google-sheets",
240+ name: "Google Sheets",
241+ summary: "Spreadsheets, values, ranges, formatting, and batch updates.",
242+ service: "sheets",
243+ version: "v4",
244+ discoveryUrl: "https://sheets.googleapis.com/$discovery/rest?version=v4",
245+ }),
246+ googleDiscoveryPreset({
247+ id: "google-slides",
248+ name: "Google Slides",
249+ summary: "Presentations, slides, page elements, and deck updates.",
250+ service: "slides",
251+ version: "v1",
252+ discoveryUrl: "https://slides.googleapis.com/$discovery/rest?version=v1",
253+ }),
254+ googleDiscoveryPreset({
255+ id: "google-forms",
256+ name: "Google Forms",
257+ summary: "Forms, questions, responses, quizzes, and form metadata.",
258+ service: "forms",
259+ version: "v1",
260+ discoveryUrl: "https://forms.googleapis.com/$discovery/rest?version=v1",
261+ }),
262+ googleDiscoveryPreset({
263+ id: "google-search-console",
264+ name: "Google Search Console",
265+ summary: "Sites, sitemaps, URL inspection, and search and Discover performance.",
266+ service: "searchconsole",
267+ version: "v1",
268+ discoveryUrl: "https://searchconsole.googleapis.com/$discovery/rest?version=v1",
269+ }),
270+ googleDiscoveryPreset({
271+ id: "google-people",
272+ name: "Google People",
273+ summary: "Contacts, profiles, directory people, and contact groups.",
274+ service: "people",
275+ version: "v1",
276+ discoveryUrl: "https://people.googleapis.com/$discovery/rest?version=v1",
277+ }),
278+ googleDiscoveryPreset({
279+ id: "google-tasks",
280+ name: "Google Tasks",
281+ summary: "Task lists, task items, notes, and due dates.",
282+ service: "tasks",
283+ version: "v1",
284+ discoveryUrl: "https://tasks.googleapis.com/$discovery/rest?version=v1",
285+ }),
286+ googleDiscoveryPreset({
287+ id: "google-chat",
288+ name: "Google Chat",
289+ summary: "Spaces, messages, members, reactions, and chat workflows.",
290+ service: "chat",
291+ version: "v1",
292+ discoveryUrl: "https://chat.googleapis.com/$discovery/rest?version=v1",
293+ }),
294+ googleDiscoveryPreset({
295+ id: "google-keep",
296+ name: "Google Keep",
297+ summary: "Notes, lists, attachments, and collaborative annotations.",
298+ service: "keep",
299+ version: "v1",
300+ discoveryUrl: "https://keep.googleapis.com/$discovery/rest?version=v1",
301+ }),
302+ googleDiscoveryPreset({
303+ id: "google-classroom",
304+ name: "Google Classroom",
305+ summary: "Courses, rosters, coursework, submissions, and grading data.",
306+ service: "classroom",
307+ version: "v1",
308+ discoveryUrl: "https://classroom.googleapis.com/$discovery/rest?version=v1",
309+ }),
310+ googleDiscoveryPreset({
311+ id: "google-admin-directory",
312+ name: "Google Admin Directory",
313+ summary: "Users, groups, org units, roles, and domain directory resources.",
314+ service: "admin",
315+ version: "directory_v1",
316+ discoveryUrl: "https://admin.googleapis.com/$discovery/rest?version=directory_v1",
317+ }),
318+ googleDiscoveryPreset({
319+ id: "google-admin-reports",
320+ name: "Google Admin Reports",
321+ summary: "Audit events, usage reports, and admin activity logs.",
322+ service: "admin",
323+ version: "reports_v1",
324+ discoveryUrl: "https://admin.googleapis.com/$discovery/rest?version=reports_v1",
325+ }),
326+ googleDiscoveryPreset({
327+ id: "google-apps-script",
328+ name: "Google Apps Script",
329+ summary: "Projects, deployments, script execution, and Apps Script metadata.",
330+ service: "script",
331+ version: "v1",
332+ discoveryUrl: "https://script.googleapis.com/$discovery/rest?version=v1",
333+ }),
334+ googleDiscoveryPreset({
335+ id: "google-bigquery",
336+ name: "Google BigQuery",
337+ summary: "Datasets, tables, jobs, routines, and analytical query workflows.",
338+ service: "bigquery",
339+ version: "v2",
340+ discoveryUrl: "https://bigquery.googleapis.com/$discovery/rest?version=v2",
341+ }),
342+ googleDiscoveryPreset({
343+ id: "google-cloud-resource-manager",
344+ name: "Google Cloud Resource Manager",
345+ summary: "Projects, folders, organizations, and IAM-oriented resource hierarchy.",
346+ service: "cloudresourcemanager",
347+ version: "v3",
348+ discoveryUrl: "https://cloudresourcemanager.googleapis.com/$discovery/rest?version=v3",
349+ }),
350+ googleDiscoveryPreset({
351+ id: "google-youtube-data",
352+ name: "YouTube Data",
353+ summary: "Channels, playlists, videos, comments, captions, and uploads.",
354+ service: "youtube",
355+ version: "v3",
356+ discoveryUrl: "https://youtube.googleapis.com/$discovery/rest?version=v3",
357+ }),
358+] as const;
359+
360+export const buildSourcePresetSearch = (
361+ preset: SourcePreset,
362+): Record<string, string> => {
363+ switch (preset.pluginKey) {
364+ case "openapi":
365+ return {
366+ preset: preset.id,
367+ presetName: preset.name,
368+ presetBaseUrl: preset.baseUrl,
369+ presetSpecUrl: preset.specUrl,
370+ };
371+ case "graphql":
372+ return {
373+ preset: preset.id,
374+ presetName: preset.name,
375+ presetEndpoint: preset.endpoint,
376+ };
377+ case "mcp":
378+ return {
379+ preset: preset.id,
380+ presetName: preset.name,
381+ ...(preset.endpoint ? { presetEndpoint: preset.endpoint } : {}),
382+ ...(preset.transport ? { presetTransport: preset.transport } : {}),
383+ ...(preset.command ? { presetCommand: preset.command } : {}),
384+ ...(preset.args ? { presetArgs: JSON.stringify(preset.args) } : {}),
385+ ...(preset.env ? { presetEnv: JSON.stringify(preset.env) } : {}),
386+ ...(preset.cwd ? { presetCwd: preset.cwd } : {}),
387+ };
388+ default:
389+ return {
390+ preset: preset.id,
391+ presetName: preset.name,
392+ presetService: preset.service,
393+ presetVersion: preset.version,
394+ presetDiscoveryUrl: preset.discoveryUrl,
395+ };
396+ }
397+};
398+
399+const normalizeComparableUrl = (value: string): string => {
400+ const trimmed = value.trim();
401+ try {
402+ const url = new URL(trimmed);
403+ if (url.pathname.length > 1 && url.pathname.endsWith("/")) {
404+ url.pathname = url.pathname.slice(0, -1);
405+ }
406+ return url.toString();
407+ } catch {
408+ return trimmed;
409+ }
410+};
411+
412+const tryParseUrl = (value: string): URL | null => {
413+ try {
414+ return new URL(value.trim());
415+ } catch {
416+ return null;
417+ }
418+};
419+
420+const sourceNameFromUrl = (url: URL, fallback: string): string => {
421+ const hostname = url.hostname.replace(/^www\./, "");
422+ if (hostname.length === 0) {
423+ return fallback;
424+ }
425+
426+ const head = hostname.split(".")[0]?.trim();
427+ if (!head) {
428+ return fallback;
429+ }
430+
431+ return `${head.charAt(0).toUpperCase()}${head.slice(1)} ${fallback}`;
432+};
433+
434+const parseGoogleDiscoveryUrl = (value: string): {
435+ service: string;
436+ version: string;
437+ discoveryUrl: string;
438+} | null => {
439+ const url = tryParseUrl(value);
440+ if (!url) {
441+ return null;
442+ }
443+
444+ const byDirectory = url.pathname.match(
445+ /^\/discovery\/v1\/apis\/([^/]+)\/([^/]+)\/rest$/,
446+ );
447+ if (byDirectory) {
448+ return {
449+ service: decodeURIComponent(byDirectory[1] ?? ""),
450+ version: decodeURIComponent(byDirectory[2] ?? ""),
451+ discoveryUrl: url.toString(),
452+ };
453+ }
454+
455+ const versionParam = url.searchParams.get("version")?.trim();
456+ const isHostScopedDiscovery =
457+ url.pathname === "/$discovery/rest"
458+ && url.hostname.endsWith(".googleapis.com")
459+ && url.hostname !== "www.googleapis.com";
460+
461+ if (versionParam && isHostScopedDiscovery) {
462+ const service = url.hostname.split(".")[0]?.trim();
463+ if (!service) {
464+ return null;
465+ }
466+
467+ return {
468+ service,
469+ version: versionParam,
470+ discoveryUrl: url.toString(),
471+ };
472+ }
473+
474+ return null;
475+};
476+
477+const isGraphqlUrl = (url: URL): boolean => {
478+ const path = url.pathname.toLowerCase();
479+ const hostname = url.hostname.toLowerCase();
480+ return (
481+ path.endsWith("/graphql")
482+ || path === "/graphql"
483+ || hostname.startsWith("graphql.")
484+ || hostname.includes("graphql")
485+ );
486+};
487+
488+const isMcpUrl = (url: URL): boolean => {
489+ const path = url.pathname.toLowerCase();
490+ const hostname = url.hostname.toLowerCase();
491+ return path.endsWith("/mcp") || path.includes("/mcp/") || hostname.startsWith("mcp.");
492+};
493+
494+const isLikelyOpenApiSpecUrl = (url: URL): boolean => {
495+ const path = url.pathname.toLowerCase();
496+ return (
497+ path.endsWith(".json")
498+ || path.endsWith(".yaml")
499+ || path.endsWith(".yml")
500+ || path.includes("openapi")
501+ || path.includes("swagger")
502+ );
503+};
504+
505+const findPresetByInput = (value: string): SourcePreset | null => {
506+ const comparable = normalizeComparableUrl(value);
507+ for (const preset of sourcePresets) {
508+ if (normalizeComparableUrl(preset.previewUrl) === comparable) {
509+ return preset;
510+ }
511+
512+ if (preset.pluginKey === "openapi") {
513+ if (normalizeComparableUrl(preset.baseUrl) === comparable) {
514+ return preset;
515+ }
516+ if (normalizeComparableUrl(preset.specUrl) === comparable) {
517+ return preset;
518+ }
519+ continue;
520+ }
521+
522+ if (preset.pluginKey === "graphql" || preset.pluginKey === "mcp") {
523+ if (preset.endpoint && normalizeComparableUrl(preset.endpoint) === comparable) {
524+ return preset;
525+ }
526+ continue;
527+ }
528+
529+ if (normalizeComparableUrl(preset.discoveryUrl) === comparable) {
530+ return preset;
531+ }
532+ }
533+
534+ return null;
535+};
536+
537+export type ResolvedQuickSourceInput = {
538+ pluginKey: SourcePreset["pluginKey"];
539+ search: Record<string, string>;
540+};
541+
542+export const resolveQuickSourceInput = (
543+ rawValue: string,
544+): ResolvedQuickSourceInput | null => {
545+ const trimmed = rawValue.trim();
546+ if (trimmed.length === 0) {
547+ return null;
548+ }
549+
550+ const commandParts =
551+ trimmed.match(/[^\s"]+|"([^"]*)"/g)?.map((part) =>
552+ part.startsWith("\"") && part.endsWith("\"")
553+ ? part.slice(1, -1)
554+ : part
555+ ) ?? null;
556+ if (commandParts && commandParts.length > 0) {
557+ const [command, ...args] = commandParts;
558+ const isKnownCommand =
559+ command === "npx"
560+ || command === "bunx"
561+ || command === "uvx"
562+ || (command === "pnpm" && args[0] === "dlx");
563+
564+ if (isKnownCommand) {
565+ return {
566+ pluginKey: "mcp",
567+ search: {
568+ presetName: "Local MCP",
569+ presetTransport: "stdio",
570+ presetCommand: command,
571+ ...(args.length > 0 ? { presetArgs: JSON.stringify(args) } : {}),
572+ quickInput: trimmed,
573+ },
574+ };
575+ }
576+ }
577+
578+ const preset = findPresetByInput(trimmed);
579+ if (preset) {
580+ return {
581+ pluginKey: preset.pluginKey,
582+ search: buildSourcePresetSearch(preset),
583+ };
584+ }
585+
586+ const googleDiscovery = parseGoogleDiscoveryUrl(trimmed);
587+ if (googleDiscovery) {
588+ const googlePreset = sourcePresets.find(
589+ (presetEntry) =>
590+ presetEntry.pluginKey === GOOGLE_DISCOVERY_PLUGIN_KEY
591+ && presetEntry.service === googleDiscovery.service
592+ && presetEntry.version === googleDiscovery.version,
593+ );
594+
595+ return {
596+ pluginKey: GOOGLE_DISCOVERY_PLUGIN_KEY,
597+ search: {
598+ ...(googlePreset ? buildSourcePresetSearch(googlePreset) : {}),
599+ presetName:
600+ googlePreset?.name
601+ ?? `Google ${googleDiscovery.service} ${googleDiscovery.version}`,
602+ presetService: googleDiscovery.service,
603+ presetVersion: googleDiscovery.version,
604+ presetDiscoveryUrl: googleDiscovery.discoveryUrl,
605+ quickInput: trimmed,
606+ },
607+ };
608+ }
609+
610+ const url = tryParseUrl(trimmed);
611+ if (!url) {
612+ return null;
613+ }
614+
615+ if (isGraphqlUrl(url)) {
616+ return {
617+ pluginKey: "graphql",
618+ search: {
619+ presetName: sourceNameFromUrl(url, "GraphQL"),
620+ presetEndpoint: trimmed,
621+ quickInput: trimmed,
622+ },
623+ };
624+ }
625+
626+ if (isMcpUrl(url)) {
627+ return {
628+ pluginKey: "mcp",
629+ search: {
630+ presetName: sourceNameFromUrl(url, "MCP"),
631+ presetEndpoint: trimmed,
632+ presetTransport: "auto",
633+ quickInput: trimmed,
634+ },
635+ };
636+ }
637+
638+ if (isLikelyOpenApiSpecUrl(url)) {
639+ return {
640+ pluginKey: "openapi",
641+ search: {
642+ presetName: sourceNameFromUrl(url, "OpenAPI"),
643+ presetSpecUrl: trimmed,
644+ quickInput: trimmed,
645+ },
646+ };
647+ }
648+
649+ return {
650+ pluginKey: "openapi",
651+ search: {
652+ presetName: sourceNameFromUrl(url, "OpenAPI"),
653+ presetBaseUrl: trimmed,
654+ quickInput: trimmed,
655+ },
656+ };
657+};
apps/web/src/views/add-source.tsx+0−2534
@@ -1,2534 +0,0 @@
1−import { useEffect, useState, type ReactNode } from "react";
2−import { Link, useNavigate } from "@tanstack/react-router";
3−import {
4− type CompleteSourceOAuthResult,
5− type ConnectSourceBatchPayload,
6− type ConnectSourcePayload,
7− type ConnectSourceResult,
8− type CreateWorkspaceOauthClientPayload,
9− type DiscoverSourcePayload,
10− type InstanceConfig,
11− type Loadable,
12− type SecretListItem,
13− type Source,
14− type SourceDiscoveryResult,
15− type WorkspaceOauthClient,
16− useConnectSourceBatch,
17− useConnectSource,
18− useCreateWorkspaceOauthClient,
19− useCreateSecret,
20− useDiscoverSource,
21− useInvalidateExecutorQueries,
22− useInstanceConfig,
23− useRefreshSecrets,
24− useSecrets,
25− useWorkspaceOauthClients,
26−} from "@executor/react";
27−import { Badge } from "../components/ui/badge";
28−import { Button } from "../components/ui/button";
29−import { LocalMcpInstallCard } from "../components/local-mcp-install-card";
30−import { SourceFavicon } from "../components/source-favicon";
31−import {
32− IconArrowLeft,
33− IconCheck,
34− IconDiscover,
35− IconPlus,
36− IconSpinner,
37−} from "../components/icons";
38−import { cn } from "../lib/utils";
39−import {
40− isStdioMcpSourceTemplate,
41− sourceTemplates,
42− type SourceTemplate,
43−} from "./source-templates";
44−import {
45− asMcpRemoteTransportValue,
46− defaultMcpRemoteTransportFields,
47− defaultMcpStdioTransportFields,
48− setMcpTransportFieldsTransport,
49− type McpRemoteTransportFields,
50− type McpStdioTransportFields,
51− type McpTransportFields,
52− type McpTransportValue,
53−} from "./mcp-transport-state";
54−import { parseJsonStringArray, parseJsonStringMap } from "./json-form";
55−import { getDomain } from "tldts";
56−
57−// ---------------------------------------------------------------------------
58−// State machine
59−// ---------------------------------------------------------------------------
60−
61−type FlowPhase =
62− | "idle"
63− | "discovering"
64− | "editing"
65− | "connecting"
66− | "connected"
67− | "credential_required"
68− | "oauth_required";
69−
70−type ProbeAuthKind = "none" | "bearer" | "basic" | "headers";
71−
72−type ProbeAuthState = {
73− kind: ProbeAuthKind;
74− token: string;
75− headerName: string;
76− prefix: string;
77− username: string;
78− password: string;
79− headersText: string;
80−};
81−
82−type ConnectFormBase = {
83− kind: "mcp" | "openapi" | "graphql" | "google_discovery";
84− endpoint: string;
85− specUrl: string;
86− service: string;
87− version: string;
88− discoveryUrl: string;
89− name: string;
90− namespace: string;
91− authKind: "none" | "bearer" | "oauth2";
92− authHeaderName: string;
93− authPrefix: string;
94− bearerToken: string;
95− bearerProviderId: string;
96− bearerHandle: string;
97− workspaceOauthClientId: string;
98− oauthClientId: string;
99− oauthClientSecret: string;
100−};
101−
102−type ConnectFormState = ConnectFormBase & McpTransportFields;
103−
104−type OAuthRequiredInfo = {
105− source: Source;
106− sessionId: string;
107− authorizationUrl: string;
108−};
109−
110−type BatchOAuthRequiredInfo = {
111− sessionId: string;
112− authorizationUrl: string;
113− sourceIds: ReadonlyArray<string>;
114−};
115−
116−// ---------------------------------------------------------------------------
117−// Constants
118−// ---------------------------------------------------------------------------
119−
120−const kindOptions: ReadonlyArray<ConnectFormState["kind"]> = [
121− "mcp",
122− "openapi",
123− "graphql",
124− "google_discovery",
125−];
126−
127−const transportOptions: ReadonlyArray<Exclude<McpTransportValue, "">> = [
128− "auto",
129− "streamable-http",
130− "sse",
131− "stdio",
132−];
133−
134−const authOptions: ReadonlyArray<ConnectFormState["authKind"]> = [
135− "none",
136− "bearer",
137− "oauth2",
138−];
139−
140−const probeAuthOptions: ReadonlyArray<ProbeAuthKind> = [
141− "none",
142− "bearer",
143− "basic",
144− "headers",
145−];
146−
147−const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000;
148−const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";
149−
150−// ---------------------------------------------------------------------------
151−// Helpers
152−// ---------------------------------------------------------------------------
153−
154−const trimToNull = (value: string): string | null => {
155− const trimmed = value.trim();
156− return trimmed.length > 0 ? trimmed : null;
157−};
158−
159−const namespaceFromUrl = (url: string): string => {
160− try {
161− const domain = getDomain(url);
162− if (!domain) return "";
163− // Strip the TLD: "github.com" -> "github", "linear.app" -> "linear"
164− const dot = domain.indexOf(".");
165− return dot > 0 ? domain.slice(0, dot) : domain;
166− } catch {
167− return "";
168− }
169−};
170−
171−const googleDiscoveryNamespace = (service: string): string =>
172− `google.${service
173− .trim()
174− .toLowerCase()
175− .replace(/[^a-z0-9]+/g, ".")
176− .replace(/^\.+|\.+$/g, "")}`;
177−
178−const googleDiscoveryDefaultsFromUrl = (
179− value: string,
180−): {
181− service: string;
182− version: string;
183− discoveryUrl: string;
184−} | null => {
185− try {
186− const url = new URL(value);
187− const byDirectory = url.pathname.match(
188− /^\/discovery\/v1\/apis\/([^/]+)\/([^/]+)\/rest$/,
189− );
190− if (byDirectory) {
191− return {
192− service: decodeURIComponent(byDirectory[1] ?? ""),
193− version: decodeURIComponent(byDirectory[2] ?? ""),
194− discoveryUrl: url.toString(),
195− };
196− }
197−
198− const versionParam = url.searchParams.get("version");
199− const version = versionParam ? trimToNull(versionParam) : null;
200− const isHostScopedDiscovery =
201− url.pathname === "/$discovery/rest" &&
202− url.hostname.endsWith(".googleapis.com") &&
203− url.hostname !== "www.googleapis.com";
204− if (version && isHostScopedDiscovery) {
205− return {
206− service: url.hostname.split(".")[0] ?? "",
207− version,
208− discoveryUrl: url.toString(),
209− };
210− }
211−
212− return null;
213− } catch {
214− return null;
215− }
216−};
217−
218−const stringifyStringMap = (
219− value: Readonly<Record<string, string>> | null | undefined,
220−): string =>
221− !value || Object.keys(value).length === 0
222− ? ""
223− : JSON.stringify(value, null, 2);
224−
225−const stringifyStringArray = (
226− value: ReadonlyArray<string> | null | undefined,
227−): string =>
228− !value || value.length === 0 ? "" : JSON.stringify(value, null, 2);
229−
230−const buildSyntheticMcpStdioEndpoint = (input: {
231− name?: string | null;
232− endpoint?: string | null;
233− command?: string | null;
234−}): string => {
235− const label =
236− input.name?.trim() ||
237− input.endpoint?.trim() ||
238− input.command?.trim() ||
239− "mcp";
240− const slug = label
241− .toLowerCase()
242− .replace(/[^a-z0-9]+/g, "-")
243− .replace(/^-+|-+$/g, "");
244−
245− return `stdio://local/${slug || "mcp"}`;
246−};
247−
248−// ---------------------------------------------------------------------------
249−// Derived defaults from discovery result
250−// ---------------------------------------------------------------------------
251−
252−const defaultConnectForm = (
253− discovery?: SourceDiscoveryResult,
254−): ConnectFormState => {
255− if (!discovery || discovery.detectedKind === "unknown") {
256− return {
257− kind: "openapi",
258− endpoint: discovery?.endpoint ?? "",
259− specUrl: discovery?.specUrl ?? "",
260− service: "",
261− version: "",
262− discoveryUrl: "",
263− name: discovery?.name ?? "",
264− namespace:
265− discovery?.namespace || namespaceFromUrl(discovery?.endpoint ?? ""),
266− authKind: "none",
267− authHeaderName: "Authorization",
268− authPrefix: "Bearer ",
269− bearerToken: "",
270− bearerProviderId: "",
271− bearerHandle: "",
272− workspaceOauthClientId: "",
273− oauthClientId: "",
274− oauthClientSecret: "",
275− ...defaultMcpRemoteTransportFields(),
276− };
277− }
278−
279− const kind = discovery.detectedKind as ConnectFormState["kind"];
280− const auth = discovery.authInference;
281−
282− // Map auth suggestion to what backend connect supports
283− let authKind: ConnectFormState["authKind"] = "none";
284− let authHeaderName = "Authorization";
285− let authPrefix = "Bearer ";
286−
287− if (auth.supported) {
288− if (auth.suggestedKind === "bearer" || auth.suggestedKind === "apiKey") {
289− authKind = "bearer";
290− authHeaderName = auth.headerName ?? "Authorization";
291− authPrefix = auth.prefix ?? "Bearer ";
292− } else if (auth.suggestedKind === "oauth2") {
293− authKind = "oauth2";
294− authHeaderName = auth.headerName ?? "Authorization";
295− authPrefix = auth.prefix ?? "Bearer ";
296− } else if (auth.suggestedKind === "basic") {
297− // Backend connect doesn't support basic auth natively; map to bearer
298− authKind = "bearer";
299− authHeaderName = "Authorization";
300− authPrefix = "Basic ";
301− }
302− }
303−
304− const googleDiscoveryDefaults =
305− kind === "google_discovery"
306− ? googleDiscoveryDefaultsFromUrl(discovery.specUrl ?? discovery.endpoint)
307− : null;
308−
309− return {
310− kind,
311− endpoint: discovery.endpoint,
312− specUrl: discovery.specUrl ?? "",
313− service: googleDiscoveryDefaults?.service ?? "",
314− version: googleDiscoveryDefaults?.version ?? "",
315− discoveryUrl: googleDiscoveryDefaults?.discoveryUrl ?? "",
316− name: discovery.name ?? "",
317− namespace: discovery.namespace || namespaceFromUrl(discovery.endpoint),
318− authKind,
319− authHeaderName,
320− authPrefix,
321− bearerToken: "",
322− bearerProviderId: "",
323− bearerHandle: "",
324− workspaceOauthClientId: "",
325− oauthClientId: "",
326− oauthClientSecret: "",
327− ...defaultMcpRemoteTransportFields(
328− kind === "mcp" ? asMcpRemoteTransportValue(discovery.transport) : "",
329− ),
330− };
331−};
332−
333−const connectFormFromTemplate = (
334− template: SourceTemplate,
335−): ConnectFormState => ({
336− ...defaultConnectForm(),
337− kind: template.kind as ConnectFormState["kind"],
338− endpoint: template.endpoint ?? "",
339− specUrl: "specUrl" in template ? template.specUrl : "",
340− service: "service" in template ? template.service : "",
341− version: "version" in template ? template.version : "",
342− discoveryUrl: "discoveryUrl" in template ? template.discoveryUrl : "",
343− name: template.name,
344− namespace:
345− template.namespace ??
346− ("service" in template
347− ? googleDiscoveryNamespace(template.service)
348− : namespaceFromUrl(template.endpoint ?? "")),
349− authKind: template.kind === "google_discovery" ? "oauth2" : "none",
350− workspaceOauthClientId: "",
351− ...(template.kind === "mcp" && template.connectionType === "command"
352− ? defaultMcpStdioTransportFields({
353− command: template.command ?? "",
354− argsText: stringifyStringArray(template.args),
355− envText: stringifyStringMap(template.env),
356− cwd: template.cwd ?? "",
357− })
358− : defaultMcpRemoteTransportFields(
359− template.kind === "mcp"
360− ? asMcpRemoteTransportValue(template.transport)
361− : "",
362− )),
363−});
364−
365−const authKindForSourceKind = (
366− currentAuthKind: ConnectFormState["authKind"],
367− nextKind: ConnectFormState["kind"],
368−): ConnectFormState["authKind"] =>
369− nextKind === "google_discovery" && currentAuthKind === "none"
370− ? "oauth2"
371− : currentAuthKind;
372−
373−const buildProbeAuth = (
374− state: ProbeAuthState,
375−): DiscoverSourcePayload["probeAuth"] => {
376− if (state.kind === "none") return { kind: "none" };
377− if (state.kind === "bearer") {
378− if (!state.token.trim())
379− throw new Error("Token is required for bearer probe auth.");
380− return {
381− kind: "bearer",
382− headerName: trimToNull(state.headerName),
383− prefix: trimToNull(state.prefix),
384− token: state.token.trim(),
385− };
386− }
387− if (state.kind === "basic") {
388− if (!state.username.trim())
389− throw new Error("Username is required for basic probe auth.");
390− return {
391− kind: "basic",
392− username: state.username.trim(),
393− password: state.password,
394− };
395− }
396− // headers
397− const headers = parseJsonStringMap("Probe headers", state.headersText);
398− if (!headers)
399− throw new Error("At least one header is required for headers probe auth.");
400− return { kind: "headers", headers };
401−};
402−
403−const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => {
404− if (form.kind === "mcp") {
405− if (form.transport === "stdio") {
406− const endpoint = buildSyntheticMcpStdioEndpoint({
407− name: form.name,
408− endpoint: form.endpoint,
409− command: form.command,
410− });
411− if (!form.command.trim()) {
412− throw new Error("Command is required for stdio MCP sources.");
413− }
414− return {
415− kind: "mcp",
416− endpoint,
417− name: trimToNull(form.name),
418− namespace: trimToNull(form.namespace),
419− transport: "stdio",
420− queryParams: null,
421− headers: null,
422− command: form.command.trim(),
423− args: parseJsonStringArray("Args", form.argsText),
424− env: parseJsonStringMap("Environment", form.envText),
425− cwd: trimToNull(form.cwd),
426− };
427− }
428−
429− const endpoint = form.endpoint.trim();
430− if (!endpoint) throw new Error("Endpoint is required.");
431− return {
432− kind: "mcp",
433− endpoint,
434− name: trimToNull(form.name),
435− namespace: trimToNull(form.namespace),
436− transport: form.transport === "" ? "auto" : form.transport,
437− queryParams: parseJsonStringMap("Query params", form.queryParamsText),
438− headers: parseJsonStringMap("Request headers", form.headersText),
439− command: null,
440− args: null,
441− env: null,
442− cwd: null,
443− };
444− }
445−
446− // Build HTTP auth for openapi/graphql
447− const auth = buildHttpAuth(form);
448−
449− if (form.kind === "openapi") {
450− const endpoint = form.endpoint.trim();
451− if (!endpoint) throw new Error("Endpoint is required.");
452− const specUrl = form.specUrl.trim();
453− if (!specUrl) throw new Error("OpenAPI sources require a spec URL.");
454− return {
455− kind: "openapi",
456− endpoint,
457− specUrl,
458− name: trimToNull(form.name),
459− namespace: trimToNull(form.namespace),
460− auth,
461− };
462− }
463−
464− if (form.kind === "google_discovery") {
465− const service = form.service.trim();
466− const version = form.version.trim();
467− if (!service)
468− throw new Error("Google Discovery sources require a service name.");
469− if (!version)
470− throw new Error("Google Discovery sources require a version.");
471− if (
472− form.authKind === "oauth2" &&
473− form.workspaceOauthClientId.trim().length === 0 &&
474− form.oauthClientId.trim().length === 0
475− ) {
476− throw new Error(
477− "Google OAuth requires a workspace OAuth client or a new client ID.",
478− );
479− }
480− return {
481− kind: "google_discovery",
482− service,
483− version,
484− discoveryUrl: trimToNull(form.discoveryUrl),
485− name: trimToNull(form.name),
486− namespace: trimToNull(form.namespace),
487− scopeOauthClientId:
488− form.authKind === "oauth2" &&
489− form.workspaceOauthClientId.trim().length > 0
490− ? form.workspaceOauthClientId.trim()
491− : undefined,
492− oauthClient:
493− form.authKind === "oauth2" &&
494− form.workspaceOauthClientId.trim().length === 0
495− ? {
496− clientId: form.oauthClientId.trim(),
497− clientSecret: trimToNull(form.oauthClientSecret),
498− }
499− : null,
500− auth,
501− };
502− }
503−
504− const endpoint = form.endpoint.trim();
505− if (!endpoint) throw new Error("Endpoint is required.");
506− return {
507− kind: "graphql",
508− endpoint,
509− name: trimToNull(form.name),
510− namespace: trimToNull(form.namespace),
511− auth,
512− };
513−};
514−
515−const buildHttpAuth = (
516− form: ConnectFormState,
517−):
518− | { kind: "none" }
519− | {
520− kind: "bearer";
521− headerName?: string | null;
522− prefix?: string | null;
523− token?: string | null;
524− tokenRef?: { providerId: string; handle: string } | null;
525− }
526− | undefined => {
527− if (form.authKind === "none") return { kind: "none" };
528−
529− if (form.authKind === "bearer") {
530− const headerName = trimToNull(form.authHeaderName);
531− const prefix = form.authPrefix.length === 0 ? null : form.authPrefix;
532−
533− // Prefer secret ref if set
534− if (form.bearerProviderId.trim() && form.bearerHandle.trim()) {
535− return {
536− kind: "bearer",
537− headerName,
538− prefix,
539− tokenRef: {
540− providerId: form.bearerProviderId.trim(),
541− handle: form.bearerHandle.trim(),
542− },
543− };
544− }
545−
546− // Fall back to inline token
547− if (form.bearerToken.trim()) {
548− return {
549− kind: "bearer",
550− headerName,
551− prefix,
552− token: form.bearerToken.trim(),
553− };
554− }
555−
556− throw new Error(
557− "Bearer auth requires a token. Select or create a secret, or enter a token directly.",
558− );
559− }
560−
561− // oauth2 is handled via the connect result flow, not pre-filled
562− return undefined;
563−};
564−
565−// ---------------------------------------------------------------------------
566−// OAuth popup helpers (shared with source-editor.tsx)
567−// ---------------------------------------------------------------------------
568−
569−type SourceOAuthPopupMessage =
570− | {
571− type: "executor:oauth-result";
572− ok: true;
573− sessionId: string;
574− auth: CompleteSourceOAuthResult["auth"];
575− }
576− | {
577− type: "executor:oauth-result";
578− ok: false;
579− sessionId: string | null;
580− error: string;
581− }
582− | {
583− type: "executor:source-oauth-result";
584− ok: true;
585− sourceId: string;
586− }
587− | {
588− type: "executor:source-oauth-result";
589− ok: false;
590− error: string;
591− };
592−
593−const readStoredSourceOAuthPopupResult = (
594− sessionId: string,
595−): SourceOAuthPopupMessage | null => {
596− if (typeof window === "undefined") return null;
597− const raw = window.localStorage.getItem(
598− `${SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX}${sessionId}`,
599− );
600− if (!raw) return null;
601− try {
602− return JSON.parse(raw) as SourceOAuthPopupMessage;
603− } catch {
604− return null;
605− }
606−};
607−
608−const clearStoredSourceOAuthPopupResult = (sessionId: string): void => {
609− if (typeof window === "undefined") return;
610− window.localStorage.removeItem(
611− `${SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX}${sessionId}`,
612− );
613−};
614−
615−const startSourceOAuthPopup = async (input: {
616− authorizationUrl: string;
617− sessionId: string;
618−}): Promise<void> => {
619− if (typeof window === "undefined") {
620− throw new Error("OAuth popup is only available in a browser context");
621− }
622−
623− clearStoredSourceOAuthPopupResult(input.sessionId);
624−
625− const popup = window.open(
626− input.authorizationUrl,
627− "executor-source-oauth",
628− "popup=yes,width=520,height=720",
629− );
630−
631− if (!popup) {
632− throw new Error("Popup blocked. Allow popups and try again.");
633− }
634−
635− popup.focus();
636−
637− return await new Promise<void>((resolve, reject) => {
638− let settled = false;
639− let closedPoll = 0;
640− let resultTimeout = 0;
641−
642− const cleanup = () => {
643− window.removeEventListener("message", onMessage);
644− if (closedPoll) window.clearInterval(closedPoll);
645− if (resultTimeout) window.clearTimeout(resultTimeout);
646− if (!popup.closed) popup.close();
647− clearStoredSourceOAuthPopupResult(input.sessionId);
648− };
649−
650− const settleWithError = (message: string) => {
651− if (settled) return;
652− settled = true;
653− cleanup();
654− reject(new Error(message));
655− };
656−
657− const settleFromPayload = (data: SourceOAuthPopupMessage) => {
658− if (!data.ok) {
659− settleWithError(data.error || "OAuth failed");
660− return;
661− }
662− if (settled) return;
663− settled = true;
664− cleanup();
665− resolve();
666− };
667−
668− const onMessage = (event: MessageEvent) => {
669− if (event.origin !== window.location.origin) return;
670− const data = event.data as SourceOAuthPopupMessage | undefined;
671− if (!data) return;
672− if (data.type === "executor:oauth-result") {
673− if (data.ok && data.sessionId !== input.sessionId) return;
674− if (
675− !data.ok &&
676− data.sessionId !== null &&
677− data.sessionId !== input.sessionId
678− )
679− return;
680− } else if (data.type !== "executor:source-oauth-result") {
681− return;
682− }
683− settleFromPayload(data);
684− };
685−
686− window.addEventListener("message", onMessage);
687−
688− resultTimeout = window.setTimeout(() => {
689− settleWithError(
690− "OAuth popup timed out before completion. Please try again.",
691− );
692− }, SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS);
693−
694− closedPoll = window.setInterval(() => {
695− const stored = readStoredSourceOAuthPopupResult(input.sessionId);
696− if (stored) {
697− settleFromPayload(stored);
698− return;
699− }
700− if (popup.closed) {
701− // Stop polling — only run one final deferred check to give the
702− // callback page time to write localStorage before we give up.
703− window.clearInterval(closedPoll);
704− closedPoll = 0;
705− window.setTimeout(() => {
706− const delayedStored = readStoredSourceOAuthPopupResult(
707− input.sessionId,
708− );
709− if (delayedStored) {
710− settleFromPayload(delayedStored);
711− return;
712− }
713− settleWithError("OAuth popup was closed before completion.");
714− }, 1500);
715− }
716− }, 300);
717− });
718−};
719−
720−// ---------------------------------------------------------------------------
721−// Confidence badge helper
722−// ---------------------------------------------------------------------------
723−
724−// ---------------------------------------------------------------------------
725−// Page
726−// ---------------------------------------------------------------------------
727−
728−export function AddSourcePage() {
729− const navigate = useNavigate();
730− const discoverSource = useDiscoverSource();
731− const connectSource = useConnectSource();
732− const connectSourceBatch = useConnectSourceBatch();
733− const createWorkspaceOauthClient = useCreateWorkspaceOauthClient();
734− const workspaceOauthClients = useWorkspaceOauthClients("google_workspace");
735− const instanceConfig = useInstanceConfig();
736− const invalidateExecutorQueries = useInvalidateExecutorQueries();
737− const secrets = useSecrets();
738− const refreshSecrets = useRefreshSecrets();
739−
740− // URL input
741− const [url, setUrl] = useState("");
742−
743− // Probe auth
744− const [showProbeAuth, setShowProbeAuth] = useState(false);
745− const [probeAuth, setProbeAuth] = useState<ProbeAuthState>({
746− kind: "none",
747− token: "",
748− headerName: "Authorization",
749− prefix: "Bearer ",
750− username: "",
751− password: "",
752− headersText: "",
753− });
754−
755− // Phase
756− const [phase, setPhase] = useState<FlowPhase>("idle");
757−
758− // Discovery result
759− // Editable connect form (populated after discovery)
760− const [connectForm, setConnectForm] =
761− useState<ConnectFormState>(defaultConnectForm());
762−
763− // Connect result
764− const [connectResult, setConnectResult] =
765− useState<ConnectSourceResult | null>(null);
766−
767− // OAuth required state
768− const [oauthInfo, setOauthInfo] = useState<OAuthRequiredInfo | null>(null);
769− const [batchOauthInfo, setBatchOauthInfo] =
770− useState<BatchOAuthRequiredInfo | null>(null);
771− const [oauthBusy, setOauthBusy] = useState(false);
772− const [batchConnecting, setBatchConnecting] = useState(false);
773− const [selectedGoogleTemplateIds, setSelectedGoogleTemplateIds] = useState<
774− ReadonlyArray<string>
775− >([]);
776−
777− // Status banner
778− const [statusBanner, setStatusBanner] = useState<{
779− tone: "info" | "success" | "error";
780− text: string;
781− } | null>(null);
782−
783− const setFormField = <K extends keyof ConnectFormBase>(
784− key: K,
785− value: ConnectFormBase[K],
786− ) => {
787− setConnectForm((current) => ({ ...current, [key]: value }));
788− };
789−
790− const setTransport = (transport: McpTransportValue) => {
791− setConnectForm((current) => ({
792− ...current,
793− ...setMcpTransportFieldsTransport(current, transport),
794− }));
795− };
796−
797− const setRemoteTransportField = <
798− K extends Exclude<keyof McpRemoteTransportFields, "transport">,
799− >(
800− key: K,
801− value: McpRemoteTransportFields[K],
802− ) => {
803− setConnectForm((current) =>
804− current.transport === "stdio" ? current : { ...current, [key]: value },
805− );
806− };
807−
808− const setStdioTransportField = <
809− K extends Exclude<keyof McpStdioTransportFields, "transport">,
810− >(
811− key: K,
812− value: McpStdioTransportFields[K],
813− ) => {
814− setConnectForm((current) =>
815− current.transport === "stdio" ? { ...current, [key]: value } : current,
816− );
817− };
818−
819− const setSourceKind = (kind: ConnectFormState["kind"]) => {
820− setConnectForm((current) => ({
821− ...current,
822− kind,
823− authKind: authKindForSourceKind(current.authKind, kind),
824− workspaceOauthClientId:
825− kind === "google_discovery" ? current.workspaceOauthClientId : "",
826− }));
827− };
828−
829− const setProbeField = <K extends keyof ProbeAuthState>(
830− key: K,
831− value: ProbeAuthState[K],
832− ) => {
833− setProbeAuth((current) => ({ ...current, [key]: value }));
834− };
835−
836− const googleTemplates = sourceTemplates.filter(
837− (template) => template.groupId === "google_workspace" && template.batchable,
838− );
839−
840− // -------------------------------------------------------------------------
841− // Handlers
842− // -------------------------------------------------------------------------
843−
844− const handleDiscover = async () => {
845− setStatusBanner(null);
846− const trimmedUrl = url.trim();
847− if (!trimmedUrl) {
848− setStatusBanner({
849− tone: "error",
850− text: "Please enter a URL to discover.",
851− });
852− return;
853− }
854−
855− setPhase("discovering");
856−
857− try {
858− const payload: DiscoverSourcePayload = {
859− url: trimmedUrl,
860− ...(showProbeAuth && probeAuth.kind !== "none"
861− ? { probeAuth: buildProbeAuth(probeAuth) }
862− : {}),
863− };
864− const result = await discoverSource.mutateAsync(payload);
865− setConnectForm(defaultConnectForm(result));
866− setPhase("editing");
867−
868− if (result.detectedKind === "unknown") {
869− setStatusBanner({
870− tone: "info",
871− text: "Could not auto-detect the source type. Please configure manually.",
872− });
873− } else if (result.warnings.length > 0) {
874− setStatusBanner({
875− tone: "info",
876− text: result.warnings.join(" "),
877− });
878− }
879− } catch (error) {
880− setPhase("idle");
881− setStatusBanner({
882− tone: "error",
883− text: error instanceof Error ? error.message : "Discovery failed.",
884− });
885− }
886− };
887−
888− const handleSkipDiscovery = () => {
889− setConnectForm(defaultConnectForm());
890− setPhase("editing");
891− setStatusBanner(null);
892− };
893−
894− const applyTemplate = async (template: SourceTemplate) => {
895− if (template.batchable) {
896− setSelectedGoogleTemplateIds((current) =>
897− current.includes(template.id)
898− ? current.filter((id) => id !== template.id)
899− : [...current, template.id],
900− );
901− setStatusBanner(null);
902− return;
903− }
904−
905− if (isStdioMcpSourceTemplate(template)) {
906− setUrl("");
907− setConnectForm(connectFormFromTemplate(template));
908− setPhase("editing");
909− setStatusBanner({
910− tone: "info",
911− text: `${template.name} loaded. Review the local command, then connect.`,
912− });
913− return;
914− }
915−
916− const discoveryUrl =
917− "specUrl" in template ? template.specUrl : (template.endpoint ?? "");
918− setUrl(template.endpoint ?? "");
919− setStatusBanner(null);
920− setPhase("discovering");
921−
922− try {
923− const result = await discoverSource.mutateAsync({ url: discoveryUrl });
924− let form: ConnectFormState = {
925− ...defaultConnectForm(result),
926− name: template.name,
927− endpoint: template.endpoint ?? "",
928− namespace: template.namespace ?? namespaceFromUrl(template.endpoint ?? ""),
929− };
930− if (template.kind === "mcp") {
931− form = {
932− ...form,
933− ...(template.connectionType === "command"
934− ? defaultMcpStdioTransportFields({
935− command: template.command ?? "",
936− argsText: stringifyStringArray(template.args),
937− envText: stringifyStringMap(template.env),
938− cwd: template.cwd ?? "",
939− })
940− : defaultMcpRemoteTransportFields(
941− asMcpRemoteTransportValue(template.transport),
942− )),
943− };
944− }
945− if ("specUrl" in template) {
946− form = {
947− ...form,
948− specUrl: template.specUrl,
949− };
950− }
951− setConnectForm(form);
952− setPhase("editing");
953−
954− if (result.warnings.length > 0) {
955− setStatusBanner({
956− tone: "info",
957− text: result.warnings.join(" "),
958− });
959− }
960− } catch (error) {
961− // Discovery failed — fall back to just the template basics
962− setConnectForm(connectFormFromTemplate(template));
963− setPhase("editing");
964− setStatusBanner({
965− tone: "error",
966− text: `Discovery failed for ${template.name}: ${error instanceof Error ? error.message : "unknown error"}. Configure manually.`,
967− });
968− }
969− };
970−
971− const handleConnect = async () => {
972− setStatusBanner(null);
973−
974− try {
975− const payload = buildConnectPayload(connectForm);
976− setPhase("connecting");
977−
978− const result = await connectSource.mutateAsync(payload);
979− setConnectResult(result);
980−
981− if (result.kind === "connected") {
982− setBatchOauthInfo(null);
983− setPhase("connected");
984− setStatusBanner({
985− tone: "success",
986− text: `"${result.source.name}" connected successfully.`,
987− });
988− // Navigate to source detail after short delay
989− setTimeout(() => {
990− void navigate({
991− to: "/sources/$sourceId",
992− params: { sourceId: result.source.id },
993− search: { tab: "model" },
994− });
995− }, 1200);
996− } else if (result.kind === "credential_required") {
997− setBatchOauthInfo(null);
998− setPhase("credential_required");
999− setStatusBanner({
1000− tone: "info",
1001− text: "This source requires credentials. Configure auth below, then connect again.",
1002− });
1003− // Pre-select bearer auth if not already set
1004− if (connectForm.authKind === "none") {
1005− setFormField("authKind", "bearer");
1006− }
1007− } else if (result.kind === "oauth_required") {
1008− setBatchOauthInfo(null);
1009− setPhase("oauth_required");
1010− setOauthInfo({
1011− source: result.source,
1012− sessionId: result.sessionId,
1013− authorizationUrl: result.authorizationUrl,
1014− });
1015− setStatusBanner({
1016− tone: "info",
1017− text: "This source requires OAuth authentication. Click the button below to sign in.",
1018− });
1019− }
1020− } catch (error) {
1021− setPhase("editing");
1022− setStatusBanner({
1023− tone: "error",
1024− text: error instanceof Error ? error.message : "Connection failed.",
1025− });
1026− }
1027− };
1028−
1029− const handleOAuthPopup = async () => {
1030− if (!oauthInfo && !batchOauthInfo) return;
1031− setStatusBanner(null);
1032− setOauthBusy(true);
1033−
1034− try {
1035− await startSourceOAuthPopup({
1036− authorizationUrl:
1037− oauthInfo?.authorizationUrl ?? batchOauthInfo!.authorizationUrl,
1038− sessionId: oauthInfo?.sessionId ?? batchOauthInfo!.sessionId,
1039− });
1040−
1041− refreshSecrets();
1042− invalidateExecutorQueries();
1043− setPhase("connected");
1044− setStatusBanner({
1045− tone: "success",
1046− text: oauthInfo
1047− ? `"${oauthInfo.source.name}" connected via OAuth.`
1048− : `Connected ${batchOauthInfo?.sourceIds.length ?? 0} Google source${(batchOauthInfo?.sourceIds.length ?? 0) === 1 ? "" : "s"} via OAuth.`,
1049− });
1050−
1051− setTimeout(() => {
1052− if (oauthInfo) {
1053− void navigate({
1054− to: "/sources/$sourceId",
1055− params: { sourceId: oauthInfo.source.id },
1056− search: { tab: "model" },
1057− });
1058− return;
1059− }
1060−
1061− void navigate({
1062− to: "/",
1063− });
1064− }, 1200);
1065− } catch (error) {
1066− setStatusBanner({
1067− tone: "error",
1068− text: error instanceof Error ? error.message : "OAuth flow failed.",
1069− });
1070− } finally {
1071− setOauthBusy(false);
1072− }
1073− };
1074−
1075− const ensureGoogleWorkspaceOauthClientId = async (): Promise<string> => {
1076− const selectedClientId = connectForm.workspaceOauthClientId.trim();
1077− if (selectedClientId.length > 0) {
1078− return selectedClientId;
1079− }
1080−
1081− const clientId = connectForm.oauthClientId.trim();
1082− if (clientId.length === 0) {
1083− throw new Error(
1084− "Choose an existing Google workspace OAuth client or enter a new client ID.",
1085− );
1086− }
1087−
1088− const created = await createWorkspaceOauthClient.mutateAsync({
1089− providerKey: "google_workspace",
1090− label: "Google Workspace",
1091− oauthClient: {
1092− clientId,
1093− clientSecret: trimToNull(connectForm.oauthClientSecret),
1094− },
1095− } satisfies CreateWorkspaceOauthClientPayload);
1096−
1097− setFormField("workspaceOauthClientId", created.id);
1098− return created.id;
1099− };
1100−
1101− const handleConnectGoogleBatch = async () => {
1102− if (selectedGoogleTemplateIds.length === 0) {
1103− return;
1104− }
1105−
1106− setStatusBanner(null);
1107−
1108− try {
1109− const workspaceOauthClientId = await ensureGoogleWorkspaceOauthClientId();
1110− const selectedTemplates = googleTemplates.filter(
1111− (
1112− template,
1113− ): template is Extract<SourceTemplate, { kind: "google_discovery" }> =>
1114− selectedGoogleTemplateIds.includes(template.id),
1115− );
1116− const payload: ConnectSourceBatchPayload = {
1117− scopeOauthClientId:
1118− workspaceOauthClientId as WorkspaceOauthClient["id"],
1119− sources: selectedTemplates.map((template) => ({
1120− service: template.service,
1121− version: template.version,
1122− discoveryUrl: template.discoveryUrl,
1123− name: template.name,
1124− namespace: googleDiscoveryNamespace(template.service),
1125− })),
1126− };
1127−
1128− setBatchConnecting(true);
1129− setPhase("connecting");
1130− const result = await connectSourceBatch.mutateAsync(payload);
1131− if (result.providerOauthSession) {
1132− setBatchOauthInfo({
1133− sessionId: result.providerOauthSession.sessionId,
1134− authorizationUrl: result.providerOauthSession.authorizationUrl,
1135− sourceIds: result.providerOauthSession.sourceIds,
1136− });
1137− setOauthInfo(null);
1138− setBatchConnecting(false);
1139− setPhase("oauth_required");
1140− setStatusBanner(null);
1141− return;
1142− }
1143−
1144− setSelectedGoogleTemplateIds([]);
1145− setBatchOauthInfo(null);
1146− setBatchConnecting(false);
1147− setPhase("connected");
1148− setStatusBanner({
1149− tone: "success",
1150− text: `Connected ${result.results.length} Google source${result.results.length === 1 ? "" : "s"}.`,
1151− });
1152− setTimeout(() => {
1153− void navigate({ to: "/" });
1154− }, 1200);
1155− } catch (error) {
1156− setBatchConnecting(false);
1157− setPhase("idle");
1158− setStatusBanner({
1159− tone: "error",
1160− text:
1161− error instanceof Error ? error.message : "Batch connection failed.",
1162− });
1163− }
1164− };
1165−
1166− const handleCredentialConnect = async () => {
1167− // Re-run connect with the auth now configured
1168− await handleConnect();
1169− };
1170−
1171− const handleBackToEditing = () => {
1172− setPhase("editing");
1173− setStatusBanner(null);
1174− setConnectResult(null);
1175− setOauthInfo(null);
1176− };
1177−
1178− const isDiscovering = phase === "discovering";
1179− const isConnecting = phase === "connecting";
1180− const isBusy =
1181− isDiscovering ||
1182− isConnecting ||
1183− oauthBusy ||
1184− connectSourceBatch.status === "pending" ||
1185− createWorkspaceOauthClient.status === "pending";
1186−
1187− // -------------------------------------------------------------------------
1188− // Render
1189− // -------------------------------------------------------------------------
1190−
1191− return (
1192− <div className="flex-1 overflow-y-auto">
1193− <div className="mx-auto max-w-3xl px-6 py-8 lg:px-10 lg:py-12">
1194− {/* Back */}
1195− <Link
1196− to="/"
1197− className="inline-flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground mb-6"
1198− >
1199− <IconArrowLeft className="size-3.5" />
1200− Back
1201− </Link>
1202−
1203− <div className="flex items-center justify-between gap-4 mb-6">
1204− <h1 className="font-display text-2xl tracking-tight text-foreground lg:text-3xl">
1205− Add source
1206− </h1>
1207− {phase === "editing" && (
1208− <Badge variant="outline">{connectForm.kind}</Badge>
1209− )}
1210− </div>
1211−
1212− {/* Step indicator */}
1213− {phase !== "idle" && phase !== "discovering" && (
1214− <div className="mb-6 flex items-center gap-1.5" aria-label="Progress">
1215− {(["discover", "configure", "connect"] as const).map((step, i) => {
1216− const stepPhases: Record<string, number> = {
1217− editing: 1,
1218− connecting: 2,
1219− credential_required: 1,
1220− oauth_required: 2,
1221− connected: 2,
1222− };
1223− const currentStep = stepPhases[phase] ?? 0;
1224− const isComplete = i < currentStep;
1225− const isCurrent = i === currentStep;
1226− return (
1227− <div key={step} className="flex items-center gap-1.5">
1228− {i > 0 && (
1229− <div
1230− className={cn(
1231− "h-px w-6 transition-colors",
1232− isComplete || isCurrent ? "bg-primary/40" : "bg-border",
1233− )}
1234− />
1235− )}
1236− <div
1237− className={cn(
1238− "flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.06em] transition-colors",
1239− isComplete
1240− ? "text-primary"
1241− : isCurrent
1242− ? "text-foreground bg-muted/50"
1243− : "text-muted-foreground/40",
1244− )}
1245− >
1246− {isComplete ? (
1247− <IconCheck className="size-2.5" />
1248− ) : (
1249− <span className="flex size-3.5 items-center justify-center rounded-full border text-[9px] tabular-nums border-current">
1250− {i + 1}
1251− </span>
1252− )}
1253− {step}
1254− </div>
1255− </div>
1256− );
1257− })}
1258− </div>
1259− )}
1260−
1261− {statusBanner && <StatusBanner state={statusBanner} className="mb-6" />}
1262−
1263− {(phase === "idle" || phase === "discovering") && (
1264− <LocalMcpInstallCard
1265− className="mb-6 rounded-xl border border-border bg-card/80 p-5"
1266− title="Install this executor as MCP"
1267− description="Prefer a one-command setup? Install this local executor server into your MCP client, or add an external MCP source below."
1268− />
1269− )}
1270− {/* Step 1: Discovery */}
1271− {(phase === "idle" || phase === "discovering") && (
1272− <div className="space-y-6">
1273− <Section title="Discover">
1274− <div className="space-y-4">
1275− <p className="text-[13px] text-muted-foreground">
1276− Enter a URL and we'll auto-detect the source type, endpoint,
1277− auth requirements, and more.
1278− </p>
1279−
1280− <Field label="URL">
1281− <div className="space-y-0">
1282− <div className="flex gap-2">
1283− <input
1284− value={url}
1285− onChange={(e) => setUrl(e.target.value)}
1286− placeholder="https://api.example.com or https://mcp.example.com/mcp"
1287− onKeyDown={(e) => {
1288− if (e.key === "Enter" && !isDiscovering) {
1289− void handleDiscover();
1290− }
1291− }}
1292− className="h-9 flex-1 rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
1293− />
1294− <Button onClick={handleDiscover} disabled={isBusy}>
1295− {isDiscovering ? (
1296− <IconSpinner className="size-3.5" />
1297− ) : (
1298− <IconDiscover className="size-3.5" />
1299− )}
1300− {isDiscovering ? "Discovering\u2026" : "Discover"}
1301− </Button>
1302− </div>
1303− {isDiscovering && (
1304− <div className="mt-1.5 h-0.5 w-full overflow-hidden rounded-full bg-muted">
1305− <div
1306− className="h-full w-1/4 rounded-full bg-primary/60"
1307− style={{
1308− animation:
1309− "indeterminate-progress 1.2s ease-in-out infinite",
1310− }}
1311− />
1312− </div>
1313− )}
1314− </div>
1315− </Field>
1316−
1317− {/* Probe auth toggle */}
1318− <div>
1319− <button
1320− type="button"
1321− onClick={() => setShowProbeAuth(!showProbeAuth)}
1322− className="text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground"
1323− >
1324− {showProbeAuth
1325− ? "Hide probe auth"
1326− : "Need auth to discover?"}
1327− </button>
1328− </div>
1329−
1330− {showProbeAuth && (
1331− <div className="rounded-lg border border-border bg-card/70 p-4 space-y-3">
1332− <Field label="Auth type">
1333− <SelectInput
1334− value={probeAuth.kind}
1335− onChange={(v) =>
1336− setProbeField("kind", v as ProbeAuthKind)
1337− }
1338− options={probeAuthOptions.map((v) => ({
1339− value: v,
1340− label: v,
1341− }))}
1342− />
1343− </Field>
1344−
1345− {probeAuth.kind === "bearer" && (
1346− <div className="grid gap-3 sm:grid-cols-2">
1347− <Field label="Header name">
1348− <TextInput
1349− value={probeAuth.headerName}
1350− onChange={(v) => setProbeField("headerName", v)}
1351− placeholder="Authorization"
1352− />
1353− </Field>
1354− <Field label="Prefix">
1355− <TextInput
1356− value={probeAuth.prefix}
1357− onChange={(v) => setProbeField("prefix", v)}
1358− placeholder="Bearer "
1359− />
1360− </Field>
1361− <Field label="Token" className="sm:col-span-2">
1362− <TextInput
1363− value={probeAuth.token}
1364− onChange={(v) => setProbeField("token", v)}
1365− placeholder="sk-..."
1366− mono
1367− />
1368− </Field>
1369− </div>
1370− )}
1371−
1372− {probeAuth.kind === "basic" && (
1373− <div className="grid gap-3 sm:grid-cols-2">
1374− <Field label="Username">
1375− <TextInput
1376− value={probeAuth.username}
1377− onChange={(v) => setProbeField("username", v)}
1378− placeholder="user"
1379− />
1380− </Field>
1381− <Field label="Password">
1382− <TextInput
1383− value={probeAuth.password}
1384− onChange={(v) => setProbeField("password", v)}
1385− placeholder="pass"
1386− />
1387− </Field>
1388− </div>
1389− )}
1390−
1391− {probeAuth.kind === "headers" && (
1392− <Field label="Headers (JSON)">
1393− <CodeEditor
1394− value={probeAuth.headersText}
1395− onChange={(v) => setProbeField("headersText", v)}
1396− placeholder={'{\n "x-api-key": "..."\n}'}
1397− />
1398− </Field>
1399− )}
1400− </div>
1401− )}
1402−
1403− {/* Skip discovery link */}
1404− <div className="flex items-center justify-end border-t border-border pt-4">
1405− <button
1406− type="button"
1407− onClick={handleSkipDiscovery}
1408− disabled={isBusy}
1409− className="text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground"
1410− >
1411− Skip discovery and configure manually
1412− </button>
1413− </div>
1414−
1415− {/* ---- Template catalogue ---- */}
1416− <div className="space-y-5 border-t border-border pt-5">
1417− <div>
1418− <p className="text-[13px] font-medium text-foreground">
1419− Start from a template
1420− </p>
1421− <p className="mt-0.5 text-[12px] text-muted-foreground">
1422− Pick a known source to skip discovery, or select multiple
1423− Google APIs for batch connect.
1424− </p>
1425− </div>
1426−
1427− {/* -- Popular / non-Google templates -- */}
1428− {(() => {
1429− const popularTemplates = sourceTemplates.filter(
1430− (t) => t.groupId !== "google_workspace",
1431− );
1432− return (
1433− popularTemplates.length > 0 && (
1434− <div className="space-y-2.5">
1435− <p className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/50">
1436− Popular
1437− </p>
1438− <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
1439− {popularTemplates.map((template) => (
1440− <button
1441− key={template.id}
1442− type="button"
1443− onClick={() => applyTemplate(template)}
1444− disabled={isBusy}
1445− className="group rounded-xl border border-border bg-card/70 px-4 py-3 text-left transition-all hover:bg-accent/50 hover:border-primary/20 hover:shadow-sm disabled:opacity-60"
1446− >
1447− <div className="mb-1.5 flex items-center gap-2.5">
1448− <div className="flex size-5 shrink-0 items-center justify-center rounded-md bg-muted/50">
1449− <SourceFavicon
1450− endpoint={template.endpoint}
1451− kind={template.kind}
1452− className="size-3.5"
1453− />
1454− </div>
1455− <span className="flex-1 truncate text-[13px] font-medium text-foreground group-hover:text-primary transition-colors">
1456− {template.name}
1457− </span>
1458− <Badge
1459− variant="outline"
1460− className="text-[9px] opacity-60 group-hover:opacity-100 transition-opacity"
1461− >
1462− {template.kind}
1463− </Badge>
1464− </div>
1465− <span className="line-clamp-1 text-[11px] text-muted-foreground/70">
1466− {template.summary}
1467− </span>
1468− </button>
1469− ))}
1470− </div>
1471− </div>
1472− )
1473− );
1474− })()}
1475−
1476− {/* -- Google Workspace batch section -- */}
1477− <div className="space-y-3">
1478− <div className="flex items-center justify-between gap-3">
1479− <p className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/50">
1480− Google Workspace
1481− </p>
1482− {googleTemplates.length > 0 && (
1483− <div className="flex items-center gap-2">
1484− {selectedGoogleTemplateIds.length > 0 && (
1485− <button
1486− type="button"
1487− onClick={() => setSelectedGoogleTemplateIds([])}
1488− disabled={isBusy}
1489− className="text-[11px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground"
1490− >
1491− Clear
1492− </button>
1493− )}
1494− <button
1495− type="button"
1496− onClick={() =>
1497− setSelectedGoogleTemplateIds(
1498− selectedGoogleTemplateIds.length ===
1499− googleTemplates.length
1500− ? []
1501− : googleTemplates.map((t) => t.id),
1502− )
1503− }
1504− disabled={isBusy}
1505− className="text-[11px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground"
1506− >
1507− {selectedGoogleTemplateIds.length ===
1508− googleTemplates.length
1509− ? "Deselect all"
1510− : "Select all"}
1511− </button>
1512− </div>
1513− )}
1514− </div>
1515− <div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
1516− {googleTemplates.map((template) => {
1517− const selected = selectedGoogleTemplateIds.includes(
1518− template.id,
1519− );
1520− return (
1521− <button
1522− key={template.id}
1523− type="button"
1524− onClick={() => applyTemplate(template)}
1525− disabled={isBusy}
1526− className={cn(
1527− "group flex items-center gap-3 rounded-lg border px-3.5 py-2.5 text-left transition-all disabled:opacity-60",
1528− selected
1529− ? "border-primary/40 bg-primary/6 shadow-sm"
1530− : "border-border bg-card/50 hover:bg-accent/40 hover:border-border",
1531− )}
1532− >
1533− <div
1534− className={cn(
1535− "flex size-4 shrink-0 items-center justify-center rounded transition-colors",
1536− selected
1537− ? "text-primary"
1538− : "text-muted-foreground/30 group-hover:text-muted-foreground/50",
1539− )}
1540− >
1541− {selected ? (
1542− <IconCheck className="size-3.5" />
1543− ) : (
1544− <SourceFavicon
1545− endpoint={template.endpoint}
1546− kind={template.kind}
1547− className="size-3.5"
1548− />
1549− )}
1550− </div>
1551− <div className="min-w-0 flex-1">
1552− <span
1553− className={cn(
1554− "block truncate text-[12px] font-medium transition-colors",
1555− selected
1556− ? "text-foreground"
1557− : "text-foreground/80",
1558− )}
1559− >
1560− {template.name}
1561− </span>
1562− <span className="block truncate text-[10px] text-muted-foreground/60">
1563− {template.summary}
1564− </span>
1565− </div>
1566− </button>
1567− );
1568− })}
1569− </div>
1570−
1571− {/* -- Batch connect panel (slides in when templates selected) -- */}
1572− {selectedGoogleTemplateIds.length > 0 && (
1573− <div className="rounded-xl border border-primary/20 bg-gradient-to-b from-primary/4 to-transparent overflow-hidden">
1574− {/* Header strip */}
1575− <div className="flex items-center justify-between gap-3 border-b border-primary/10 px-5 py-3">
1576− <div className="flex items-center gap-2.5">
1577− <div className="flex size-6 items-center justify-center rounded-md bg-primary/10 text-primary">
1578− <span className="text-[11px] font-bold tabular-nums">
1579− {selectedGoogleTemplateIds.length}
1580− </span>
1581− </div>
1582− <p className="text-[13px] font-medium text-foreground">
1583− Connect Google API
1584− {selectedGoogleTemplateIds.length === 1
1585− ? ""
1586− : "s"}
1587− </p>
1588− </div>
1589− <p className="hidden text-[11px] text-muted-foreground/60 sm:block">
1590− One consent screen for all
1591− </p>
1592− </div>
1593−
1594− {/* Body */}
1595− <div className="px-5 py-4 space-y-4">
1596− <Field label="Workspace OAuth client">
1597− <select
1598− value={connectForm.workspaceOauthClientId}
1599− onChange={(event) =>
1600− setFormField(
1601− "workspaceOauthClientId",
1602− event.target.value,
1603− )
1604− }
1605− className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
1606− >
1607− <option value="">
1608− Enter new client credentials
1609− </option>
1610− {workspaceOauthClients.status === "ready" &&
1611− workspaceOauthClients.data.map((client) => (
1612− <option key={client.id} value={client.id}>
1613− {client.label ?? client.clientId}
1614− </option>
1615− ))}
1616− </select>
1617− </Field>
1618− {connectForm.workspaceOauthClientId.trim().length ===
1619− 0 && (
1620− <div className="grid gap-3 sm:grid-cols-2">
1621− <Field
1622− label="Client ID"
1623− className="sm:col-span-2"
1624− >
1625− <TextInput
1626− type="password"
1627− value={connectForm.oauthClientId}
1628− onChange={(v) =>
1629− setFormField("oauthClientId", v)
1630− }
1631− placeholder="1234567890-abcdef.apps.googleusercontent.com"
1632− />
1633− </Field>
1634− <Field
1635− label="Client secret"
1636− className="sm:col-span-2"
1637− >
1638− <TextInput
1639− type="password"
1640− value={connectForm.oauthClientSecret}
1641− onChange={(v) =>
1642− setFormField("oauthClientSecret", v)
1643− }
1644− placeholder="GOCSPX-..."
1645− />
1646− </Field>
1647− </div>
1648− )}
1649− <div className="flex items-center justify-end pt-1">
1650− <Button
1651− type="button"
1652− onClick={handleConnectGoogleBatch}
1653− disabled={isBusy}
1654− >
1655− {isBusy ? (
1656− <IconSpinner className="size-3.5" />
1657− ) : (
1658− <IconPlus className="size-3.5" />
1659− )}
1660− Connect {selectedGoogleTemplateIds.length} source
1661− {selectedGoogleTemplateIds.length === 1
1662− ? ""
1663− : "s"}
1664− </Button>
1665− </div>
1666− </div>
1667− </div>
1668− )}
1669− </div>
1670− </div>
1671− </div>
1672− </Section>
1673− </div>
1674− )}
1675−
1676− {/* Batch connecting interstitial */}
1677− {batchConnecting && phase === "connecting" && (
1678− <div className="flex flex-col items-center justify-center py-20">
1679− <div className="space-y-5 text-center">
1680− <div className="mx-auto flex size-14 items-center justify-center rounded-2xl border border-primary/15 bg-primary/6">
1681− <IconSpinner className="size-6 text-primary" />
1682− </div>
1683− <div className="space-y-1.5">
1684− <h2 className="font-display text-xl tracking-tight text-foreground">
1685− Connecting {selectedGoogleTemplateIds.length} source
1686− {selectedGoogleTemplateIds.length === 1 ? "" : "s"}
1687− </h2>
1688− <p className="text-[13px] text-muted-foreground/70">
1689− Setting up Google Workspace APIs\u2026
1690− </p>
1691− </div>
1692− </div>
1693− </div>
1694− )}
1695−
1696− {/* Step 2-4: Editing / Connecting */}
1697− {(phase === "editing" ||
1698− (phase === "connecting" && !batchConnecting) ||
1699− phase === "credential_required") && (
1700− <div className="space-y-6">
1701− <Section title="Configuration">
1702− <div className="grid gap-4 sm:grid-cols-2">
1703− <Field label="Name">
1704− <TextInput
1705− value={connectForm.name}
1706− onChange={(v) => setFormField("name", v)}
1707− placeholder="My API"
1708− />
1709− </Field>
1710− <Field label="Kind">
1711− <SelectInput
1712− value={connectForm.kind}
1713− onChange={(v) =>
1714− setSourceKind(v as ConnectFormState["kind"])
1715− }
1716− options={kindOptions.map((v) => ({ value: v, label: v }))}
1717− />
1718− </Field>
1719− {connectForm.kind !== "google_discovery" &&
1720− !(
1721− connectForm.kind === "mcp" &&
1722− connectForm.transport === "stdio"
1723− ) && (
1724− <Field label="Endpoint" className="sm:col-span-2">
1725− <TextInput
1726− value={connectForm.endpoint}
1727− onChange={(v) => setFormField("endpoint", v)}
1728− placeholder="https://api.example.com"
1729− mono
1730− />
1731− </Field>
1732− )}
1733− <Field label="Namespace">
1734− <TextInput
1735− value={connectForm.namespace}
1736− onChange={(v) => setFormField("namespace", v)}
1737− placeholder="example"
1738− />
1739− </Field>
1740− {connectForm.kind === "openapi" && (
1741− <Field label="Spec URL" className="sm:col-span-2">
1742− <TextInput
1743− value={connectForm.specUrl}
1744− onChange={(v) => setFormField("specUrl", v)}
1745− placeholder="https://example.com/openapi.yaml"
1746− mono
1747− />
1748− </Field>
1749− )}
1750− {connectForm.kind === "google_discovery" && (
1751− <>
1752− <Field label="Service">
1753− <TextInput
1754− value={connectForm.service}
1755− onChange={(v) => setFormField("service", v)}
1756− placeholder="sheets"
1757− />
1758− </Field>
1759− <Field label="Version">
1760− <TextInput
1761− value={connectForm.version}
1762− onChange={(v) => setFormField("version", v)}
1763− placeholder="v4"
1764− />
1765− </Field>
1766− <Field
1767− label="Discovery URL (optional)"
1768− className="sm:col-span-2"
1769− >
1770− <TextInput
1771− value={connectForm.discoveryUrl}
1772− onChange={(v) => setFormField("discoveryUrl", v)}
1773− placeholder="https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest"
1774− mono
1775− />
1776− </Field>
1777− <div className="sm:col-span-2 rounded-lg border border-border bg-card/60 px-3 py-2 text-[12px] text-muted-foreground">
1778− Common Google API versions: Gmail `v1`, Sheets `v4`, Drive
1779− `v3`, Calendar `v3`, Docs `v1`.
1780− </div>
1781− </>
1782− )}
1783− </div>
1784− </Section>
1785−
1786− {/* MCP Transport */}
1787− {connectForm.kind === "mcp" && (
1788− <Section title="Transport">
1789− <div className="grid gap-4 sm:grid-cols-2">
1790− <Field label="Transport mode">
1791− <SelectInput
1792− value={connectForm.transport || "auto"}
1793− onChange={(v) => setTransport(v as McpTransportValue)}
1794− options={transportOptions.map((v) => ({
1795− value: v,
1796− label: v,
1797− }))}
1798− />
1799− </Field>
1800− {connectForm.transport === "stdio" ? (
1801− <div className="sm:col-span-2 grid gap-4 sm:grid-cols-2">
1802− <Field label="Command">
1803− <TextInput
1804− value={connectForm.command}
1805− onChange={(v) => setStdioTransportField("command", v)}
1806− placeholder="npx"
1807− mono
1808− />
1809− </Field>
1810− <Field label="Working directory (optional)">
1811− <TextInput
1812− value={connectForm.cwd}
1813− onChange={(v) => setStdioTransportField("cwd", v)}
1814− placeholder="/path/to/project"
1815− mono
1816− />
1817− </Field>
1818− <Field label="Args (JSON)" className="sm:col-span-2">
1819− <CodeEditor
1820− value={connectForm.argsText}
1821− onChange={(v) =>
1822− setStdioTransportField("argsText", v)
1823− }
1824− placeholder={
1825− '[\n "-y",\n "chrome-devtools-mcp@latest"\n]'
1826− }
1827− />
1828− </Field>
1829− <Field
1830− label="Environment (JSON)"
1831− className="sm:col-span-2"
1832− >
1833− <CodeEditor
1834− value={connectForm.envText}
1835− onChange={(v) => setStdioTransportField("envText", v)}
1836− placeholder={
1837− '{\n "CHROME_PATH": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"\n}'
1838− }
1839− />
1840− </Field>
1841− </div>
1842− ) : (
1843− <div className="sm:col-span-2 grid gap-4 sm:grid-cols-2">
1844− <Field label="Query params (JSON)">
1845− <CodeEditor
1846− value={connectForm.queryParamsText}
1847− onChange={(v) =>
1848− setRemoteTransportField("queryParamsText", v)
1849− }
1850− placeholder={'{\n "workspace": "demo"\n}'}
1851− />
1852− </Field>
1853− <Field label="Headers (JSON)">
1854− <CodeEditor
1855− value={connectForm.headersText}
1856− onChange={(v) =>
1857− setRemoteTransportField("headersText", v)
1858− }
1859− placeholder={'{\n "x-api-key": "..."\n}'}
1860− />
1861− </Field>
1862− </div>
1863− )}
1864− </div>
1865− </Section>
1866− )}
1867−
1868− {/* Auth section (for non-MCP kinds, or when credential_required) */}
1869− {phase === "credential_required" && (
1870− <div className="flex items-start gap-3 rounded-xl border border-amber-300/40 bg-amber-50/50 px-4 py-3.5 dark:border-amber-500/20 dark:bg-amber-950/20">
1871− <div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-amber-100/80 dark:bg-amber-900/40">
1872− <svg
1873− className="size-3.5 text-amber-600 dark:text-amber-400"
1874− viewBox="0 0 24 24"
1875− fill="none"
1876− stroke="currentColor"
1877− strokeWidth="2"
1878− strokeLinecap="round"
1879− strokeLinejoin="round"
1880− >
1881− <path d="M12 9v4" />
1882− <path d="M12 17h.01" />
1883− <path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
1884− </svg>
1885− </div>
1886− <div className="space-y-0.5">
1887− <p className="text-[12px] font-medium text-amber-900 dark:text-amber-200">
1888− Credentials required
1889− </p>
1890− <p className="text-[11px] leading-relaxed text-amber-800/70 dark:text-amber-300/60">
1891− The server responded that this source requires
1892− authentication. Add a token or secret below, then try
1893− connecting again.
1894− </p>
1895− </div>
1896− </div>
1897− )}
1898− {connectForm.kind !== "mcp" && (
1899− <Section title="Authentication">
1900− <div className="grid gap-4 sm:grid-cols-2">
1901− <Field label="Auth mode">
1902− <SelectInput
1903− value={connectForm.authKind}
1904− onChange={(v) =>
1905− setFormField(
1906− "authKind",
1907− v as ConnectFormState["authKind"],
1908− )
1909− }
1910− options={authOptions.map((v) => ({ value: v, label: v }))}
1911− />
1912− </Field>
1913− {connectForm.kind === "google_discovery" && (
1914− <div className="sm:col-span-2 rounded-lg border border-border bg-card/60 px-3 py-2 text-[12px] text-muted-foreground">
1915− {connectForm.authKind === "oauth2"
1916− ? "OAuth starts after you click Connect. Choose an existing workspace OAuth client or enter a new client once, then reuse it across Google sources."
1917− : "Choosing auth mode 'none' skips the Google OAuth popup. Use 'oauth2' if you want executor to start the sign-in flow for you."}
1918− </div>
1919− )}
1920− {connectForm.kind === "google_discovery" &&
1921− connectForm.authKind === "oauth2" && (
1922− <>
1923− <Field
1924− label="Workspace OAuth client"
1925− className="sm:col-span-2"
1926− >
1927− <SelectInput
1928− value={connectForm.workspaceOauthClientId}
1929− onChange={(value) =>
1930− setFormField("workspaceOauthClientId", value)
1931− }
1932− options={[
1933− { value: "", label: "Enter new client" },
1934− ...(workspaceOauthClients.status === "ready"
1935− ? workspaceOauthClients.data.map((client) => ({
1936− value: client.id,
1937− label: client.label ?? client.clientId,
1938− }))
1939− : []),
1940− ]}
1941− />
1942− </Field>
1943− {connectForm.workspaceOauthClientId.trim().length ===
1944− 0 && (
1945− <>
1946− <Field
1947− label="New client ID"
1948− className="sm:col-span-2"
1949− >
1950− <TextInput
1951− type="password"
1952− value={connectForm.oauthClientId}
1953− onChange={(v) =>
1954− setFormField("oauthClientId", v)
1955− }
1956− placeholder="1234567890-abcdef.apps.googleusercontent.com"
1957− />
1958− </Field>
1959− <Field
1960− label="New client secret"
1961− className="sm:col-span-2"
1962− >
1963− <TextInput
1964− type="password"
1965− value={connectForm.oauthClientSecret}
1966− onChange={(v) =>
1967− setFormField("oauthClientSecret", v)
1968− }
1969− placeholder="GOCSPX-..."
1970− />
1971− </Field>
1972− </>
1973− )}
1974− </>
1975− )}
1976− {connectForm.authKind !== "none" && (
1977− <>
1978− <Field label="Header name">
1979− <TextInput
1980− value={connectForm.authHeaderName}
1981− onChange={(v) => setFormField("authHeaderName", v)}
1982− placeholder="Authorization"
1983− />
1984− </Field>
1985− <Field label="Prefix">
1986− <TextInput
1987− value={connectForm.authPrefix}
1988− onChange={(v) => setFormField("authPrefix", v)}
1989− placeholder="Bearer "
1990− />
1991− </Field>
1992− </>
1993− )}
1994− {connectForm.authKind === "bearer" && (
1995− <Field label="Token" className="sm:col-span-2">
1996− <SecretOrTokenInput
1997− instanceConfig={instanceConfig}
1998− secrets={secrets}
1999− handle={connectForm.bearerHandle}
2000− inlineToken={connectForm.bearerToken}
2001− onSelectSecret={(providerId, handle) => {
2002− setFormField("bearerProviderId", providerId);
2003− setFormField("bearerHandle", handle);
2004− setFormField("bearerToken", "");
2005− }}
2006− onChangeToken={(token) => {
2007− setFormField("bearerToken", token);
2008− setFormField("bearerProviderId", "");
2009− setFormField("bearerHandle", "");
2010− }}
2011− />
2012− </Field>
2013− )}
2014− </div>
2015− </Section>
2016− )}
2017−
2018− {/* Actions */}
2019− <div className="flex items-center justify-end gap-3 border-t border-border pt-5">
2020− {phase === "credential_required" && (
2021− <Button
2022− variant="ghost"
2023− type="button"
2024− onClick={handleBackToEditing}
2025− >
2026− Back to edit
2027− </Button>
2028− )}
2029− <Link to="/" className="inline-flex">
2030− <Button variant="ghost" type="button">
2031− Cancel
2032− </Button>
2033− </Link>
2034− <Button
2035− onClick={
2036− phase === "credential_required"
2037− ? handleCredentialConnect
2038− : handleConnect
2039− }
2040− disabled={isBusy}
2041− >
2042− {isConnecting ? (
2043− <IconSpinner className="size-3.5" />
2044− ) : (
2045− <IconPlus className="size-3.5" />
2046− )}
2047− {isConnecting ? "Connecting\u2026" : "Connect"}
2048− </Button>
2049− </div>
2050− </div>
2051− )}
2052−
2053− {/* Step 5a: OAuth required */}
2054− {phase === "oauth_required" && (oauthInfo || batchOauthInfo) && (
2055− <div className="flex flex-col items-center justify-center py-16">
2056− <div className="w-full max-w-sm space-y-6 text-center">
2057− {/* Visual anchor */}
2058− <div className="mx-auto flex size-16 items-center justify-center rounded-2xl border border-primary/20 bg-gradient-to-b from-primary/10 to-primary/4">
2059− {oauthBusy ? (
2060− <IconSpinner className="size-6 text-primary" />
2061− ) : (
2062− <svg
2063− className="size-6 text-primary"
2064− viewBox="0 0 24 24"
2065− fill="none"
2066− stroke="currentColor"
2067− strokeWidth="1.5"
2068− strokeLinecap="round"
2069− strokeLinejoin="round"
2070− >
2071− <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
2072− <path d="M7 11V7a5 5 0 0110 0v4" />
2073− </svg>
2074− )}
2075− </div>
2076−
2077− <div className="space-y-2">
2078− <h2 className="font-display text-xl tracking-tight text-foreground">
2079− {oauthBusy
2080− ? "Waiting for sign-in\u2026"
2081− : "Sign in to continue"}
2082− </h2>
2083− <p className="text-[13px] leading-relaxed text-muted-foreground">
2084− {oauthInfo ? (
2085− <>
2086− <strong className="text-foreground">
2087− {oauthInfo.source.name}
2088− </strong>{" "}
2089− requires OAuth authentication. A popup will open for you
2090− to authorize access.
2091− </>
2092− ) : (
2093− <>
2094− {batchOauthInfo!.sourceIds.length} Google source
2095− {batchOauthInfo!.sourceIds.length === 1 ? "" : "s"} need
2096− {batchOauthInfo!.sourceIds.length === 1 ? "s" : ""} OAuth.
2097− One consent screen covers all selected APIs.
2098− </>
2099− )}
2100− </p>
2101− </div>
2102−
2103− <div className="flex flex-col items-center gap-3">
2104− <Button
2105− onClick={handleOAuthPopup}
2106− disabled={oauthBusy}
2107− className="w-full max-w-xs"
2108− >
2109− {oauthBusy ? <IconSpinner className="size-3.5" /> : null}
2110− {oauthBusy ? "Authenticating\u2026" : "Open sign-in"}
2111− </Button>
2112− {!oauthBusy && (
2113− <button
2114− type="button"
2115− onClick={handleBackToEditing}
2116− className="text-[12px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground"
2117− >
2118− Back to configuration
2119− </button>
2120− )}
2121− </div>
2122− </div>
2123− </div>
2124− )}
2125−
2126− {/* Step 5b: Connected */}
2127− {phase === "connected" && (
2128− <div className="flex flex-col items-center justify-center py-20">
2129− <div className="space-y-5 text-center">
2130− <div className="mx-auto flex size-14 items-center justify-center rounded-full bg-primary/10 ring-4 ring-primary/5">
2131− <IconCheck className="size-6 text-primary" />
2132− </div>
2133− <div className="space-y-1.5">
2134− <h2 className="font-display text-xl tracking-tight text-foreground">
2135− {connectResult
2136− ? connectResult.source.name
2137− : batchOauthInfo
2138− ? `${batchOauthInfo.sourceIds.length} source${batchOauthInfo.sourceIds.length === 1 ? "" : "s"}`
2139− : "Source"}{" "}
2140− connected
2141− </h2>
2142− <p className="text-[13px] text-muted-foreground/70">
2143− Redirecting\u2026
2144− </p>
2145− </div>
2146− </div>
2147− </div>
2148− )}
2149− </div>
2150− </div>
2151− );
2152−}
2153−
2154−// ---------------------------------------------------------------------------
2155−// Form building blocks
2156−// ---------------------------------------------------------------------------
2157−
2158−function Section(props: {
2159− title: string;
2160− children: ReactNode;
2161− className?: string;
2162−}) {
2163− return (
2164− <section
2165− className={cn(
2166− "rounded-xl border border-border bg-card/80",
2167− props.className,
2168− )}
2169− >
2170− <div className="border-b border-border px-5 py-3">
2171− <h2 className="text-sm font-semibold text-foreground">{props.title}</h2>
2172− </div>
2173− <div className="p-5">{props.children}</div>
2174− </section>
2175− );
2176−}
2177−
2178−function Field(props: {
2179− label: string;
2180− className?: string;
2181− children: ReactNode;
2182−}) {
2183− return (
2184− <label className={cn("block space-y-1.5", props.className)}>
2185− <span className="text-[12px] font-medium text-foreground">
2186− {props.label}
2187− </span>
2188− {props.children}
2189− </label>
2190− );
2191−}
2192−
2193−function TextInput(props: {
2194− type?: "text" | "password";
2195− value: string;
2196− onChange: (value: string) => void;
2197− placeholder?: string;
2198− mono?: boolean;
2199−}) {
2200− return (
2201− <input
2202− type={props.type || "text"}
2203− value={props.value}
2204− onChange={(event) => props.onChange(event.target.value)}
2205− placeholder={props.placeholder}
2206− className={cn(
2207− "h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25",
2208− props.mono && "font-mono text-[12px]",
2209− )}
2210− />
2211− );
2212−}
2213−
2214−function SelectInput(props: {
2215− value: string;
2216− onChange: (value: string) => void;
2217− options: ReadonlyArray<{ value: string; label: string }>;
2218−}) {
2219− return (
2220− <select
2221− value={props.value}
2222− onChange={(event) => props.onChange(event.target.value)}
2223− className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
2224− >
2225− {props.options.map((option) => (
2226− <option key={option.value} value={option.value}>
2227− {option.label}
2228− </option>
2229− ))}
2230− </select>
2231− );
2232−}
2233−
2234−function CodeEditor(props: {
2235− value: string;
2236− onChange: (value: string) => void;
2237− placeholder?: string;
2238−}) {
2239− return (
2240− <textarea
2241− value={props.value}
2242− onChange={(event) => props.onChange(event.target.value)}
2243− placeholder={props.placeholder}
2244− spellCheck={false}
2245− className="min-h-28 w-full rounded-lg border border-input bg-background px-3 py-2.5 font-mono text-[12px] leading-relaxed text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
2246− />
2247− );
2248−}
2249−
2250−function StatusBanner(props: {
2251− state: { tone: "info" | "success" | "error"; text: string };
2252− className?: string;
2253−}) {
2254− return (
2255− <div
2256− className={cn(
2257− "flex items-start gap-2.5 rounded-lg border px-4 py-3 text-[13px]",
2258− props.state.tone === "success" &&
2259− "border-primary/30 bg-primary/8 text-foreground",
2260− props.state.tone === "info" &&
2261− "border-border bg-card text-muted-foreground",
2262− props.state.tone === "error" &&
2263− "border-destructive/30 bg-destructive/8 text-destructive",
2264− props.className,
2265− )}
2266− >
2267− <span className="mt-0.5 shrink-0">
2268− {props.state.tone === "success" && (
2269− <IconCheck className="size-3.5 text-primary" />
2270− )}
2271− {props.state.tone === "error" && (
2272− <svg
2273− className="size-3.5"
2274− viewBox="0 0 24 24"
2275− fill="none"
2276− stroke="currentColor"
2277− strokeWidth="2"
2278− strokeLinecap="round"
2279− strokeLinejoin="round"
2280− >
2281− <circle cx="12" cy="12" r="10" />
2282− <path d="M15 9l-6 6" />
2283− <path d="M9 9l6 6" />
2284− </svg>
2285− )}
2286− {props.state.tone === "info" && (
2287− <svg
2288− className="size-3.5"
2289− viewBox="0 0 24 24"
2290− fill="none"
2291− stroke="currentColor"
2292− strokeWidth="2"
2293− strokeLinecap="round"
2294− strokeLinejoin="round"
2295− >
2296− <circle cx="12" cy="12" r="10" />
2297− <path d="M12 16v-4" />
2298− <path d="M12 8h.01" />
2299− </svg>
2300− )}
2301− </span>
2302− <span>{props.state.text}</span>
2303− </div>
2304− );
2305−}
2306−
2307−// ---------------------------------------------------------------------------
2308−// Secret or inline token picker
2309−// ---------------------------------------------------------------------------
2310−
2311−const CREATE_NEW_VALUE = "__create_new__";
2312−const INLINE_TOKEN_VALUE = "__inline_token__";
2313−
2314−function SecretOrTokenInput(props: {
2315− instanceConfig: Loadable<InstanceConfig>;
2316− secrets: Loadable<ReadonlyArray<SecretListItem>>;
2317− handle: string;
2318− inlineToken: string;
2319− onSelectSecret: (providerId: string, handle: string) => void;
2320− onChangeToken: (token: string) => void;
2321−}) {
2322− const {
2323− instanceConfig,
2324− secrets,
2325− handle,
2326− inlineToken,
2327− onSelectSecret,
2328− onChangeToken,
2329− } = props;
2330− const createSecret = useCreateSecret();
2331− const [showCreate, setShowCreate] = useState(false);
2332− const [useInline, setUseInline] = useState(false);
2333− const [newName, setNewName] = useState("");
2334− const [newValue, setNewValue] = useState("");
2335− const [newProviderId, setNewProviderId] = useState("");
2336− const [createError, setCreateError] = useState<string | null>(null);
2337− const storableProviders =
2338− instanceConfig.status === "ready"
2339− ? instanceConfig.data.secretProviders.filter(
2340− (provider) => provider.canStore,
2341− )
2342− : [];
2343−
2344− useEffect(() => {
2345− if (newProviderId.length > 0) {
2346− return;
2347− }
2348− if (instanceConfig.status === "ready") {
2349− setNewProviderId(instanceConfig.data.defaultSecretStoreProvider);
2350− }
2351− }, [instanceConfig, newProviderId]);
2352−
2353− const selectedValue = showCreate
2354− ? CREATE_NEW_VALUE
2355− : useInline
2356− ? INLINE_TOKEN_VALUE
2357− : handle || "";
2358−
2359− const handleSelectChange = (value: string) => {
2360− if (value === CREATE_NEW_VALUE) {
2361− setShowCreate(true);
2362− setUseInline(false);
2363− setNewName("");
2364− setNewValue("");
2365− setNewProviderId(
2366− instanceConfig.status === "ready"
2367− ? instanceConfig.data.defaultSecretStoreProvider
2368− : "",
2369− );
2370− setCreateError(null);
2371− return;
2372− }
2373− if (value === INLINE_TOKEN_VALUE) {
2374− setUseInline(true);
2375− setShowCreate(false);
2376− onSelectSecret("", "");
2377− return;
2378− }
2379− if (value === "") {
2380− setUseInline(false);
2381− setShowCreate(false);
2382− onSelectSecret("", "");
2383− return;
2384− }
2385− setUseInline(false);
2386− setShowCreate(false);
2387− const matchedSecret =
2388− secrets.status === "ready"
2389− ? secrets.data.find((secret) => secret.id === value)
2390− : null;
2391− onSelectSecret(matchedSecret?.providerId ?? "local", value);
2392− };
2393−
2394− const handleCreate = async () => {
2395− setCreateError(null);
2396− const trimmedName = newName.trim();
2397− if (!trimmedName) {
2398− setCreateError("Name is required.");
2399− return;
2400− }
2401− if (!newValue) {
2402− setCreateError("Value is required.");
2403− return;
2404− }
2405−
2406− try {
2407− const result = await createSecret.mutateAsync({
2408− name: trimmedName,
2409− value: newValue,
2410− ...(newProviderId ? { providerId: newProviderId } : {}),
2411− });
2412− onSelectSecret(result.providerId, result.id);
2413− setShowCreate(false);
2414− } catch (err) {
2415− setCreateError(
2416− err instanceof Error ? err.message : "Failed creating secret.",
2417− );
2418− }
2419− };
2420−
2421− if (secrets.status !== "ready") {
2422− return (
2423− <select
2424− disabled
2425− className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-muted-foreground outline-none opacity-60"
2426− >
2427− <option>Loading...</option>
2428− </select>
2429− );
2430− }
2431−
2432− const items = secrets.data;
2433−
2434− return (
2435− <div className="space-y-2">
2436− <select
2437− value={selectedValue}
2438− onChange={(e) => handleSelectChange(e.target.value)}
2439− className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
2440− >
2441− <option value="">Select a secret...</option>
2442− {items.map((secret) => (
2443− <option key={secret.id} value={secret.id}>
2444− {secret.name || secret.id} ({secret.providerId})
2445− </option>
2446− ))}
2447− <option value={INLINE_TOKEN_VALUE}>Paste token directly</option>
2448− <option value={CREATE_NEW_VALUE}>+ Create new secret</option>
2449− </select>
2450−
2451− {useInline && (
2452− <input
2453− type="password"
2454− value={inlineToken}
2455− onChange={(e) => onChangeToken(e.target.value)}
2456− placeholder="sk-... or ghp_..."
2457− className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
2458− autoFocus
2459− />
2460− )}
2461−
2462− {showCreate && (
2463− <div className="rounded-lg border border-primary/20 bg-card/80 p-3 space-y-3">
2464− {createError && (
2465− <div className="rounded-md border border-destructive/30 bg-destructive/8 px-3 py-2 text-[12px] text-destructive">
2466− {createError}
2467− </div>
2468− )}
2469− <div className="grid gap-3 sm:grid-cols-2">
2470− <label className="block space-y-1">
2471− <span className="text-[11px] font-medium text-muted-foreground">
2472− Name
2473− </span>
2474− <input
2475− value={newName}
2476− onChange={(e) => setNewName(e.target.value)}
2477− placeholder="API Key"
2478− className="h-8 w-full rounded-lg border border-input bg-background px-3 text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
2479− autoFocus
2480− />
2481− </label>
2482− <label className="block space-y-1">
2483− <span className="text-[11px] font-medium text-muted-foreground">
2484− Value
2485− </span>
2486− <input
2487− type="password"
2488− value={newValue}
2489− onChange={(e) => setNewValue(e.target.value)}
2490− placeholder="sk-..."
2491− className="h-8 w-full rounded-lg border border-input bg-background px-3 font-mono text-[11px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
2492− />
2493− </label>
2494− <label className="block space-y-1">
2495− <span className="text-[11px] font-medium text-muted-foreground">
2496− Store in
2497− </span>
2498− <select
2499− value={newProviderId}
2500− onChange={(e) => setNewProviderId(e.target.value)}
2501− className="h-8 w-full rounded-lg border border-input bg-background px-3 text-[12px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
2502− >
2503− {storableProviders.map((provider) => (
2504− <option key={provider.id} value={provider.id}>
2505− {provider.name}
2506− </option>
2507− ))}
2508− </select>
2509− </label>
2510− </div>
2511− <div className="flex items-center justify-end gap-2">
2512− <button
2513− type="button"
2514− onClick={() => setShowCreate(false)}
2515− className="rounded-md px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground"
2516− >
2517− Cancel
2518− </button>
2519− <Button
2520− size="sm"
2521− onClick={handleCreate}
2522− disabled={createSecret.status === "pending"}
2523− >
2524− {createSecret.status === "pending" && (
2525− <IconSpinner className="size-3" />
2526− )}
2527− Store & use
2528− </Button>
2529− </div>
2530− </div>
2531− )}
2532− </div>
2533− );
2534−}
apps/web/src/views/home.tsx+63−34
@@ -1,17 +1,28 @@
11 import { Link } from "@tanstack/react-router";
2−import { useSources, type Source } from "@executor/react";
2+import { useSources } from "@executor/react";
3+import { sourcePluginsIndexPath } from "@executor/react/plugins";
34 import { LoadableBlock } from "../components/loadable";
45 import { LocalMcpInstallCard } from "../components/local-mcp-install-card";
56 import { SourceFavicon } from "../components/source-favicon";
67 import { Badge } from "../components/ui/badge";
78 import { Button } from "../components/ui/button";
89 import { IconSources, IconPlus } from "../components/icons";
10+import {
11+ getSourceFrontendPaths,
12+} from "../plugins";
13+
14+const statusVariant = (status: string) =>
15+ status === "connected"
16+ ? "default" as const
17+ : status === "error"
18+ ? "destructive" as const
19+ : "muted" as const;
920
1021 export function HomePage() {
1122 const sources = useSources();
1223
1324 return (
14− <div className="flex-1 overflow-y-auto">
25+ <div className="min-h-0 flex-1 overflow-y-auto">
1526 <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">
1627 {/* Header */}
1728 <div className="flex items-end justify-between mb-8">
@@ -23,7 +34,7 @@ export function HomePage() { · 5 unmodified lines
2334 Connected tool providers in this workspace.
2435 </p>
2536 </div>
26− <Link to="/sources/add">
37+ <Link to={sourcePluginsIndexPath}>
2738 <Button size="sm">
2839 <IconPlus className="size-3.5" />
2940 Add source
@@ -36,7 +47,11 @@ export function HomePage() { · 6 unmodified lines
3647 {/* Source list */}
3748 <LoadableBlock loadable={sources} loading="Loading sources...">
3849 {(items) =>
39− items.length === 0 ? (
50+ !Array.isArray(items) ? (
51+ <div className="rounded-xl border border-destructive/20 bg-destructive/5 px-5 py-4 text-sm text-destructive">
52+ Sources returned an unexpected payload.
53+ </div>
54+ ) : items.length === 0 ? (
4055 <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20">
4156 <div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground mb-4">
4257 <IconSources className="size-5" />
@@ -47,7 +62,7 @@ export function HomePage() { · 4 unmodified lines
4762 <p className="text-[13px] text-muted-foreground/60 mb-5">
4863 Add a source to get started.
4964 </p>
50− <Link to="/sources/add">
65+ <Link to={sourcePluginsIndexPath}>
5166 <Button size="sm">
5267 <IconPlus className="size-3.5" />
5368 Add source
@@ -56,9 +71,49 @@ export function HomePage() { · 2 unmodified lines
5671 </div>
5772 ) : (
5873 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
59− {items.map((source) => (
60− <SourceCard key={source.id} source={source} />
61− ))}
74+ {items.map((source) => {
75+ const paths = getSourceFrontendPaths(source.kind);
76+ const card = (
77+ <div className="flex h-full flex-col rounded-2xl border border-border bg-card px-5 py-4 transition-colors hover:border-primary/25 hover:bg-card/90">
78+ <div className="flex items-start gap-3">
79+ <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
80+ <SourceFavicon source={source} className="size-4" />
81+ </div>
82+ <div className="min-w-0 flex-1">
83+ <div className="flex items-start justify-between gap-2">
84+ <div className="truncate text-sm font-semibold text-foreground">
85+ {source.name}
86+ </div>
87+ <Badge variant={statusVariant(source.status)} className="shrink-0">
88+ {source.status}
89+ </Badge>
90+ </div>
91+ <div className="mt-0.5 text-xs text-muted-foreground">
92+ {source.kind}
93+ </div>
94+ </div>
95+ </div>
96+ </div>
97+ );
98+
99+ if (!paths) {
100+ return (
101+ <div key={source.id}>
102+ {card}
103+ </div>
104+ );
105+ }
106+
107+ return (
108+ <Link
109+ key={source.id}
110+ to={paths.detail(source.id)}
111+ search={{ tab: "model" }}
112+ >
113+ {card}
114+ </Link>
115+ );
116+ })}
62117 </div>
63118 )
64119 }
@@ -67,29 +122,3 @@ export function HomePage() { · 2 unmodified lines
67122 </div>
68123 );
69124 }
70−
71−function SourceCard({ source }: { source: Source }) {
72− return (
73− <Link
74− to="/sources/$sourceId"
75− params={{ sourceId: source.id }}
76− search={{ tab: "model" }}
77− className="group flex flex-col rounded-xl border border-border bg-card p-4 transition-all hover:border-primary/30 hover:shadow-[0_2px_12px_-4px_rgba(0,0,0,0.12)]"
78− >
79− <div className="flex items-start justify-between gap-3 mb-3">
80− <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary">
81− <SourceFavicon endpoint={source.endpoint} kind={source.kind} className="size-4.5" />
82− </div>
83− <Badge variant={source.status === "connected" ? "default" : source.status === "error" ? "destructive" : "muted"} className="shrink-0">
84− {source.status}
85− </Badge>
86− </div>
87− <h3 className="text-[14px] font-semibold text-foreground group-hover:text-primary transition-colors mb-0.5">
88− {source.name}
89− </h3>
90− <div className="flex items-center gap-2 mt-auto pt-2">
91− <Badge variant="outline" className="text-[9px]">{source.kind}</Badge>
92− </div>
93− </Link>
94− );
95−}
apps/web/src/views/json-form.ts+0−74
@@ -1,74 +0,0 @@
1−import * as Either from "effect/Either";
2−import * as ParseResult from "effect/ParseResult";
3−import * as Schema from "effect/Schema";
4−
5−const JsonStringMapSchema = Schema.parseJson(
6− Schema.Record({
7− key: Schema.String,
8− value: Schema.String,
9− }),
10−);
11−
12−const JsonStringArraySchema = Schema.parseJson(
13− Schema.Array(Schema.String),
14−);
15−
16−const formatJsonFieldError = (
17− label: string,
18− error: ParseResult.ParseError,
19−): Error =>
20− new Error(
21− `${label} is invalid: ${ParseResult.TreeFormatter.formatErrorSync(error)}`,
22− );
23−
24−const decodeJsonField = <A>(input: {
25− label: string;
26− text: string;
27− schema: Schema.Schema<A, string>;
28−}): A | null => {
29− const trimmed = input.text.trim();
30− if (trimmed.length === 0) {
31− return null;
32− }
33−
34− const decoded = Schema.decodeUnknownEither(input.schema)(trimmed);
35− if (Either.isLeft(decoded)) {
36− throw formatJsonFieldError(input.label, decoded.left);
37− }
38−
39− return decoded.right;
40−};
41−
42−export const parseJsonStringMap = (
43− label: string,
44− text: string,
45−): Record<string, string> | null => {
46− const decoded = decodeJsonField({
47− label,
48− text,
49− schema: JsonStringMapSchema,
50− });
51−
52− return decoded && Object.keys(decoded).length > 0 ? decoded : null;
53−};
54−
55−export const parseJsonStringArray = (
56− label: string,
57− text: string,
58−): Array<string> | null => {
59− const decoded = decodeJsonField({
60− label,
61− text,
62− schema: JsonStringArraySchema,
63− });
64−
65− if (!decoded) {
66− return null;
67− }
68−
69− const normalized = decoded
70− .map((value) => value.trim())
71− .filter((value) => value.length > 0);
72−
73− return normalized.length > 0 ? normalized : null;
74−};
apps/web/src/views/secrets.tsx+42−22
@@ -1,5 +1,4 @@
11 import { useEffect, useState, type ReactNode } from "react";
2−import { Link } from "@tanstack/react-router";
32 import {
43 type InstanceConfig,
54 type SecretListItem,
@@ -42,7 +41,7 @@ export function SecretsPage() { · 36 unmodified lines
4241 : null;
4342
4443 return (
45− <div className="flex-1 overflow-y-auto">
44+ <div className="min-h-0 flex-1 overflow-y-auto">
4645 <div className="mx-auto max-w-3xl px-6 py-10 lg:px-10 lg:py-14">
4746 {/* Header */}
4847 <div className="flex items-end justify-between mb-8">
@@ -259,19 +258,16 @@ function SecretRow(props: { · 210 unmodified lines
259258 const { secret, providerLabel, isEditing } = props;
260259 const deleteSecret = useDeleteSecret();
261260 const [isDeleting, setIsDeleting] = useState(false);
261+ const [confirmDelete, setConfirmDelete] = useState(false);
262262
263263 const handleDelete = async () => {
264− const confirmed = window.confirm(
265− `Delete "${secret.name || secret.id}"? This cannot be undone. Sources using this secret will lose access.`,
266− );
267− if (!confirmed) return;
268−
269264 setIsDeleting(true);
270265 try {
271266 await deleteSecret.mutateAsync(secret.id);
272267 } catch {
273268 // refresh will show the secret still there
274269 } finally {
270+ setConfirmDelete(false);
275271 setIsDeleting(false);
276272 }
277273 };
@@ -313,15 +309,12 @@ function SecretRow(props: { · 35 unmodified lines
313309 <div className="flex items-center gap-1.5 mt-1 flex-wrap">
314310 <span className="text-[10px] text-muted-foreground/50">Used by</span>
315311 {secret.linkedSources.map((ls) => (
316− <Link
312+ <span
317313 key={ls.sourceId}
318− to="/sources/$sourceId"
319− params={{ sourceId: ls.sourceId }}
320− search={{ tab: "model" }}
321− className="inline-flex items-center gap-1 rounded-md bg-accent/60 px-1.5 py-0.5 text-[10px] font-medium text-foreground/70 transition-colors hover:bg-accent hover:text-foreground"
314+ className="inline-flex items-center gap-1 rounded-md bg-accent/60 px-1.5 py-0.5 text-[10px] font-medium text-foreground/70"
322315 >
323316 {ls.sourceName}
324− </Link>
317+ </span>
325318 ))}
326319 </div>
327320 )}
@@ -341,15 +334,42 @@ function SecretRow(props: { · 13 unmodified lines
341334 <IconPencil className="size-3" />
342335 Edit
343336 </button>
344− <button
345− type="button"
346− onClick={handleDelete}
347− disabled={isDeleting}
348− className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:text-destructive hover:bg-destructive/8 disabled:opacity-50"
349− >
350− {isDeleting ? <IconSpinner className="size-3" /> : <IconTrash className="size-3" />}
351− Delete
352− </button>
337+ {confirmDelete ? (
338+ <>
339+ <span className="text-[10px] font-medium text-destructive">
340+ Confirm delete?
341+ </span>
342+ <button
343+ type="button"
344+ onClick={() => setConfirmDelete(false)}
345+ disabled={isDeleting}
346+ className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-50"
347+ >
348+ Cancel
349+ </button>
350+ <button
351+ type="button"
352+ onClick={() => {
353+ void handleDelete();
354+ }}
355+ disabled={isDeleting}
356+ className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-destructive transition-colors hover:bg-destructive/8 disabled:opacity-50"
357+ >
358+ {isDeleting ? <IconSpinner className="size-3" /> : <IconTrash className="size-3" />}
359+ {isDeleting ? "Deleting..." : "Delete"}
360+ </button>
361+ </>
362+ ) : (
363+ <button
364+ type="button"
365+ onClick={() => setConfirmDelete(true)}
366+ disabled={isDeleting}
367+ className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:text-destructive hover:bg-destructive/8 disabled:opacity-50"
368+ >
369+ {isDeleting ? <IconSpinner className="size-3" /> : <IconTrash className="size-3" />}
370+ Delete
371+ </button>
372+ )}
353373 </div>
354374 </div>
355375
apps/web/src/views/source-detail.tsx+0−1172
@@ -1,1172 +0,0 @@
1−import { useState, useEffect, useCallback, useRef, useMemo } from "react";
2−import { Link } from "@tanstack/react-router";
3−import {
4− type ConnectSourcePayload,
5− useSource,
6− useSources,
7− useConnectSource,
8− useSourceInspection,
9− useSourceToolDetail,
10− useSourceDiscovery,
11− type Loadable,
12− type Source,
13− type SourceInspection,
14− type SourceInspectionToolDetail,
15− type SourceInspectionDiscoverResult,
16−} from "@executor/react";
17−import { cn } from "../lib/utils";
18−import { Badge, MethodBadge } from "../components/ui/badge";
19−import { Button } from "../components/ui/button";
20−import { LoadableBlock, EmptyState } from "../components/loadable";
21−import { DocumentPanel } from "../components/document-panel";
22−import { SourceNotFoundState } from "../components/source-not-found-state";
23−import { SourceRecoveryState } from "../components/source-recovery-state";
24−import {
25− IconSearch,
26− IconChevron,
27− IconTool,
28− IconFolder,
29− IconCopy,
30− IconCheck,
31− IconClose,
32− IconPencil,
33− IconSpinner,
34−} from "../components/icons";
35−import { Markdown } from "../components/markdown";
36−
37−// ---------------------------------------------------------------------------
38−// Types
39−// ---------------------------------------------------------------------------
40−
41−type SourceRouteSearch = {
42− tab: "model" | "discover";
43− tool?: string;
44− query?: string;
45−};
46−
47−const visibleTabs: Array<{ id: SourceRouteSearch["tab"]; label: string }> = [
48− { id: "model", label: "Tools" },
49− { id: "discover", label: "Search" },
50−];
51−
52−const isSourceNotFoundError = (loadable: Loadable<unknown>): boolean =>
53− loadable.status === "error"
54− && loadable.error.message.toLowerCase().includes("source not found");
55−
56−const isMissingCatalogArtifactError = (loadable: Loadable<unknown>): boolean =>
57− loadable.status === "error"
58− && loadable.error.message.toLowerCase().includes("catalog artifact missing for source");
59−
60−const listExcludesSource = (
61− sources: Loadable<ReadonlyArray<Source>>,
62− sourceId: string,
63−): boolean => sources.status === "ready" && !sources.data.some((source) => source.id === sourceId);
64−
65−type SourceDetailPageState =
66− | { kind: "source_not_found" }
67− | { kind: "tools_missing"; source: Source }
68− | { kind: "ready" };
69−
70−const resolveSourceDetailPageState = (input: {
71− sources: Loadable<ReadonlyArray<Source>>;
72− source: Loadable<Source>;
73− inspection: Loadable<SourceInspection>;
74− sourceId: string;
75−}): SourceDetailPageState => {
76− if (
77− listExcludesSource(input.sources, input.sourceId)
78− || isSourceNotFoundError(input.source)
79− || isSourceNotFoundError(input.inspection)
80− ) {
81− return { kind: "source_not_found" };
82− }
83−
84− if (input.source.status === "ready" && isMissingCatalogArtifactError(input.inspection)) {
85− return {
86− kind: "tools_missing",
87− source: input.source.data,
88− };
89− }
90−
91− return { kind: "ready" };
92−};
93−
94−const readBindingString = (source: Source, key: string): string | null =>
95− typeof source.binding[key] === "string" && source.binding[key].trim().length > 0
96− ? String(source.binding[key])
97− : null;
98−
99−const readBindingStringMap = (source: Source, key: string): Record<string, string> | null => {
100− const candidate = source.binding[key];
101− if (candidate === null || candidate === undefined || typeof candidate !== "object" || Array.isArray(candidate)) {
102− return null;
103− }
104−
105− const entries = Object.entries(candidate as Record<string, unknown>);
106− return entries.every(([, value]) => typeof value === "string")
107− ? Object.fromEntries(entries as ReadonlyArray<readonly [string, string]>)
108− : null;
109−};
110−
111−const readBindingStringArray = (source: Source, key: string): Array<string> | undefined => {
112− const candidate = source.binding[key];
113− return Array.isArray(candidate) && candidate.every((value) => typeof value === "string")
114− ? [...candidate]
115− : undefined;
116−};
117−
118−const readBindingTransport = (source: Source): "auto" | "streamable-http" | "sse" | "stdio" | undefined => {
119− const candidate = source.binding.transport;
120− return candidate === "auto" || candidate === "streamable-http" || candidate === "sse" || candidate === "stdio"
121− ? candidate
122− : undefined;
123−};
124−
125−type RefreshableHttpAuth =
126− | { kind: "none" }
127− | {
128− kind: "bearer";
129− headerName?: string | null;
130− prefix?: string | null;
131− token?: string | null;
132− tokenRef?: { providerId: string; handle: string } | null;
133− }
134− | {
135− kind: "oauth2";
136− headerName?: string | null;
137− prefix?: string | null;
138− accessToken?: string | null;
139− accessTokenRef?: { providerId: string; handle: string } | null;
140− refreshToken?: string | null;
141− refreshTokenRef?: { providerId: string; handle: string } | null;
142− };
143−
144−const refreshableHttpAuth = (
145− auth: Source["auth"],
146−): RefreshableHttpAuth | undefined => {
147− switch (auth.kind) {
148− case "none":
149− return { kind: "none" };
150− case "bearer":
151− return {
152− kind: "bearer",
153− headerName: auth.headerName,
154− prefix: auth.prefix,
155− tokenRef: auth.token,
156− };
157− case "oauth2":
158− return {
159− kind: "oauth2",
160− headerName: auth.headerName,
161− prefix: auth.prefix,
162− accessTokenRef: auth.accessToken,
163− refreshTokenRef: auth.refreshToken,
164− };
165− default:
166− return undefined;
167− }
168−};
169−
170−const refreshPayloadFromSource = (source: Source): ConnectSourcePayload | null => {
171− switch (source.kind) {
172− case "mcp":
173− return {
174− kind: "mcp",
175− endpoint: source.endpoint,
176− name: source.name,
177− namespace: source.namespace,
178− ...(readBindingTransport(source) ? { transport: readBindingTransport(source) } : {}),
179− queryParams: readBindingStringMap(source, "queryParams"),
180− headers: readBindingStringMap(source, "headers"),
181− command: readBindingString(source, "command"),
182− args: readBindingStringArray(source, "args"),
183− env: readBindingStringMap(source, "env"),
184− cwd: readBindingString(source, "cwd"),
185− };
186− case "openapi": {
187− const specUrl = readBindingString(source, "specUrl");
188− if (!specUrl) {
189− return null;
190− }
191−
192− return {
193− kind: "openapi",
194− endpoint: source.endpoint,
195− specUrl,
196− name: source.name,
197− namespace: source.namespace,
198− importAuthPolicy: source.importAuthPolicy,
199− importAuth: refreshableHttpAuth(source.importAuth),
200− auth: refreshableHttpAuth(source.auth),
201− };
202− }
203− case "graphql":
204− return {
205− kind: "graphql",
206− endpoint: source.endpoint,
207− name: source.name,
208− namespace: source.namespace,
209− importAuthPolicy: source.importAuthPolicy,
210− importAuth: refreshableHttpAuth(source.importAuth),
211− auth: refreshableHttpAuth(source.auth),
212− };
213− case "google_discovery": {
214− const service = readBindingString(source, "service");
215− const version = readBindingString(source, "version");
216− if (!service || !version) {
217− return null;
218− }
219−
220− return {
221− kind: "google_discovery",
222− service,
223− version,
224− discoveryUrl: readBindingString(source, "discoveryUrl"),
225− scopes: readBindingStringArray(source, "scopes"),
226− oauthClient: null,
227− name: source.name,
228− namespace: source.namespace,
229− importAuthPolicy: source.importAuthPolicy,
230− importAuth: refreshableHttpAuth(source.importAuth),
231− auth: refreshableHttpAuth(source.auth),
232− };
233− }
234− default:
235− return null;
236− }
237−};
238−
239−// ---------------------------------------------------------------------------
240−// SourceDetailPage (main export)
241−// ---------------------------------------------------------------------------
242−
243−export function SourceDetailPage(props: {
244− sourceId: string;
245− search: SourceRouteSearch;
246− navigate: (opts: { search: (prev: SourceRouteSearch) => SourceRouteSearch; replace?: boolean }) => void;
247−}) {
248− const { sourceId, search, navigate } = props;
249− const sources = useSources();
250− const source = useSource(sourceId);
251− const connectSource = useConnectSource();
252− const inspection = useSourceInspection(sourceId);
253− const [refreshFeedback, setRefreshFeedback] = useState<{
254− tone: "success" | "error";
255− text: string;
256− } | null>(null);
257− const sourceDetailPageState = resolveSourceDetailPageState({
258− sources,
259− source,
260− inspection,
261− sourceId,
262− });
263−
264− const selectedToolPath =
265− search.tool
266− ?? (inspection.status === "ready" ? inspection.data.tools[0]?.path : undefined);
267−
268− const toolDetail = useSourceToolDetail(
269− sourceId,
270− search.tab === "model" ? selectedToolPath ?? null : null,
271− );
272−
273− const discovery = useSourceDiscovery({
274− sourceId,
275− query: search.query ?? "",
276− limit: 12,
277− });
278−
279− const handleRefresh = useCallback(async (currentSource: Source) => {
280− const payload = refreshPayloadFromSource(currentSource);
281− if (!payload) {
282− setRefreshFeedback({
283− tone: "error",
284− text: "Refresh is unavailable until this source has complete connection details.",
285− });
286− return;
287− }
288−
289− setRefreshFeedback(null);
290− try {
291− await connectSource.mutateAsync(payload);
292− setRefreshFeedback({
293− tone: "success",
294− text: `Refreshed ${currentSource.name}.`,
295− });
296− } catch (error) {
297− setRefreshFeedback({
298− tone: "error",
299− text: error instanceof Error ? error.message : "Failed to refresh source.",
300− });
301− }
302− }, [connectSource]);
303−
304− // Auto-select first tool
305− useEffect(() => {
306− if (search.tab !== "model" || search.tool || inspection.status !== "ready") return;
307− const firstTool = inspection.data.tools[0]?.path;
308− if (!firstTool) return;
309− void navigate({ search: (prev) => ({ ...prev, tool: firstTool }), replace: true });
310− }, [inspection, navigate, search.tab, search.tool]);
311−
312− if (sourceDetailPageState.kind === "source_not_found") {
313− return <SourceNotFoundState />;
314− }
315−
316− if (sourceDetailPageState.kind === "tools_missing") {
317− const refreshPayload = refreshPayloadFromSource(sourceDetailPageState.source);
318−
319− return (
320− <SourceRecoveryState
321− source={sourceDetailPageState.source}
322− title="Tools need to be rebuilt"
323− description="This source exists in your workspace, but its generated tool catalog is missing. Refresh to reconnect and rebuild the tools."
324− refreshLabel="Refresh source"
325− refreshTitle={
326− refreshPayload === null
327− ? "Refresh is unavailable for this source configuration"
328− : "Reconnect and rebuild this source"
329− }
330− refreshDisabled={connectSource.status === "pending" || refreshPayload === null}
331− refreshPending={connectSource.status === "pending"}
332− feedback={refreshFeedback}
333− onRefresh={() => void handleRefresh(sourceDetailPageState.source)}
334− />
335− );
336− }
337−
338− return (
339− <LoadableBlock loadable={inspection} loading="Loading source...">
340− {(bundle) => {
341− const selectedTool =
342− bundle.tools.find((t) => t.path === selectedToolPath) ?? bundle.tools[0] ?? null;
343−
344− return (
345− <div className="flex h-full flex-col overflow-hidden">
346− {/* Header bar */}
347− <div className="flex shrink-0 items-center justify-between border-b border-border bg-background/95 backdrop-blur-sm px-4 h-12">
348− <div className="flex items-center gap-3 min-w-0">
349− <h2 className="truncate text-sm font-semibold text-foreground">
350− {bundle.source.name}
351− </h2>
352− <Badge variant="outline">{bundle.source.kind}</Badge>
353− <span className="hidden text-[11px] tabular-nums text-muted-foreground/50 sm:block">
354− {bundle.toolCount} {bundle.toolCount === 1 ? "tool" : "tools"}
355− </span>
356− </div>
357− <div className="flex items-center gap-2">
358− {refreshFeedback && (
359− <span
360− className={cn(
361− "hidden max-w-72 truncate text-[11px] font-medium sm:inline-flex sm:items-center sm:gap-1.5",
362− refreshFeedback.tone === "success"
363− ? "text-primary"
364− : "text-destructive",
365− )}
366− title={refreshFeedback.text}
367− >
368− {refreshFeedback.tone === "success" && (
369− <IconCheck className="size-3 shrink-0" />
370− )}
371− {refreshFeedback.text}
372− </span>
373− )}
374− <div className="flex items-center gap-0.5 rounded-lg bg-muted/50 p-0.5">
375− {visibleTabs.map((tab) => (
376− <button
377− key={tab.id}
378− type="button"
379− onClick={() => void navigate({ search: (prev) => ({ ...prev, tab: tab.id }) })}
380− className={cn(
381− "rounded-md px-3 py-1 text-[12px] font-medium transition-colors",
382− tab.id === search.tab
383− ? "bg-background text-foreground shadow-sm"
384− : "text-muted-foreground hover:text-foreground",
385− )}
386− >
387− {tab.label}
388− </button>
389− ))}
390− </div>
391− <Button
392− variant="outline"
393− size="sm"
394− onClick={() => void handleRefresh(bundle.source)}
395− disabled={connectSource.status === "pending" || refreshPayloadFromSource(bundle.source) === null}
396− title={
397− refreshPayloadFromSource(bundle.source) === null
398− ? "Refresh is unavailable for this source configuration"
399− : "Reconnect and resync this source"
400− }
401− >
402− {connectSource.status === "pending"
403− ? <IconSpinner className="size-3" />
404− : null}
405− Refresh
406− </Button>
407− <Link
408− to="/sources/$sourceId/edit"
409− params={{ sourceId }}
410− className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-accent/50"
411− >
412− <IconPencil className="size-3" />
413− Edit
414− </Link>
415− </div>
416− </div>
417−
418− {/* Tab content */}
419− <div className="flex flex-1 min-h-0 overflow-hidden">
420− {search.tab === "model" && (
421− <ModelView
422− bundle={bundle}
423− detail={toolDetail}
424− selectedToolPath={selectedTool?.path ?? null}
425− onSelectTool={(toolPath) =>
426− void navigate({ search: (prev) => ({ ...prev, tool: toolPath, tab: "model" }) })
427− }
428− sourceId={sourceId}
429− />
430− )}
431− {search.tab === "discover" && (
432− <DiscoveryView
433− bundle={bundle}
434− discovery={discovery}
435− initialQuery={search.query ?? ""}
436− onSubmitQuery={(query) =>
437− void navigate({ search: (prev) => ({ ...prev, query, tab: "discover" }) })
438− }
439− onOpenTool={(toolPath) =>
440− void navigate({ search: (prev) => ({ ...prev, tab: "model", tool: toolPath }) })
441− }
442− />
443− )}
444− </div>
445− </div>
446− );
447− }}
448− </LoadableBlock>
449− );
450−}
451−
452−// ---------------------------------------------------------------------------
453−// ModelView — two-panel: tool list + tool detail
454−// ---------------------------------------------------------------------------
455−
456−function ModelView(props: {
457− bundle: SourceInspection;
458− detail: Loadable<SourceInspectionToolDetail | null>;
459− selectedToolPath: string | null;
460− onSelectTool: (toolPath: string) => void;
461− sourceId: string;
462−}) {
463− const [search, setSearch] = useState("");
464− const searchRef = useRef<HTMLInputElement>(null);
465− const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
466−
467− const filteredTools = props.bundle.tools.filter((tool) => {
468− if (terms.length === 0) return true;
469− const corpus = [
470− tool.path,
471− tool.method ?? "",
472− tool.inputTypePreview ?? "",
473− tool.outputTypePreview ?? "",
474− ]
475− .join(" ")
476− .toLowerCase();
477− return terms.every((t) => corpus.includes(t));
478− });
479−
480− // Keyboard shortcut: / to focus search
481− useEffect(() => {
482− const handler = (e: KeyboardEvent) => {
483− if (e.key === "/" && document.activeElement?.tagName !== "INPUT") {
484− e.preventDefault();
485− searchRef.current?.focus();
486− }
487− if (e.key === "Escape") {
488− searchRef.current?.blur();
489− if (search.length > 0) setSearch("");
490− }
491− };
492− document.addEventListener("keydown", handler);
493− return () => document.removeEventListener("keydown", handler);
494− }, [search]);
495−
496− return (
497− <>
498− {/* Left panel: tool list */}
499− <div className="flex w-72 shrink-0 flex-col border-r border-border bg-card/30 lg:w-80 xl:w-[22rem]">
500− {/* Search */}
501− <div className="shrink-0 border-b border-border px-3 py-2">
502− <div className="relative">
503− <IconSearch className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/40" />
504− <input
505− ref={searchRef}
506− value={search}
507− onChange={(e) => setSearch(e.target.value)}
508− placeholder={`Filter ${props.bundle.toolCount} tools\u2026`}
509− className="h-8 w-full rounded-md border border-input bg-background pl-8 pr-8 text-[13px] outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/30"
510− />
511− {search.length > 0 ? (
512− <button
513− type="button"
514− onClick={() => setSearch("")}
515− className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground/40 hover:text-foreground"
516− >
517− <IconClose />
518− </button>
519− ) : (
520− <kbd className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded border border-border bg-muted px-1 py-px text-[10px] text-muted-foreground/50">
521− /
522− </kbd>
523− )}
524− </div>
525− </div>
526−
527− {/* Tool list */}
528− <div className="flex-1 overflow-y-auto">
529− {filteredTools.length === 0 ? (
530− <div className="p-4 text-center text-[13px] text-muted-foreground/50">
531− {terms.length > 0 ? "No tools match your filter" : "No tools available"}
532− </div>
533− ) : (
534− <div className="p-1.5">
535− <ToolTree
536− tools={filteredTools}
537− selectedToolPath={props.selectedToolPath}
538− onSelectTool={props.onSelectTool}
539− search={search}
540− isFiltered={terms.length > 0}
541− />
542− </div>
543− )}
544− </div>
545− </div>
546−
547− {/* Right panel: tool detail */}
548− <div className="flex flex-1 flex-col min-w-0 overflow-hidden">
549− <LoadableBlock loadable={props.detail} loading="Loading tool...">
550− {(detail) =>
551− detail ? (
552− <ToolDetailPanel detail={detail} />
553− ) : (
554− <EmptyState
555− title={props.bundle.toolCount > 0 ? "Select a tool" : "No tools available"}
556− description={props.bundle.toolCount > 0 ? "Choose from the list or press / to search" : undefined}
557− />
558− )
559− }
560− </LoadableBlock>
561− </div>
562− </>
563− );
564−}
565−
566−// ---------------------------------------------------------------------------
567−// Tool Tree (nested by . segments)
568−// ---------------------------------------------------------------------------
569−
570−type ToolTreeNode = {
571− segment: string;
572− tool?: SourceInspection["tools"][number];
573− children: Map<string, ToolTreeNode>;
574−};
575−
576−function buildToolTree(tools: SourceInspection["tools"]): ToolTreeNode {
577− const root: ToolTreeNode = { segment: "", children: new Map() };
578− for (const tool of tools) {
579− const parts = tool.path.split(".");
580− let node = root;
581− for (const part of parts) {
582− if (!node.children.has(part)) {
583− node.children.set(part, { segment: part, children: new Map() });
584− }
585− node = node.children.get(part)!;
586− }
587− node.tool = tool;
588− }
589− return root;
590−}
591−
592−function ToolTree(props: {
593− tools: SourceInspection["tools"];
594− selectedToolPath: string | null;
595− onSelectTool: (path: string) => void;
596− search: string;
597− isFiltered: boolean;
598−}) {
599− const tree = useMemo(() => buildToolTree(props.tools), [props.tools]);
600− const entries = [...tree.children.values()].sort((a, b) =>
601− a.segment.localeCompare(b.segment),
602− );
603−
604− return (
605− <div className="flex flex-col gap-px">
606− {entries.map((node) => (
607− <ToolTreeNodeView
608− key={node.segment}
609− node={node}
610− depth={0}
611− selectedToolPath={props.selectedToolPath}
612− onSelectTool={props.onSelectTool}
613− search={props.search}
614− defaultOpen={props.isFiltered}
615− />
616− ))}
617− </div>
618− );
619−}
620−
621−function ToolTreeNodeView(props: {
622− node: ToolTreeNode;
623− depth: number;
624− selectedToolPath: string | null;
625− onSelectTool: (path: string) => void;
626− search: string;
627− defaultOpen: boolean;
628−}) {
629− const { node, depth, selectedToolPath, onSelectTool, search, defaultOpen } = props;
630− const hasChildren = node.children.size > 0;
631− const isLeaf = !!node.tool && !hasChildren;
632−
633− const hasSelectedDescendant = useMemo(() => {
634− if (!selectedToolPath) return false;
635− function check(n: ToolTreeNode): boolean {
636− if (n.tool?.path === selectedToolPath) return true;
637− for (const child of n.children.values()) {
638− if (check(child)) return true;
639− }
640− return false;
641− }
642− return check(node);
643− }, [node, selectedToolPath]);
644−
645− const [open, setOpen] = useState(defaultOpen || hasSelectedDescendant);
646−
647− useEffect(() => {
648− if (defaultOpen || hasSelectedDescendant) setOpen(true);
649− }, [defaultOpen, hasSelectedDescendant]);
650−
651− if (isLeaf) {
652− return (
653− <ToolListItem
654− tool={node.tool!}
655− active={node.tool!.path === selectedToolPath}
656− onSelect={() => onSelectTool(node.tool!.path)}
657− search={search}
658− depth={depth}
659− />
660− );
661− }
662−
663− const paddingLeft = 8 + depth * 16;
664− const sortedChildren = [...node.children.values()].sort((a, b) =>
665− a.segment.localeCompare(b.segment),
666− );
667−
668− const leafCount = countToolLeaves(node);
669−
670− return (
671− <div>
672− {node.tool ? (
673− <div className="flex items-center">
674− <button
675− type="button"
676− onClick={() => setOpen((o) => !o)}
677− className="shrink-0 rounded p-0.5 text-muted-foreground/30 hover:text-muted-foreground"
678− style={{ marginLeft: paddingLeft }}
679− >
680− <IconChevron
681− className={cn(
682− "size-2.5 transition-transform duration-150",
683− open && "rotate-90",
684− )}
685− />
686− </button>
687− <ToolListItem
688− tool={node.tool}
689− active={node.tool.path === selectedToolPath}
690− onSelect={() => onSelectTool(node.tool!.path)}
691− search={search}
692− depth={-1}
693− className="flex-1 pl-1"
694− />
695− </div>
696− ) : (
697− <button
698− type="button"
699− onClick={() => setOpen((o) => !o)}
700− className={cn(
701− "group flex w-full items-center gap-1.5 rounded-md py-1 pr-2.5 text-[12px] transition-colors hover:bg-accent/40",
702− open ? "text-foreground/80" : "text-muted-foreground/60",
703− )}
704− style={{ paddingLeft }}
705− >
706− <IconChevron
707− className={cn(
708− "size-2.5 shrink-0 text-muted-foreground/30 transition-transform duration-150",
709− open && "rotate-90",
710− )}
711− />
712− <IconFolder className={cn(
713− "size-3 shrink-0",
714− open ? "text-primary/60" : "text-muted-foreground/30",
715− )} />
716− <span className="flex-1 truncate text-left font-mono">
717− {highlightMatch(node.segment, search)}
718− </span>
719− <span className="shrink-0 tabular-nums text-[10px] text-muted-foreground/25">
720− {leafCount}
721− </span>
722− </button>
723− )}
724−
725− {open && hasChildren && (
726− <div className="relative flex flex-col gap-px">
727− <span
728− className="absolute top-0 bottom-1 w-px bg-border/40"
729− style={{ left: paddingLeft + 5 }}
730− aria-hidden
731− />
732− {sortedChildren.map((child) => (
733− <ToolTreeNodeView
734− key={child.segment}
735− node={child}
736− depth={depth + 1}
737− selectedToolPath={selectedToolPath}
738− onSelectTool={onSelectTool}
739− search={search}
740− defaultOpen={defaultOpen}
741− />
742− ))}
743− </div>
744− )}
745− </div>
746− );
747−}
748−
749−function countToolLeaves(node: ToolTreeNode): number {
750− let count = node.tool ? 1 : 0;
751− for (const child of node.children.values()) {
752− count += countToolLeaves(child);
753− }
754− return count;
755−}
756−
757−// ---------------------------------------------------------------------------
758−// ToolListItem
759−// ---------------------------------------------------------------------------
760−
761−function ToolListItem(props: {
762− tool: SourceInspection["tools"][number];
763− active: boolean;
764− onSelect: () => void;
765− search: string;
766− depth: number;
767− className?: string;
768−}) {
769− const ref = useRef<HTMLButtonElement>(null);
770− const paddingLeft = props.depth >= 0 ? 8 + props.depth * 16 + 8 : undefined;
771−
772− useEffect(() => {
773− if (props.active && ref.current) {
774− ref.current.scrollIntoView({ block: "nearest" });
775− }
776− }, [props.active]);
777−
778− const label = props.depth >= 0
779− ? props.tool.path.split(".").pop() ?? props.tool.path
780− : props.tool.path;
781−
782− return (
783− <button
784− ref={ref}
785− type="button"
786− onClick={props.onSelect}
787− className={cn(
788− "group flex w-full items-center gap-2 rounded-md py-1.5 pr-2.5 text-left transition-colors",
789− props.active
790− ? "bg-primary/10 text-foreground border-l-2 border-l-primary"
791− : "hover:bg-accent/50 text-foreground/70 hover:text-foreground",
792− props.className,
793− )}
794− style={paddingLeft != null ? { paddingLeft } : undefined}
795− >
796− <IconTool className="size-3 shrink-0 text-muted-foreground/40" />
797− <span className="flex-1 truncate font-mono text-[12px]">
798− {highlightMatch(label, props.search)}
799− </span>
800− {props.tool.method && <MethodBadge method={props.tool.method} />}
801− </button>
802− );
803−}
804−
805−// ---------------------------------------------------------------------------
806−// ToolDetailPanel
807−// ---------------------------------------------------------------------------
808−
809−function ToolDetailPanel(props: { detail: SourceInspectionToolDetail }) {
810− const { detail } = props;
811− const [copiedField, setCopiedField] = useState<string | null>(null);
812−
813− const copy = useCallback((text: string, field: string) => {
814− void navigator.clipboard.writeText(text).then(() => {
815− setCopiedField(field);
816− setTimeout(() => setCopiedField(null), 1500);
817− });
818− }, []);
819−
820− return (
821− <div className="flex h-full flex-col overflow-hidden">
822− {/* Header */}
823− <div className="shrink-0 border-b border-border bg-background/95 backdrop-blur-sm">
824− <div className="flex items-start gap-3 px-5 py-3.5">
825− <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
826− <IconTool className="size-4" />
827− </div>
828− <div className="min-w-0 flex-1">
829− <div className="flex items-center gap-2">
830− <h3 className="truncate text-sm font-semibold text-foreground">
831− {detail.summary.path}
832− </h3>
833− <CopyButton text={detail.summary.path} field="path" copiedField={copiedField} onCopy={copy} />
834− </div>
835− <div className="mt-1 flex flex-wrap items-center gap-1.5">
836− {detail.summary.method && <MethodBadge method={detail.summary.method} />}
837− {detail.summary.pathTemplate && (
838− <span className="font-mono text-[11px] text-muted-foreground/60">
839− {detail.summary.pathTemplate}
840− </span>
841− )}
842− </div>
843− <div className="mt-2 rounded-md border border-border bg-card/70 px-2.5 py-2">
844− <div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
845− TypeScript Signature
846− </div>
847− <pre className="max-h-40 overflow-auto whitespace-pre-wrap font-mono text-[12px] text-foreground">
848− {detail.contract.callDeclaration}
849− </pre>
850− </div>
851− </div>
852− </div>
853− </div>
854−
855− {/* Body */}
856− <div className="flex-1 overflow-y-auto">
857− <div className="px-5 py-4 space-y-4">
858− <section className="overflow-hidden rounded-lg border border-border bg-card/60">
859− <div className="border-b border-border px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
860− Contract
861− </div>
862− <div className="grid grid-cols-1 gap-3 p-3 xl:grid-cols-2">
863− <DocumentPanel
864− title="Input Type"
865− body={detail.contract.input.typeDeclaration}
866− lang="typescript"
867− empty="No input type."
868− />
869− <DocumentPanel
870− title="Output Type"
871− body={detail.contract.output.typeDeclaration}
872− lang="typescript"
873− empty="No output type."
874− />
875− </div>
876− </section>
877−
878− <section className="overflow-hidden rounded-lg border border-border bg-card/60">
879− <div className="border-b border-border px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
880− Schemas
881− </div>
882− <div className="grid grid-cols-1 gap-3 p-3 xl:grid-cols-2">
883− <DocumentPanel
884− title="Input Schema"
885− body={detail.contract.input.schemaJson}
886− lang="json"
887− empty="No input schema."
888− compact
889− />
890− <DocumentPanel
891− title="Output Schema"
892− body={detail.contract.output.schemaJson}
893− lang="json"
894− empty="No output schema."
895− compact
896− />
897− {detail.contract.input.exampleJson && (
898− <DocumentPanel
899− title="Example Request"
900− body={detail.contract.input.exampleJson}
901− lang="json"
902− empty=""
903− compact
904− />
905− )}
906− {detail.contract.output.exampleJson && (
907− <DocumentPanel
908− title="Example Response"
909− body={detail.contract.output.exampleJson}
910− lang="json"
911− empty=""
912− compact
913− />
914− )}
915− </div>
916− </section>
917−
918− {detail.sections.map((section, index) => {
919− if (section.kind === "facts") {
920− return (
921− <section
922− key={`${section.kind}:${section.title}:${String(index)}`}
923− className="overflow-hidden rounded-lg border border-border bg-card/60"
924− >
925− <div className="border-b border-border px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
926− {section.title}
927− </div>
928− <div className="grid grid-cols-1 gap-3 p-3 md:grid-cols-2">
929− {section.items.map((item) => (
930− <div key={`${section.title}:${item.label}`} className="min-w-0">
931− <div className="text-[11px] uppercase tracking-wider text-muted-foreground/60">
932− {item.label}
933− </div>
934− <div className={cn(
935− "mt-1 text-sm text-foreground",
936− item.mono && "font-mono text-[12px]",
937− )}
938− >
939− {item.value}
940− </div>
941− </div>
942− ))}
943− </div>
944− </section>
945− );
946− }
947−
948− if (section.kind === "markdown") {
949− return (
950− <section
951− key={`${section.kind}:${section.title}:${String(index)}`}
952− className="overflow-hidden rounded-lg border border-border bg-card/60"
953− >
954− <div className="border-b border-border px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
955− {section.title}
956− </div>
957− <div className="p-4">
958− <Markdown>{section.body}</Markdown>
959− </div>
960− </section>
961− );
962− }
963−
964− return (
965− <DocumentPanel
966− key={`${section.kind}:${section.title}:${String(index)}`}
967− title={section.title}
968− body={section.body}
969− lang={section.language === "text" ? undefined : section.language}
970− empty=""
971− compact
972− />
973− );
974− })}
975− </div>
976− </div>
977− </div>
978− );
979−}
980−
981−// ---------------------------------------------------------------------------
982−// DiscoveryView
983−// ---------------------------------------------------------------------------
984−
985−function DiscoveryView(props: {
986− bundle: SourceInspection;
987− discovery: Loadable<SourceInspectionDiscoverResult>;
988− initialQuery: string;
989− onSubmitQuery: (query: string) => void;
990− onOpenTool: (toolPath: string) => void;
991−}) {
992− const [draftQuery, setDraftQuery] = useState(props.initialQuery);
993−
994− useEffect(() => {
995− setDraftQuery(props.initialQuery);
996− }, [props.initialQuery]);
997−
998− return (
999− <div className="flex flex-1 flex-col overflow-hidden">
1000− {/* Search bar */}
1001− <div className="shrink-0 border-b border-border px-4 py-3">
1002− <form
1003− className="flex items-center gap-2 max-w-2xl"
1004− onSubmit={(e) => {
1005− e.preventDefault();
1006− props.onSubmitQuery(draftQuery.trim());
1007− }}
1008− >
1009− <div className="relative flex-1">
1010− <IconSearch className="absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/40" />
1011− <input
1012− value={draftQuery}
1013− onChange={(e) => setDraftQuery(e.target.value)}
1014− placeholder="Search tools\u2026"
1015− className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/30"
1016− />
1017− </div>
1018− <Button type="submit" size="sm">Search</Button>
1019− </form>
1020− </div>
1021−
1022− {/* Results */}
1023− <div className="flex-1 overflow-y-auto p-4">
1024− <LoadableBlock loadable={props.discovery} loading="Searching\u2026">
1025− {(result) =>
1026− result.query.length === 0 ? (
1027− <EmptyState
1028− title="Search your tools"
1029− description="Type a query to find matching tools across this source."
1030− />
1031− ) : result.results.length === 0 ? (
1032− <EmptyState
1033− title="No results"
1034− description="Try different search terms."
1035− />
1036− ) : (
1037− <div className="max-w-3xl space-y-2">
1038− {result.results.map((item, index) => (
1039− <button
1040− key={item.path}
1041− type="button"
1042− onClick={() => props.onOpenTool(item.path)}
1043− className="group w-full rounded-lg border border-border bg-card/60 p-3.5 text-left transition-all hover:border-primary/30 hover:shadow-sm"
1044− >
1045− <div className="flex items-center justify-between gap-3 mb-1.5">
1046− <div className="flex items-center gap-2.5 min-w-0">
1047− <span className="flex size-5 shrink-0 items-center justify-center rounded-md bg-muted text-[10px] font-mono tabular-nums text-muted-foreground/60">
1048− {index + 1}
1049− </span>
1050− <h4 className="truncate font-mono text-[13px] font-medium text-foreground group-hover:text-primary transition-colors">
1051− {item.path}
1052− </h4>
1053− </div>
1054− <span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground/50">
1055− {item.score.toFixed(2)}
1056− </span>
1057− </div>
1058− {item.description && (
1059− <p className="text-[12px] text-muted-foreground leading-relaxed line-clamp-2">
1060− {item.description}
1061− </p>
1062− )}
1063− {(item.inputTypePreview || item.outputTypePreview) && (
1064− <div className="mt-2 grid grid-cols-1 gap-2 md:grid-cols-2">
1065− {item.inputTypePreview && (
1066− <div className="rounded-md border border-border/70 bg-background/70 px-2.5 py-2">
1067− <div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/55">
1068− Input
1069− </div>
1070− <div className="font-mono text-[11px] text-foreground/85 line-clamp-3">
1071− {item.inputTypePreview}
1072− </div>
1073− </div>
1074− )}
1075− {item.outputTypePreview && (
1076− <div className="rounded-md border border-border/70 bg-background/70 px-2.5 py-2">
1077− <div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/55">
1078− Output
1079− </div>
1080− <div className="font-mono text-[11px] text-foreground/85 line-clamp-3">
1081− {item.outputTypePreview}
1082− </div>
1083− </div>
1084− )}
1085− </div>
1086− )}
1087− </button>
1088− ))}
1089− </div>
1090− )
1091− }
1092− </LoadableBlock>
1093− </div>
1094− </div>
1095− );
1096−}
1097−
1098−// ---------------------------------------------------------------------------
1099−// Shared sub-components
1100−// ---------------------------------------------------------------------------
1101−
1102−function CopyButton(props: {
1103− text: string;
1104− field: string;
1105− copiedField: string | null;
1106− onCopy: (text: string, field: string) => void;
1107−}) {
1108− return (
1109− <button
1110− type="button"
1111− onClick={() => props.onCopy(props.text, props.field)}
1112− className="shrink-0 rounded p-1 text-muted-foreground/30 transition-colors hover:text-muted-foreground"
1113− title={`Copy ${props.field}`}
1114− >
1115− {props.copiedField === props.field ? <IconCheck /> : <IconCopy />}
1116− </button>
1117− );
1118−}
1119−
1120−function highlightMatch(text: string, search: string) {
1121− if (!search.trim()) return text;
1122− const terms = search.trim().toLowerCase().split(/\s+/);
1123− const lowerText = text.toLowerCase();
1124− const ranges: Array<[number, number]> = [];
1125−
1126− for (const term of terms) {
1127− let idx = 0;
1128− while (idx < lowerText.length) {
1129− const found = lowerText.indexOf(term, idx);
1130− if (found === -1) break;
1131− ranges.push([found, found + term.length]);
1132− idx = found + 1;
1133− }
1134− }
1135−
1136− if (ranges.length === 0) return text;
1137−
1138− ranges.sort((a, b) => a[0] - b[0]);
1139− const merged: Array<[number, number]> = [ranges[0]!];
1140− for (let i = 1; i < ranges.length; i++) {
1141− const last = merged[merged.length - 1]!;
1142− const current = ranges[i]!;
1143− if (current[0] <= last[1]) {
1144− last[1] = Math.max(last[1], current[1]);
1145− } else {
1146− merged.push(current);
1147− }
1148− }
1149−
1150− const parts: Array<{ text: string; hl: boolean }> = [];
1151− let cursor = 0;
1152− for (const [start, end] of merged) {
1153− if (cursor < start) parts.push({ text: text.slice(cursor, start), hl: false });
1154− parts.push({ text: text.slice(start, end), hl: true });
1155− cursor = end;
1156− }
1157− if (cursor < text.length) parts.push({ text: text.slice(cursor), hl: false });
1158−
1159− return (
1160− <>
1161− {parts.map((part, i) =>
1162− part.hl ? (
1163− <mark key={i} className="rounded-sm bg-primary/20 text-foreground px-px">
1164− {part.text}
1165− </mark>
1166− ) : (
1167− <span key={i}>{part.text}</span>
1168− ),
1169− )}
1170− </>
1171− );
1172−}
apps/web/src/views/source-editor.tsx+0−1919
@@ -1,1919 +0,0 @@
1−import { useEffect, useState, type ReactNode } from "react";
2−import { Link, useNavigate } from "@tanstack/react-router";
3−import {
4− type CompleteSourceOAuthResult,
5− type CreateSourcePayload,
6− type InstanceConfig,
7− type Loadable,
8− type StartSourceOAuthPayload,
9− type SecretListItem,
10− type Source,
11− type UpdateSourcePayload,
12− useCreateSecret,
13− useCreateSource,
14− useInstanceConfig,
15− useRefreshSecrets,
16− useRemoveProviderAuthGrant,
17− useRemoveSource,
18− useSecrets,
19− useSources,
20− useStartSourceOAuth,
21− useSource,
22− useUpdateSource,
23−} from "@executor/react";
24−import { Badge } from "../components/ui/badge";
25−import { Button } from "../components/ui/button";
26−import { LoadableBlock } from "../components/loadable";
27−import { SourceNotFoundState } from "../components/source-not-found-state";
28−import { SourceFavicon } from "../components/source-favicon";
29−import {
30− IconArrowLeft,
31− IconPencil,
32− IconPlus,
33− IconSpinner,
34− IconTrash,
35−} from "../components/icons";
36−import { cn } from "../lib/utils";
37−import {
38− asMcpRemoteTransportValue,
39− defaultMcpRemoteTransportFields,
40− defaultMcpStdioTransportFields,
41− setMcpTransportFieldsTransport,
42− type McpRemoteTransportFields,
43− type McpStdioTransportFields,
44− type McpTransportFields,
45− type McpTransportValue,
46−} from "./mcp-transport-state";
47−import { parseJsonStringArray, parseJsonStringMap } from "./json-form";
48−import { sourceTemplates, type SourceTemplate } from "./source-templates";
49−import { getDomain } from "tldts";
50−
51−type StatusBannerState = {
52− tone: "info" | "success" | "error";
53− text: string;
54−};
55−
56−type SourceOAuthPopupMessage =
57− | {
58− type: "executor:oauth-result";
59− ok: true;
60− sessionId: string;
61− auth: CompleteSourceOAuthResult["auth"];
62− }
63− | {
64− type: "executor:oauth-result";
65− ok: false;
66− sessionId: string | null;
67− error: string;
68− };
69−
70−const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000;
71−const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";
72−
73−const isSourceNotFoundLoadable = (loadable: Loadable<unknown>): boolean =>
74− loadable.status === "error" &&
75− loadable.error.message.toLowerCase().includes("source not found");
76−
77−type SourceFormBase = {
78− name: string;
79− kind: Source["kind"];
80− endpoint: string;
81− namespace: string;
82− enabled: boolean;
83− specUrl: string;
84− service: string;
85− version: string;
86− defaultHeadersText: string;
87− authKind: Source["auth"]["kind"];
88− authHeaderName: string;
89− authPrefix: string;
90− bearerProviderId: string;
91− bearerHandle: string;
92− oauthAccessProviderId: string;
93− oauthAccessHandle: string;
94− oauthRefreshProviderId: string;
95− oauthRefreshHandle: string;
96− managedAuth: Extract<
97− Source["auth"],
98− { kind: "provider_grant_ref" | "mcp_oauth" }
99− > | null;
100−};
101−
102−type SourceFormState = SourceFormBase & McpTransportFields;
103−
104−const kindOptions: ReadonlyArray<Source["kind"]> = [
105− "mcp",
106− "openapi",
107− "graphql",
108− "google_discovery",
109− "internal",
110−];
111−
112−const transportOptions: ReadonlyArray<Exclude<McpTransportValue, "">> = [
113− "auto",
114− "streamable-http",
115− "sse",
116− "stdio",
117−];
118−
119−const authOptions: ReadonlyArray<Source["auth"]["kind"]> = [
120− "none",
121− "bearer",
122− "oauth2",
123− "provider_grant_ref",
124− "mcp_oauth",
125−];
126−
127−const trimToNull = (value: string): string | null => {
128− const trimmed = value.trim();
129− return trimmed.length > 0 ? trimmed : null;
130−};
131−
132−const namespaceFromUrl = (url: string): string => {
133− try {
134− const domain = getDomain(url);
135− if (!domain) return "";
136− const dot = domain.indexOf(".");
137− return dot > 0 ? domain.slice(0, dot) : domain;
138− } catch {
139− return "";
140− }
141−};
142−
143−const googleDiscoveryNamespace = (service: string): string =>
144− `google.${service
145− .trim()
146− .toLowerCase()
147− .replace(/[^a-z0-9]+/g, ".")
148− .replace(/^\.+|\.+$/g, "")}`;
149−
150−const readStoredSourceOAuthPopupResult = (
151− sessionId: string,
152−): SourceOAuthPopupMessage | null => {
153− if (typeof window === "undefined") {
154− return null;
155− }
156−
157− const raw = window.localStorage.getItem(
158− `${SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX}${sessionId}`,
159− );
160− if (!raw) {
161− return null;
162− }
163−
164− try {
165− return JSON.parse(raw) as SourceOAuthPopupMessage;
166− } catch {
167− return null;
168− }
169−};
170−
171−const clearStoredSourceOAuthPopupResult = (sessionId: string): void => {
172− if (typeof window === "undefined") {
173− return;
174− }
175−
176− window.localStorage.removeItem(
177− `${SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX}${sessionId}`,
178− );
179−};
180−
181−const startSourceOAuthPopup = async (input: {
182− authorizationUrl: string;
183− sessionId: string;
184−}): Promise<CompleteSourceOAuthResult["auth"]> => {
185− if (typeof window === "undefined") {
186− throw new Error("OAuth popup is only available in a browser context");
187− }
188−
189− clearStoredSourceOAuthPopupResult(input.sessionId);
190−
191− const popup = window.open(
192− input.authorizationUrl,
193− "executor-source-oauth",
194− "popup=yes,width=520,height=720",
195− );
196−
197− if (!popup) {
198− throw new Error("Popup blocked. Allow popups and try again.");
199− }
200−
201− popup.focus();
202−
203− return await new Promise<CompleteSourceOAuthResult["auth"]>(
204− (resolve, reject) => {
205− let settled = false;
206− let closedPoll = 0;
207− let resultTimeout = 0;
208−
209− const cleanup = () => {
210− window.removeEventListener("message", onMessage);
211− if (closedPoll) {
212− window.clearInterval(closedPoll);
213− }
214− if (resultTimeout) {
215− window.clearTimeout(resultTimeout);
216− }
217− if (!popup.closed) {
218− popup.close();
219− }
220− clearStoredSourceOAuthPopupResult(input.sessionId);
221− };
222−
223− const settleWithError = (message: string) => {
224− if (settled) {
225− return;
226− }
227−
228− settled = true;
229− cleanup();
230− reject(new Error(message));
231− };
232−
233− const settleFromPayload = (data: SourceOAuthPopupMessage) => {
234− if (!data.ok) {
235− settleWithError(data.error || "OAuth failed");
236− return;
237− }
238−
239− if (settled) {
240− return;
241− }
242−
243− settled = true;
244− cleanup();
245− resolve(data.auth);
246− };
247−
248− const onMessage = (event: MessageEvent) => {
249− if (event.origin !== window.location.origin) {
250− return;
251− }
252−
253− const data = event.data as SourceOAuthPopupMessage | undefined;
254− if (!data || data.type !== "executor:oauth-result") {
255− return;
256− }
257−
258− if (data.ok && data.sessionId !== input.sessionId) {
259− return;
260− }
261−
262− if (
263− !data.ok &&
264− data.sessionId !== null &&
265− data.sessionId !== input.sessionId
266− ) {
267− return;
268− }
269−
270− settleFromPayload(data);
271− };
272−
273− window.addEventListener("message", onMessage);
274−
275− resultTimeout = window.setTimeout(() => {
276− settleWithError(
277− "OAuth popup timed out before completion. Please try again.",
278− );
279− }, SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS);
280−
281− closedPoll = window.setInterval(() => {
282− const stored = readStoredSourceOAuthPopupResult(input.sessionId);
283− if (stored) {
284− settleFromPayload(stored);
285− return;
286− }
287− if (popup.closed) {
288− // Stop polling — only run one final deferred check to give the
289− // callback page time to write localStorage before we give up.
290− window.clearInterval(closedPoll);
291− closedPoll = 0;
292− window.setTimeout(() => {
293− const delayedStored = readStoredSourceOAuthPopupResult(
294− input.sessionId,
295− );
296− if (delayedStored) {
297− settleFromPayload(delayedStored);
298− return;
299− }
300− settleWithError("OAuth popup was closed before completion.");
301− }, 1500);
302− }
303− }, 300);
304− },
305− );
306−};
307−
308−const stringMapToEditor = (value: Record<string, string> | null): string =>
309− value === null ? "" : JSON.stringify(value, null, 2);
310−
311−const stringArrayToEditor = (
312− value: ReadonlyArray<string> | null | undefined,
313−): string =>
314− !value || value.length === 0 ? "" : JSON.stringify(value, null, 2);
315−
316−const readBindingStringMap = (
317− source: Source,
318− key: string,
319−): Record<string, string> | null => {
320− const candidate = source.binding[key];
321− if (
322− candidate === null ||
323− candidate === undefined ||
324− typeof candidate !== "object" ||
325− Array.isArray(candidate)
326− ) {
327− return null;
328− }
329−
330− const entries = Object.entries(candidate as Record<string, unknown>);
331− return entries.every(([, value]) => typeof value === "string")
332− ? Object.fromEntries(entries as ReadonlyArray<readonly [string, string]>)
333− : null;
334−};
335−
336−const readBindingStringArray = (
337− source: Source,
338− key: string,
339−): Array<string> | null => {
340− const candidate = source.binding[key];
341− return Array.isArray(candidate) &&
342− candidate.every((value) => typeof value === "string")
343− ? [...candidate]
344− : null;
345−};
346−
347−const readBindingString = (source: Source, key: string): string =>
348− typeof source.binding[key] === "string" ? String(source.binding[key]) : "";
349−
350−const readBindingTransport = (source: Source): McpTransportValue => {
351− const candidate = source.binding.transport;
352− return typeof candidate === "string" &&
353− (candidate === "auto" ||
354− candidate === "streamable-http" ||
355− candidate === "sse" ||
356− candidate === "stdio")
357− ? candidate
358− : "";
359−};
360−
361−const buildSyntheticMcpStdioEndpoint = (input: {
362− name?: string | null;
363− endpoint?: string | null;
364− command?: string | null;
365−}): string => {
366− const label =
367− input.name?.trim() ||
368− input.endpoint?.trim() ||
369− input.command?.trim() ||
370− "mcp";
371− const slug = label
372− .toLowerCase()
373− .replace(/[^a-z0-9]+/g, "-")
374− .replace(/^-+|-+$/g, "");
375−
376− return `stdio://local/${slug || "mcp"}`;
377−};
378−
379−const defaultFormState = (template?: SourceTemplate): SourceFormState => ({
380− name: template?.name ?? "",
381− kind: template?.kind ?? "openapi",
382− endpoint: template?.endpoint ?? "",
383− namespace: template
384− ? (template.namespace ??
385− ("service" in template
386− ? googleDiscoveryNamespace(template.service)
387− : namespaceFromUrl(template.endpoint ?? "")))
388− : "",
389− enabled: true,
390− specUrl: template && "specUrl" in template ? template.specUrl : "",
391− service: template && "service" in template ? template.service : "",
392− version: template && "version" in template ? template.version : "",
393− defaultHeadersText: "",
394− authKind: template?.kind === "google_discovery" ? "oauth2" : "none",
395− authHeaderName: "Authorization",
396− authPrefix: "Bearer ",
397− bearerProviderId: "",
398− bearerHandle: "",
399− oauthAccessProviderId: "",
400− oauthAccessHandle: "",
401− oauthRefreshProviderId: "",
402− oauthRefreshHandle: "",
403− managedAuth: null,
404− ...(template?.kind === "mcp" && template.connectionType === "command"
405− ? defaultMcpStdioTransportFields({
406− command: template.command ?? "",
407− argsText: stringArrayToEditor(template.args),
408− envText: stringMapToEditor(template.env ?? null),
409− cwd: template.cwd ?? "",
410− })
411− : defaultMcpRemoteTransportFields(
412− template?.kind === "mcp"
413− ? asMcpRemoteTransportValue(template.transport)
414− : "",
415− )),
416−});
417−
418−const formStateFromSource = (source: Source): SourceFormState => ({
419− name: source.name,
420− kind: source.kind,
421− endpoint: source.endpoint,
422− namespace: source.namespace ?? "",
423− enabled: source.enabled,
424− specUrl: readBindingString(source, "specUrl"),
425− service: readBindingString(source, "service"),
426− version: readBindingString(source, "version"),
427− defaultHeadersText: stringMapToEditor(
428− readBindingStringMap(source, "defaultHeaders"),
429− ),
430− authKind: source.auth.kind,
431− authHeaderName:
432− source.auth.kind === "none" || source.auth.kind === "mcp_oauth"
433− ? "Authorization"
434− : source.auth.headerName,
435− authPrefix:
436− source.auth.kind === "none" || source.auth.kind === "mcp_oauth"
437− ? "Bearer "
438− : source.auth.prefix,
439− bearerProviderId:
440− source.auth.kind === "bearer" ? source.auth.token.providerId : "",
441− bearerHandle: source.auth.kind === "bearer" ? source.auth.token.handle : "",
442− oauthAccessProviderId:
443− source.auth.kind === "oauth2" ? source.auth.accessToken.providerId : "",
444− oauthAccessHandle:
445− source.auth.kind === "oauth2" ? source.auth.accessToken.handle : "",
446− oauthRefreshProviderId:
447− source.auth.kind === "oauth2" && source.auth.refreshToken !== null
448− ? source.auth.refreshToken.providerId
449− : "",
450− oauthRefreshHandle:
451− source.auth.kind === "oauth2" && source.auth.refreshToken !== null
452− ? source.auth.refreshToken.handle
453− : "",
454− managedAuth:
455− source.auth.kind === "provider_grant_ref" ||
456− source.auth.kind === "mcp_oauth"
457− ? source.auth
458− : null,
459− ...(source.kind === "mcp" && readBindingTransport(source) === "stdio"
460− ? defaultMcpStdioTransportFields({
461− command: readBindingString(source, "command"),
462− argsText: stringArrayToEditor(readBindingStringArray(source, "args")),
463− envText: stringMapToEditor(readBindingStringMap(source, "env")),
464− cwd: readBindingString(source, "cwd"),
465− })
466− : defaultMcpRemoteTransportFields(
467− source.kind === "mcp"
468− ? asMcpRemoteTransportValue(readBindingTransport(source) || "auto")
469− : "",
470− )),
471− ...(source.kind === "mcp" && readBindingTransport(source) !== "stdio"
472− ? {
473− queryParamsText: stringMapToEditor(
474− readBindingStringMap(source, "queryParams"),
475− ),
476− headersText: stringMapToEditor(readBindingStringMap(source, "headers")),
477− }
478− : {}),
479−});
480−
481−const buildAuthPayload = (
482− state: SourceFormState,
483−): CreateSourcePayload["auth"] => {
484− if (state.authKind === "none") {
485− return { kind: "none" };
486− }
487−
488− if (
489− (state.authKind === "provider_grant_ref" ||
490− state.authKind === "mcp_oauth") &&
491− state.managedAuth !== null
492− ) {
493− return state.managedAuth;
494− }
495−
496− const headerName = state.authHeaderName.trim() || "Authorization";
497− const prefix = state.authPrefix;
498−
499− if (state.authKind === "bearer") {
500− const providerId = state.bearerProviderId.trim();
501− const handle = state.bearerHandle.trim();
502− if (!providerId || !handle) {
503− throw new Error(
504− "Bearer auth requires a token. Select or create a secret.",
505− );
506− }
507−
508− return {
509− kind: "bearer",
510− headerName,
511− prefix,
512− token: {
513− providerId,
514− handle,
515− },
516− };
517− }
518−
519− const accessProviderId = state.oauthAccessProviderId.trim();
520− const accessHandle = state.oauthAccessHandle.trim();
521− if (!accessProviderId || !accessHandle) {
522− throw new Error(
523− "OAuth2 auth requires an access token. Select or create a secret.",
524− );
525− }
526−
527− const refreshProviderId = trimToNull(state.oauthRefreshProviderId);
528− const refreshHandle = trimToNull(state.oauthRefreshHandle);
529− if ((refreshProviderId === null) !== (refreshHandle === null)) {
530− throw new Error(
531− "OAuth2 refresh token provider ID and handle must be set together.",
532− );
533− }
534−
535− return {
536− kind: "oauth2",
537− headerName,
538− prefix,
539− accessToken: {
540− providerId: accessProviderId,
541− handle: accessHandle,
542− },
543− refreshToken:
544− refreshProviderId === null || refreshHandle === null
545− ? null
546− : {
547− providerId: refreshProviderId,
548− handle: refreshHandle,
549− },
550− };
551−};
552−
553−const buildRequestedSourceStatus = (
554− state: SourceFormState,
555−): CreateSourcePayload["status"] => {
556− if (
557− state.kind !== "mcp" &&
558− state.kind !== "openapi" &&
559− state.kind !== "graphql"
560− ) {
561− return undefined;
562− }
563−
564− return state.enabled ? "connected" : "draft";
565−};
566−
567−const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => {
568− const name = state.name.trim();
569− const isMcpStdio = state.kind === "mcp" && state.transport === "stdio";
570− const endpoint =
571− state.kind === "mcp" && state.transport === "stdio"
572− ? buildSyntheticMcpStdioEndpoint({
573− name: state.name,
574− endpoint: state.endpoint,
575− command: state.command,
576− })
577− : state.endpoint.trim();
578−
579− if (!name) {
580− throw new Error("Source name is required.");
581− }
582−
583− if (!endpoint) {
584− throw new Error("Source endpoint is required.");
585− }
586− if (isMcpStdio && !state.command.trim()) {
587− throw new Error("MCP stdio transport requires a command.");
588− }
589−
590− const shared = {
591− name,
592− kind: state.kind,
593− endpoint,
594− status: buildRequestedSourceStatus(state),
595− enabled: state.enabled,
596− namespace: trimToNull(state.namespace),
597− auth: isMcpStdio ? { kind: "none" as const } : buildAuthPayload(state),
598− } satisfies Pick<
599− CreateSourcePayload,
600− "name" | "kind" | "endpoint" | "status" | "enabled" | "namespace" | "auth"
601− >;
602−
603− if (state.kind === "mcp") {
604− if (state.transport === "stdio") {
605− return {
606− ...shared,
607− binding: {
608− transport: "stdio",
609− queryParams: null,
610− headers: null,
611− command: state.command.trim(),
612− args: parseJsonStringArray("Args", state.argsText),
613− env: parseJsonStringMap("Environment", state.envText),
614− cwd: trimToNull(state.cwd),
615− },
616− };
617− }
618−
619− return {
620− ...shared,
621− binding: {
622− transport: state.transport === "" ? "auto" : state.transport,
623− queryParams: parseJsonStringMap("Query params", state.queryParamsText),
624− headers: parseJsonStringMap("Request headers", state.headersText),
625− command: null,
626− args: null,
627− env: null,
628− cwd: null,
629− },
630− };
631− }
632−
633− if (state.kind === "openapi") {
634− const specUrl = state.specUrl.trim();
635− if (!specUrl) {
636− throw new Error("OpenAPI sources require a spec URL.");
637− }
638−
639− return {
640− ...shared,
641− binding: {
642− specUrl,
643− defaultHeaders: parseJsonStringMap(
644− "Default headers",
645− state.defaultHeadersText,
646− ),
647− },
648− };
649− }
650−
651− if (state.kind === "graphql") {
652− return {
653− ...shared,
654− binding: {
655− defaultHeaders: parseJsonStringMap(
656− "Default headers",
657− state.defaultHeadersText,
658− ),
659− },
660− };
661− }
662−
663− if (state.kind === "google_discovery") {
664− const service = state.service.trim();
665− const version = state.version.trim();
666− if (!service) {
667− throw new Error("Google Discovery sources require a service.");
668− }
669− if (!version) {
670− throw new Error("Google Discovery sources require a version.");
671− }
672−
673− return {
674− ...shared,
675− binding: {
676− service,
677− version,
678− discoveryUrl: endpoint,
679− defaultHeaders: parseJsonStringMap(
680− "Default headers",
681− state.defaultHeadersText,
682− ),
683− },
684− };
685− }
686−
687− return {
688− ...shared,
689− binding: {},
690− };
691−};
692−
693−const buildUpdatePayload = (state: SourceFormState): UpdateSourcePayload => ({
694− ...buildSourcePayload(state),
695−});
696−
697−const buildStartSourceOAuthPayload = (
698− state: SourceFormState,
699−): StartSourceOAuthPayload => {
700− if (state.kind !== "mcp") {
701− throw new Error("OAuth sign-in is only available for MCP sources.");
702− }
703− if (state.transport === "stdio") {
704− throw new Error("OAuth sign-in is not available for MCP stdio sources.");
705− }
706−
707− const endpoint = state.endpoint.trim();
708− if (!endpoint) {
709− throw new Error("Source endpoint is required before starting OAuth.");
710− }
711−
712− return {
713− provider: "mcp",
714− name: trimToNull(state.name),
715− endpoint,
716− transport: state.transport === "" ? "auto" : state.transport,
717− queryParams: parseJsonStringMap("Query params", state.queryParamsText),
718− headers: parseJsonStringMap("Request headers", state.headersText),
719− };
720−};
721−
722−export function NewSourcePage() {
723− return <SourceEditor key="create" mode="create" />;
724−}
725−
726−export function EditSourcePage(props: { sourceId: string }) {
727− const sources = useSources();
728− const source = useSource(props.sourceId);
729− const missingSource =
730− (sources.status === "ready" &&
731− !sources.data.some((candidate) => candidate.id === props.sourceId)) ||
732− isSourceNotFoundLoadable(source);
733−
734− if (missingSource) {
735− return <SourceNotFoundState />;
736− }
737−
738− return (
739− <LoadableBlock loadable={source} loading="Loading source...">
740− {(loadedSource) => (
741− <SourceEditor
742− key={`${loadedSource.id}:${loadedSource.updatedAt}`}
743− mode="edit"
744− source={loadedSource}
745− />
746− )}
747− </LoadableBlock>
748− );
749−}
750−
751−function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) {
752− const navigate = useNavigate();
753− const createSource = useCreateSource();
754− const startSourceOAuth = useStartSourceOAuth();
755− const updateSource = useUpdateSource();
756− const removeSource = useRemoveSource();
757− const removeProviderAuthGrant = useRemoveProviderAuthGrant();
758− const instanceConfig = useInstanceConfig();
759− const secrets = useSecrets();
760− const refreshSecrets = useRefreshSecrets();
761− const [formState, setFormState] = useState<SourceFormState>(() =>
762− props.source ? formStateFromSource(props.source) : defaultFormState(),
763− );
764− const [statusBanner, setStatusBanner] = useState<StatusBannerState | null>(
765− null,
766− );
767− const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(
768− null,
769− );
770− const [oauthPopupBusy, setOauthPopupBusy] = useState(false);
771− const [expandedOauthSecretRefTarget, setExpandedOauthSecretRefTarget] =
772− useState<string | null>(null);
773−
774− const isSubmitting =
775− createSource.status === "pending" || updateSource.status === "pending";
776− const isDeleting = removeSource.status === "pending";
777− const isRevokingGrant = removeProviderAuthGrant.status === "pending";
778− const isOAuthSubmitting =
779− startSourceOAuth.status === "pending" || oauthPopupBusy;
780− const oauthSecretRefTarget =
781− formState.authKind === "oauth2" &&
782− formState.oauthAccessHandle.trim().length > 0
783− ? `${formState.oauthAccessProviderId}:${formState.oauthAccessHandle}:${formState.oauthRefreshProviderId}:${formState.oauthRefreshHandle}`
784− : null;
785− const showOauthSecretRefs =
786− oauthSecretRefTarget !== null &&
787− expandedOauthSecretRefTarget === oauthSecretRefTarget;
788−
789− const setField = <K extends keyof SourceFormBase>(
790− key: K,
791− value: SourceFormBase[K],
792− ) => {
793− setFormState((current) => ({ ...current, [key]: value }));
794− };
795−
796− const setTransport = (transport: McpTransportValue) => {
797− setFormState((current) => ({
798− ...current,
799− ...setMcpTransportFieldsTransport(current, transport),
800− }));
801− };
802−
803− const setRemoteTransportField = <
804− K extends Exclude<keyof McpRemoteTransportFields, "transport">,
805− >(
806− key: K,
807− value: McpRemoteTransportFields[K],
808− ) => {
809− setFormState((current) =>
810− current.transport === "stdio" ? current : { ...current, [key]: value },
811− );
812− };
813−
814− const setStdioTransportField = <
815− K extends Exclude<keyof McpStdioTransportFields, "transport">,
816− >(
817− key: K,
818− value: McpStdioTransportFields[K],
819− ) => {
820− setFormState((current) =>
821− current.transport === "stdio" ? { ...current, [key]: value } : current,
822− );
823− };
824−
825− const applyTemplate = (template: SourceTemplate) => {
826− setSelectedTemplateId(template.id);
827− setFormState((current) => ({
828− ...defaultFormState(template),
829− name: current.name.trim().length > 0 ? current.name : template.name,
830− enabled: current.enabled,
831− }));
832− setStatusBanner({
833− tone: "info",
834− text: `${template.name} loaded. Add auth if needed, then save.`,
835− });
836− };
837−
838− const handleSubmit = async () => {
839− setStatusBanner(null);
840−
841− try {
842− if (props.mode === "create") {
843− const createdSource = await createSource.mutateAsync(
844− buildSourcePayload(formState),
845− );
846− void navigate({
847− to: "/sources/$sourceId",
848− params: { sourceId: createdSource.id },
849− search: { tab: "model" },
850− });
851− return;
852− }
853−
854− if (!props.source) {
855− throw new Error("Cannot update a source before it has loaded.");
856− }
857−
858− const updatedSource = await updateSource.mutateAsync({
859− sourceId: props.source.id,
860− payload: buildUpdatePayload(formState),
861− });
862− void navigate({
863− to: "/sources/$sourceId",
864− params: { sourceId: updatedSource.id },
865− search: { tab: "model" },
866− });
867− } catch (error) {
868− setStatusBanner({
869− tone: "error",
870− text: error instanceof Error ? error.message : "Failed saving source.",
871− });
872− }
873− };
874−
875− const handleMcpOAuthConnect = async () => {
876− setStatusBanner(null);
877−
878− try {
879− const result = await startSourceOAuth.mutateAsync(
880− buildStartSourceOAuthPayload(formState),
881− );
882−
883− setStatusBanner({
884− tone: "info",
885− text: "Finish OAuth in the popup. Saving will create the source and connect its tools.",
886− });
887−
888− setOauthPopupBusy(true);
889− const auth = await startSourceOAuthPopup({
890− authorizationUrl: result.authorizationUrl,
891− sessionId: result.sessionId,
892− });
893− setOauthPopupBusy(false);
894− refreshSecrets();
895− setExpandedOauthSecretRefTarget(null);
896− setFormState((current) => ({
897− ...current,
898− authKind: "oauth2",
899− authHeaderName: auth.headerName,
900− authPrefix: auth.prefix,
901− oauthAccessProviderId: auth.accessToken.providerId,
902− oauthAccessHandle: auth.accessToken.handle,
903− oauthRefreshProviderId: auth.refreshToken?.providerId ?? "",
904− oauthRefreshHandle: auth.refreshToken?.handle ?? "",
905− managedAuth: null,
906− }));
907− setStatusBanner({
908− tone: "success",
909− text: "OAuth credentials are ready. Save the source to connect and index tools.",
910− });
911− } catch (error) {
912− setOauthPopupBusy(false);
913− setStatusBanner({
914− tone: "error",
915− text: error instanceof Error ? error.message : "Failed starting OAuth.",
916− });
917− }
918− };
919−
920− const handleRemove = async () => {
921− if (!props.source || isDeleting) {
922− return;
923− }
924−
925− const confirmed = window.confirm(
926− `Remove "${props.source.name}" and its indexed tools?`,
927− );
928− if (!confirmed) {
929− return;
930− }
931−
932− setStatusBanner(null);
933−
934− try {
935− const result = await removeSource.mutateAsync(props.source.id);
936− if (!result.removed) {
937− throw new Error("Source was not removed.");
938− }
939− void navigate({ to: "/" });
940− } catch (error) {
941− setStatusBanner({
942− tone: "error",
943− text:
944− error instanceof Error ? error.message : "Failed removing source.",
945− });
946− }
947− };
948−
949− const handleRevokeProviderGrant = async () => {
950− if (
951− !props.source ||
952− isRevokingGrant ||
953− formState.managedAuth?.kind !== "provider_grant_ref"
954− ) {
955− return;
956− }
957−
958− const confirmed = window.confirm(
959− `Revoke the shared Google auth for "${props.source.name}"? This disconnects every source using the same shared grant.`,
960− );
961− if (!confirmed) {
962− return;
963− }
964−
965− setStatusBanner(null);
966−
967− try {
968− const result = await removeProviderAuthGrant.mutateAsync(
969− formState.managedAuth.grantId,
970− );
971− if (!result.removed) {
972− throw new Error("Shared provider grant was not removed.");
973− }
974−
975− setStatusBanner({
976− tone: "success",
977− text: "Shared provider grant revoked. Linked sources now require authentication again.",
978− });
979− } catch (error) {
980− setStatusBanner({
981− tone: "error",
982− text:
983− error instanceof Error
984− ? error.message
985− : "Failed revoking shared provider grant.",
986− });
987− }
988− };
989−
990− const backLink =
991− props.mode === "edit" && props.source
992− ? {
993− to: "/sources/$sourceId" as const,
994− params: { sourceId: props.source.id },
995− search: { tab: "model" as const },
996− }
997− : { to: "/" as const };
998−
999− return (
1000− <div className="flex-1 overflow-y-auto">
1001− <div className="mx-auto max-w-3xl px-6 py-8 lg:px-10 lg:py-12">
1002− {/* Back + title */}
1003− <Link
1004− {...backLink}
1005− className="inline-flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground mb-6"
1006− >
1007− <IconArrowLeft className="size-3.5" />
1008− {props.mode === "edit" ? "Back to source" : "Back"}
1009− </Link>
1010−
1011− <div className="flex items-center justify-between gap-4 mb-6">
1012− <h1 className="font-display text-2xl tracking-tight text-foreground lg:text-3xl">
1013− {props.mode === "edit" ? "Edit source" : "New source"}
1014− </h1>
1015− <div className="flex items-center gap-2">
1016− <Badge variant="outline">{formState.kind}</Badge>
1017− <Badge variant={formState.enabled ? "default" : "muted"}>
1018− {formState.enabled ? "enabled" : "disabled"}
1019− </Badge>
1020− </div>
1021− </div>
1022−
1023− {statusBanner && <StatusBanner state={statusBanner} className="mb-6" />}
1024−
1025− {/* Templates (create mode) */}
1026− {props.mode === "create" && (
1027− <Section title="Templates" className="mb-6">
1028− <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
1029− {sourceTemplates.map((template) => (
1030− <button
1031− key={template.id}
1032− type="button"
1033− onClick={() => applyTemplate(template)}
1034− className={cn(
1035− "rounded-xl border px-4 py-3 text-left transition-colors",
1036− selectedTemplateId === template.id
1037− ? "border-primary/40 bg-primary/8"
1038− : "border-border bg-card/70 hover:bg-accent/50",
1039− )}
1040− >
1041− <div className="mb-1 flex items-center justify-between gap-2">
1042− <div className="flex min-w-0 items-center gap-2">
1043− <div className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
1044− <SourceFavicon
1045− endpoint={template.endpoint}
1046− kind={template.kind}
1047− className="size-4"
1048− />
1049− </div>
1050− <span className="truncate text-[13px] font-medium text-foreground">
1051− {template.name}
1052− </span>
1053− </div>
1054− <Badge variant="outline" className="text-[9px]">
1055− {template.kind}
1056− </Badge>
1057− </div>
1058− <span className="text-[11px] text-muted-foreground line-clamp-1">
1059− {template.summary}
1060− </span>
1061− </button>
1062− ))}
1063− </div>
1064− </Section>
1065− )}
1066−
1067− {/* Form */}
1068− <div className="space-y-6">
1069− <Section title="Basics">
1070− <div className="grid gap-4 sm:grid-cols-2">
1071− <Field label="Name">
1072− <TextInput
1073− value={formState.name}
1074− onChange={(value) => setField("name", value)}
1075− placeholder="GitHub REST"
1076− />
1077− </Field>
1078− <Field label="Kind">
1079− <SelectInput
1080− value={formState.kind}
1081− onChange={(value) =>
1082− setField("kind", value as Source["kind"])
1083− }
1084− options={kindOptions.map((value) => ({
1085− value,
1086− label: value,
1087− }))}
1088− />
1089− </Field>
1090− {!(
1091− formState.kind === "mcp" && formState.transport === "stdio"
1092− ) && (
1093− <Field label="Endpoint" className="sm:col-span-2">
1094− <TextInput
1095− value={formState.endpoint}
1096− onChange={(value) => setField("endpoint", value)}
1097− placeholder={
1098− formState.kind === "openapi"
1099− ? "https://api.github.com"
1100− : formState.kind === "graphql"
1101− ? "https://api.linear.app/graphql"
1102− : formState.kind === "google_discovery"
1103− ? "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest"
1104− : "https://mcp.deepwiki.com/mcp"
1105− }
1106− mono
1107− />
1108− </Field>
1109− )}
1110− <Field label="Namespace">
1111− <TextInput
1112− value={formState.namespace}
1113− onChange={(value) => setField("namespace", value)}
1114− placeholder="github"
1115− />
1116− </Field>
1117− <Field label="Status">
1118− <ToggleButton
1119− checked={formState.enabled}
1120− onChange={(checked) => setField("enabled", checked)}
1121− />
1122− </Field>
1123− </div>
1124− </Section>
1125−
1126− {formState.kind === "mcp" && (
1127− <Section title="Transport">
1128− <div className="grid gap-4 sm:grid-cols-2">
1129− <Field label="Transport mode">
1130− <SelectInput
1131− value={formState.transport || "auto"}
1132− onChange={(value) => setTransport(value as McpTransportValue)}
1133− options={transportOptions.map((value) => ({
1134− value,
1135− label: value,
1136− }))}
1137− />
1138− </Field>
1139− {formState.transport === "stdio" ? (
1140− <div className="sm:col-span-2 grid gap-4 sm:grid-cols-2">
1141− <Field label="Command">
1142− <TextInput
1143− value={formState.command}
1144− onChange={(value) =>
1145− setStdioTransportField("command", value)
1146− }
1147− placeholder="npx"
1148− mono
1149− />
1150− </Field>
1151− <Field label="Working directory (optional)">
1152− <TextInput
1153− value={formState.cwd}
1154− onChange={(value) =>
1155− setStdioTransportField("cwd", value)
1156− }
1157− placeholder="/path/to/project"
1158− mono
1159− />
1160− </Field>
1161− <Field label="Args (JSON)" className="sm:col-span-2">
1162− <CodeEditor
1163− value={formState.argsText}
1164− onChange={(value) =>
1165− setStdioTransportField("argsText", value)
1166− }
1167− placeholder={
1168− '[\n "-y",\n "chrome-devtools-mcp@latest"\n]'
1169− }
1170− />
1171− </Field>
1172− <Field label="Environment (JSON)" className="sm:col-span-2">
1173− <CodeEditor
1174− value={formState.envText}
1175− onChange={(value) =>
1176− setStdioTransportField("envText", value)
1177− }
1178− placeholder={
1179− '{\n "CHROME_PATH": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"\n}'
1180− }
1181− />
1182− </Field>
1183− </div>
1184− ) : (
1185− <div className="sm:col-span-2 grid gap-4 sm:grid-cols-2">
1186− <Field label="Query params (JSON)">
1187− <CodeEditor
1188− value={formState.queryParamsText}
1189− onChange={(value) =>
1190− setRemoteTransportField("queryParamsText", value)
1191− }
1192− placeholder={'{\n "workspace": "demo"\n}'}
1193− />
1194− </Field>
1195− <Field label="Headers (JSON)">
1196− <CodeEditor
1197− value={formState.headersText}
1198− onChange={(value) =>
1199− setRemoteTransportField("headersText", value)
1200− }
1201− placeholder={'{\n "x-api-key": "..."\n}'}
1202− />
1203− </Field>
1204− </div>
1205− )}
1206− </div>
1207− </Section>
1208− )}
1209−
1210− {formState.kind === "openapi" && (
1211− <Section title="OpenAPI">
1212− <div className="grid gap-4">
1213− <Field label="Spec URL">
1214− <TextInput
1215− value={formState.specUrl}
1216− onChange={(value) => setField("specUrl", value)}
1217− placeholder="https://raw.githubusercontent.com/.../openapi.yaml"
1218− mono
1219− />
1220− </Field>
1221− <Field label="Default headers (JSON)">
1222− <CodeEditor
1223− value={formState.defaultHeadersText}
1224− onChange={(value) => setField("defaultHeadersText", value)}
1225− placeholder={'{\n "x-api-version": "2026-03-01"\n}'}
1226− />
1227− </Field>
1228− </div>
1229− </Section>
1230− )}
1231−
1232− {formState.kind === "graphql" && (
1233− <Section title="GraphQL">
1234− <div className="grid gap-4">
1235− <Field label="Default headers (JSON)">
1236− <CodeEditor
1237− value={formState.defaultHeadersText}
1238− onChange={(value) => setField("defaultHeadersText", value)}
1239− placeholder={'{\n "x-api-version": "2026-03-01"\n}'}
1240− />
1241− </Field>
1242− </div>
1243− </Section>
1244− )}
1245−
1246− {formState.kind === "google_discovery" && (
1247− <Section title="Google Discovery">
1248− <div className="grid gap-4 sm:grid-cols-2">
1249− <Field label="Service">
1250− <TextInput
1251− value={formState.service}
1252− onChange={(value) => setField("service", value)}
1253− placeholder="sheets"
1254− />
1255− </Field>
1256− <Field label="Version">
1257− <TextInput
1258− value={formState.version}
1259− onChange={(value) => setField("version", value)}
1260− placeholder="v4"
1261− />
1262− </Field>
1263− <Field label="Default headers (JSON)" className="sm:col-span-2">
1264− <CodeEditor
1265− value={formState.defaultHeadersText}
1266− onChange={(value) => setField("defaultHeadersText", value)}
1267− placeholder={'{\n "x-goog-user-project": "my-project"\n}'}
1268− />
1269− </Field>
1270− </div>
1271− </Section>
1272− )}
1273−
1274− <Section title="Authentication">
1275− <div className="grid gap-4 sm:grid-cols-2">
1276− {formState.kind === "mcp" && formState.transport !== "stdio" && (
1277− <div className="sm:col-span-2 rounded-xl border border-border bg-gradient-to-b from-card/90 to-card/50 overflow-hidden">
1278− <div className="flex items-start gap-3 px-4 py-3.5">
1279− <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary mt-0.5">
1280− <svg
1281− className="size-4"
1282− viewBox="0 0 24 24"
1283− fill="none"
1284− stroke="currentColor"
1285− strokeWidth="1.5"
1286− strokeLinecap="round"
1287− strokeLinejoin="round"
1288− >
1289− <rect
1290− x="3"
1291− y="11"
1292− width="18"
1293− height="11"
1294− rx="2"
1295− ry="2"
1296− />
1297− <path d="M7 11V7a5 5 0 0110 0v4" />
1298− </svg>
1299− </div>
1300− <div className="flex-1 min-w-0 space-y-2">
1301− <div className="space-y-0.5">
1302− <p className="text-[12px] font-medium text-foreground">
1303− MCP OAuth
1304− </p>
1305− <p className="text-[11px] text-muted-foreground">
1306− Opens the server's built-in OAuth flow to authenticate
1307− this source.
1308− </p>
1309− </div>
1310− <Button
1311− type="button"
1312− variant="outline"
1313− size="sm"
1314− onClick={handleMcpOAuthConnect}
1315− disabled={
1316− isSubmitting || isDeleting || isOAuthSubmitting
1317− }
1318− >
1319− {isOAuthSubmitting ? (
1320− <IconSpinner className="size-3" />
1321− ) : null}
1322− {formState.authKind === "oauth2" &&
1323− formState.oauthAccessHandle.trim().length > 0
1324− ? "Reconnect"
1325− : "Sign in with OAuth"}
1326− </Button>
1327− </div>
1328− </div>
1329− {formState.authKind === "oauth2" &&
1330− formState.oauthAccessHandle.trim().length > 0 && (
1331− <div className="flex items-center justify-between gap-3 border-t border-border/50 px-4 py-2.5">
1332− <p className="text-[11px] text-muted-foreground/70">
1333− Token refs attached to this draft
1334− </p>
1335− <button
1336− type="button"
1337− className="text-[11px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground"
1338− onClick={() =>
1339− setExpandedOauthSecretRefTarget((current) =>
1340− current === oauthSecretRefTarget
1341− ? null
1342− : oauthSecretRefTarget,
1343− )
1344− }
1345− >
1346− {showOauthSecretRefs ? "Hide refs" : "Show refs"}
1347− </button>
1348− </div>
1349− )}
1350− </div>
1351− )}
1352− {formState.kind === "mcp" && formState.transport === "stdio" ? (
1353− <div className="sm:col-span-2 rounded-xl border border-border bg-muted/40 px-4 py-3 text-[12px] text-muted-foreground">
1354− Local MCP stdio sources do not use executor-managed HTTP or
1355− OAuth credentials.
1356− </div>
1357− ) : (
1358− <Field label="Auth mode">
1359− <SelectInput
1360− value={formState.authKind}
1361− onChange={(value) =>
1362− setField("authKind", value as Source["auth"]["kind"])
1363− }
1364− disabled={formState.managedAuth !== null}
1365− options={authOptions.map((value) => ({
1366− value,
1367− label: value,
1368− }))}
1369− />
1370− </Field>
1371− )}
1372− {formState.managedAuth !== null &&
1373− !(
1374− formState.kind === "mcp" && formState.transport === "stdio"
1375− ) && (
1376− <div className="sm:col-span-2 rounded-xl border border-border bg-gradient-to-b from-muted/40 to-transparent overflow-hidden">
1377− <div className="flex items-start gap-3 px-4 py-3.5">
1378− <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary mt-0.5">
1379− {formState.managedAuth.kind === "provider_grant_ref" ? (
1380− <svg
1381− className="size-4"
1382− viewBox="0 0 24 24"
1383− fill="none"
1384− stroke="currentColor"
1385− strokeWidth="1.5"
1386− strokeLinecap="round"
1387− strokeLinejoin="round"
1388− >
1389− <path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" />
1390− <path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" />
1391− </svg>
1392− ) : (
1393− <svg
1394− className="size-4"
1395− viewBox="0 0 24 24"
1396− fill="none"
1397− stroke="currentColor"
1398− strokeWidth="1.5"
1399− strokeLinecap="round"
1400− strokeLinejoin="round"
1401− >
1402− <rect
1403− x="3"
1404− y="11"
1405− width="18"
1406− height="11"
1407− rx="2"
1408− ry="2"
1409− />
1410− <path d="M7 11V7a5 5 0 0110 0v4" />
1411− </svg>
1412− )}
1413− </div>
1414− <div className="flex-1 min-w-0 space-y-1">
1415− <p className="text-[12px] font-medium text-foreground">
1416− {formState.managedAuth.kind === "provider_grant_ref"
1417− ? "Shared provider grant"
1418− : "Managed MCP OAuth"}
1419− </p>
1420− <p className="text-[11px] leading-relaxed text-muted-foreground">
1421− {formState.managedAuth.kind === "provider_grant_ref"
1422− ? "This source uses a shared Google auth grant. Reconnect from Add Source to change the linked account or scopes."
1423− : "Authenticated through a persisted MCP OAuth session. Reconnect the source to refresh or replace the binding."}
1424− </p>
1425− </div>
1426− </div>
1427− {formState.managedAuth.kind === "provider_grant_ref" &&
1428− props.mode === "edit" && (
1429− <div className="flex items-center justify-end border-t border-border/50 px-4 py-2.5">
1430− <Button
1431− type="button"
1432− variant="ghost"
1433− size="sm"
1434− className="text-destructive/70 hover:text-destructive hover:bg-destructive/8"
1435− onClick={handleRevokeProviderGrant}
1436− disabled={isRevokingGrant}
1437− >
1438− <IconTrash className="size-3" />
1439− {isRevokingGrant
1440− ? "Revoking\u2026"
1441− : "Revoke shared auth"}
1442− </Button>
1443− </div>
1444− )}
1445− </div>
1446− )}
1447− {formState.authKind !== "none" &&
1448− formState.managedAuth === null &&
1449− !(
1450− formState.kind === "mcp" && formState.transport === "stdio"
1451− ) && (
1452− <>
1453− <Field label="Header name">
1454− <TextInput
1455− value={formState.authHeaderName}
1456− onChange={(value) => setField("authHeaderName", value)}
1457− placeholder="Authorization"
1458− />
1459− </Field>
1460− <Field label="Prefix">
1461− <TextInput
1462− value={formState.authPrefix}
1463− onChange={(value) => setField("authPrefix", value)}
1464− placeholder="Bearer "
1465− />
1466− </Field>
1467− </>
1468− )}
1469−
1470− {formState.authKind === "bearer" &&
1471− formState.managedAuth === null &&
1472− !(
1473− formState.kind === "mcp" && formState.transport === "stdio"
1474− ) && (
1475− <Field label="Token" className="sm:col-span-2">
1476− <SecretPicker
1477− instanceConfig={instanceConfig}
1478− secrets={secrets}
1479− providerId={formState.bearerProviderId}
1480− handle={formState.bearerHandle}
1481− onSelect={(providerId, handle) => {
1482− setField("bearerProviderId", providerId);
1483− setField("bearerHandle", handle);
1484− }}
1485− />
1486− </Field>
1487− )}
1488−
1489− {formState.authKind === "oauth2" &&
1490− formState.managedAuth === null &&
1491− !(
1492− formState.kind === "mcp" && formState.transport === "stdio"
1493− ) &&
1494− (formState.kind !== "mcp" ||
1495− formState.oauthAccessHandle.trim().length === 0 ||
1496− showOauthSecretRefs) && (
1497− <>
1498− <Field label="Access token" className="sm:col-span-2">
1499− <SecretPicker
1500− instanceConfig={instanceConfig}
1501− secrets={secrets}
1502− providerId={formState.oauthAccessProviderId}
1503− handle={formState.oauthAccessHandle}
1504− onSelect={(providerId, handle) => {
1505− setField("oauthAccessProviderId", providerId);
1506− setField("oauthAccessHandle", handle);
1507− }}
1508− />
1509− </Field>
1510− <Field
1511− label="Refresh token (optional)"
1512− className="sm:col-span-2"
1513− >
1514− <SecretPicker
1515− instanceConfig={instanceConfig}
1516− secrets={secrets}
1517− providerId={formState.oauthRefreshProviderId}
1518− handle={formState.oauthRefreshHandle}
1519− onSelect={(providerId, handle) => {
1520− setField("oauthRefreshProviderId", providerId);
1521− setField("oauthRefreshHandle", handle);
1522− }}
1523− allowEmpty
1524− />
1525− </Field>
1526− </>
1527− )}
1528− </div>
1529− </Section>
1530−
1531− {/* Danger zone (edit mode) */}
1532− {props.mode === "edit" && props.source && (
1533− <Section title="Danger zone">
1534− <Button
1535− variant="outline"
1536− className="border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
1537− onClick={handleRemove}
1538− disabled={isDeleting || isRevokingGrant}
1539− >
1540− <IconTrash className="size-3.5" />
1541− {isDeleting ? "Removing\u2026" : "Remove source"}
1542− </Button>
1543− </Section>
1544− )}
1545−
1546− {/* Actions */}
1547− <div className="flex items-center justify-end gap-3 border-t border-border pt-5">
1548− <Link {...backLink} className="inline-flex">
1549− <Button variant="ghost" type="button">
1550− Cancel
1551− </Button>
1552− </Link>
1553− <Button
1554− onClick={handleSubmit}
1555− disabled={isSubmitting || isRevokingGrant}
1556− >
1557− {props.mode === "edit" ? (
1558− <IconPencil className="size-3.5" />
1559− ) : (
1560− <IconPlus className="size-3.5" />
1561− )}
1562− {isSubmitting
1563− ? props.mode === "edit"
1564− ? "Saving\u2026"
1565− : "Creating\u2026"
1566− : props.mode === "edit"
1567− ? "Save"
1568− : "Create source"}
1569− </Button>
1570− </div>
1571− </div>
1572− </div>
1573− </div>
1574− );
1575−}
1576−
1577−// ---------------------------------------------------------------------------
1578−// Form building blocks
1579−// ---------------------------------------------------------------------------
1580−
1581−function Section(props: {
1582− title: string;
1583− children: ReactNode;
1584− className?: string;
1585−}) {
1586− return (
1587− <section
1588− className={cn(
1589− "rounded-xl border border-border bg-card/80",
1590− props.className,
1591− )}
1592− >
1593− <div className="border-b border-border px-5 py-3">
1594− <h2 className="text-sm font-semibold text-foreground">{props.title}</h2>
1595− </div>
1596− <div className="p-5">{props.children}</div>
1597− </section>
1598− );
1599−}
1600−
1601−function Field(props: {
1602− label: string;
1603− className?: string;
1604− children: ReactNode;
1605−}) {
1606− return (
1607− <label className={cn("block space-y-1.5", props.className)}>
1608− <span className="text-[12px] font-medium text-foreground">
1609− {props.label}
1610− </span>
1611− {props.children}
1612− </label>
1613− );
1614−}
1615−
1616−function TextInput(props: {
1617− value: string;
1618− onChange: (value: string) => void;
1619− placeholder?: string;
1620− mono?: boolean;
1621−}) {
1622− return (
1623− <input
1624− value={props.value}
1625− onChange={(event) => props.onChange(event.target.value)}
1626− placeholder={props.placeholder}
1627− className={cn(
1628− "h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25",
1629− props.mono && "font-mono text-[12px]",
1630− )}
1631− />
1632− );
1633−}
1634−
1635−function SelectInput(props: {
1636− value: string;
1637− onChange: (value: string) => void;
1638− options: ReadonlyArray<{ value: string; label: string }>;
1639− disabled?: boolean;
1640−}) {
1641− return (
1642− <select
1643− value={props.value}
1644− disabled={props.disabled}
1645− onChange={(event) => props.onChange(event.target.value)}
1646− className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
1647− >
1648− {props.options.map((option) => (
1649− <option key={option.value} value={option.value}>
1650− {option.label}
1651− </option>
1652− ))}
1653− </select>
1654− );
1655−}
1656−
1657−function CodeEditor(props: {
1658− value: string;
1659− onChange: (value: string) => void;
1660− placeholder?: string;
1661−}) {
1662− return (
1663− <textarea
1664− value={props.value}
1665− onChange={(event) => props.onChange(event.target.value)}
1666− placeholder={props.placeholder}
1667− spellCheck={false}
1668− className="min-h-28 w-full rounded-lg border border-input bg-background px-3 py-2.5 font-mono text-[12px] leading-relaxed text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
1669− />
1670− );
1671−}
1672−
1673−function ToggleButton(props: {
1674− checked: boolean;
1675− onChange: (checked: boolean) => void;
1676−}) {
1677− return (
1678− <button
1679− type="button"
1680− onClick={() => props.onChange(!props.checked)}
1681− className={cn(
1682− "flex h-9 w-full items-center justify-between rounded-lg border px-3 text-[13px] transition-colors",
1683− props.checked
1684− ? "border-primary/40 bg-primary/8 text-foreground"
1685− : "border-input bg-background text-muted-foreground hover:text-foreground",
1686− )}
1687− >
1688− <span>{props.checked ? "Enabled" : "Disabled"}</span>
1689− <span
1690− className={cn(
1691− "size-2 rounded-full",
1692− props.checked ? "bg-primary" : "bg-muted-foreground/30",
1693− )}
1694− />
1695− </button>
1696− );
1697−}
1698−
1699−function StatusBanner(props: { state: StatusBannerState; className?: string }) {
1700− return (
1701− <div
1702− className={cn(
1703− "rounded-lg border px-4 py-3 text-[13px]",
1704− props.state.tone === "success" &&
1705− "border-primary/30 bg-primary/8 text-foreground",
1706− props.state.tone === "info" &&
1707− "border-border bg-card text-muted-foreground",
1708− props.state.tone === "error" &&
1709− "border-destructive/30 bg-destructive/8 text-destructive",
1710− props.className,
1711− )}
1712− >
1713− {props.state.text}
1714− </div>
1715− );
1716−}
1717−
1718−const CREATE_NEW_VALUE = "__create_new__";
1719−
1720−function SecretPicker(props: {
1721− instanceConfig: Loadable<InstanceConfig>;
1722− secrets: Loadable<ReadonlyArray<SecretListItem>>;
1723− providerId: string;
1724− handle: string;
1725− onSelect: (providerId: string, handle: string) => void;
1726− allowEmpty?: boolean;
1727−}) {
1728− const { instanceConfig, secrets, providerId, handle, onSelect, allowEmpty } =
1729− props;
1730− const createSecret = useCreateSecret();
1731− const [showCreate, setShowCreate] = useState(false);
1732− const [newName, setNewName] = useState("");
1733− const [newValue, setNewValue] = useState("");
1734− const [newProviderId, setNewProviderId] = useState("");
1735− const [createError, setCreateError] = useState<string | null>(null);
1736− const storableProviders =
1737− instanceConfig.status === "ready"
1738− ? instanceConfig.data.secretProviders.filter(
1739− (provider) => provider.canStore,
1740− )
1741− : [];
1742−
1743− useEffect(() => {
1744− if (newProviderId.length > 0) {
1745− return;
1746− }
1747− if (instanceConfig.status === "ready") {
1748− setNewProviderId(instanceConfig.data.defaultSecretStoreProvider);
1749− }
1750− }, [instanceConfig, newProviderId]);
1751−
1752− // Build the selected value key: if handle is set, use it (it's the secret ID)
1753− const selectedValue = handle || "";
1754−
1755− const handleSelectChange = (value: string) => {
1756− if (value === CREATE_NEW_VALUE) {
1757− setShowCreate(true);
1758− setNewName("");
1759− setNewValue("");
1760− setNewProviderId(
1761− instanceConfig.status === "ready"
1762− ? instanceConfig.data.defaultSecretStoreProvider
1763− : "",
1764− );
1765− setCreateError(null);
1766− return;
1767− }
1768− if (value === "") {
1769− onSelect("", "");
1770− return;
1771− }
1772− const matchedSecret =
1773− secrets.status === "ready"
1774− ? secrets.data.find((secret) => secret.id === value)
1775− : null;
1776− onSelect(matchedSecret?.providerId ?? "local", value);
1777− };
1778−
1779− const handleCreate = async () => {
1780− setCreateError(null);
1781− const trimmedName = newName.trim();
1782− if (!trimmedName) {
1783− setCreateError("Name is required.");
1784− return;
1785− }
1786− if (!newValue) {
1787− setCreateError("Value is required.");
1788− return;
1789− }
1790−
1791− try {
1792− const result = await createSecret.mutateAsync({
1793− name: trimmedName,
1794− value: newValue,
1795− ...(newProviderId ? { providerId: newProviderId } : {}),
1796− });
1797− onSelect(result.providerId, result.id);
1798− setShowCreate(false);
1799− } catch (err) {
1800− setCreateError(
1801− err instanceof Error ? err.message : "Failed creating secret.",
1802− );
1803− }
1804− };
1805−
1806− if (secrets.status !== "ready") {
1807− return (
1808− <select
1809− disabled
1810− className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-muted-foreground outline-none opacity-60"
1811− >
1812− <option>Loading…</option>
1813− </select>
1814− );
1815− }
1816−
1817− const items = secrets.data;
1818−
1819− // Check if the current handle matches a known secret
1820− const matchedSecret = items.find((s) => s.id === handle);
1821− const isExternalRef = handle && !matchedSecret && providerId !== "local";
1822−
1823− return (
1824− <div className="space-y-2">
1825− <select
1826− value={showCreate ? CREATE_NEW_VALUE : selectedValue}
1827− onChange={(e) => handleSelectChange(e.target.value)}
1828− className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
1829− >
1830− {allowEmpty && <option value="">None</option>}
1831− {!allowEmpty && !selectedValue && (
1832− <option value="">Select a secret…</option>
1833− )}
1834− {items.map((secret) => (
1835− <option key={secret.id} value={secret.id}>
1836− {secret.name || secret.id} ({secret.providerId})
1837− </option>
1838− ))}
1839− {isExternalRef && (
1840− <option value={handle}>
1841− {providerId}:{handle}
1842− </option>
1843− )}
1844− <option value={CREATE_NEW_VALUE}>+ Create new secret</option>
1845− </select>
1846−
1847− {showCreate && (
1848− <div className="rounded-lg border border-primary/20 bg-card/80 p-3 space-y-3">
1849− {createError && (
1850− <div className="rounded-md border border-destructive/30 bg-destructive/8 px-3 py-2 text-[12px] text-destructive">
1851− {createError}
1852− </div>
1853− )}
1854− <div className="grid gap-3 sm:grid-cols-2">
1855− <label className="block space-y-1">
1856− <span className="text-[11px] font-medium text-muted-foreground">
1857− Name
1858− </span>
1859− <input
1860− value={newName}
1861− onChange={(e) => setNewName(e.target.value)}
1862− placeholder="GitHub PAT"
1863− className="h-8 w-full rounded-lg border border-input bg-background px-3 text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
1864− autoFocus
1865− />
1866− </label>
1867− <label className="block space-y-1">
1868− <span className="text-[11px] font-medium text-muted-foreground">
1869− Value
1870− </span>
1871− <input
1872− type="password"
1873− value={newValue}
1874− onChange={(e) => setNewValue(e.target.value)}
1875− placeholder="ghp_..."
1876− className="h-8 w-full rounded-lg border border-input bg-background px-3 font-mono text-[11px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
1877− />
1878− </label>
1879− <label className="block space-y-1">
1880− <span className="text-[11px] font-medium text-muted-foreground">
1881− Store in
1882− </span>
1883− <select
1884− value={newProviderId}
1885− onChange={(e) => setNewProviderId(e.target.value)}
1886− className="h-8 w-full rounded-lg border border-input bg-background px-3 text-[12px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
1887− >
1888− {storableProviders.map((provider) => (
1889− <option key={provider.id} value={provider.id}>
1890− {provider.name}
1891− </option>
1892− ))}
1893− </select>
1894− </label>
1895− </div>
1896− <div className="flex items-center justify-end gap-2">
1897− <button
1898− type="button"
1899− onClick={() => setShowCreate(false)}
1900− className="rounded-md px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground"
1901− >
1902− Cancel
1903− </button>
1904− <Button
1905− size="sm"
1906− onClick={handleCreate}
1907− disabled={createSecret.status === "pending"}
1908− >
1909− {createSecret.status === "pending" && (
1910− <IconSpinner className="size-3" />
1911− )}
1912− Store & use
1913− </Button>
1914− </div>
1915− </div>
1916− )}
1917− </div>
1918− );
1919−}
apps/web/src/views/source-templates.ts+0−358
@@ -1,358 +0,0 @@
1−type SourceTemplateBase = {
2− id: string;
3− name: string;
4− summary: string;
5− endpoint?: string;
6− namespace?: string;
7− groupId?: string;
8− groupLabel?: string;
9− batchable?: boolean;
10−};
11−
12−export type McpSourceTemplate = SourceTemplateBase & {
13− kind: "mcp";
14− connectionType?: "endpoint" | "command";
15− transport?: "auto" | "streamable-http" | "sse" | "stdio";
16− command?: string;
17− args?: ReadonlyArray<string>;
18− env?: Record<string, string>;
19− cwd?: string;
20−};
21−
22−export type OpenApiSourceTemplate = SourceTemplateBase & {
23− kind: "openapi";
24− specUrl: string;
25−};
26−
27−export type GoogleDiscoverySourceTemplate = SourceTemplateBase & {
28− kind: "google_discovery";
29− service: string;
30− version: string;
31− discoveryUrl: string;
32−};
33−
34−export type GraphqlSourceTemplate = SourceTemplateBase & {
35− kind: "graphql";
36−};
37−
38−export type SourceTemplate =
39− | McpSourceTemplate
40− | OpenApiSourceTemplate
41− | GoogleDiscoverySourceTemplate
42− | GraphqlSourceTemplate;
43−
44−export const isStdioMcpSourceTemplate = (
45− template: SourceTemplate,
46−): template is McpSourceTemplate & { transport: "stdio" } =>
47− template.kind === "mcp" &&
48− template.connectionType === "command" &&
49− template.transport === "stdio";
50−
51−const googleDiscoveryUrl = (service: string, version: string): string =>
52− `https://www.googleapis.com/discovery/v1/apis/${encodeURIComponent(service)}/${encodeURIComponent(version)}/rest`;
53−
54−const googleDiscoveryTemplate = (input: {
55− id: string;
56− name: string;
57− summary: string;
58− service: string;
59− version: string;
60− discoveryUrl?: string;
61−}): GoogleDiscoverySourceTemplate => {
62− const discoveryUrl =
63− input.discoveryUrl ?? googleDiscoveryUrl(input.service, input.version);
64− return {
65− id: input.id,
66− name: input.name,
67− summary: input.summary,
68− kind: "google_discovery",
69− endpoint: discoveryUrl,
70− groupId: "google_workspace",
71− groupLabel: "Google Workspace",
72− batchable: true,
73− service: input.service,
74− version: input.version,
75− discoveryUrl,
76− };
77−};
78−
79−export const sourceTemplates: ReadonlyArray<SourceTemplate> = [
80− {
81− id: "deepwiki-mcp",
82− name: "DeepWiki MCP",
83− summary: "Repository docs and knowledge graphs via MCP.",
84− kind: "mcp",
85− endpoint: "https://mcp.deepwiki.com/mcp",
86− },
87− {
88− id: "axiom-mcp",
89− name: "Axiom MCP",
90− summary: "Query, stream, and analyze logs, traces, and event data.",
91− kind: "mcp",
92− endpoint: "https://mcp.axiom.co/mcp",
93− },
94− {
95− id: "neon-mcp",
96− name: "Neon MCP",
97− summary: "Manage Postgres databases, branches, and queries via MCP.",
98− kind: "mcp",
99− endpoint: "https://mcp.neon.tech/mcp",
100− },
101− {
102− id: "chrome-devtools-mcp",
103− name: "Chrome DevTools MCP",
104− summary:
105− "Debug a live Chrome browser session over a local MCP stdio transport.",
106− kind: "mcp",
107− namespace: "chrome.devtools",
108− connectionType: "command",
109− transport: "stdio",
110− command: "npx",
111− args: ["-y", "chrome-devtools-mcp@latest"],
112− },
113− {
114− id: "neon-api",
115− name: "Neon API",
116− summary: "Projects, branches, endpoints, databases, and API keys.",
117− kind: "openapi",
118− endpoint: "https://console.neon.tech/api/v2",
119− specUrl: "https://neon.com/api_spec/release/v2.json",
120− },
121− {
122− id: "github-rest",
123− name: "GitHub REST API",
124− summary: "Repos, issues, pull requests, actions, and org settings.",
125− kind: "openapi",
126− endpoint: "https://api.github.com",
127− specUrl:
128− "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml",
129− groupId: "github",
130− groupLabel: "GitHub",
131− batchable: false,
132− },
133− {
134− id: "github-graphql",
135− name: "GitHub GraphQL",
136− summary:
137− "Issues, pull requests, discussions, and repository objects via GraphQL.",
138− kind: "graphql",
139− endpoint: "https://api.github.com/graphql",
140− groupId: "github",
141− groupLabel: "GitHub",
142− batchable: false,
143− },
144− {
145− id: "gitlab-graphql",
146− name: "GitLab GraphQL",
147− summary: "Projects, merge requests, issues, CI pipelines, and users.",
148− kind: "graphql",
149− endpoint: "https://gitlab.com/api/graphql",
150− },
151− {
152− id: "openai-api",
153− name: "OpenAI API",
154− summary: "Models, files, responses, and fine-tuning.",
155− kind: "openapi",
156− endpoint: "https://api.openai.com/v1",
157− specUrl:
158− "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml",
159− },
160− {
161− id: "vercel-api",
162− name: "Vercel API",
163− summary: "Deployments, projects, domains, and environments.",
164− kind: "openapi",
165− endpoint: "https://api.vercel.com",
166− specUrl: "https://openapi.vercel.sh",
167− },
168− {
169− id: "stripe-api",
170− name: "Stripe API",
171− summary: "Payments, billing, subscriptions, and invoices.",
172− kind: "openapi",
173− endpoint: "https://api.stripe.com",
174− specUrl:
175− "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json",
176− },
177− {
178− id: "linear-graphql",
179− name: "Linear GraphQL",
180− summary: "Issues, teams, cycles, and projects.",
181− kind: "graphql",
182− endpoint: "https://api.linear.app/graphql",
183− },
184− {
185− id: "monday-graphql",
186− name: "Monday GraphQL",
187− summary: "Boards, items, updates, users, and workspace metadata.",
188− kind: "graphql",
189− endpoint: "https://api.monday.com/v2",
190− },
191− {
192− id: "anilist-graphql",
193− name: "AniList GraphQL",
194− summary: "Anime, manga, characters, media lists, and recommendations.",
195− kind: "graphql",
196− endpoint: "https://graphql.anilist.co",
197− },
198− googleDiscoveryTemplate({
199− id: "google-calendar",
200− name: "Google Calendar",
201− summary: "Calendars, events, ACLs, and scheduling workflows.",
202− service: "calendar",
203− version: "v3",
204− discoveryUrl:
205− "https://calendar-json.googleapis.com/$discovery/rest?version=v3",
206− }),
207− googleDiscoveryTemplate({
208− id: "google-drive",
209− name: "Google Drive",
210− summary: "Files, folders, permissions, comments, and shared drives.",
211− service: "drive",
212− version: "v3",
213− }),
214− googleDiscoveryTemplate({
215− id: "google-gmail",
216− name: "Gmail",
217− summary: "Messages, threads, labels, drafts, and mailbox automation.",
218− service: "gmail",
219− version: "v1",
220− discoveryUrl: "https://gmail.googleapis.com/$discovery/rest?version=v1",
221− }),
222− googleDiscoveryTemplate({
223− id: "google-docs",
224− name: "Google Docs",
225− summary: "Documents, structural edits, text ranges, and formatting.",
226− service: "docs",
227− version: "v1",
228− discoveryUrl: "https://docs.googleapis.com/$discovery/rest?version=v1",
229− }),
230− googleDiscoveryTemplate({
231− id: "google-sheets",
232− name: "Google Sheets",
233− summary: "Spreadsheets, values, ranges, formatting, and batch updates.",
234− service: "sheets",
235− version: "v4",
236− discoveryUrl: "https://sheets.googleapis.com/$discovery/rest?version=v4",
237− }),
238− googleDiscoveryTemplate({
239− id: "google-slides",
240− name: "Google Slides",
241− summary: "Presentations, slides, page elements, and deck updates.",
242− service: "slides",
243− version: "v1",
244− discoveryUrl: "https://slides.googleapis.com/$discovery/rest?version=v1",
245− }),
246− googleDiscoveryTemplate({
247− id: "google-forms",
248− name: "Google Forms",
249− summary: "Forms, questions, responses, quizzes, and form metadata.",
250− service: "forms",
251− version: "v1",
252− discoveryUrl: "https://forms.googleapis.com/$discovery/rest?version=v1",
253− }),
254− googleDiscoveryTemplate({
255− id: "google-search-console",
256− name: "Google Search Console",
257− summary:
258− "Sites, sitemaps, URL inspection, and search and Discover performance.",
259− service: "searchconsole",
260− version: "v1",
261− discoveryUrl:
262− "https://searchconsole.googleapis.com/$discovery/rest?version=v1",
263− }),
264− googleDiscoveryTemplate({
265− id: "google-people",
266− name: "Google People",
267− summary: "Contacts, profiles, directory people, and contact groups.",
268− service: "people",
269− version: "v1",
270− discoveryUrl: "https://people.googleapis.com/$discovery/rest?version=v1",
271− }),
272− googleDiscoveryTemplate({
273− id: "google-tasks",
274− name: "Google Tasks",
275− summary: "Task lists, task items, notes, and due dates.",
276− service: "tasks",
277− version: "v1",
278− discoveryUrl: "https://tasks.googleapis.com/$discovery/rest?version=v1",
279− }),
280− googleDiscoveryTemplate({
281− id: "google-chat",
282− name: "Google Chat",
283− summary: "Spaces, messages, members, reactions, and chat workflows.",
284− service: "chat",
285− version: "v1",
286− discoveryUrl: "https://chat.googleapis.com/$discovery/rest?version=v1",
287− }),
288− googleDiscoveryTemplate({
289− id: "google-keep",
290− name: "Google Keep",
291− summary: "Notes, lists, attachments, and collaborative annotations.",
292− service: "keep",
293− version: "v1",
294− discoveryUrl: "https://keep.googleapis.com/$discovery/rest?version=v1",
295− }),
296− googleDiscoveryTemplate({
297− id: "google-classroom",
298− name: "Google Classroom",
299− summary: "Courses, rosters, coursework, submissions, and grading data.",
300− service: "classroom",
301− version: "v1",
302− discoveryUrl: "https://classroom.googleapis.com/$discovery/rest?version=v1",
303− }),
304− googleDiscoveryTemplate({
305− id: "google-admin-directory",
306− name: "Google Admin Directory",
307− summary: "Users, groups, org units, roles, and domain directory resources.",
308− service: "admin",
309− version: "directory_v1",
310− discoveryUrl:
311− "https://admin.googleapis.com/$discovery/rest?version=directory_v1",
312− }),
313− googleDiscoveryTemplate({
314− id: "google-admin-reports",
315− name: "Google Admin Reports",
316− summary: "Audit events, usage reports, and admin activity logs.",
317− service: "admin",
318− version: "reports_v1",
319− discoveryUrl:
320− "https://admin.googleapis.com/$discovery/rest?version=reports_v1",
321− }),
322− googleDiscoveryTemplate({
323− id: "google-apps-script",
324− name: "Google Apps Script",
325− summary:
326− "Projects, deployments, script execution, and Apps Script metadata.",
327− service: "script",
328− version: "v1",
329− discoveryUrl: "https://script.googleapis.com/$discovery/rest?version=v1",
330− }),
331− googleDiscoveryTemplate({
332− id: "google-bigquery",
333− name: "Google BigQuery",
334− summary:
335− "Datasets, tables, jobs, routines, and analytical query workflows.",
336− service: "bigquery",
337− version: "v2",
338− discoveryUrl: "https://bigquery.googleapis.com/$discovery/rest?version=v2",
339− }),
340− googleDiscoveryTemplate({
341− id: "google-cloud-resource-manager",
342− name: "Google Cloud Resource Manager",
343− summary:
344− "Projects, folders, organizations, and IAM-oriented resource hierarchy.",
345− service: "cloudresourcemanager",
346− version: "v3",
347− discoveryUrl:
348− "https://cloudresourcemanager.googleapis.com/$discovery/rest?version=v3",
349− }),
350− googleDiscoveryTemplate({
351− id: "google-youtube-data",
352− name: "YouTube Data",
353− summary: "Channels, playlists, videos, comments, captions, and uploads.",
354− service: "youtube",
355− version: "v3",
356− discoveryUrl: "https://youtube.googleapis.com/$discovery/rest?version=v3",
357− }),
358−];
apps/web/vite.config.ts+18−0
@@ -23,6 +23,16 @@ const githubUrl = (executorPackage.homepage ?? repositoryUrl ?? "https://github. · 22 unmodified lines
2323 .replace(/^git\+/, "")
2424 .replace(/\.git$/, "");
2525
26+const webPackage = JSON.parse(
27+ readFileSync(new URL("./package.json", import.meta.url), "utf8"),
28+) as {
29+ dependencies?: Record<string, string>;
30+};
31+
32+const workspaceExecutorPackages = Object.keys(webPackage.dependencies ?? {}).filter(
33+ (name) => name.startsWith("@executor/"),
34+);
35+
2636 export default defineConfig({
2737 root: "src",
2838 plugins: [
@@ -47,7 +57,15 @@ export default defineConfig({ · 18 unmodified lines
4757 outDir: "../dist",
4858 emptyOutDir: true,
4959 },
60+ optimizeDeps: {
61+ exclude: workspaceExecutorPackages,
62+ },
5063 server: {
5164 port: 8788,
65+ watch: {
66+ ignored: [
67+ "!**/node_modules/@executor/**",
68+ ],
69+ },
5270 },
5371 });
bun.lock+276−92
@@ -18,7 +18,7 @@ · 17 unmodified lines
1818 },
1919 "apps/executor": {
2020 "name": "executor",
21− "version": "1.2.4-beta.4",
21+ "version": "1.2.6",
2222 "bin": {
2323 "executor": "bin/executor",
2424 },
@@ -27,10 +27,14 @@ · 2 unmodified lines
2727 "@effect/platform": "catalog:",
2828 "@effect/platform-node": "catalog:",
2929 "@executor/codemode-core": "workspace:*",
30+ "@executor/executor-mcp": "workspace:*",
3031 "@executor/platform-api": "workspace:*",
31− "@executor/platform-internal": "workspace:*",
3232 "@executor/platform-sdk": "workspace:*",
3333 "@executor/platform-sdk-file": "workspace:*",
34+ "@executor/plugin-google-discovery-sdk": "workspace:*",
35+ "@executor/plugin-graphql-sdk": "workspace:*",
36+ "@executor/plugin-mcp-sdk": "workspace:*",
37+ "@executor/plugin-openapi-sdk": "workspace:*",
3438 "@executor/server": "workspace:*",
3539 "effect": "catalog:",
3640 },
@@ -50,6 +54,11 @@ · 13 unmodified lines
5054 "name": "@executor/web",
5155 "version": "0.0.0",
5256 "dependencies": {
57+ "@executor/plugin-execution-history-react": "workspace:*",
58+ "@executor/plugin-google-discovery-react": "workspace:*",
59+ "@executor/plugin-graphql-react": "workspace:*",
60+ "@executor/plugin-mcp-react": "workspace:*",
61+ "@executor/plugin-openapi-react": "workspace:*",
5362 "@executor/react": "workspace:*",
5463 "@executor/server": "workspace:*",
5564 "@shikijs/langs": "^4.0.1",
@@ -114,41 +123,25 @@ · 58 unmodified lines
114123 "bun-types": "catalog:",
115124 },
116125 },
117− "packages/auth/mcp-oauth": {
118− "name": "@executor/auth-mcp-oauth",
119− "version": "0.0.0",
120− "dependencies": {
121− "@modelcontextprotocol/sdk": "^1.26.0",
122− "effect": "catalog:",
123− },
124− "devDependencies": {
125− "@types/node": "catalog:",
126− "bun-types": "catalog:",
127− },
128− },
129− "packages/auth/oauth2": {
130− "name": "@executor/auth-oauth2",
131− "version": "0.0.0",
132− "dependencies": {
133− "effect": "catalog:",
134− },
135− "devDependencies": {
136− "@types/node": "catalog:",
137− "bun-types": "catalog:",
138− },
139− },
140126 "packages/clients/react": {
141127 "name": "@executor/react",
142128 "version": "0.0.0",
143129 "dependencies": {
144130 "@effect-atom/atom": "0.5.3",
145131 "@effect-atom/atom-react": "0.5.0",
132+ "@effect/platform": "catalog:",
146133 "@executor/platform-api": "workspace:*",
147134 "@executor/platform-sdk": "workspace:*",
135+ "@shikijs/langs": "^4.0.1",
136+ "@shikijs/themes": "^4.0.1",
137+ "class-variance-authority": "^0.7.1",
138+ "clsx": "^2.1.1",
148139 "effect": "catalog:",
140+ "shiki": "^4.0.1",
141+ "streamdown": "^2.3.0",
142+ "tailwind-merge": "^3.5.0",
149143 },
150144 "devDependencies": {
151− "@effect/platform": "catalog:",
152145 "@effect/platform-node": "catalog:",
153146 "@effect/vitest": "catalog:",
154147 "@executor/effect-test-utils": "workspace:*",
@@ -189,7 +182,6 @@ · 34 unmodified lines
189182 "@executor/codemode-core": "workspace:*",
190183 "@executor/platform-sdk": "workspace:*",
191184 "@executor/runtime-ses": "workspace:*",
192− "@executor/source-openapi": "workspace:*",
193185 "effect": "catalog:",
194186 },
195187 "devDependencies": {
@@ -316,39 +308,18 @@ · 120 unmodified lines
316308 "vitest": "catalog:",
317309 },
318310 },
319− "packages/platform/internal": {
320− "name": "@executor/platform-internal",
321− "version": "0.0.0",
322− "dependencies": {
323− "@executor/codemode-core": "workspace:*",
324− "@executor/platform-sdk": "workspace:*",
325− "effect": "catalog:",
326− },
327− "devDependencies": {
328− "@types/node": "catalog:",
329− "bun-types": "catalog:",
330− "typescript": "^5.9.3",
331− },
332− },
333311 "packages/platform/sdk": {
334312 "name": "@executor/platform-sdk",
335313 "version": "0.0.0",
336314 "dependencies": {
337315 "@effect/platform": "catalog:",
338316 "@effect/platform-node": "catalog:",
339− "@executor/auth-mcp-oauth": "workspace:*",
340− "@executor/auth-oauth2": "workspace:*",
341317 "@executor/codemode-core": "workspace:*",
342318 "@executor/ir": "workspace:*",
343319 "@executor/runtime-deno-subprocess": "workspace:*",
344320 "@executor/runtime-quickjs": "workspace:*",
345321 "@executor/runtime-ses": "workspace:*",
346− "@executor/source-builtins": "workspace:*",
347322 "@executor/source-core": "workspace:*",
348− "@executor/source-google-discovery": "workspace:*",
349− "@executor/source-graphql": "workspace:*",
350− "@executor/source-mcp": "workspace:*",
351− "@executor/source-openapi": "workspace:*",
352323 "effect": "catalog:",
353324 },
354325 "devDependencies": {
@@ -374,8 +345,6 @@ · 19 unmodified lines
374345 "@executor/ir": "workspace:*",
375346 "@executor/platform-sdk": "workspace:*",
376347 "@executor/source-core": "workspace:*",
377− "@executor/source-graphql": "workspace:*",
378− "@executor/source-openapi": "workspace:*",
379348 "effect": "catalog:",
380349 "jsonc-parser": "3.3.1",
381350 },
@@ -396,9 +365,20 @@ · 14 unmodified lines
396365 "@effect/platform-node": "catalog:",
397366 "@executor/executor-mcp": "workspace:*",
398367 "@executor/platform-api": "workspace:*",
399− "@executor/platform-internal": "workspace:*",
400368 "@executor/platform-sdk": "workspace:*",
401369 "@executor/platform-sdk-file": "workspace:*",
370+ "@executor/plugin-google-discovery-http": "workspace:*",
371+ "@executor/plugin-google-discovery-sdk": "workspace:*",
372+ "@executor/plugin-google-discovery-shared": "workspace:*",
373+ "@executor/plugin-graphql-http": "workspace:*",
374+ "@executor/plugin-graphql-sdk": "workspace:*",
375+ "@executor/plugin-graphql-shared": "workspace:*",
376+ "@executor/plugin-mcp-http": "workspace:*",
377+ "@executor/plugin-mcp-sdk": "workspace:*",
378+ "@executor/plugin-mcp-shared": "workspace:*",
379+ "@executor/plugin-openapi-http": "workspace:*",
380+ "@executor/plugin-openapi-sdk": "workspace:*",
381+ "@executor/plugin-openapi-shared": "workspace:*",
402382 "@opentelemetry/api": "^1.9.0",
403383 "@opentelemetry/exporter-trace-otlp-grpc": "^0.213.0",
404384 "@opentelemetry/resources": "^2.6.0",
@@ -408,33 +388,52 @@ · 3 unmodified lines
408388 "effect": "catalog:",
409389 },
410390 "devDependencies": {
391+ "@effect/vitest": "catalog:",
392+ "@executor/effect-test-utils": "workspace:*",
393+ "@executor/mcp-elicitation-demo": "workspace:*",
394+ "@executor/runtime-ses": "workspace:*",
395+ "@modelcontextprotocol/sdk": "^1.26.0",
411396 "@types/node": "catalog:",
412397 "bun-types": "catalog:",
398+ "vitest": "catalog:",
399+ "zod": "catalog:",
413400 },
414401 },
415− "packages/sources/builtins": {
416− "name": "@executor/source-builtins",
402+ "packages/sources/core": {
403+ "name": "@executor/source-core",
417404 "version": "0.0.0",
418405 "dependencies": {
419− "@executor/source-core": "workspace:*",
420− "@executor/source-google-discovery": "workspace:*",
421− "@executor/source-graphql": "workspace:*",
422− "@executor/source-mcp": "workspace:*",
423− "@executor/source-openapi": "workspace:*",
406+ "@executor/codemode-core": "workspace:*",
407+ "@executor/ir": "workspace:*",
408+ "@modelcontextprotocol/sdk": "^1.26.0",
409+ "effect": "catalog:",
424410 },
425411 "devDependencies": {
426412 "@types/node": "catalog:",
427413 "bun-types": "catalog:",
428414 },
429415 },
430− "packages/sources/core": {
431− "name": "@executor/source-core",
416+ "plugins/execution-history/react": {
417+ "name": "@executor/plugin-execution-history-react",
432418 "version": "0.0.0",
433419 "dependencies": {
434− "@executor/auth-oauth2": "workspace:*",
435− "@executor/codemode-core": "workspace:*",
436− "@executor/ir": "workspace:*",
437− "@modelcontextprotocol/sdk": "^1.26.0",
420+ "@executor/react": "workspace:*",
421+ "react": "^19.2.0",
422+ },
423+ "devDependencies": {
424+ "@types/node": "catalog:",
425+ "@types/react": "^19.2.10",
426+ "bun-types": "catalog:",
427+ },
428+ },
429+ "plugins/google-discovery/http": {
430+ "name": "@executor/plugin-google-discovery-http",
431+ "version": "0.0.0",
432+ "dependencies": {
433+ "@effect/platform": "catalog:",
434+ "@executor/platform-api": "workspace:*",
435+ "@executor/platform-sdk": "workspace:*",
436+ "@executor/plugin-google-discovery-shared": "workspace:*",
438437 "effect": "catalog:",
439438 },
440439 "devDependencies": {
@@ -442,13 +441,30 @@ · 1 unmodified line
442441 "bun-types": "catalog:",
443442 },
444443 },
445− "packages/sources/google-discovery": {
446− "name": "@executor/source-google-discovery",
444+ "plugins/google-discovery/react": {
445+ "name": "@executor/plugin-google-discovery-react",
446+ "version": "0.0.0",
447+ "dependencies": {
448+ "@executor/plugin-google-discovery-http": "workspace:*",
449+ "@executor/plugin-google-discovery-shared": "workspace:*",
450+ "@executor/react": "workspace:*",
451+ "react": "^19.2.0",
452+ },
453+ "devDependencies": {
454+ "@types/node": "catalog:",
455+ "@types/react": "^19.2.10",
456+ "bun-types": "catalog:",
457+ },
458+ },
459+ "plugins/google-discovery/sdk": {
460+ "name": "@executor/plugin-google-discovery-sdk",
447461 "version": "0.0.0",
448462 "dependencies": {
449463 "@effect/platform": "catalog:",
450464 "@executor/codemode-core": "workspace:*",
451465 "@executor/ir": "workspace:*",
466+ "@executor/platform-sdk": "workspace:*",
467+ "@executor/plugin-google-discovery-shared": "workspace:*",
452468 "@executor/source-core": "workspace:*",
453469 "effect": "catalog:",
454470 },
@@ -459,60 +475,210 @@ · 4 unmodified lines
459475 "vitest": "catalog:",
460476 },
461477 },
462− "packages/sources/graphql": {
463− "name": "@executor/source-graphql",
478+ "plugins/google-discovery/shared": {
479+ "name": "@executor/plugin-google-discovery-shared",
480+ "version": "0.0.0",
481+ "dependencies": {
482+ "@executor/platform-sdk": "workspace:*",
483+ "@executor/source-core": "workspace:*",
484+ "effect": "catalog:",
485+ },
486+ "devDependencies": {
487+ "@types/node": "catalog:",
488+ "bun-types": "catalog:",
489+ },
490+ },
491+ "plugins/graphql/http": {
492+ "name": "@executor/plugin-graphql-http",
493+ "version": "0.0.0",
494+ "dependencies": {
495+ "@effect/platform": "catalog:",
496+ "@executor/platform-api": "workspace:*",
497+ "@executor/platform-sdk": "workspace:*",
498+ "@executor/plugin-graphql-shared": "workspace:*",
499+ "effect": "catalog:",
500+ },
501+ "devDependencies": {
502+ "@types/node": "catalog:",
503+ "bun-types": "catalog:",
504+ },
505+ },
506+ "plugins/graphql/react": {
507+ "name": "@executor/plugin-graphql-react",
508+ "version": "0.0.0",
509+ "dependencies": {
510+ "@executor/plugin-graphql-http": "workspace:*",
511+ "@executor/plugin-graphql-shared": "workspace:*",
512+ "@executor/react": "workspace:*",
513+ "react": "^19.2.0",
514+ },
515+ "devDependencies": {
516+ "@types/node": "catalog:",
517+ "@types/react": "^19.2.10",
518+ "bun-types": "catalog:",
519+ },
520+ },
521+ "plugins/graphql/sdk": {
522+ "name": "@executor/plugin-graphql-sdk",
464523 "version": "0.0.0",
465524 "dependencies": {
466525 "@executor/codemode-core": "workspace:*",
467526 "@executor/ir": "workspace:*",
527+ "@executor/platform-sdk": "workspace:*",
528+ "@executor/plugin-graphql-shared": "workspace:*",
468529 "@executor/source-core": "workspace:*",
469530 "effect": "catalog:",
470531 "graphql": "^16.12.0",
471532 },
533+ "devDependencies": {
534+ "@effect/vitest": "catalog:",
535+ "@types/node": "catalog:",
536+ "bun-types": "catalog:",
537+ "vitest": "catalog:",
538+ },
539+ },
540+ "plugins/graphql/shared": {
541+ "name": "@executor/plugin-graphql-shared",
542+ "version": "0.0.0",
543+ "dependencies": {
544+ "@executor/platform-sdk": "workspace:*",
545+ "@executor/source-core": "workspace:*",
546+ "effect": "catalog:",
547+ },
472548 "devDependencies": {
473549 "@types/node": "catalog:",
474550 "bun-types": "catalog:",
475551 },
476552 },
477− "packages/sources/mcp": {
478− "name": "@executor/source-mcp",
553+ "plugins/mcp/http": {
554+ "name": "@executor/plugin-mcp-http",
555+ "version": "0.0.0",
556+ "dependencies": {
557+ "@effect/platform": "catalog:",
558+ "@executor/platform-api": "workspace:*",
559+ "@executor/platform-sdk": "workspace:*",
560+ "@executor/plugin-mcp-shared": "workspace:*",
561+ "effect": "catalog:",
562+ },
563+ "devDependencies": {
564+ "@types/node": "catalog:",
565+ "bun-types": "catalog:",
566+ },
567+ },
568+ "plugins/mcp/react": {
569+ "name": "@executor/plugin-mcp-react",
570+ "version": "0.0.0",
571+ "dependencies": {
572+ "@executor/plugin-mcp-http": "workspace:*",
573+ "@executor/plugin-mcp-shared": "workspace:*",
574+ "@executor/react": "workspace:*",
575+ "react": "^19.2.0",
576+ },
577+ "devDependencies": {
578+ "@types/node": "catalog:",
579+ "@types/react": "^19.2.10",
580+ "bun-types": "catalog:",
581+ },
582+ },
583+ "plugins/mcp/sdk": {
584+ "name": "@executor/plugin-mcp-sdk",
479585 "version": "0.0.0",
480586 "dependencies": {
481− "@executor/auth-mcp-oauth": "workspace:*",
482587 "@executor/codemode-core": "workspace:*",
483588 "@executor/ir": "workspace:*",
484− "@executor/runtime-ses": "workspace:*",
589+ "@executor/platform-sdk": "workspace:*",
590+ "@executor/plugin-mcp-shared": "workspace:*",
485591 "@executor/source-core": "workspace:*",
486592 "@modelcontextprotocol/sdk": "^1.26.0",
487593 "effect": "catalog:",
488594 },
489595 "devDependencies": {
490596 "@effect/vitest": "catalog:",
597+ "@executor/runtime-ses": "workspace:*",
491598 "@types/node": "catalog:",
492599 "bun-types": "catalog:",
493600 "vitest": "catalog:",
494601 "zod": "catalog:",
495602 },
496603 },
497− "packages/sources/openapi": {
498− "name": "@executor/source-openapi",
604+ "plugins/mcp/shared": {
605+ "name": "@executor/plugin-mcp-shared",
606+ "version": "0.0.0",
607+ "dependencies": {
608+ "@executor/platform-sdk": "workspace:*",
609+ "@executor/source-core": "workspace:*",
610+ "effect": "catalog:",
611+ },
612+ "devDependencies": {
613+ "@types/node": "catalog:",
614+ "bun-types": "catalog:",
615+ },
616+ },
617+ "plugins/openapi/http": {
618+ "name": "@executor/plugin-openapi-http",
499619 "version": "0.0.0",
500620 "dependencies": {
501621 "@effect/platform": "catalog:",
622+ "@executor/platform-api": "workspace:*",
623+ "@executor/platform-sdk": "workspace:*",
624+ "@executor/plugin-openapi-shared": "workspace:*",
625+ "effect": "catalog:",
626+ },
627+ "devDependencies": {
628+ "@types/node": "catalog:",
629+ "bun-types": "catalog:",
630+ },
631+ },
632+ "plugins/openapi/react": {
633+ "name": "@executor/plugin-openapi-react",
634+ "version": "0.0.0",
635+ "dependencies": {
636+ "@executor/plugin-openapi-http": "workspace:*",
637+ "@executor/plugin-openapi-shared": "workspace:*",
638+ "@executor/react": "workspace:*",
639+ "react": "^19.2.0",
640+ },
641+ "devDependencies": {
642+ "@types/node": "catalog:",
643+ "@types/react": "^19.2.10",
644+ "bun-types": "catalog:",
645+ },
646+ },
647+ "plugins/openapi/sdk": {
648+ "name": "@executor/plugin-openapi-sdk",
649+ "version": "0.0.0",
650+ "dependencies": {
502651 "@executor/codemode-core": "workspace:*",
503652 "@executor/ir": "workspace:*",
653+ "@executor/platform-sdk": "workspace:*",
654+ "@executor/plugin-openapi-shared": "workspace:*",
504655 "@executor/source-core": "workspace:*",
505656 "effect": "catalog:",
506657 "yaml": "^2.8.1",
507658 },
508659 "devDependencies": {
509− "@effect/platform-node": "^0.104.1",
510− "@effect/vitest": "catalog:",
660+ "@effect/platform": "catalog:",
661+ "@effect/platform-node": "catalog:",
662+ "@executor/effect-test-utils": "workspace:*",
511663 "@types/node": "catalog:",
512664 "bun-types": "catalog:",
513665 "vitest": "catalog:",
514666 },
515667 },
668+ "plugins/openapi/shared": {
669+ "name": "@executor/plugin-openapi-shared",
670+ "version": "0.0.0",
671+ "dependencies": {
672+ "@executor/platform-sdk": "workspace:*",
673+ "@executor/source-core": "workspace:*",
674+ "effect": "catalog:",
675+ "yaml": "^2.8.1",
676+ },
677+ "devDependencies": {
678+ "@types/node": "catalog:",
679+ "bun-types": "catalog:",
680+ },
681+ },
516682 },
517683 "catalog": {
518684 "@effect/cli": "^0.73.2",
@@ -718,10 +884,6 @@ · 199 unmodified lines
718884
719885 "@executor/ai-sdk-adapter": ["@executor/ai-sdk-adapter@workspace:packages/hosts/ai-sdk"],
720886
721− "@executor/auth-mcp-oauth": ["@executor/auth-mcp-oauth@workspace:packages/auth/mcp-oauth"],
722−
723− "@executor/auth-oauth2": ["@executor/auth-oauth2@workspace:packages/auth/oauth2"],
724−
725887 "@executor/codemode-core": ["@executor/codemode-core@workspace:packages/kernel/core"],
726888
727889 "@executor/docs": ["@executor/docs@workspace:apps/docs"],
@@ -738,12 +900,44 @@ · 10 unmodified lines
738900
739901 "@executor/platform-api": ["@executor/platform-api@workspace:packages/platform/api"],
740902
741− "@executor/platform-internal": ["@executor/platform-internal@workspace:packages/platform/internal"],
742−
743903 "@executor/platform-sdk": ["@executor/platform-sdk@workspace:packages/platform/sdk"],
744904
745905 "@executor/platform-sdk-file": ["@executor/platform-sdk-file@workspace:packages/platform/sdk-file"],
746906
907+ "@executor/plugin-execution-history-react": ["@executor/plugin-execution-history-react@workspace:plugins/execution-history/react"],
908+
909+ "@executor/plugin-google-discovery-http": ["@executor/plugin-google-discovery-http@workspace:plugins/google-discovery/http"],
910+
911+ "@executor/plugin-google-discovery-react": ["@executor/plugin-google-discovery-react@workspace:plugins/google-discovery/react"],
912+
913+ "@executor/plugin-google-discovery-sdk": ["@executor/plugin-google-discovery-sdk@workspace:plugins/google-discovery/sdk"],
914+
915+ "@executor/plugin-google-discovery-shared": ["@executor/plugin-google-discovery-shared@workspace:plugins/google-discovery/shared"],
916+
917+ "@executor/plugin-graphql-http": ["@executor/plugin-graphql-http@workspace:plugins/graphql/http"],
918+
919+ "@executor/plugin-graphql-react": ["@executor/plugin-graphql-react@workspace:plugins/graphql/react"],
920+
921+ "@executor/plugin-graphql-sdk": ["@executor/plugin-graphql-sdk@workspace:plugins/graphql/sdk"],
922+
923+ "@executor/plugin-graphql-shared": ["@executor/plugin-graphql-shared@workspace:plugins/graphql/shared"],
924+
925+ "@executor/plugin-mcp-http": ["@executor/plugin-mcp-http@workspace:plugins/mcp/http"],
926+
927+ "@executor/plugin-mcp-react": ["@executor/plugin-mcp-react@workspace:plugins/mcp/react"],
928+
929+ "@executor/plugin-mcp-sdk": ["@executor/plugin-mcp-sdk@workspace:plugins/mcp/sdk"],
930+
931+ "@executor/plugin-mcp-shared": ["@executor/plugin-mcp-shared@workspace:plugins/mcp/shared"],
932+
933+ "@executor/plugin-openapi-http": ["@executor/plugin-openapi-http@workspace:plugins/openapi/http"],
934+
935+ "@executor/plugin-openapi-react": ["@executor/plugin-openapi-react@workspace:plugins/openapi/react"],
936+
937+ "@executor/plugin-openapi-sdk": ["@executor/plugin-openapi-sdk@workspace:plugins/openapi/sdk"],
938+
939+ "@executor/plugin-openapi-shared": ["@executor/plugin-openapi-shared@workspace:plugins/openapi/shared"],
940+
747941 "@executor/react": ["@executor/react@workspace:packages/clients/react"],
748942
749943 "@executor/runtime-deno-subprocess": ["@executor/runtime-deno-subprocess@workspace:packages/kernel/runtime-deno-subprocess"],
@@ -756,18 +950,8 @@ · 6 unmodified lines
756950
757951 "@executor/server": ["@executor/server@workspace:packages/platform/server"],
758952
759− "@executor/source-builtins": ["@executor/source-builtins@workspace:packages/sources/builtins"],
760−
761953 "@executor/source-core": ["@executor/source-core@workspace:packages/sources/core"],
762954
763− "@executor/source-google-discovery": ["@executor/source-google-discovery@workspace:packages/sources/google-discovery"],
764−
765− "@executor/source-graphql": ["@executor/source-graphql@workspace:packages/sources/graphql"],
766−
767− "@executor/source-mcp": ["@executor/source-mcp@workspace:packages/sources/mcp"],
768−
769− "@executor/source-openapi": ["@executor/source-openapi@workspace:packages/sources/openapi"],
770−
771955 "@executor/sqlite-sdk-consumer-example": ["@executor/sqlite-sdk-consumer-example@workspace:examples/sqlite-sdk-consumer"],
772956
773957 "@executor/web": ["@executor/web@workspace:apps/web"],
examples/sqlite-sdk-consumer/src/sqlite-backend.ts+7−466
@@ -20,19 +20,13 @@ import { · 19 unmodified lines
2020 type ExecutorWorkspaceSourceArtifactRepository,
2121 } from "@executor/platform-sdk";
2222 import type {
23− AuthArtifact,
24− AuthLease,
2523 Execution,
2624 ExecutionInteraction,
2725 ExecutionStep,
2826 LocalExecutorConfig,
2927 LocalInstallation,
30− ProviderAuthGrant,
31− ScopeOauthClient,
32− ScopedSourceOauthClient,
3328 SecretMaterial,
3429 SecretRef,
35− SourceAuthSession,
3630 } from "@executor/platform-sdk/schema";
3731 import {
3832 ScopeIdSchema,
@@ -94,54 +88,6 @@ const sourceArtifacts = sqliteTable("source_artifacts", { · 55 unmodified lines
9488 artifactJson: text("artifact_json").notNull(),
9589 });
9690
97−const authArtifacts = sqliteTable("auth_artifacts", {
98− id: text("id").primaryKey(),
99− scopeId: text("scope_id").notNull(),
100− sourceId: text("source_id").notNull(),
101− actorScopeId: text("actor_scope_id"),
102− slot: text("slot").notNull(),
103− json: text("json").notNull(),
104−});
105−
106−const authLeases = sqliteTable("auth_leases", {
107− authArtifactId: text("auth_artifact_id").primaryKey(),
108− json: text("json").notNull(),
109−});
110−
111−const sourceOauthClients = sqliteTable("source_oauth_clients", {
112− id: text("id").primaryKey(),
113− scopeId: text("scope_id").notNull(),
114− sourceId: text("source_id").notNull(),
115− providerKey: text("provider_key").notNull(),
116− json: text("json").notNull(),
117−});
118−
119−const scopeOauthClients = sqliteTable("scope_oauth_clients", {
120− id: text("id").primaryKey(),
121− scopeId: text("scope_id").notNull(),
122− providerKey: text("provider_key").notNull(),
123− json: text("json").notNull(),
124−});
125−
126−const providerAuthGrants = sqliteTable("provider_auth_grants", {
127− id: text("id").primaryKey(),
128− scopeId: text("scope_id").notNull(),
129− actorScopeId: text("actor_scope_id"),
130− providerKey: text("provider_key").notNull(),
131− json: text("json").notNull(),
132−});
133−
134−const sourceAuthSessions = sqliteTable("source_auth_sessions", {
135− id: text("id").primaryKey(),
136− scopeId: text("scope_id").notNull(),
137− sourceId: text("source_id").notNull(),
138− actorScopeId: text("actor_scope_id"),
139− state: text("state").notNull(),
140− status: text("status").notNull(),
141− credentialSlot: text("credential_slot"),
142− json: text("json").notNull(),
143−});
144−
14591 const secretMaterials = sqliteTable("secret_materials", {
14692 id: text("id").primaryKey(),
14793 providerId: text("provider_id").notNull(),
@@ -175,25 +121,15 @@ const executionSteps = sqliteTable("execution_steps", { · 27 unmodified lines
175121 const makeHash = (value: string): string =>
176122 createHash("sha256").update(value).digest("hex").slice(0, 24);
177123
178−const sameActor = (
179− left: string | null | undefined,
180− right: string | null | undefined,
181−): boolean => (left ?? null) === (right ?? null);
182−
183124 const parseJson = <T>(value: string): T => JSON.parse(value) as T;
184125
185126 const createSourceArtifact = (input: SourceArtifactBuildInput): SourceArtifact => {
186127 const snapshot = snapshotFromSourceCatalogSyncResult(input.syncResult);
187128 const sourceConfigJson = JSON.stringify({
188129 kind: input.source.kind,
189− endpoint: input.source.endpoint,
190130 namespace: input.source.namespace,
191131 name: input.source.name,
192132 enabled: input.source.enabled,
193− binding: input.source.binding,
194− auth: input.source.auth,
195− importAuth: input.source.importAuth,
196− importAuthPolicy: input.source.importAuthPolicy,
197133 });
198134 const importMetadataJson = JSON.stringify(snapshot.import);
199135 const catalogId = SourceCatalogIdSchema.make(`src_catalog_${makeHash(sourceConfigJson)}`);
@@ -273,54 +209,6 @@ const openSqliteStore = (databasePath: string) => { · 73 unmodified lines
273209 artifact_json TEXT NOT NULL
274210 );
275211
276− CREATE TABLE IF NOT EXISTS auth_artifacts (
277− id TEXT PRIMARY KEY NOT NULL,
278− scope_id TEXT NOT NULL,
279− source_id TEXT NOT NULL,
280− actor_scope_id TEXT,
281− slot TEXT NOT NULL,
282− json TEXT NOT NULL
283− );
284−
285− CREATE TABLE IF NOT EXISTS auth_leases (
286− auth_artifact_id TEXT PRIMARY KEY NOT NULL,
287− json TEXT NOT NULL
288− );
289−
290− CREATE TABLE IF NOT EXISTS source_oauth_clients (
291− id TEXT PRIMARY KEY NOT NULL,
292− scope_id TEXT NOT NULL,
293− source_id TEXT NOT NULL,
294− provider_key TEXT NOT NULL,
295− json TEXT NOT NULL
296− );
297−
298− CREATE TABLE IF NOT EXISTS scope_oauth_clients (
299− id TEXT PRIMARY KEY NOT NULL,
300− scope_id TEXT NOT NULL,
301− provider_key TEXT NOT NULL,
302− json TEXT NOT NULL
303− );
304−
305− CREATE TABLE IF NOT EXISTS provider_auth_grants (
306− id TEXT PRIMARY KEY NOT NULL,
307− scope_id TEXT NOT NULL,
308− actor_scope_id TEXT,
309− provider_key TEXT NOT NULL,
310− json TEXT NOT NULL
311− );
312−
313− CREATE TABLE IF NOT EXISTS source_auth_sessions (
314− id TEXT PRIMARY KEY NOT NULL,
315− scope_id TEXT NOT NULL,
316− source_id TEXT NOT NULL,
317− actor_scope_id TEXT,
318− state TEXT NOT NULL,
319− status TEXT NOT NULL,
320− credential_slot TEXT,
321− json TEXT NOT NULL
322− );
323−
324212 CREATE TABLE IF NOT EXISTS secret_materials (
325213 id TEXT PRIMARY KEY NOT NULL,
326214 provider_id TEXT NOT NULL,
@@ -363,358 +251,6 @@ const openSqliteStore = (databasePath: string) => { · 36 unmodified lines
363251 type SqliteStore = ReturnType<typeof openSqliteStore>;
364252
365253 const createStorageDomains = (store: SqliteStore) => ({
366− auth: {
367− artifacts: {
368− listByScopeId: (scopeId: AuthArtifact["scopeId"]) =>
369− store.db.select().from(authArtifacts).where(eq(authArtifacts.scopeId, scopeId)).all()
370− .map((row) => parseJson<AuthArtifact>(row.json)),
371− listByScopeAndSourceId: (input: {
372− scopeId: AuthArtifact["scopeId"];
373− sourceId: AuthArtifact["sourceId"];
374− }) =>
375− store.db.select().from(authArtifacts).where(
376− and(
377− eq(authArtifacts.scopeId, input.scopeId),
378− eq(authArtifacts.sourceId, input.sourceId),
379− ),
380− ).all().map((row) => parseJson<AuthArtifact>(row.json)),
381− getByScopeSourceAndActor: (input: {
382− scopeId: AuthArtifact["scopeId"];
383− sourceId: AuthArtifact["sourceId"];
384− actorScopeId: AuthArtifact["actorScopeId"];
385− slot: AuthArtifact["slot"];
386− }) =>
387− store.db.select().from(authArtifacts).where(
388− and(
389− eq(authArtifacts.scopeId, input.scopeId),
390− eq(authArtifacts.sourceId, input.sourceId),
391− eq(authArtifacts.slot, input.slot),
392− ),
393− ).all().map((row) => parseJson<AuthArtifact>(row.json))
394− .find((item) => sameActor(item.actorScopeId, input.actorScopeId)) ?? null,
395− upsert: (artifact: AuthArtifact) =>
396− store.db.insert(authArtifacts).values({
397− id: artifact.id,
398− scopeId: artifact.scopeId,
399− sourceId: artifact.sourceId,
400− actorScopeId: artifact.actorScopeId,
401− slot: artifact.slot,
402− json: JSON.stringify(artifact),
403− }).onConflictDoUpdate({
404− target: authArtifacts.id,
405− set: {
406− scopeId: artifact.scopeId,
407− sourceId: artifact.sourceId,
408− actorScopeId: artifact.actorScopeId,
409− slot: artifact.slot,
410− json: JSON.stringify(artifact),
411− },
412− }).run(),
413− removeByScopeSourceAndActor: (input: {
414− scopeId: AuthArtifact["scopeId"];
415− sourceId: AuthArtifact["sourceId"];
416− actorScopeId: AuthArtifact["actorScopeId"];
417− slot?: AuthArtifact["slot"];
418− }) => {
419− const rows = store.db.select().from(authArtifacts).where(
420− and(
421− eq(authArtifacts.scopeId, input.scopeId),
422− eq(authArtifacts.sourceId, input.sourceId),
423− ),
424− ).all();
425− const match = rows.find((row) => {
426− const item = parseJson<AuthArtifact>(row.json);
427− return sameActor(item.actorScopeId, input.actorScopeId)
428− && (input.slot === undefined || item.slot === input.slot);
429− });
430− if (!match) return false;
431− store.db.delete(authArtifacts).where(eq(authArtifacts.id, match.id)).run();
432− return true;
433− },
434− removeByScopeAndSourceId: (input: {
435− scopeId: AuthArtifact["scopeId"];
436− sourceId: AuthArtifact["sourceId"];
437− }) => {
438− const rows = store.db.select({ id: authArtifacts.id }).from(authArtifacts).where(
439− and(
440− eq(authArtifacts.scopeId, input.scopeId),
441− eq(authArtifacts.sourceId, input.sourceId),
442− ),
443− ).all();
444− store.db.delete(authArtifacts).where(
445− and(
446− eq(authArtifacts.scopeId, input.scopeId),
447− eq(authArtifacts.sourceId, input.sourceId),
448− ),
449− ).run();
450− return rows.length;
451− },
452− },
453− leases: {
454− listAll: () =>
455− store.db.select().from(authLeases).all().map((row) => parseJson<AuthLease>(row.json)),
456− getByAuthArtifactId: (authArtifactId: AuthLease["authArtifactId"]) => {
457− const row = store.db.select().from(authLeases).where(
458− eq(authLeases.authArtifactId, authArtifactId),
459− ).get();
460− return row ? parseJson<AuthLease>(row.json) : null;
461− },
462− upsert: (lease: AuthLease) =>
463− store.db.insert(authLeases).values({
464− authArtifactId: lease.authArtifactId,
465− json: JSON.stringify(lease),
466− }).onConflictDoUpdate({
467− target: authLeases.authArtifactId,
468− set: { json: JSON.stringify(lease) },
469− }).run(),
470− removeByAuthArtifactId: (authArtifactId: AuthLease["authArtifactId"]) => {
471− const row = store.db.select({ id: authLeases.authArtifactId }).from(authLeases).where(
472− eq(authLeases.authArtifactId, authArtifactId),
473− ).get();
474− if (!row) return false;
475− store.db.delete(authLeases).where(eq(authLeases.authArtifactId, authArtifactId)).run();
476− return true;
477− },
478− },
479− sourceOauthClients: {
480− getByScopeSourceAndProvider: (input: {
481− scopeId: ScopedSourceOauthClient["scopeId"];
482− sourceId: ScopedSourceOauthClient["sourceId"];
483− providerKey: string;
484− }) => {
485− const row = store.db.select().from(sourceOauthClients).where(
486− and(
487− eq(sourceOauthClients.scopeId, input.scopeId),
488− eq(sourceOauthClients.sourceId, input.sourceId),
489− eq(sourceOauthClients.providerKey, input.providerKey),
490− ),
491− ).get();
492− return row ? parseJson<ScopedSourceOauthClient>(row.json) : null;
493− },
494− upsert: (oauthClient: ScopedSourceOauthClient) =>
495− store.db.insert(sourceOauthClients).values({
496− id: oauthClient.id,
497− scopeId: oauthClient.scopeId,
498− sourceId: oauthClient.sourceId,
499− providerKey: oauthClient.providerKey,
500− json: JSON.stringify(oauthClient),
501− }).onConflictDoUpdate({
502− target: sourceOauthClients.id,
503− set: {
504− scopeId: oauthClient.scopeId,
505− sourceId: oauthClient.sourceId,
506− providerKey: oauthClient.providerKey,
507− json: JSON.stringify(oauthClient),
508− },
509− }).run(),
510− removeByScopeAndSourceId: (input: {
511− scopeId: ScopedSourceOauthClient["scopeId"];
512− sourceId: ScopedSourceOauthClient["sourceId"];
513− }) => {
514− const rows = store.db.select({ id: sourceOauthClients.id }).from(sourceOauthClients).where(
515− and(
516− eq(sourceOauthClients.scopeId, input.scopeId),
517− eq(sourceOauthClients.sourceId, input.sourceId),
518− ),
519− ).all();
520− store.db.delete(sourceOauthClients).where(
521− and(
522− eq(sourceOauthClients.scopeId, input.scopeId),
523− eq(sourceOauthClients.sourceId, input.sourceId),
524− ),
525− ).run();
526− return rows.length;
527− },
528− },
529− scopeOauthClients: {
530− listByScopeAndProvider: (input: {
531− scopeId: ScopeOauthClient["scopeId"];
532− providerKey: string;
533− }) =>
534− store.db.select().from(scopeOauthClients).where(
535− and(
536− eq(scopeOauthClients.scopeId, input.scopeId),
537− eq(scopeOauthClients.providerKey, input.providerKey),
538− ),
539− ).all().map((row) => parseJson<ScopeOauthClient>(row.json)),
540− getById: (id: ScopeOauthClient["id"]) => {
541− const row = store.db.select().from(scopeOauthClients).where(eq(scopeOauthClients.id, id)).get();
542− return row ? parseJson<ScopeOauthClient>(row.json) : null;
543− },
544− upsert: (oauthClient: ScopeOauthClient) =>
545− store.db.insert(scopeOauthClients).values({
546− id: oauthClient.id,
547− scopeId: oauthClient.scopeId,
548− providerKey: oauthClient.providerKey,
549− json: JSON.stringify(oauthClient),
550− }).onConflictDoUpdate({
551− target: scopeOauthClients.id,
552− set: {
553− scopeId: oauthClient.scopeId,
554− providerKey: oauthClient.providerKey,
555− json: JSON.stringify(oauthClient),
556− },
557− }).run(),
558− removeById: (id: ScopeOauthClient["id"]) => {
559− const row = store.db.select({ id: scopeOauthClients.id }).from(scopeOauthClients).where(
560− eq(scopeOauthClients.id, id),
561− ).get();
562− if (!row) return false;
563− store.db.delete(scopeOauthClients).where(eq(scopeOauthClients.id, id)).run();
564− return true;
565− },
566− },
567− providerGrants: {
568− listByScopeId: (scopeId: ProviderAuthGrant["scopeId"]) =>
569− store.db.select().from(providerAuthGrants).where(
570− eq(providerAuthGrants.scopeId, scopeId),
571− ).all().map((row) => parseJson<ProviderAuthGrant>(row.json)),
572− listByScopeActorAndProvider: (input: {
573− scopeId: ProviderAuthGrant["scopeId"];
574− actorScopeId: ProviderAuthGrant["actorScopeId"];
575− providerKey: string;
576− }) =>
577− store.db.select().from(providerAuthGrants).where(
578− and(
579− eq(providerAuthGrants.scopeId, input.scopeId),
580− eq(providerAuthGrants.providerKey, input.providerKey),
581− ),
582− ).all().map((row) => parseJson<ProviderAuthGrant>(row.json))
583− .filter((item) => sameActor(item.actorScopeId, input.actorScopeId)),
584− getById: (id: ProviderAuthGrant["id"]) => {
585− const row = store.db.select().from(providerAuthGrants).where(
586− eq(providerAuthGrants.id, id),
587− ).get();
588− return row ? parseJson<ProviderAuthGrant>(row.json) : null;
589− },
590− upsert: (grant: ProviderAuthGrant) =>
591− store.db.insert(providerAuthGrants).values({
592− id: grant.id,
593− scopeId: grant.scopeId,
594− actorScopeId: grant.actorScopeId,
595− providerKey: grant.providerKey,
596− json: JSON.stringify(grant),
597− }).onConflictDoUpdate({
598− target: providerAuthGrants.id,
599− set: {
600− scopeId: grant.scopeId,
601− actorScopeId: grant.actorScopeId,
602− providerKey: grant.providerKey,
603− json: JSON.stringify(grant),
604− },
605− }).run(),
606− removeById: (id: ProviderAuthGrant["id"]) => {
607− const row = store.db.select({ id: providerAuthGrants.id }).from(providerAuthGrants).where(
608− eq(providerAuthGrants.id, id),
609− ).get();
610− if (!row) return false;
611− store.db.delete(providerAuthGrants).where(eq(providerAuthGrants.id, id)).run();
612− return true;
613− },
614− },
615− sourceSessions: {
616− listAll: () =>
617− store.db.select().from(sourceAuthSessions).all().map((row) => parseJson<SourceAuthSession>(row.json)),
618− listByScopeId: (scopeId: SourceAuthSession["scopeId"]) =>
619− store.db.select().from(sourceAuthSessions).where(
620− eq(sourceAuthSessions.scopeId, scopeId),
621− ).all().map((row) => parseJson<SourceAuthSession>(row.json)),
622− getById: (id: SourceAuthSession["id"]) => {
623− const row = store.db.select().from(sourceAuthSessions).where(eq(sourceAuthSessions.id, id)).get();
624− return row ? parseJson<SourceAuthSession>(row.json) : null;
625− },
626− getByState: (state: SourceAuthSession["state"]) => {
627− const row = store.db.select().from(sourceAuthSessions).where(
628− eq(sourceAuthSessions.state, state),
629− ).get();
630− return row ? parseJson<SourceAuthSession>(row.json) : null;
631− },
632− getPendingByScopeSourceAndActor: (input: {
633− scopeId: SourceAuthSession["scopeId"];
634− sourceId: SourceAuthSession["sourceId"];
635− actorScopeId: SourceAuthSession["actorScopeId"];
636− credentialSlot?: SourceAuthSession["credentialSlot"];
637− }) =>
638− store.db.select().from(sourceAuthSessions).where(
639− and(
640− eq(sourceAuthSessions.scopeId, input.scopeId),
641− eq(sourceAuthSessions.sourceId, input.sourceId),
642− eq(sourceAuthSessions.status, "pending"),
643− ),
644− ).all().map((row) => parseJson<SourceAuthSession>(row.json))
645− .find((item) =>
646− sameActor(item.actorScopeId, input.actorScopeId)
647− && (input.credentialSlot === undefined || item.credentialSlot === input.credentialSlot)
648− ) ?? null,
649− insert: (session: SourceAuthSession) =>
650− store.db.insert(sourceAuthSessions).values({
651− id: session.id,
652− scopeId: session.scopeId,
653− sourceId: session.sourceId,
654− actorScopeId: session.actorScopeId,
655− state: session.state,
656− status: session.status,
657− credentialSlot: session.credentialSlot,
658− json: JSON.stringify(session),
659− }).run(),
660− update: (
661− id: SourceAuthSession["id"],
662− patch: Partial<Omit<SourceAuthSession, "id" | "scopeId" | "sourceId" | "createdAt">>,
663− ) => {
664− const current = store.db.select().from(sourceAuthSessions).where(eq(sourceAuthSessions.id, id)).get();
665− if (!current) return null;
666− const next = { ...parseJson<SourceAuthSession>(current.json), ...patch };
667− store.db.update(sourceAuthSessions).set({
668− state: next.state,
669− status: next.status,
670− credentialSlot: next.credentialSlot,
671− actorScopeId: next.actorScopeId,
672− json: JSON.stringify(next),
673− }).where(eq(sourceAuthSessions.id, id)).run();
674− return next;
675− },
676− upsert: (session: SourceAuthSession) =>
677− store.db.insert(sourceAuthSessions).values({
678− id: session.id,
679− scopeId: session.scopeId,
680− sourceId: session.sourceId,
681− actorScopeId: session.actorScopeId,
682− state: session.state,
683− status: session.status,
684− credentialSlot: session.credentialSlot,
685− json: JSON.stringify(session),
686− }).onConflictDoUpdate({
687− target: sourceAuthSessions.id,
688− set: {
689− scopeId: session.scopeId,
690− sourceId: session.sourceId,
691− actorScopeId: session.actorScopeId,
692− state: session.state,
693− status: session.status,
694− credentialSlot: session.credentialSlot,
695− json: JSON.stringify(session),
696− },
697− }).run(),
698− removeByScopeAndSourceId: (
699− scopeId: SourceAuthSession["scopeId"],
700− sourceId: SourceAuthSession["sourceId"],
701− ) => {
702− const rows = store.db.select({ id: sourceAuthSessions.id }).from(sourceAuthSessions).where(
703− and(
704− eq(sourceAuthSessions.scopeId, scopeId),
705− eq(sourceAuthSessions.sourceId, sourceId),
706− ),
707− ).all();
708− store.db.delete(sourceAuthSessions).where(
709− and(
710− eq(sourceAuthSessions.scopeId, scopeId),
711− eq(sourceAuthSessions.sourceId, sourceId),
712− ),
713− ).run();
714− return rows.length > 0;
715− },
716− },
717− },
718254 secrets: {
719255 getById: (id: SecretMaterial["id"]) => {
720256 const row = store.db.select().from(secretMaterials).where(eq(secretMaterials.id, id)).get();
@@ -790,6 +326,12 @@ const createStorageDomains = (store: SqliteStore) => ({ · 69 unmodified lines
790326 },
791327 executions: {
792328 runs: {
329+ listByScope: (scopeId: Execution["scopeId"]) =>
330+ store.db.select().from(executions).where(
331+ eq(executions.scopeId, scopeId),
332+ ).all()
333+ .map((row) => parseJson<Execution>(row.json))
334+ .sort((left, right) => right.createdAt - left.createdAt),
793335 getById: (executionId: Execution["id"]) => {
794336 const row = store.db.select().from(executions).where(eq(executions.id, executionId)).get();
795337 return row ? parseJson<Execution>(row.json) : null;
@@ -929,7 +471,7 @@ export const createSqliteExecutorBackend = ( · 133 unmodified lines
929471 return createExecutorBackend({
930472 loadRepositories: () => {
931473 const store = openSqliteStore(databasePath);
932− const { auth, secrets, executions } = createStorageDomains(store);
474+ const { secrets, executions } = createStorageDomains(store);
933475
934476 return {
935477 scope: {
@@ -1042,7 +584,6 @@ export const createSqliteExecutorBackend = ( · 106 unmodified lines
1042584 store.db.delete(sourceArtifacts).where(eq(sourceArtifacts.sourceId, sourceId)).run();
1043585 },
1044586 },
1045− sourceAuth: auth,
1046587 },
1047588 secrets: {
1048589 ...secrets,
knip.json+0−3
@@ -9,9 +9,6 @@ · 8 unmodified lines
99 "ignoreIssues": {
1010 "packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs": [
1111 "files"
12− ],
13− "packages/sources/openapi/src/openapi-extractor-wasm/openapi_extractor.js": [
14− "exports"
1512 ]
1613 },
1714 "workspaces": {
opencode.json+11−4
@@ -1,10 +1,17 @@
11 {
22 "$schema": "https://opencode.ai/config.json",
33 "mcp": {
4− "executor": {
5− "type": "remote",
6− "url": "http://localhost:8788/mcp",
7− "enabled": true
4+ "executor-stdio": {
5+ "type": "local",
6+ "command": [
7+ "bun",
8+ "run",
9+ "executor",
10+ "mcp",
11+ "--stdio"
12+ ],
13+ "enabled": true,
14+ "environment": {}
815 }
916 }
1017 }
package.json+1−0
@@ -5,6 +5,7 @@ · 4 unmodified lines
55 "packageManager": "bun@1.2.22",
66 "workspaces": [
77 "packages/*/*",
8+ "plugins/*/*",
89 "apps/*",
910 "examples/*",
1011 "apps/docs"
packages/auth/mcp-oauth/tsconfig.json+0−19
@@ -1,19 +0,0 @@
1−{
2− "compilerOptions": {
3− "target": "ES2022",
4− "module": "ESNext",
5− "moduleResolution": "Bundler",
6− "strict": true,
7− "skipLibCheck": true,
8− "noEmit": true,
9− "lib": ["ES2022"],
10− "types": ["bun-types", "node"]
11− },
12− "include": [
13− "src/**/*.ts",
14− "src/**/*.d.ts"
15− ],
16− "exclude": [
17− "src/**/*.test.ts"
18− ]
19−}
packages/auth/oauth2/tsconfig.json+0−19
@@ -1,19 +0,0 @@
1−{
2− "compilerOptions": {
3− "target": "ES2022",
4− "module": "ESNext",
5− "moduleResolution": "Bundler",
6− "strict": true,
7− "skipLibCheck": true,
8− "noEmit": true,
9− "lib": ["ES2022"],
10− "types": ["bun-types", "node"]
11− },
12− "include": [
13− "src/**/*.ts",
14− "src/**/*.d.ts"
15− ],
16− "exclude": [
17− "src/**/*.test.ts"
18− ]
19−}
packages/clients/react/package.json+12−3
@@ -3,7 +3,9 @@ · 2 unmodified lines
33 "private": true,
44 "type": "module",
55 "exports": {
6− ".": "./src/index.ts"
6+ ".": "./src/index.ts",
7+ "./plugins": "./src/plugins/index.ts",
8+ "./source-plugins": "./src/source-plugins/index.ts"
79 },
810 "scripts": {
911 "typecheck": "bunx tsc --noEmit -p tsconfig.json",
@@ -11,17 +13,24 @@ · 1 unmodified line
1113 "test:watch": "bunx vitest"
1214 },
1315 "dependencies": {
16+ "@shikijs/langs": "^4.0.1",
17+ "@shikijs/themes": "^4.0.1",
1418 "@effect-atom/atom": "0.5.3",
1519 "@effect-atom/atom-react": "0.5.0",
20+ "@effect/platform": "catalog:",
1621 "@executor/platform-api": "workspace:*",
1722 "@executor/platform-sdk": "workspace:*",
18− "effect": "catalog:"
23+ "class-variance-authority": "^0.7.1",
24+ "clsx": "^2.1.1",
25+ "effect": "catalog:",
26+ "shiki": "^4.0.1",
27+ "streamdown": "^2.3.0",
28+ "tailwind-merge": "^3.5.0"
1929 },
2030 "peerDependencies": {
2131 "react": ">=18 <20"
2232 },
2333 "devDependencies": {
24− "@effect/platform": "catalog:",
2534 "@effect/platform-node": "catalog:",
2635 "@effect/vitest": "catalog:",
2736 "@executor/effect-test-utils": "workspace:*",
packages/clients/react/src/core/api-atoms.ts+191−0
@@ -0,0 +1,191 @@
1+import {
2+ Atom,
3+} from "@effect-atom/atom-react";
4+import type {
5+ InstanceConfig,
6+ LocalInstallation,
7+ SecretListItem,
8+} from "@executor/platform-api";
9+import type {
10+ Execution,
11+ ExecutionEnvelope,
12+ Source,
13+ SourceInspection,
14+ SourceInspectionDiscoverResult,
15+ SourceInspectionToolDetail,
16+} from "@executor/platform-sdk/schema";
17+import * as Effect from "effect/Effect";
18+
19+import { getExecutorApiBaseUrl } from "./base-url";
20+import { getExecutorApiHttpClient } from "./http-client";
21+import {
22+ executionReactivityKey,
23+ executionsReactivityKey,
24+ instanceConfigReactivityKey,
25+ localInstallationReactivityKey,
26+ secretsReactivityKey,
27+ sourceDiscoveryReactivityKey,
28+ sourceInspectionReactivityKey,
29+ sourceInspectionToolReactivityKey,
30+ sourceReactivityKey,
31+ sourcesReactivityKey,
32+} from "./reactivity";
33+import { disabledAtom } from "./loadable";
34+
35+export const localInstallationAtom = (baseUrl: string = getExecutorApiBaseUrl()) =>
36+ getExecutorApiHttpClient(baseUrl).query("local", "installation", {
37+ reactivityKeys: localInstallationReactivityKey(),
38+ timeToLive: "5 minutes",
39+ });
40+
41+export const instanceConfigAtom = (baseUrl: string = getExecutorApiBaseUrl()) =>
42+ getExecutorApiHttpClient(baseUrl).query("local", "config", {
43+ reactivityKeys: instanceConfigReactivityKey(),
44+ timeToLive: "5 minutes",
45+ });
46+
47+export const secretsAtom = (baseUrl: string = getExecutorApiBaseUrl()) =>
48+ getExecutorApiHttpClient(baseUrl).query("local", "listSecrets", {
49+ reactivityKeys: secretsReactivityKey(),
50+ timeToLive: "1 minute",
51+ });
52+
53+export const executionsAtom = (workspaceId: Execution["scopeId"]) =>
54+ getExecutorApiHttpClient().query("executions", "list", {
55+ path: {
56+ workspaceId,
57+ },
58+ reactivityKeys: executionsReactivityKey(workspaceId),
59+ timeToLive: "15 seconds",
60+ });
61+
62+export const executionAtom = (
63+ workspaceId: Execution["scopeId"],
64+ executionId: Execution["id"],
65+) =>
66+ getExecutorApiHttpClient().query("executions", "get", {
67+ path: {
68+ workspaceId,
69+ executionId,
70+ },
71+ reactivityKeys: executionReactivityKey(workspaceId, executionId),
72+ timeToLive: "15 seconds",
73+ });
74+
75+export const sourcesAtom = (workspaceId: Source["scopeId"]) =>
76+ getExecutorApiHttpClient().query("sources", "list", {
77+ path: {
78+ workspaceId,
79+ },
80+ reactivityKeys: sourcesReactivityKey(workspaceId),
81+ timeToLive: "30 seconds",
82+ });
83+
84+export const sourceAtom = (
85+ workspaceId: Source["scopeId"],
86+ sourceId: Source["id"],
87+) =>
88+ getExecutorApiHttpClient().query("sources", "get", {
89+ path: {
90+ workspaceId,
91+ sourceId,
92+ },
93+ reactivityKeys: sourceReactivityKey(workspaceId, sourceId),
94+ timeToLive: "30 seconds",
95+ });
96+
97+export const sourceInspectionAtom = (
98+ workspaceId: Source["scopeId"],
99+ sourceId: Source["id"],
100+) =>
101+ getExecutorApiHttpClient().query("sources", "inspection", {
102+ path: {
103+ workspaceId,
104+ sourceId,
105+ },
106+ reactivityKeys: sourceInspectionReactivityKey(workspaceId, sourceId),
107+ timeToLive: "30 seconds",
108+ });
109+
110+export const sourceInspectionToolAtom = (
111+ workspaceId: Source["scopeId"],
112+ sourceId: Source["id"],
113+ toolPath: string | null,
114+) =>
115+ toolPath === null
116+ ? disabledAtom<SourceInspectionToolDetail | null>()
117+ : getExecutorApiHttpClient().query("sources", "inspectionTool", {
118+ path: {
119+ workspaceId,
120+ sourceId,
121+ toolPath,
122+ },
123+ reactivityKeys: sourceInspectionToolReactivityKey(
124+ workspaceId,
125+ sourceId,
126+ toolPath,
127+ ),
128+ timeToLive: "30 seconds",
129+ });
130+
131+const emptyDiscoveryResult: SourceInspectionDiscoverResult = {
132+ query: "",
133+ queryTokens: [],
134+ bestPath: null,
135+ total: 0,
136+ results: [],
137+};
138+
139+export const sourceDiscoveryAtom = (
140+ workspaceId: Source["scopeId"],
141+ sourceId: Source["id"],
142+ query: string,
143+ limit: number | null,
144+) =>
145+ query.trim().length === 0
146+ ? Atom.make(
147+ Effect.succeed(emptyDiscoveryResult),
148+ ).pipe(Atom.keepAlive)
149+ : getExecutorApiHttpClient().query("sources", "inspectionDiscover", {
150+ path: {
151+ workspaceId,
152+ sourceId,
153+ },
154+ payload: {
155+ query,
156+ ...(limit !== null ? { limit } : {}),
157+ },
158+ reactivityKeys: sourceDiscoveryReactivityKey(
159+ workspaceId,
160+ sourceId,
161+ query,
162+ limit,
163+ ),
164+ timeToLive: "15 seconds",
165+ });
166+
167+export type ExecutorApiAtoms = {
168+ localInstallationAtom: typeof localInstallationAtom;
169+ instanceConfigAtom: typeof instanceConfigAtom;
170+ secretsAtom: typeof secretsAtom;
171+ executionsAtom: typeof executionsAtom;
172+ executionAtom: typeof executionAtom;
173+ sourcesAtom: typeof sourcesAtom;
174+ sourceAtom: typeof sourceAtom;
175+ sourceInspectionAtom: typeof sourceInspectionAtom;
176+ sourceInspectionToolAtom: typeof sourceInspectionToolAtom;
177+ sourceDiscoveryAtom: typeof sourceDiscoveryAtom;
178+};
179+
180+export type ExecutorApiAtomValues = {
181+ localInstallation: LocalInstallation;
182+ instanceConfig: InstanceConfig;
183+ secrets: ReadonlyArray<SecretListItem>;
184+ executions: ReadonlyArray<Execution>;
185+ execution: ExecutionEnvelope;
186+ sources: ReadonlyArray<Source>;
187+ source: Source;
188+ sourceInspection: SourceInspection;
189+ sourceInspectionTool: SourceInspectionToolDetail | null;
190+ sourceDiscovery: SourceInspectionDiscoverResult;
191+};
packages/clients/react/src/core/base-url.ts+12−0
@@ -0,0 +1,12 @@
1+const DEFAULT_EXECUTOR_API_BASE_URL = "http://127.0.0.1:8788";
2+
3+let apiBaseUrl =
4+ typeof window !== "undefined" && typeof window.location?.origin === "string"
5+ ? window.location.origin
6+ : DEFAULT_EXECUTOR_API_BASE_URL;
7+
8+export const getExecutorApiBaseUrl = (): string => apiBaseUrl;
9+
10+export const setExecutorApiBaseUrl = (baseUrl: string): void => {
11+ apiBaseUrl = baseUrl;
12+};
packages/clients/react/src/core/http-client.ts+71−0
@@ -0,0 +1,71 @@
1+import {
2+ AtomHttpApi,
3+} from "@effect-atom/atom-react";
4+import {
5+ FetchHttpClient,
6+} from "@effect/platform";
7+import type * as HttpApi from "@effect/platform/HttpApi";
8+import type * as HttpApiGroup from "@effect/platform/HttpApiGroup";
9+import {
10+ ExecutorApi,
11+ createExecutorApi,
12+ type ExecutorHttpApiExtension,
13+} from "@executor/platform-api";
14+
15+import { getExecutorApiBaseUrl } from "./base-url";
16+
17+export const defineExecutorHttpApiClient =
18+ <Self>() =>
19+ <
20+ const Id extends string,
21+ ApiId extends string,
22+ Groups extends HttpApiGroup.HttpApiGroup.Any,
23+ ApiE,
24+ R,
25+ >(
26+ id: Id,
27+ api: HttpApi.HttpApi<ApiId, Groups, ApiE, R>,
28+ ) => {
29+ const build = (baseUrl: string | URL) =>
30+ AtomHttpApi.Tag<Self>()(id, {
31+ api,
32+ httpClient: FetchHttpClient.layer as any,
33+ baseUrl,
34+ });
35+
36+ const cache = new Map<string, ReturnType<typeof build>>();
37+
38+ return (baseUrl: string | URL = getExecutorApiBaseUrl()) => {
39+ const key = String(baseUrl);
40+ const cached = cache.get(key);
41+ if (cached !== undefined) {
42+ return cached;
43+ }
44+
45+ const client = build(baseUrl);
46+ cache.set(key, client);
47+ return client;
48+ };
49+ };
50+
51+export const defineExecutorPluginHttpApiClient =
52+ <Self>() =>
53+ <
54+ const Id extends string,
55+ TExtensions extends readonly ExecutorHttpApiExtension[],
56+ >(
57+ id: Id,
58+ extensions: TExtensions,
59+ ) =>
60+ defineExecutorHttpApiClient<Self>()(
61+ id,
62+ createExecutorApi({
63+ plugins: extensions,
64+ }),
65+ );
66+
67+export const getExecutorApiHttpClient =
68+ defineExecutorHttpApiClient<"ExecutorReactHttpClient">()(
69+ "ExecutorReactHttpClient",
70+ ExecutorApi,
71+ );
packages/clients/react/src/core/loadable.ts+61−0
@@ -0,0 +1,61 @@
1+import {
2+ Atom,
3+ Result,
4+ useAtomValue,
5+} from "@effect-atom/atom-react";
6+import * as Cause from "effect/Cause";
7+import * as Effect from "effect/Effect";
8+import * as React from "react";
9+
10+import type { Loadable } from "./types";
11+
12+const causeMessage = (cause: Cause.Cause<unknown>): Error =>
13+ new Error(Cause.pretty(cause));
14+
15+export const toLoadable = <T>(result: Result.Result<T, unknown>): Loadable<T> => {
16+ if (Result.isSuccess(result)) {
17+ return {
18+ status: "ready",
19+ data: result.value,
20+ };
21+ }
22+
23+ if (Result.isFailure(result)) {
24+ return {
25+ status: "error",
26+ error: causeMessage(result.cause),
27+ };
28+ }
29+
30+ return {
31+ status: "loading",
32+ };
33+};
34+
35+export const pendingResultAtom = Atom.make(
36+ Effect.never as Effect.Effect<never, Error>,
37+).pipe(Atom.keepAlive);
38+
39+export const pendingLoadable = <T>(
40+ workspace: Loadable<unknown>,
41+): Loadable<T> => {
42+ if (workspace.status === "loading") {
43+ return { status: "loading" };
44+ }
45+
46+ if (workspace.status === "error") {
47+ return { status: "error", error: workspace.error };
48+ }
49+
50+ throw new Error("Expected workspace loadable to be pending or errored");
51+};
52+
53+export const useLoadableAtom = <T>(
54+ atom: Atom.Atom<Result.Result<T, unknown>>,
55+): Loadable<T> => {
56+ const result = useAtomValue(atom);
57+ return React.useMemo(() => toLoadable(result), [result]);
58+};
59+
60+export const disabledAtom = <T>() =>
61+ pendingResultAtom as unknown as Atom.Atom<Result.Result<T, unknown>>;
packages/clients/react/src/core/reactivity.ts+71−0
@@ -0,0 +1,71 @@
1+import type { Execution, Source } from "@executor/platform-sdk/schema";
2+
3+import type { ReactivityKeys } from "./types";
4+
5+export const localInstallationReactivityKey = (): ReactivityKeys => ({
6+ localInstallation: [],
7+});
8+
9+export const instanceConfigReactivityKey = (): ReactivityKeys => ({
10+ instanceConfig: [],
11+});
12+
13+export const secretsReactivityKey = (): ReactivityKeys => ({
14+ secrets: [],
15+});
16+
17+export const sourcesReactivityKey = (
18+ workspaceId: Source["scopeId"],
19+): ReactivityKeys => ({
20+ sources: [workspaceId],
21+});
22+
23+export const executionsReactivityKey = (
24+ workspaceId: Execution["scopeId"],
25+): ReactivityKeys => ({
26+ executions: [workspaceId],
27+});
28+
29+export const executionReactivityKey = (
30+ workspaceId: Execution["scopeId"],
31+ executionId: Execution["id"],
32+): ReactivityKeys => ({
33+ execution: [workspaceId, executionId],
34+});
35+
36+export const sourceReactivityKey = (
37+ workspaceId: Source["scopeId"],
38+ sourceId: Source["id"],
39+): ReactivityKeys => ({
40+ source: [workspaceId, sourceId],
41+});
42+
43+export const sourceInspectionReactivityKey = (
44+ workspaceId: Source["scopeId"],
45+ sourceId: Source["id"],
46+): ReactivityKeys => ({
47+ sourceInspection: [workspaceId, sourceId],
48+});
49+
50+export const sourceInspectionToolReactivityKey = (
51+ workspaceId: Source["scopeId"],
52+ sourceId: Source["id"],
53+ toolPath?: string | null,
54+): ReactivityKeys => ({
55+ sourceInspectionTool:
56+ toolPath === undefined || toolPath === null
57+ ? [workspaceId, sourceId]
58+ : [workspaceId, sourceId, toolPath],
59+});
60+
61+export const sourceDiscoveryReactivityKey = (
62+ workspaceId: Source["scopeId"],
63+ sourceId: Source["id"],
64+ query?: string,
65+ limit?: number | null,
66+): ReactivityKeys => ({
67+ sourceDiscovery:
68+ query === undefined
69+ ? [workspaceId, sourceId]
70+ : [workspaceId, sourceId, query, limit ?? null],
71+});
packages/clients/react/src/core/types.ts+16−0
@@ -0,0 +1,16 @@
1+export type SourceRemoveResult = {
2+ removed: boolean;
3+};
4+
5+export type Loadable<T> =
6+ | { status: "loading" }
7+ | { status: "error"; error: Error }
8+ | { status: "ready"; data: T };
9+
10+export type MutationState<T> = {
11+ status: "idle" | "pending" | "success" | "error";
12+ data: T | null;
13+ error: Error | null;
14+};
15+
16+export type ReactivityKeys = Readonly<Record<string, ReadonlyArray<unknown>>>;
packages/clients/react/src/core/workspace.ts+62−0
@@ -0,0 +1,62 @@
1+import type { LocalInstallation } from "@executor/platform-api";
2+import type { Source } from "@executor/platform-sdk/schema";
3+import * as React from "react";
4+
5+import { getExecutorApiBaseUrl } from "./base-url";
6+import { localInstallationAtom } from "./api-atoms";
7+import { pendingLoadable, useLoadableAtom } from "./loadable";
8+import type { Loadable } from "./types";
9+
10+const PLACEHOLDER_WORKSPACE_ID = "ws_placeholder" as Source["scopeId"];
11+
12+export type WorkspaceContext = {
13+ installation: LocalInstallation;
14+ workspaceId: Source["scopeId"];
15+};
16+
17+const useWorkspaceContext = (): Loadable<WorkspaceContext> => {
18+ const installation = useLoadableAtom(
19+ localInstallationAtom(getExecutorApiBaseUrl()),
20+ );
21+
22+ return React.useMemo(() => {
23+ if (installation.status !== "ready") {
24+ return installation;
25+ }
26+
27+ return {
28+ status: "ready",
29+ data: {
30+ installation: installation.data,
31+ workspaceId: installation.data.scopeId,
32+ },
33+ } satisfies Loadable<WorkspaceContext>;
34+ }, [installation]);
35+};
36+
37+export const useWorkspaceRequestContext = () => {
38+ const workspace = useWorkspaceContext();
39+ const enabled = workspace.status === "ready";
40+
41+ return React.useMemo(
42+ () => ({
43+ workspace,
44+ enabled,
45+ workspaceId: enabled
46+ ? workspace.data.workspaceId
47+ : PLACEHOLDER_WORKSPACE_ID,
48+ }),
49+ [enabled, workspace],
50+ );
51+};
52+
53+export const useWorkspaceId = (): Source["scopeId"] => {
54+ const workspace = useWorkspaceRequestContext();
55+ if (!workspace.enabled) {
56+ throw new Error("Workspace is still loading.");
57+ }
58+
59+ return workspace.workspaceId;
60+};
61+
62+export { pendingLoadable };
packages/clients/react/src/hooks/executions.ts+46−0
@@ -0,0 +1,46 @@
1+import type {
2+ Execution,
3+ ExecutionEnvelope,
4+} from "@executor/platform-sdk/schema";
5+
6+import {
7+ executionAtom,
8+ executionsAtom,
9+} from "../core/api-atoms";
10+import {
11+ disabledAtom,
12+ useLoadableAtom,
13+} from "../core/loadable";
14+import type {
15+ Loadable,
16+} from "../core/types";
17+import {
18+ pendingLoadable,
19+ useWorkspaceRequestContext,
20+} from "../core/workspace";
21+
22+export const useExecutions = (): Loadable<ReadonlyArray<Execution>> => {
23+ const workspace = useWorkspaceRequestContext();
24+ const atom = workspace.enabled
25+ ? executionsAtom(workspace.workspaceId)
26+ : disabledAtom<ReadonlyArray<Execution>>();
27+ const executions = useLoadableAtom(atom);
28+
29+ return workspace.enabled
30+ ? executions
31+ : pendingLoadable(workspace.workspace);
32+};
33+
34+export const useExecution = (
35+ executionId: string,
36+): Loadable<ExecutionEnvelope> => {
37+ const workspace = useWorkspaceRequestContext();
38+ const atom = workspace.enabled
39+ ? executionAtom(workspace.workspaceId, executionId as Execution["id"])
40+ : disabledAtom<ExecutionEnvelope>();
41+ const execution = useLoadableAtom(atom);
42+
43+ return workspace.enabled
44+ ? execution
45+ : pendingLoadable(workspace.workspace);
46+};
packages/clients/react/src/hooks/local.ts+21−0
@@ -0,0 +1,21 @@
1+import {
2+ useAtomRefresh,
3+} from "@effect-atom/atom-react";
4+import type {
5+ InstanceConfig,
6+ LocalInstallation,
7+} from "@executor/platform-api";
8+
9+import { getExecutorApiBaseUrl } from "../core/base-url";
10+import { instanceConfigAtom, localInstallationAtom } from "../core/api-atoms";
11+import { useLoadableAtom } from "../core/loadable";
12+import type { Loadable } from "../core/types";
13+
14+export const useLocalInstallation = (): Loadable<LocalInstallation> =>
15+ useLoadableAtom(localInstallationAtom(getExecutorApiBaseUrl()));
16+
17+export const useInstanceConfig = (): Loadable<InstanceConfig> =>
18+ useLoadableAtom(instanceConfigAtom(getExecutorApiBaseUrl()));
19+
20+export const useRefreshLocalInstallation = (): (() => void) =>
21+ useAtomRefresh(localInstallationAtom(getExecutorApiBaseUrl()));
packages/clients/react/src/hooks/mutations.ts+44−0
@@ -0,0 +1,44 @@
1+import * as React from "react";
2+
3+import type { MutationState } from "../core/types";
4+
5+export const useExecutorMutation = <TInput, TOutput>(
6+ execute: (input: TInput) => Promise<TOutput>,
7+) => {
8+ const [state, setState] = React.useState<MutationState<TOutput>>({
9+ status: "idle",
10+ data: null,
11+ error: null,
12+ });
13+
14+ const mutateAsync = React.useCallback(async (payload: TInput) => {
15+ setState((current) => ({
16+ status: "pending",
17+ data: current.data,
18+ error: null,
19+ }));
20+
21+ try {
22+ const data = await execute(payload);
23+ setState({ status: "success", data, error: null });
24+ return data;
25+ } catch (cause) {
26+ const error = cause instanceof Error ? cause : new Error(String(cause));
27+ setState({ status: "error", data: null, error });
28+ throw error;
29+ }
30+ }, [execute]);
31+
32+ const reset = React.useCallback(() => {
33+ setState({ status: "idle", data: null, error: null });
34+ }, []);
35+
36+ return React.useMemo(
37+ () => ({
38+ ...state,
39+ mutateAsync,
40+ reset,
41+ }),
42+ [mutateAsync, reset, state],
43+ );
44+};
packages/clients/react/src/hooks/secrets.ts+85−0
@@ -0,0 +1,85 @@
1+import {
2+ useAtomRefresh,
3+ useAtomSet,
4+} from "@effect-atom/atom-react";
5+import type {
6+ CreateSecretPayload,
7+ CreateSecretResult,
8+ DeleteSecretResult,
9+ SecretListItem,
10+ UpdateSecretPayload,
11+ UpdateSecretResult,
12+} from "@executor/platform-api";
13+import * as React from "react";
14+
15+import { getExecutorApiBaseUrl } from "../core/base-url";
16+import { getExecutorApiHttpClient } from "../core/http-client";
17+import { secretsAtom } from "../core/api-atoms";
18+import { useLoadableAtom } from "../core/loadable";
19+import { secretsReactivityKey } from "../core/reactivity";
20+import type { Loadable } from "../core/types";
21+import { useExecutorMutation } from "./mutations";
22+
23+export const useSecrets = (): Loadable<ReadonlyArray<SecretListItem>> =>
24+ useLoadableAtom(secretsAtom(getExecutorApiBaseUrl()));
25+
26+export const useRefreshSecrets = (): (() => void) =>
27+ useAtomRefresh(secretsAtom(getExecutorApiBaseUrl()));
28+
29+export const useCreateSecret = () => {
30+ const mutate = useAtomSet(
31+ getExecutorApiHttpClient().mutation("local", "createSecret"),
32+ { mode: "promise" },
33+ );
34+
35+ return useExecutorMutation<CreateSecretPayload, CreateSecretResult>(
36+ React.useCallback(
37+ (payload) =>
38+ mutate({
39+ payload,
40+ reactivityKeys: secretsReactivityKey(),
41+ }),
42+ [mutate],
43+ ),
44+ );
45+};
46+
47+export const useUpdateSecret = () => {
48+ const mutate = useAtomSet(
49+ getExecutorApiHttpClient().mutation("local", "updateSecret"),
50+ { mode: "promise" },
51+ );
52+
53+ return useExecutorMutation<
54+ { secretId: string; payload: UpdateSecretPayload },
55+ UpdateSecretResult
56+ >(
57+ React.useCallback(
58+ (input) =>
59+ mutate({
60+ path: { secretId: input.secretId },
61+ payload: input.payload,
62+ reactivityKeys: secretsReactivityKey(),
63+ }),
64+ [mutate],
65+ ),
66+ );
67+};
68+
69+export const useDeleteSecret = () => {
70+ const mutate = useAtomSet(
71+ getExecutorApiHttpClient().mutation("local", "deleteSecret"),
72+ { mode: "promise" },
73+ );
74+
75+ return useExecutorMutation<string, DeleteSecretResult>(
76+ React.useCallback(
77+ (secretId) =>
78+ mutate({
79+ path: { secretId },
80+ reactivityKeys: secretsReactivityKey(),
81+ }),
82+ [mutate],
83+ ),
84+ );
85+};
packages/clients/react/src/hooks/sources.ts+156−0
@@ -0,0 +1,156 @@
1+import {
2+ RegistryContext,
3+ useAtomSet,
4+} from "@effect-atom/atom-react";
5+import type {
6+ Source,
7+ SourceInspection,
8+ SourceInspectionDiscoverResult,
9+ SourceInspectionToolDetail,
10+} from "@executor/platform-sdk/schema";
11+import * as React from "react";
12+
13+import {
14+ sourceAtom,
15+ sourceDiscoveryAtom,
16+ sourceInspectionAtom,
17+ sourceInspectionToolAtom,
18+ sourcesAtom,
19+} from "../core/api-atoms";
20+import { disabledAtom, useLoadableAtom } from "../core/loadable";
21+import {
22+ sourceDiscoveryReactivityKey,
23+ sourceInspectionReactivityKey,
24+ sourceInspectionToolReactivityKey,
25+ sourceReactivityKey,
26+ sourcesReactivityKey,
27+} from "../core/reactivity";
28+import type { Loadable, SourceRemoveResult } from "../core/types";
29+import { pendingLoadable, useWorkspaceRequestContext } from "../core/workspace";
30+import { getExecutorApiHttpClient } from "../core/http-client";
31+import { useExecutorMutation } from "./mutations";
32+
33+export const useSources = (): Loadable<ReadonlyArray<Source>> => {
34+ const workspace = useWorkspaceRequestContext();
35+ const atom = workspace.enabled
36+ ? sourcesAtom(workspace.workspaceId)
37+ : disabledAtom<ReadonlyArray<Source>>();
38+ const sources = useLoadableAtom(atom);
39+
40+ return workspace.enabled ? sources : pendingLoadable(workspace.workspace);
41+};
42+
43+export const useSource = (sourceId: string): Loadable<Source> => {
44+ const workspace = useWorkspaceRequestContext();
45+ const atom = workspace.enabled
46+ ? sourceAtom(workspace.workspaceId, sourceId as Source["id"])
47+ : disabledAtom<Source>();
48+ const source = useLoadableAtom(atom);
49+
50+ return workspace.enabled ? source : pendingLoadable(workspace.workspace);
51+};
52+
53+export const useSourceInspection = (
54+ sourceId: string,
55+): Loadable<SourceInspection> => {
56+ const workspace = useWorkspaceRequestContext();
57+ const atom = workspace.enabled
58+ ? sourceInspectionAtom(workspace.workspaceId, sourceId as Source["id"])
59+ : disabledAtom<SourceInspection>();
60+ const inspection = useLoadableAtom(atom);
61+
62+ return workspace.enabled ? inspection : pendingLoadable(workspace.workspace);
63+};
64+
65+export const useSourceToolDetail = (
66+ sourceId: string,
67+ toolPath: string | null,
68+): Loadable<SourceInspectionToolDetail | null> => {
69+ const workspace = useWorkspaceRequestContext();
70+ const atom = workspace.enabled
71+ ? sourceInspectionToolAtom(
72+ workspace.workspaceId,
73+ sourceId as Source["id"],
74+ toolPath,
75+ )
76+ : disabledAtom<SourceInspectionToolDetail | null>();
77+ const detail = useLoadableAtom(atom);
78+
79+ return workspace.enabled ? detail : pendingLoadable(workspace.workspace);
80+};
81+
82+export const useSourceDiscovery = (input: {
83+ sourceId: string;
84+ query: string;
85+ limit?: number;
86+}): Loadable<SourceInspectionDiscoverResult> => {
87+ const workspace = useWorkspaceRequestContext();
88+ const atom = workspace.enabled
89+ ? sourceDiscoveryAtom(
90+ workspace.workspaceId,
91+ input.sourceId as Source["id"],
92+ input.query,
93+ input.limit ?? null,
94+ )
95+ : disabledAtom<SourceInspectionDiscoverResult>();
96+ const results = useLoadableAtom(atom);
97+
98+ return workspace.enabled ? results : pendingLoadable(workspace.workspace);
99+};
100+
101+export const usePrefetchToolDetail = () => {
102+ const registry = React.useContext(RegistryContext);
103+ const workspace = useWorkspaceRequestContext();
104+
105+ return React.useCallback(
106+ (sourceId: string, toolPath: string): (() => void) => {
107+ if (!workspace.enabled) {
108+ return () => {};
109+ }
110+
111+ return registry.mount(
112+ sourceInspectionToolAtom(
113+ workspace.workspaceId,
114+ sourceId as Source["id"],
115+ toolPath,
116+ ),
117+ );
118+ },
119+ [registry, workspace.enabled, workspace.workspaceId],
120+ );
121+};
122+
123+export const useRemoveSource = () => {
124+ const workspace = useWorkspaceRequestContext();
125+ const mutate = useAtomSet(
126+ getExecutorApiHttpClient().mutation("sources", "remove"),
127+ { mode: "promise" },
128+ );
129+
130+ return useExecutorMutation<Source["id"], SourceRemoveResult>(
131+ React.useCallback(
132+ (sourceId) => {
133+ if (!workspace.enabled) {
134+ return Promise.reject(
135+ new Error("Executor workspace context is not ready"),
136+ );
137+ }
138+
139+ return mutate({
140+ path: {
141+ workspaceId: workspace.workspaceId,
142+ sourceId,
143+ },
144+ reactivityKeys: {
145+ ...sourcesReactivityKey(workspace.workspaceId),
146+ ...sourceReactivityKey(workspace.workspaceId, sourceId),
147+ ...sourceInspectionReactivityKey(workspace.workspaceId, sourceId),
148+ ...sourceInspectionToolReactivityKey(workspace.workspaceId, sourceId),
149+ ...sourceDiscoveryReactivityKey(workspace.workspaceId, sourceId),
150+ },
151+ });
152+ },
153+ [mutate, workspace.enabled, workspace.workspaceId],
154+ ),
155+ );
156+};
packages/clients/react/src/index.test.tsx+105−381
@@ -1,30 +1,17 @@
1−import { createServer } from "node:http";
2−
31 import {
42 FileSystem,
53 HttpApi,
64 HttpApiBuilder,
75 HttpApiEndpoint,
86 HttpApiGroup,
9− HttpServer,
107 OpenApi,
118 } from "@effect/platform";
12−import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node";
13−import { describe, expect, it } from "@effect/vitest";
14−import {
15− startOpenApiTestServer,
16−} from "@executor/effect-test-utils";
17−import {
18− createExecutorApiLayer,
19−} from "@executor/platform-api/http";
20−import { createExecutorEffect } from "@executor/platform-sdk/effect";
21−import type { ExecutorRuntime } from "@executor/platform-sdk/runtime";
22−import { createLocalExecutorRuntime as createExecutorRuntime } from "@executor/platform-sdk-file/runtime";
9+import { NodeFileSystem } from "@effect/platform-node";
10+import { startOpenApiTestServer } from "@executor/effect-test-utils";
2311 import type {
2412 LocalInstallation,
2513 Source,
2614 } from "@executor/platform-sdk/schema";
27−import * as Context from "effect/Context";
2815 import * as Effect from "effect/Effect";
2916 import * as Exit from "effect/Exit";
3017 import * as Layer from "effect/Layer";
@@ -33,18 +20,19 @@ import * as Scope from "effect/Scope"; · 2 unmodified lines
3320 import { JSDOM } from "jsdom";
3421 import * as React from "react";
3522 import { createRoot } from "react-dom/client";
23+import { describe, expect, it } from "vitest";
3624
3725 import {
3826 ExecutorReactProvider,
3927 setExecutorApiBaseUrl,
4028 type Loadable,
41− useCreateSource,
29+ useLocalInstallation,
4230 useRemoveSource,
4331 useSource,
4432 useSourceInspection,
4533 useSources,
46− useUpdateSource,
4734 } from "./index";
35+import { createLocalExecutorServer } from "../../../platform/server/src/index";
4836
4937 const dom = new JSDOM("<!doctype html><html><body></body></html>", {
5038 url: "http://127.0.0.1/",
@@ -61,7 +49,6 @@ globalThis.Node = dom.window.Node; · 10 unmodified lines
6149 globalThis.MutationObserver = dom.window.MutationObserver;
6250 globalThis.Event = dom.window.Event;
6351 globalThis.EventTarget = dom.window.EventTarget;
64−
6552 globalThis.requestAnimationFrame = (callback: FrameRequestCallback) =>
6653 setTimeout(() => callback(Date.now()), 0) as unknown as number;
6754 globalThis.cancelAnimationFrame = (handle: number) => {
@@ -70,173 +57,62 @@ globalThis.cancelAnimationFrame = (handle: number) => { · 2 unmodified lines
7057
7158 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
7259
73−type DelayRule = {
74− method: string;
75− pathname: RegExp;
76− durationMs: number;
77−};
78−
7960 type RunningServer = {
8061 baseUrl: string;
8162 close: () => Promise<void>;
8263 };
8364
84−type ApiServer = RunningServer & {
85− runtime: ExecutorRuntime;
86−};
87−
8865 type HookHarness<T> = {
8966 current: T | null;
9067 unmount: () => Promise<void>;
9168 };
9269
93−type CreateHarnessState = {
70+type WorkspaceHarnessState = {
71+ installation: Loadable<LocalInstallation>;
9472 sources: Loadable<ReadonlyArray<Source>>;
95− createSource: ReturnType<typeof useCreateSource>;
9673 };
9774
9875 type SourceHarnessState = {
9976 sources: Loadable<ReadonlyArray<Source>>;
10077 source: Loadable<Source>;
10178 inspection: ReturnType<typeof useSourceInspection>;
102− updateSource: ReturnType<typeof useUpdateSource>;
10379 removeSource: ReturnType<typeof useRemoveSource>;
10480 };
10581
10682 const closeScope = (scope: Scope.CloseableScope) =>
10783 Scope.close(scope, Exit.void).pipe(Effect.orDie);
10884
109−const startControlPlaneServer = async (): Promise<ApiServer> => {
85+const startControlPlaneServer = async (): Promise<RunningServer> => {
11086 const workspaceRoot = await Effect.runPromise(
11187 FileSystem.FileSystem.pipe(
112− Effect.flatMap((fs) => fs.makeTempDirectory({ prefix: "executor-react-test-" })),
88+ Effect.flatMap((fs) =>
89+ fs.makeTempDirectory({ prefix: "executor-react-test-" })),
11390 Effect.provide(NodeFileSystem.layer),
11491 ),
11592 );
116− const runtime = await Effect.runPromise(
117− createExecutorRuntime({
118− workspaceRoot,
119− }),
120− );
12193 const scope = await Effect.runPromise(Scope.make());
12294
12395 try {
124− const executor = await Effect.runPromise(
125− createExecutorEffect({
126− backend: {
127− createRuntime: () => Effect.succeed(runtime),
128− },
129− }),
130− );
131− const serverLayer = HttpApiBuilder.serve().pipe(
132− Layer.provide(createExecutorApiLayer(executor)),
133− Layer.provideMerge(NodeHttpServer.layerTest),
96+ const server = await Effect.runPromise(
97+ createLocalExecutorServer({
98+ port: 0,
99+ localDataDir: ":memory:",
100+ workspaceRoot,
101+ }).pipe(Effect.provideService(Scope.Scope, scope)),
134102 );
135− const context = await Effect.runPromise(Layer.buildWithScope(serverLayer, scope));
136− const server = Context.get(context, HttpServer.HttpServer);
137103
138104 return {
139− runtime,
140− baseUrl: HttpServer.formatAddress(server.address),
105+ baseUrl: server.baseUrl,
141106 close: async () => {
142107 await Effect.runPromise(closeScope(scope));
143− await runtime.close();
144108 },
145109 };
146110 } catch (error) {
147111 await Effect.runPromise(closeScope(scope));
148− await runtime.close();
149112 throw error;
150113 }
151114 };
152115
153−const startProxyServer = async (input: {
154− targetBaseUrl: string;
155− delays?: ReadonlyArray<DelayRule>;
156−}): Promise<RunningServer> => {
157− const server = createServer(async (req, res) => {
158− try {
159− const method = req.method ?? "GET";
160− const url = new URL(req.url ?? "/", "http://127.0.0.1");
161− const rule = input.delays?.find(
162− (candidate) =>
163− candidate.method === method && candidate.pathname.test(url.pathname),
164− );
165−
166− if (rule) {
167− await new Promise((resolve) => setTimeout(resolve, rule.durationMs));
168− }
169−
170− const headers = new Headers();
171− for (const [name, value] of Object.entries(req.headers)) {
172− if (value === undefined) {
173− continue;
174− }
175−
176− if (Array.isArray(value)) {
177− value.forEach((item) => headers.append(name, item));
178− continue;
179− }
180−
181− headers.set(name, value);
182− }
183− headers.delete("host");
184−
185− const bodyChunks: Array<Uint8Array> = [];
186− for await (const chunk of req) {
187− bodyChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
188− }
189−
190− const targetUrl = new URL(`${url.pathname}${url.search}`, input.targetBaseUrl);
191− const response = await fetch(targetUrl, {
192− method,
193− headers,
194− body: bodyChunks.length > 0 ? Buffer.concat(bodyChunks) : undefined,
195− });
196−
197− res.statusCode = response.status;
198− response.headers.forEach((value, name) => {
199− res.setHeader(name, value);
200− });
201− res.end(Buffer.from(await response.arrayBuffer()));
202− } catch (error) {
203− res.statusCode = 500;
204− res.end(error instanceof Error ? error.message : String(error));
205− }
206− });
207−
208− await new Promise<void>((resolve, reject) => {
209− server.once("error", reject);
210− server.listen(0, "127.0.0.1", () => resolve());
211− });
212−
213− const address = server.address();
214− if (!address || typeof address === "string") {
215− throw new Error("Failed to bind proxy server");
216− }
217−
218− const baseUrl = `http://127.0.0.1:${address.port}`;
219−
220− return {
221− baseUrl,
222− close: () =>
223− new Promise<void>((resolve, reject) => {
224− server.close((error) => {
225− if (error) {
226− reject(error);
227− return;
228− }
229−
230− resolve();
231− });
232− }),
233− };
234−};
235−
236−type OpenApiSpecServer = RunningServer & {
237− specUrl: string;
238−};
239−
240116 class HooksTestPingApi extends HttpApiGroup.make("ping")
241117 .add(
242118 HttpApiEndpoint.get("ping")`/ping`
@@ -251,30 +127,26 @@ class HooksTestPingApi extends HttpApiGroup.make("ping") · 8 unmodified lines
251127 class HooksTestOpenApi extends HttpApi.make("hooksTest").add(HooksTestPingApi) {}
252128
253129 const hooksTestOpenApiSpec = OpenApi.fromApi(HooksTestOpenApi);
254−const hooksTestOpenApiLive = HttpApiBuilder.group(
255− HooksTestOpenApi,
256− "ping",
257− (handlers) =>
258− handlers.handle("ping", () =>
259− Effect.succeed({
260− ok: true,
261− }),
262− ),
263−);
264130 const hooksTestOpenApiLayer = HttpApiBuilder.api(HooksTestOpenApi).pipe(
265− Layer.provide(hooksTestOpenApiLive),
131+ Layer.provide(
132+ HttpApiBuilder.group(HooksTestOpenApi, "ping", (handlers) =>
133+ handlers.handle("ping", () =>
134+ Effect.succeed({
135+ ok: true,
136+ })),
137+ ),
138+ ),
266139 );
267140
268−const startOpenApiSpecServer = async (): Promise<OpenApiSpecServer> => {
269− return startOpenApiTestServer({
141+const startOpenApiSpecServer = async (): Promise<OpenApiSpecServer> =>
142+ startOpenApiTestServer({
270143 apiLayer: hooksTestOpenApiLayer,
271144 });
272−};
273145
274146 async function requestJson<T>(input: {
275147 baseUrl: string;
276148 path: string;
277− method?: "GET" | "POST" | "PATCH" | "DELETE";
149+ method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
278150 payload?: unknown;
279151 accountId?: string;
280152 }): Promise<T> {
@@ -305,29 +177,24 @@ const getInstallation = (baseUrl: string) => · 24 unmodified lines
305177 });
306178
307179 const seedStoredOpenApiSource = async (input: {
308− server: ApiServer;
180+ server: RunningServer;
309181 installation: LocalInstallation;
310182 name: string;
311183 specUrl?: string;
312184 }): Promise<Source> => {
313185 const sourceDocumentText = JSON.stringify(hooksTestOpenApiSpec);
314− const specUrl = input.specUrl ?? `data:application/json,${encodeURIComponent(sourceDocumentText)}`;
186+ const specUrl = input.specUrl
187+ ?? `data:application/json,${encodeURIComponent(sourceDocumentText)}`;
315188
316189 return requestJson<Source>({
317190 baseUrl: input.server.baseUrl,
318− path: `/v1/workspaces/${input.installation.scopeId}/sources`,
191+ path: `/v1/workspaces/${input.installation.scopeId}/plugins/openapi/sources`,
319192 method: "POST",
320193 accountId: input.installation.actorScopeId,
321194 payload: {
322195 name: input.name,
323− kind: "openapi",
324− endpoint: "https://example.com/api",
325− status: "connected",
326− enabled: true,
327− namespace: "hooks-test",
328− binding: {
329− specUrl,
330− },
196+ specUrl,
197+ baseUrl: "https://example.com/api",
331198 auth: {
332199 kind: "none",
333200 },
@@ -372,10 +239,10 @@ async function renderExecutorHarness<T>(useValue: () => T): Promise<HookHarness< · 38 unmodified lines
372239 };
373240 }
374241
375−const renderCreateHarness = () =>
376− renderExecutorHarness<CreateHarnessState>(() => ({
242+const renderWorkspaceHarness = () =>
243+ renderExecutorHarness<WorkspaceHarnessState>(() => ({
244+ installation: useLocalInstallation(),
377245 sources: useSources(),
378− createSource: useCreateSource(),
379246 }));
380247
381248 const renderSourceHarness = (sourceId: string) =>
@@ -383,7 +250,6 @@ const renderSourceHarness = (sourceId: string) => · 1 unmodified line
383250 sources: useSources(),
384251 source: useSource(sourceId),
385252 inspection: useSourceInspection(sourceId),
386− updateSource: useUpdateSource(),
387253 removeSource: useRemoveSource(),
388254 }));
389255
@@ -414,190 +280,74 @@ function isReady<T>( · 24 unmodified lines
414280 return loadable.status === "ready";
415281 }
416282
417−function getReadyData<T>(loadable: Loadable<T>): T {
418− if (!isReady(loadable)) {
419− throw new Error("Expected loadable data to be ready");
420− }
421−
422− return loadable.data;
423−}
424−
283+type OpenApiSpecServer = RunningServer & {
284+ specUrl: string;
285+};
425286
426287 describe("executor-react source hooks", () => {
427− it.effect("rejects invalid source creation before applying optimistic state", () =>
428− Effect.promise(async () => {
429− const apiServer = await startControlPlaneServer();
430− const proxyServer = await startProxyServer({
431− targetBaseUrl: apiServer.baseUrl,
432− delays: [
433− {
434− method: "POST",
435− pathname: /^\/v1\/workspaces\/[^/]+\/sources$/,
436− durationMs: 150,
437− },
438− ],
439− });
440−
441− setExecutorApiBaseUrl(proxyServer.baseUrl);
442−
443− const harness = await renderCreateHarness();
444−
445− try {
446− await waitForValue(
447− () => harness.current,
448− (value) => isReady(value.sources) && value.sources.data.length === 0,
449− );
450−
451− let mutationPromise!: Promise<{ ok: true; value: Source } | { ok: false; error: unknown }>;
452− await React.act(async () => {
453− mutationPromise = harness.current!.createSource.mutateAsync({
454− name: "" as never,
455− kind: "openapi",
456− endpoint: "https://example.com",
457− binding: {
458− specUrl: "https://example.com/openapi.json",
459− },
460− }).then(
461− (value) => ({ ok: true as const, value }),
462− (error) => ({ ok: false as const, error }),
463− );
464− await Promise.resolve();
465− });
466−
467− const failed = await waitForValue(
468− () => harness.current,
469− (value) =>
470− value.createSource.status === "error"
471− && isReady(value.sources)
472− && value.sources.data.length === 0,
473− );
474−
475− const result = await mutationPromise;
476− expect(result.ok).toBe(false);
477− if (result.ok) {
478− throw new Error("Expected invalid source creation to fail");
479− }
480−
481− expect(result.error).toBeInstanceOf(Error);
482− expect(failed.createSource.error).toBeInstanceOf(Error);
483− } finally {
484− await harness.unmount();
485− await proxyServer.close();
486− await apiServer.close();
487− }
488− }),
489− 60_000,
490− );
491−
492− it.effect("applies optimistic source updates and refreshes live inspection data after success", () =>
493− Effect.promise(async () => {
288+ it("loads the local installation, sources, and inspection data", async () => {
494289 const apiServer = await startControlPlaneServer();
495− const proxyServer = await startProxyServer({
496− targetBaseUrl: apiServer.baseUrl,
497− delays: [
498− {
499− method: "PATCH",
500− pathname: /^\/v1\/workspaces\/[^/]+\/sources\/[^/]+$/,
501− durationMs: 150,
502− },
503− ],
504− });
505290 const specServer = await startOpenApiSpecServer();
506291
507− setExecutorApiBaseUrl(proxyServer.baseUrl);
292+ setExecutorApiBaseUrl(apiServer.baseUrl);
508293
509294 try {
510295 const installation = await getInstallation(apiServer.baseUrl);
511296 const source = await seedStoredOpenApiSource({
512297 server: apiServer,
513298 installation,
514− name: "Original Source",
299+ name: "Hooks Test Source",
515300 specUrl: specServer.specUrl,
516301 });
517− const harness = await renderSourceHarness(source.id);
518302
519− try {
520− await waitForValue(
521− () => harness.current,
522− (value) =>
523− isReady(value.sources)
524− && isReady(value.source)
525− && isReady(value.inspection)
526− && value.source.data.name === "Original Source"
527− && value.inspection.data.source.name === "Original Source",
528− );
303+ const workspaceHarness = await renderWorkspaceHarness();
304+ const sourceHarness = await renderSourceHarness(source.id);
529305
530− let mutationPromise!: Promise<Source>;
531− await React.act(async () => {
532− mutationPromise = harness.current!.updateSource.mutateAsync({
533− sourceId: source.id,
534− payload: {
535− name: "Renamed Source",
536− },
537− });
538− await Promise.resolve();
539− });
540−
541− const optimistic = await waitForValue(
542− () => harness.current,
306+ try {
307+ const workspace = await waitForValue(
308+ () => workspaceHarness.current,
543309 (value) =>
544− value.updateSource.status === "pending"
310+ isReady(value.installation)
545311 && isReady(value.sources)
546− && isReady(value.source)
547− && isReady(value.inspection)
548− && value.sources.data.some((item) => item.id === source.id && item.name === "Renamed Source")
549− && value.source.data.name === "Renamed Source"
550− && value.inspection.data.source.name === "Original Source",
312+ && value.installation.data.scopeId === installation.scopeId
313+ && value.sources.data.some((item) => item.id === source.id),
551314 );
552315
553− expect(getReadyData(optimistic.source).name).toBe("Renamed Source");
554− expect(getReadyData(optimistic.inspection).source.name).toBe("Original Source");
555−
556− let updated!: Source;
557− await React.act(async () => {
558− updated = await mutationPromise;
559− });
316+ expect(workspace.installation.status).toBe("ready");
317+ expect(workspace.sources.status).toBe("ready");
560318
561− expect(updated.name).toBe("Renamed Source");
562−
563− const refreshed = await waitForValue(
564− () => harness.current,
319+ const loaded = await waitForValue(
320+ () => sourceHarness.current,
565321 (value) =>
566− value.updateSource.status === "success"
567− && isReady(value.source)
322+ isReady(value.source)
568323 && isReady(value.inspection)
569− && value.source.data.name === "Renamed Source"
570− && value.inspection.data.source.name === "Renamed Source",
324+ && value.source.data.name === "Hooks Test Source"
325+ && value.inspection.data.source.id === source.id
326+ && value.inspection.data.toolCount > 0,
571327 );
572328
573− expect(getReadyData(refreshed.inspection).source.name).toBe("Renamed Source");
329+ if (!isReady(loaded.source) || !isReady(loaded.inspection)) {
330+ throw new Error("Expected source and inspection to be ready");
331+ }
332+
333+ expect(loaded.inspection.data.namespace).toBe("hooks-test-source");
334+ expect(loaded.inspection.data.source.name).toBe("Hooks Test Source");
335+ expect(loaded.inspection.data.tools[0]?.path).toContain("ping");
574336 } finally {
575− await harness.unmount();
337+ await sourceHarness.unmount();
338+ await workspaceHarness.unmount();
576339 }
577340 } finally {
578341 await specServer.close();
579− await proxyServer.close();
580342 await apiServer.close();
581343 }
582− }),
583− 60_000,
584− );
344+ }, 60_000);
585345
586− it.effect("optimistically removes deleted sources and invalidates mounted source queries", () =>
587− Effect.promise(async () => {
346+ it("invalidates mounted source and inspection queries after removal", async () => {
588347 const apiServer = await startControlPlaneServer();
589− const proxyServer = await startProxyServer({
590− targetBaseUrl: apiServer.baseUrl,
591− delays: [
592− {
593− method: "DELETE",
594− pathname: /^\/v1\/workspaces\/[^/]+\/sources\/[^/]+$/,
595− durationMs: 150,
596− },
597− ],
598− });
348+ const specServer = await startOpenApiSpecServer();
599349
600− setExecutorApiBaseUrl(proxyServer.baseUrl);
350+ setExecutorApiBaseUrl(apiServer.baseUrl);
601351
602352 try {
603353 const installation = await getInstallation(apiServer.baseUrl);
@@ -605,6 +355,7 @@ describe("executor-react source hooks", () => { · 1 unmodified line
605355 server: apiServer,
606356 installation,
607357 name: "Disposable Source",
358+ specUrl: specServer.specUrl,
608359 });
609360 const harness = await renderSourceHarness(source.id);
610361
@@ -618,36 +369,14 @@ describe("executor-react source hooks", () => { · 7 unmodified lines
618369 && value.sources.data.some((item) => item.id === source.id),
619370 );
620371
621− let mutationPromise!: Promise<{ removed: boolean }>;
622− await React.act(async () => {
623− mutationPromise = harness.current!.removeSource.mutateAsync(source.id);
624− await Promise.resolve();
625− });
626−
627− const optimistic = await waitForValue(
628− () => harness.current,
629− (value) =>
630− value.removeSource.status === "pending"
631− && isReady(value.sources)
632− && !value.sources.data.some((item) => item.id === source.id),
633− );
634−
635− expect(
636− getReadyData(optimistic.sources).some((item: Source) => item.id === source.id),
637− ).toBe(false);
638−
639− let removed!: { removed: boolean };
640− await React.act(async () => {
641− removed = await mutationPromise;
642− });
643−
372+ const removed = await React.act(async () =>
373+ harness.current!.removeSource.mutateAsync(source.id));
644374 expect(removed.removed).toBe(true);
645375
646376 const invalidated = await waitForValue(
647377 () => harness.current,
648378 (value) =>
649− value.removeSource.status === "success"
650− && isReady(value.sources)
379+ isReady(value.sources)
651380 && value.sources.data.length === 0
652381 && value.source.status === "error"
653382 && value.inspection.status === "error",
@@ -659,46 +388,41 @@ describe("executor-react source hooks", () => { · 5 unmodified lines
659388 await harness.unmount();
660389 }
661390 } finally {
662− await proxyServer.close();
391+ await specServer.close();
663392 await apiServer.close();
664393 }
665− }),
666− 60_000,
667− );
394+ }, 60_000);
668395
669− it.effect("surfaces missing sources as errors instead of staying loading", () =>
670− Effect.promise(async () => {
671− const apiServer = await startControlPlaneServer();
672− setExecutorApiBaseUrl(apiServer.baseUrl);
396+ it("surfaces missing sources as errors instead of staying loading", async () => {
397+ const apiServer = await startControlPlaneServer();
398+ setExecutorApiBaseUrl(apiServer.baseUrl);
399+
400+ try {
401+ const harness = await renderSourceHarness("src_missing");
673402
674403 try {
675− const harness = await renderSourceHarness("src_missing");
676−
677− try {
678− const missing = await waitForValue(
679− () => harness.current,
680− (value) =>
681− isReady(value.sources)
682− && value.sources.data.length === 0
683− && value.source.status === "error"
684− && value.inspection.status === "error",
685− );
686−
687− expect(missing.source.status).toBe("error");
688− expect(missing.inspection.status).toBe("error");
689− if (missing.source.status === "error") {
690− expect(missing.source.error.message).toContain("Source not found");
691− }
692− if (missing.inspection.status === "error") {
693− expect(missing.inspection.error.message).toContain("Source not found");
694− }
695− } finally {
696− await harness.unmount();
404+ const missing = await waitForValue(
405+ () => harness.current,
406+ (value) =>
407+ isReady(value.sources)
408+ && value.sources.data.length === 0
409+ && value.source.status === "error"
410+ && value.inspection.status === "error",
411+ );
412+
413+ expect(missing.source.status).toBe("error");
414+ expect(missing.inspection.status).toBe("error");
415+ if (missing.source.status === "error") {
416+ expect(missing.source.error.message).toContain("Source not found");
417+ }
418+ if (missing.inspection.status === "error") {
419+ expect(missing.inspection.error.message).toContain("Source not found");
697420 }
698421 } finally {
699− await apiServer.close();
422+ await harness.unmount();
700423 }
701− }),
702− 60_000,
703− );
424+ } finally {
425+ await apiServer.close();
426+ }
427+ }, 60_000);
704428 });
packages/clients/react/src/index.ts+65−1420
@@ -1,1436 +1,81 @@
1−import { Atom, Result } from "@effect-atom/atom";
2−import type * as Registry from "@effect-atom/atom/Registry";
3−import { RegistryContext, RegistryProvider, useAtomValue } from "@effect-atom/atom-react";
4−import {
5− createExecutorApiClient,
6− type ExecutorApiClient,
7−} from "@executor/platform-api/client";
8−import type {
9− CompleteSourceOAuthResult,
10− ConnectSourceBatchPayload,
11− ConnectSourceBatchResult,
12− ConnectSourcePayload,
13− ConnectSourceResult,
1+export {
2+ Atom,
3+ AtomHttpApi,
4+ RegistryContext,
5+ RegistryProvider,
6+ Result,
7+ useAtomRefresh,
8+ useAtomSet,
9+ useAtomValue,
10+} from "@effect-atom/atom-react";
11+
12+export type {
1413 CreateSecretPayload,
1514 CreateSecretResult,
16− CreateSourcePayload,
17− CreateWorkspaceOauthClientPayload,
1815 DeleteSecretResult,
19− DiscoverSourcePayload,
2016 InstanceConfig,
2117 LocalInstallation,
2218 SecretListItem,
23− StartSourceOAuthPayload,
24− StartSourceOAuthResult,
2519 UpdateSecretPayload,
2620 UpdateSecretResult,
27− UpdateSourcePayload,
2821 } from "@executor/platform-api";
29−import type {
22+
23+export type {
24+ Execution,
25+ ExecutionEnvelope,
26+ ExecutionStatus,
3027 Source,
31− SourceDiscoveryResult,
3228 SourceInspection,
3329 SourceInspectionDiscoverResult,
3430 SourceInspectionToolDetail,
35− ScopeOauthClient as WorkspaceOauthClient,
3631 } from "@executor/platform-sdk/schema";
37−import * as Cause from "effect/Cause";
38−import * as Effect from "effect/Effect";
39−import * as Option from "effect/Option";
40−import * as Runtime from "effect/Runtime";
41−import * as React from "react";
42−
43−const DEFAULT_EXECUTOR_API_BASE_URL = "http://127.0.0.1:8788";
44−const PLACEHOLDER_WORKSPACE_ID = "ws_placeholder" as Source["scopeId"];
45−const PLACEHOLDER_ACCOUNT_ID = "acc_placeholder";
46−const PLACEHOLDER_SOURCE_ID = "src_placeholder" as Source["id"];
47−
48−type SourceMutationState<T> = {
49− status: "idle" | "pending" | "success" | "error";
50− data: T | null;
51− error: Error | null;
52−};
53−
54−export type SourceRemoveResult = {
55− removed: boolean;
56−};
57−
58−type AtomKeyPart = string | number | boolean | null | undefined;
59−
60−type SourcesKeyParts = readonly [boolean, Source["scopeId"], string];
61−type SourceKeyParts = readonly [boolean, Source["scopeId"], string, Source["id"]];
62−type SourceToolDetailKeyParts = readonly [
63− boolean,
64− Source["scopeId"],
65− string,
66− Source["id"],
67− string | null,
68−];
69−type SourceDiscoveryKeyParts = readonly [
70− boolean,
71− Source["scopeId"],
72− string,
73− Source["id"],
74− string,
75− number | null,
76−];
77−type WorkspaceOauthClientsKeyParts = readonly [
78− boolean,
79− Source["scopeId"],
80− string,
81− string,
82−];
83−
84−type InvalidationTarget = {
85− workspaceId?: Source["scopeId"];
86− accountId?: string;
87− sourceId?: Source["id"];
88−};
89−
90−type ActiveQueryCollections = {
91− sourceLists: Set<string>;
92− sources: Set<string>;
93− workspaceOauthClients: Set<string>;
94− inspections: Set<string>;
95− toolDetails: Set<string>;
96− discoveries: Set<string>;
97−};
98−
99−type ExecutorQueryContextValue = {
100− registry: Registry.Registry;
101− activeQueries: ActiveQueryCollections;
102− invalidateQueries: (target?: InvalidationTarget) => void;
103−};
104−
105−type MutationExecutionContext = {
106− workspaceId: Source["scopeId"];
107− accountId: string;
108− registry: Registry.Registry;
109− invalidateQueries: (target?: InvalidationTarget) => void;
110−};
111−
112−type OptimisticMutationResult<T> =
113− | void
114− | (() => void)
115− | {
116− rollback?: () => void;
117− value?: T;
118− };
119−
120−type MutationOptions<TInput, TOutput, TOptimistic = never> = {
121− optimisticUpdate?: (
122− context: MutationExecutionContext,
123− payload: TInput,
124− ) => OptimisticMutationResult<TOptimistic>;
125− onSuccess?: (
126− context: MutationExecutionContext,
127− payload: TInput,
128− data: TOutput,
129− optimisticValue: TOptimistic | undefined,
130− ) => void;
131−};
132−
133−type InternalNode<A> = {
134− setValue: (value: A) => void;
135− valueOption?: () => Option.Option<A>;
136−};
137−
138−let apiBaseUrl =
139− typeof window !== "undefined" && typeof window.location?.origin === "string"
140− ? window.location.origin
141− : DEFAULT_EXECUTOR_API_BASE_URL;
142−
143−const ExecutorQueryContext = React.createContext<ExecutorQueryContextValue | null>(null);
144−
145−const encodeAtomKey = (parts: ReadonlyArray<AtomKeyPart>): string => JSON.stringify(parts);
146−
147−const decodeAtomKey = <T extends ReadonlyArray<AtomKeyPart>>(key: string): T => JSON.parse(key) as T;
148−
149−const encodeSourcesKey = (
150− enabled: boolean,
151− workspaceId: Source["scopeId"],
152− accountId: string,
153−): string => encodeAtomKey([enabled, workspaceId, accountId] satisfies SourcesKeyParts);
154−
155−const encodeSourceKey = (
156− enabled: boolean,
157− workspaceId: Source["scopeId"],
158− accountId: string,
159− sourceId: Source["id"],
160−): string => encodeAtomKey([enabled, workspaceId, accountId, sourceId] satisfies SourceKeyParts);
161−
162−const encodeToolDetailKey = (
163− enabled: boolean,
164− workspaceId: Source["scopeId"],
165− accountId: string,
166− sourceId: Source["id"],
167− toolPath: string | null,
168−): string =>
169− encodeAtomKey([enabled, workspaceId, accountId, sourceId, toolPath] satisfies SourceToolDetailKeyParts);
170−
171−const encodeDiscoveryKey = (
172− enabled: boolean,
173− workspaceId: Source["scopeId"],
174− accountId: string,
175− sourceId: Source["id"],
176− query: string,
177− limit: number | null,
178−): string =>
179− encodeAtomKey([enabled, workspaceId, accountId, sourceId, query, limit] satisfies SourceDiscoveryKeyParts);
180−
181−const encodeWorkspaceOauthClientsKey = (
182− enabled: boolean,
183− workspaceId: Source["scopeId"],
184− accountId: string,
185− providerKey: string,
186−): string =>
187− encodeAtomKey(
188− [enabled, workspaceId, accountId, providerKey] satisfies WorkspaceOauthClientsKeyParts,
189− );
190−
191−const toError = (cause: unknown): Error =>
192− cause instanceof Error ? cause : new Error(String(cause));
193−
194−const causeMessage = (cause: Cause.Cause<unknown>): Error =>
195− new Error(Cause.pretty(cause));
196−
197−const shouldLogExecutorDevErrors = (): boolean => {
198− if (typeof window === "undefined") {
199− return false;
200− }
201−
202− const hostname = window.location.hostname;
203− return hostname === "localhost" || hostname === "127.0.0.1";
204−};
205−
206−const describeExecutorDevError = (cause: unknown): Record<string, unknown> => {
207− if (Runtime.isFiberFailure(cause)) {
208− const inner = cause[Runtime.FiberFailureCauseId];
209− return {
210− name: cause.name,
211− message: cause.message,
212− cause: Cause.pretty(inner),
213− };
214− }
215−
216− if (cause instanceof Error) {
217− return {
218− name: cause.name,
219− message: cause.message,
220− stack: cause.stack,
221− };
222− }
223−
224− return {
225− message: String(cause),
226− };
227−};
228−
229−const logExecutorDevError = (label: string, details: Record<string, unknown>): void => {
230− if (!shouldLogExecutorDevErrors()) {
231− return;
232− }
233−
234− console.error(`[executor react] ${label}`, details);
235−};
236−
237−const runControlPlane = async <A>(input: {
238− baseUrl?: string;
239− accountId?: string;
240− execute: (client: ExecutorApiClient) => Promise<A>;
241−}): Promise<A> => {
242− const baseUrl = input.baseUrl ?? apiBaseUrl;
243− const accountId = input.accountId;
244−
245− try {
246− const client = await createExecutorApiClient({
247− baseUrl,
248− ...(accountId !== undefined ? { accountId } : {}),
249− });
250− return await input.execute(client);
251− } catch (cause) {
252− const error = toError(cause);
253− logExecutorDevError("control-plane request failed", {
254− baseUrl,
255− accountId,
256− error: describeExecutorDevError(error),
257− });
258− throw error;
259− }
260−};
261−
262−const controlPlaneRequest = <A>(input: {
263− baseUrl?: string;
264− accountId?: string;
265− execute: (client: ExecutorApiClient) => Promise<A>;
266−}): Effect.Effect<A, Error> =>
267− Effect.tryPromise({
268− try: () => runControlPlane(input),
269− catch: toError,
270− });
271−
272−const localInstallationAtom = Atom.family((baseUrl: string) =>
273− Atom.make(
274− controlPlaneRequest({
275− baseUrl,
276− execute: (client) => client.local.installation({}),
277− }),
278− ).pipe(Atom.keepAlive),
279−);
280−
281−const instanceConfigAtom = Atom.family((baseUrl: string) =>
282− Atom.make(
283− controlPlaneRequest({
284− baseUrl,
285− execute: (client) => client.local.config({}),
286− }),
287− ).pipe(Atom.keepAlive),
288−);
289−
290−const secretsAtom = Atom.family((baseUrl: string) =>
291− Atom.make(
292− controlPlaneRequest({
293− baseUrl,
294− execute: (client) => client.local.listSecrets({}),
295− }),
296− ).pipe(Atom.keepAlive),
297−);
298−
299−const sourcesAtom = Atom.family((key: string) => {
300− const [enabled, workspaceId, accountId] = decodeAtomKey<SourcesKeyParts>(key);
301−
302− return Atom.make(
303− enabled
304− ? controlPlaneRequest({
305− accountId,
306− execute: (client) => client.sources.list({
307− path: {
308− workspaceId,
309− },
310− }),
311− })
312− : Effect.never,
313− ).pipe(Atom.keepAlive);
314−});
315−
316−const sourceAtom = Atom.family((key: string) => {
317− const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceKeyParts>(key);
318−
319− return Atom.make(
320− enabled
321− ? controlPlaneRequest({
322− accountId,
323− execute: (client) => client.sources.get({
324− path: {
325− workspaceId,
326− sourceId,
327− },
328− }),
329− })
330− : Effect.never,
331− ).pipe(Atom.keepAlive);
332−});
333−
334−const sourceInspectionAtom = Atom.family((key: string) => {
335− const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceKeyParts>(key);
336−
337− return Atom.make(
338− enabled
339− ? controlPlaneRequest({
340− accountId,
341− execute: (client) => client.sources.inspection({
342− path: {
343− workspaceId,
344− sourceId,
345− },
346− }),
347− })
348− : Effect.never,
349− ).pipe(Atom.keepAlive);
350−});
351−
352−const sourceInspectionToolAtom = Atom.family((key: string) => {
353− const [enabled, workspaceId, accountId, sourceId, toolPath] = decodeAtomKey<SourceToolDetailKeyParts>(key);
354−
355− return Atom.make(
356− enabled && toolPath
357− ? controlPlaneRequest({
358− accountId,
359− execute: (client) => client.sources.inspectionTool({
360− path: {
361− workspaceId,
362− sourceId,
363− toolPath,
364− },
365− }),
366− })
367− : Effect.succeed<SourceInspectionToolDetail | null>(null),
368− ).pipe(Atom.keepAlive);
369−});
370−
371−const sourceDiscoveryAtom = Atom.family((key: string) => {
372− const [enabled, workspaceId, accountId, sourceId, query, limit] = decodeAtomKey<SourceDiscoveryKeyParts>(key);
373−
374− return Atom.make(
375− !enabled
376− ? Effect.never
377− : query.trim().length === 0
378− ? Effect.succeed<SourceInspectionDiscoverResult>({
379− query: "",
380− queryTokens: [],
381− bestPath: null,
382− total: 0,
383− results: [],
384− })
385− : controlPlaneRequest({
386− accountId,
387− execute: (client) => client.sources.inspectionDiscover({
388− path: {
389− workspaceId,
390− sourceId,
391− },
392− payload: {
393− query,
394− ...(limit !== null ? { limit } : {}),
395− },
396− }),
397− }),
398− ).pipe(Atom.keepAlive);
399−});
400−
401−const workspaceOauthClientsAtom = Atom.family((key: string) => {
402− const [enabled, workspaceId, accountId, providerKey] = decodeAtomKey<WorkspaceOauthClientsKeyParts>(key);
403−
404− return Atom.make(
405− enabled
406− ? controlPlaneRequest({
407− accountId,
408− execute: (client) => client.sources.listWorkspaceOauthClients({
409− path: {
410− workspaceId,
411− },
412− urlParams: {
413− providerKey,
414− },
415− }),
416− })
417− : Effect.never,
418− ).pipe(Atom.keepAlive);
419−});
420−
421−export type Loadable<T> =
422− | { status: "loading" }
423− | { status: "error"; error: Error }
424− | { status: "ready"; data: T };
425−
426−type WorkspaceContext = {
427− installation: LocalInstallation;
428− workspaceId: Source["scopeId"];
429− accountId: string;
430−};
431−
432−const toLoadable = <T>(result: Result.Result<T, Error>): Loadable<T> => {
433− if (Result.isSuccess(result)) {
434− return {
435− status: "ready",
436− data: result.value,
437− };
438− }
439−
440− if (Result.isFailure(result)) {
441− return {
442− status: "error",
443− error: causeMessage(result.cause),
444− };
445− }
446−
447− return {
448− status: "loading",
449− };
450−};
451−
452−const pendingLoadable = <T>(workspace: Loadable<WorkspaceContext>): Loadable<T> => {
453− if (workspace.status === "loading") {
454− return { status: "loading" };
455− }
456−
457− if (workspace.status === "error") {
458− return { status: "error", error: workspace.error };
459− }
460−
461− throw new Error("Expected workspace loadable to be pending or errored");
462−};
463−
464−const useLoadableAtom = <T>(atom: Atom.Atom<Result.Result<T, Error>>): Loadable<T> => {
465− const result = useAtomValue(atom);
466− return React.useMemo(() => toLoadable(result), [result]);
467−};
468−
469−const useWorkspaceContext = (): Loadable<WorkspaceContext> => {
470− const installation = useLoadableAtom(localInstallationAtom(apiBaseUrl));
471−
472− return React.useMemo(() => {
473− if (installation.status !== "ready") {
474− return installation;
475− }
476−
477− return {
478− status: "ready",
479− data: {
480− installation: installation.data,
481− workspaceId: installation.data.scopeId,
482− accountId: installation.data.actorScopeId,
483− },
484− } satisfies Loadable<WorkspaceContext>;
485− }, [installation]);
486−};
487−
488−const useWorkspaceRequestContext = () => {
489− const workspace = useWorkspaceContext();
490− const enabled = workspace.status === "ready";
491−
492− const workspaceId = enabled
493− ? workspace.data.workspaceId
494− : PLACEHOLDER_WORKSPACE_ID;
495− const accountId = enabled
496− ? workspace.data.accountId
497− : PLACEHOLDER_ACCOUNT_ID;
498−
499− return React.useMemo(
500− () => ({
501− workspace,
502− enabled,
503− workspaceId,
504− accountId,
505− }),
506− [accountId, enabled, workspace, workspaceId],
507− );
508−};
509−
510−const getCachedAtomValue = <A>(
511− registry: Registry.Registry,
512− atom: Atom.Atom<Result.Result<A, Error>>,
513−): A | undefined => {
514− const node = (registry.getNodes().get(atom) ?? null) as InternalNode<Result.Result<A, Error>> | null;
515− if (node === null || typeof node.valueOption !== "function") {
516− return undefined;
517− }
518−
519− const option = node.valueOption();
520− if (Option.isNone(option)) {
521− return undefined;
522− }
523−
524− if (!Result.isSuccess(option.value)) {
525− return undefined;
526− }
527−
528− return option.value.value;
529−};
530−
531−const setCachedAtomValue = <A>(
532− registry: Registry.Registry,
533− atom: Atom.Atom<Result.Result<A, Error>>,
534− value: A,
535−): void => {
536− const ensureNode = (registry as {
537− ensureNode?: (atom: Atom.Atom<Result.Result<A, Error>>) => InternalNode<Result.Result<A, Error>>;
538− }).ensureNode;
539− if (typeof ensureNode !== "function") {
540− return;
541− }
542−
543− ensureNode.call(registry, atom).setValue(Result.success(value));
544−};
545−
546−const createActiveQueryCollections = (): ActiveQueryCollections => ({
547− sourceLists: new Set(),
548− sources: new Set(),
549− workspaceOauthClients: new Set(),
550− inspections: new Set(),
551− toolDetails: new Set(),
552− discoveries: new Set(),
553−});
554−
555−const targetMatches = (
556− target: InvalidationTarget | undefined,
557− workspaceId: Source["scopeId"],
558− accountId: string,
559− sourceId?: Source["id"],
560−): boolean => {
561− if (target?.workspaceId !== undefined && target.workspaceId !== workspaceId) {
562− return false;
563− }
564− if (target?.accountId !== undefined && target.accountId !== accountId) {
565− return false;
566− }
567− if (target?.sourceId !== undefined && target.sourceId !== sourceId) {
568− return false;
569− }
570− return true;
571−};
572−
573−const invalidateTrackedQueries = (
574− registry: Registry.Registry,
575− activeQueries: ActiveQueryCollections,
576− target?: InvalidationTarget,
577−): void => {
578− activeQueries.sourceLists.forEach((key) => {
579− const [enabled, workspaceId, accountId] = decodeAtomKey<SourcesKeyParts>(key);
580− if (enabled && targetMatches(target, workspaceId, accountId)) {
581− registry.refresh(sourcesAtom(key));
582− }
583− });
584−
585− activeQueries.sources.forEach((key) => {
586− const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceKeyParts>(key);
587− if (enabled && targetMatches(target, workspaceId, accountId, sourceId)) {
588− registry.refresh(sourceAtom(key));
589− }
590− });
591−
592− activeQueries.workspaceOauthClients.forEach((key) => {
593− const [enabled, workspaceId, accountId] = decodeAtomKey<WorkspaceOauthClientsKeyParts>(key);
594− if (enabled && targetMatches(target, workspaceId, accountId)) {
595− registry.refresh(workspaceOauthClientsAtom(key));
596− }
597− });
598−
599− activeQueries.inspections.forEach((key) => {
600− const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceKeyParts>(key);
601− if (enabled && targetMatches(target, workspaceId, accountId, sourceId)) {
602− registry.refresh(sourceInspectionAtom(key));
603− }
604− });
605−
606− activeQueries.toolDetails.forEach((key) => {
607− const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceToolDetailKeyParts>(key);
608− if (enabled && targetMatches(target, workspaceId, accountId, sourceId)) {
609− registry.refresh(sourceInspectionToolAtom(key));
610− }
611− });
612−
613− activeQueries.discoveries.forEach((key) => {
614− const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceDiscoveryKeyParts>(key);
615− if (enabled && targetMatches(target, workspaceId, accountId, sourceId)) {
616− registry.refresh(sourceDiscoveryAtom(key));
617− }
618− });
619−};
620−
621−const useExecutorQueryContext = (): ExecutorQueryContextValue => {
622− const context = React.useContext(ExecutorQueryContext);
623− if (context === null) {
624− throw new Error("ExecutorReactProvider is missing from the React tree");
625− }
626− return context;
627−};
628−
629−const useTrackActiveKey = (
630− collection: keyof ActiveQueryCollections,
631− key: string,
632− enabled: boolean,
633−): void => {
634− const { activeQueries } = useExecutorQueryContext();
635−
636− React.useEffect(() => {
637− if (!enabled) {
638− return;
639− }
640−
641− const bucket = activeQueries[collection];
642− bucket.add(key);
643− return () => {
644− bucket.delete(key);
645− };
646− }, [activeQueries, collection, enabled, key]);
647−};
648−
649−const upsertSourceInList = (
650− sources: ReadonlyArray<Source>,
651− nextSource: Source,
652−): ReadonlyArray<Source> => {
653− const index = sources.findIndex((source) => source.id === nextSource.id);
654− if (index === -1) {
655− return [nextSource, ...sources];
656− }
657−
658− const next = sources.slice();
659− next[index] = nextSource;
660− return next;
661−};
662−
663−const removeSourceFromList = (
664− sources: ReadonlyArray<Source>,
665− sourceId: Source["id"],
666−): ReadonlyArray<Source> => sources.filter((source) => source.id !== sourceId);
667−
668−const createOptimisticSource = (input: {
669− workspaceId: Source["scopeId"];
670− payload: CreateSourcePayload;
671−}): Source => {
672− const now = Date.now();
673−
674− return {
675− id: `src_optimistic_${crypto.randomUUID()}` as Source["id"],
676− scopeId: input.workspaceId,
677− name: input.payload.name,
678− kind: input.payload.kind,
679− endpoint: input.payload.endpoint,
680− status: input.payload.status ?? "draft",
681− enabled: input.payload.enabled ?? true,
682− namespace: input.payload.namespace ?? null,
683− bindingVersion: 1,
684− binding: input.payload.binding ?? {},
685− importAuthPolicy: input.payload.importAuthPolicy ?? "reuse_runtime",
686− importAuth: input.payload.importAuth ?? { kind: "none" },
687− auth: input.payload.auth ?? { kind: "none" },
688− sourceHash: input.payload.sourceHash ?? null,
689− lastError: input.payload.lastError ?? null,
690− createdAt: now,
691− updatedAt: now,
692− };
693−};
694−
695−const applyUpdatePayloadToSource = (source: Source, payload: UpdateSourcePayload): Source => ({
696− ...source,
697− name: payload.name ?? source.name,
698− endpoint: payload.endpoint ?? source.endpoint,
699− status: payload.status ?? source.status,
700− enabled: payload.enabled ?? source.enabled,
701− namespace: payload.namespace !== undefined ? payload.namespace : source.namespace,
702− binding: payload.binding !== undefined ? payload.binding : source.binding,
703− auth: payload.auth !== undefined ? payload.auth : source.auth,
704− sourceHash: payload.sourceHash !== undefined ? payload.sourceHash : source.sourceHash,
705− lastError: payload.lastError !== undefined ? payload.lastError : source.lastError,
706− updatedAt: Date.now(),
707−});
708−
709−const useSourceMutation = <TInput, TOutput, TOptimistic = never>(
710− execute: (input: {
711− workspaceId: Source["scopeId"];
712− accountId: string;
713− payload: TInput;
714− }) => Promise<TOutput>,
715− options?: MutationOptions<TInput, TOutput, TOptimistic>,
716−) => {
717− const workspace = useWorkspaceRequestContext();
718− const { registry, invalidateQueries } = useExecutorQueryContext();
719− const [state, setState] = React.useState<SourceMutationState<TOutput>>({
720− status: "idle",
721− data: null,
722− error: null,
723− });
724−
725− const mutateAsync = React.useCallback(async (payload: TInput) => {
726− if (!workspace.enabled) {
727− const error = new Error("Executor workspace context is not ready");
728− setState({ status: "error", data: null, error });
729− throw error;
730− }
731−
732− const executionContext: MutationExecutionContext = {
733− workspaceId: workspace.workspaceId,
734− accountId: workspace.accountId,
735− registry,
736− invalidateQueries,
737− };
738−
739− setState((current) => ({
740− status: "pending",
741− data: current.data,
742− error: null,
743− }));
744−
745− const optimistic = options?.optimisticUpdate?.(executionContext, payload);
746− const rollback = typeof optimistic === "function"
747− ? optimistic
748− : optimistic?.rollback;
749− const optimisticValue = typeof optimistic === "function"
750− ? undefined
751− : optimistic?.value;
752−
753− try {
754− const data = await execute({
755− workspaceId: workspace.workspaceId,
756− accountId: workspace.accountId,
757− payload,
758− });
759− options?.onSuccess?.(executionContext, payload, data, optimisticValue);
760− setState({ status: "success", data, error: null });
761− return data;
762− } catch (cause) {
763− rollback?.();
764− logExecutorDevError("source mutation failed", {
765− workspaceId: workspace.workspaceId,
766− accountId: workspace.accountId,
767− payload,
768− error: describeExecutorDevError(cause),
769− cause,
770− });
771− const error = cause instanceof Error ? cause : new Error(String(cause));
772− setState({ status: "error", data: null, error });
773− throw error;
774− }
775− }, [execute, invalidateQueries, options, registry, workspace.accountId, workspace.enabled, workspace.workspaceId]);
776−
777− const reset = React.useCallback(() => {
778− setState({ status: "idle", data: null, error: null });
779− }, []);
780−
781− return React.useMemo(
782− () => ({
783− ...state,
784− mutateAsync,
785− reset,
786− }),
787− [mutateAsync, reset, state],
788− );
789−};
790−
791−const ExecutorReactProviderInner = (props: React.PropsWithChildren) => {
792− const registry = React.useContext(RegistryContext);
793− const activeQueries = React.useMemo(createActiveQueryCollections, []);
794− const invalidateQueries = React.useCallback((target?: InvalidationTarget) => {
795− invalidateTrackedQueries(registry, activeQueries, target);
796− }, [activeQueries, registry]);
797−
798− const value = React.useMemo<ExecutorQueryContextValue>(() => ({
799− registry,
800− activeQueries,
801− invalidateQueries,
802− }), [activeQueries, invalidateQueries, registry]);
803−
804− return React.createElement(ExecutorQueryContext.Provider, { value }, props.children);
805−};
806−
807−export const setExecutorApiBaseUrl = (baseUrl: string): void => {
808− apiBaseUrl = baseUrl;
809−};
810−
811−export const ExecutorReactProvider = (props: React.PropsWithChildren) =>
812− React.createElement(
813− RegistryProvider,
814− null,
815− React.createElement(ExecutorReactProviderInner, null, props.children),
816− );
817−
818−export const useInvalidateExecutorQueries = (): (() => void) => {
819− const { invalidateQueries } = useExecutorQueryContext();
820− return React.useCallback(() => {
821− invalidateQueries();
822− }, [invalidateQueries]);
823−};
824−
825−export const useLocalInstallation = (): Loadable<LocalInstallation> =>
826− useLoadableAtom(localInstallationAtom(apiBaseUrl));
827−
828−export const useInstanceConfig = (): Loadable<InstanceConfig> =>
829− useLoadableAtom(instanceConfigAtom(apiBaseUrl));
830−
831−export const useSecrets = (): Loadable<ReadonlyArray<SecretListItem>> =>
832− useLoadableAtom(secretsAtom(apiBaseUrl));
833−
834−export const useRefreshSecrets = (): (() => void) => {
835− const registry = React.useContext(RegistryContext);
836− return React.useCallback(() => {
837− registry.refresh(secretsAtom(apiBaseUrl));
838− }, [registry]);
839−};
840−
841−type SecretMutationState<T> = {
842− status: "idle" | "pending" | "success" | "error";
843− data: T | null;
844− error: Error | null;
845−};
846−
847−const useSecretMutation = <TInput, TOutput>(
848− execute: (input: TInput) => Promise<TOutput>,
849−) => {
850− const registry = React.useContext(RegistryContext);
851− const [state, setState] = React.useState<SecretMutationState<TOutput>>({
852− status: "idle",
853− data: null,
854− error: null,
855− });
856−
857− const mutateAsync = React.useCallback(async (payload: TInput) => {
858− setState((current) => ({
859− status: "pending",
860− data: current.data,
861− error: null,
862− }));
863−
864− try {
865− const data = await execute(payload);
866− registry.refresh(secretsAtom(apiBaseUrl));
867− setState({ status: "success", data, error: null });
868− return data;
869− } catch (cause) {
870− const error = cause instanceof Error ? cause : new Error(String(cause));
871− setState({ status: "error", data: null, error });
872− throw error;
873− }
874− }, [execute, registry]);
875−
876− const reset = React.useCallback(() => {
877− setState({ status: "idle", data: null, error: null });
878− }, []);
879−
880− return React.useMemo(
881− () => ({
882− ...state,
883− mutateAsync,
884− reset,
885− }),
886− [mutateAsync, reset, state],
887− );
888−};
889−
890−export const useCreateSecret = () =>
891− useSecretMutation<CreateSecretPayload, CreateSecretResult>(
892− React.useCallback(
893− (payload) =>
894− runControlPlane({
895− execute: (client) => client.local.createSecret({
896− payload,
897− }),
898− }),
899− [],
900− ),
901− );
902−
903−export const useUpdateSecret = () =>
904− useSecretMutation<{ secretId: string; payload: UpdateSecretPayload }, UpdateSecretResult>(
905− React.useCallback(
906− (input) =>
907− runControlPlane({
908− execute: (client) => client.local.updateSecret({
909− path: { secretId: input.secretId },
910− payload: input.payload,
911− }),
912− }),
913− [],
914− ),
915− );
916−
917−export const useDeleteSecret = () =>
918− useSecretMutation<string, DeleteSecretResult>(
919− React.useCallback(
920− (secretId) =>
921− runControlPlane({
922− execute: (client) => client.local.deleteSecret({
923− path: { secretId },
924− }),
925− }),
926− [],
927− ),
928− );
929−
930−export const useSources = (): Loadable<ReadonlyArray<Source>> => {
931− const workspace = useWorkspaceRequestContext();
932− const key = encodeSourcesKey(workspace.enabled, workspace.workspaceId, workspace.accountId);
933− useTrackActiveKey("sourceLists", key, workspace.enabled);
934− const sources = useLoadableAtom(sourcesAtom(key));
935−
936− return workspace.enabled ? sources : pendingLoadable(workspace.workspace);
937−};
938−
939−export const useSource = (sourceId: string): Loadable<Source> => {
940− const workspace = useWorkspaceRequestContext();
941− const requestedSourceId = workspace.enabled
942− ? (sourceId as Source["id"])
943− : PLACEHOLDER_SOURCE_ID;
944− const key = encodeSourceKey(
945− workspace.enabled,
946− workspace.workspaceId,
947− workspace.accountId,
948− requestedSourceId,
949− );
950− useTrackActiveKey("sources", key, workspace.enabled);
951− const source = useLoadableAtom(sourceAtom(key));
952−
953− return workspace.enabled ? source : pendingLoadable(workspace.workspace);
954−};
955−
956−export const useWorkspaceOauthClients = (
957− providerKey: string | null,
958−): Loadable<ReadonlyArray<WorkspaceOauthClient>> => {
959− const workspace = useWorkspaceRequestContext();
960− const key = encodeWorkspaceOauthClientsKey(
961− workspace.enabled && providerKey !== null,
962− workspace.workspaceId,
963− workspace.accountId,
964− providerKey ?? "",
965− );
966− useTrackActiveKey(
967− "workspaceOauthClients",
968− key,
969− workspace.enabled && providerKey !== null,
970− );
971− const oauthClients = useLoadableAtom(workspaceOauthClientsAtom(key));
972−
973− if (!workspace.enabled) {
974− return pendingLoadable(workspace.workspace);
975− }
976−
977− if (providerKey === null) {
978− return {
979− status: "ready",
980− data: [],
981− };
982− }
983−
984− return oauthClients;
985−};
986−
987−export const useSourceInspection = (sourceId: string): Loadable<SourceInspection> => {
988− const workspace = useWorkspaceRequestContext();
989− const requestedSourceId = workspace.enabled
990− ? (sourceId as Source["id"])
991− : PLACEHOLDER_SOURCE_ID;
992− const key = encodeSourceKey(
993− workspace.enabled,
994− workspace.workspaceId,
995− workspace.accountId,
996− requestedSourceId,
997− );
998− useTrackActiveKey("inspections", key, workspace.enabled);
999− const inspection = useLoadableAtom(sourceInspectionAtom(key));
1000−
1001− return workspace.enabled ? inspection : pendingLoadable(workspace.workspace);
1002−};
1003−
1004−export const useSourceToolDetail = (
1005− sourceId: string,
1006− toolPath: string | null,
1007−): Loadable<SourceInspectionToolDetail | null> => {
1008− const workspace = useWorkspaceRequestContext();
1009− const requestedSourceId = workspace.enabled
1010− ? (sourceId as Source["id"])
1011− : PLACEHOLDER_SOURCE_ID;
1012− const key = encodeToolDetailKey(
1013− workspace.enabled,
1014− workspace.workspaceId,
1015− workspace.accountId,
1016− requestedSourceId,
1017− toolPath,
1018− );
1019− useTrackActiveKey("toolDetails", key, workspace.enabled && toolPath !== null);
1020− const detail = useLoadableAtom(sourceInspectionToolAtom(key));
1021−
1022− return workspace.enabled ? detail : pendingLoadable(workspace.workspace);
1023−};
1024−
1025−export const useSourceDiscovery = (input: {
1026− sourceId: string;
1027− query: string;
1028− limit?: number;
1029−}): Loadable<SourceInspectionDiscoverResult> => {
1030− const workspace = useWorkspaceRequestContext();
1031− const requestedSourceId = workspace.enabled
1032− ? (input.sourceId as Source["id"])
1033− : PLACEHOLDER_SOURCE_ID;
1034− const key = encodeDiscoveryKey(
1035− workspace.enabled,
1036− workspace.workspaceId,
1037− workspace.accountId,
1038− requestedSourceId,
1039− input.query,
1040− input.limit ?? null,
1041− );
1042− useTrackActiveKey("discoveries", key, workspace.enabled);
1043− const results = useLoadableAtom(sourceDiscoveryAtom(key));
1044−
1045− return workspace.enabled ? results : pendingLoadable(workspace.workspace);
1046−};
1047−
1048−export const usePrefetchToolDetail = () => {
1049− const registry = React.useContext(RegistryContext);
1050− const workspace = useWorkspaceRequestContext();
1051−
1052− return React.useCallback(
1053− (sourceId: string, toolPath: string): (() => void) => {
1054− if (!workspace.enabled) return () => {};
1055− const requestedSourceId = sourceId as Source["id"];
1056− const atom = sourceInspectionToolAtom(
1057− encodeToolDetailKey(
1058− workspace.enabled,
1059− workspace.workspaceId,
1060− workspace.accountId,
1061− requestedSourceId,
1062− toolPath,
1063− ),
1064− );
1065− return registry.mount(atom);
1066− },
1067− [registry, workspace.accountId, workspace.enabled, workspace.workspaceId],
1068− );
1069−};
1070−
1071−export const useCreateSource = () =>
1072− useSourceMutation<CreateSourcePayload, Source, Source>(
1073− React.useCallback(
1074− ({ workspaceId, accountId, payload }) =>
1075− runControlPlane({
1076− accountId,
1077− execute: (client) => client.sources.create({
1078− path: {
1079− workspaceId,
1080− },
1081− payload,
1082− }),
1083− }),
1084− [],
1085− ),
1086− {
1087− optimisticUpdate: (context, payload) => {
1088− const listAtom = sourcesAtom(encodeSourcesKey(true, context.workspaceId, context.accountId));
1089− const previousList = getCachedAtomValue(context.registry, listAtom);
1090− if (previousList === undefined) {
1091− return;
1092− }
1093−
1094− const optimisticSource = createOptimisticSource({
1095− workspaceId: context.workspaceId,
1096− payload,
1097− });
1098− setCachedAtomValue(context.registry, listAtom, [optimisticSource, ...previousList]);
1099− return {
1100− value: optimisticSource,
1101− rollback: () => {
1102− setCachedAtomValue(context.registry, listAtom, previousList);
1103− },
1104− };
1105− },
1106− onSuccess: (context, _payload, source, optimisticSource) => {
1107− const listAtom = sourcesAtom(encodeSourcesKey(true, context.workspaceId, context.accountId));
1108− const currentList = getCachedAtomValue(context.registry, listAtom);
1109− if (currentList !== undefined) {
1110− const withoutOptimistic = optimisticSource
1111− ? currentList.filter((candidate) => candidate.id !== optimisticSource.id)
1112− : currentList;
1113− setCachedAtomValue(context.registry, listAtom, upsertSourceInList(withoutOptimistic, source));
1114− }
1115−
1116− setCachedAtomValue(
1117− context.registry,
1118− sourceAtom(encodeSourceKey(true, context.workspaceId, context.accountId, source.id)),
1119− source,
1120− );
1121− context.invalidateQueries({
1122− workspaceId: context.workspaceId,
1123− accountId: context.accountId,
1124− });
1125− },
1126− },
1127− );
1128−
1129−export const useUpdateSource = () =>
1130− useSourceMutation<{ sourceId: Source["id"]; payload: UpdateSourcePayload }, Source>(
1131− React.useCallback(
1132− ({ workspaceId, accountId, payload }) =>
1133− runControlPlane({
1134− accountId,
1135− execute: (client) => client.sources.update({
1136− path: {
1137− workspaceId,
1138− sourceId: payload.sourceId,
1139− },
1140− payload: payload.payload,
1141− }),
1142− }),
1143− [],
1144− ),
1145− {
1146− optimisticUpdate: (context, input) => {
1147− const listAtom = sourcesAtom(encodeSourcesKey(true, context.workspaceId, context.accountId));
1148− const detailAtom = sourceAtom(encodeSourceKey(true, context.workspaceId, context.accountId, input.sourceId));
1149− const previousList = getCachedAtomValue(context.registry, listAtom);
1150− const previousSource = getCachedAtomValue(context.registry, detailAtom)
1151− ?? previousList?.find((source) => source.id === input.sourceId);
1152− if (previousSource === undefined) {
1153− return;
1154− }
1155−
1156− const optimisticSource = applyUpdatePayloadToSource(previousSource, input.payload);
1157− if (previousList !== undefined) {
1158− setCachedAtomValue(context.registry, listAtom, upsertSourceInList(previousList, optimisticSource));
1159− }
1160− setCachedAtomValue(context.registry, detailAtom, optimisticSource);
1161−
1162− return () => {
1163− if (previousList !== undefined) {
1164− setCachedAtomValue(context.registry, listAtom, previousList);
1165− }
1166− setCachedAtomValue(context.registry, detailAtom, previousSource);
1167− };
1168− },
1169− onSuccess: (context, input, source) => {
1170− const listAtom = sourcesAtom(encodeSourcesKey(true, context.workspaceId, context.accountId));
1171− const detailAtom = sourceAtom(encodeSourceKey(true, context.workspaceId, context.accountId, input.sourceId));
1172− const currentList = getCachedAtomValue(context.registry, listAtom);
1173− if (currentList !== undefined) {
1174− setCachedAtomValue(context.registry, listAtom, upsertSourceInList(currentList, source));
1175− }
1176− setCachedAtomValue(context.registry, detailAtom, source);
1177− context.invalidateQueries({
1178− workspaceId: context.workspaceId,
1179− accountId: context.accountId,
1180− sourceId: input.sourceId,
1181− });
1182− },
1183− },
1184− );
1185−
1186−export const useStartSourceOAuth = () =>
1187− useSourceMutation<StartSourceOAuthPayload, StartSourceOAuthResult>(
1188− React.useCallback(
1189− ({ workspaceId, accountId, payload }) =>
1190− runControlPlane({
1191− accountId,
1192− execute: (client) => client.oauth.startSourceAuth({
1193− path: {
1194− workspaceId,
1195− },
1196− payload,
1197− }),
1198− }),
1199− [],
1200− ),
1201− );
1202−
1203−export const useRemoveSource = () =>
1204− useSourceMutation<Source["id"], SourceRemoveResult>(
1205− React.useCallback(
1206− ({ workspaceId, accountId, payload }) =>
1207− runControlPlane({
1208− accountId,
1209− execute: (client) => client.sources.remove({
1210− path: {
1211− workspaceId,
1212− sourceId: payload,
1213− },
1214− }),
1215− }),
1216− [],
1217− ),
1218− {
1219− optimisticUpdate: (context, sourceId) => {
1220− const listAtom = sourcesAtom(encodeSourcesKey(true, context.workspaceId, context.accountId));
1221− const previousList = getCachedAtomValue(context.registry, listAtom);
1222− if (previousList === undefined) {
1223− return;
1224− }
1225−
1226− setCachedAtomValue(context.registry, listAtom, removeSourceFromList(previousList, sourceId));
1227− return () => {
1228− setCachedAtomValue(context.registry, listAtom, previousList);
1229− };
1230− },
1231− onSuccess: (context, sourceId) => {
1232− context.invalidateQueries({
1233− workspaceId: context.workspaceId,
1234− accountId: context.accountId,
1235− sourceId,
1236− });
1237− },
1238− },
1239− );
1240−
1241−export const useDiscoverSource = () =>
1242− useSourceMutation<DiscoverSourcePayload, SourceDiscoveryResult>(
1243− React.useCallback(
1244− ({ accountId, payload }) =>
1245− runControlPlane({
1246− accountId,
1247− execute: (client) => client.sources.discover({
1248− payload,
1249− }),
1250− }),
1251− [],
1252− ),
1253− );
1254−
1255−export const useConnectSource = () =>
1256− useSourceMutation<ConnectSourcePayload, ConnectSourceResult, Source>(
1257− React.useCallback(
1258− ({ workspaceId, accountId, payload }) =>
1259− runControlPlane({
1260− accountId,
1261− execute: (client) => client.sources.connect({
1262− path: {
1263− workspaceId,
1264− },
1265− payload,
1266− } as any),
1267− }),
1268− [],
1269− ),
1270− {
1271− onSuccess: (context, _payload, result) => {
1272− const listAtom = sourcesAtom(encodeSourcesKey(true, context.workspaceId, context.accountId));
1273− const currentList = getCachedAtomValue(context.registry, listAtom);
1274− if (currentList !== undefined) {
1275− setCachedAtomValue(context.registry, listAtom, upsertSourceInList(currentList, result.source));
1276− }
1277−
1278− setCachedAtomValue(
1279− context.registry,
1280− sourceAtom(encodeSourceKey(true, context.workspaceId, context.accountId, result.source.id)),
1281− result.source,
1282− );
1283− context.invalidateQueries({
1284− workspaceId: context.workspaceId,
1285− accountId: context.accountId,
1286− });
1287− },
1288− },
1289− );
1290−
1291−export const useConnectSourceBatch = () =>
1292− useSourceMutation<ConnectSourceBatchPayload, ConnectSourceBatchResult>(
1293− React.useCallback(
1294− ({ workspaceId, accountId, payload }) =>
1295− runControlPlane({
1296− accountId,
1297− execute: (client) => client.sources.connectBatch({
1298− path: {
1299− workspaceId,
1300− },
1301− payload,
1302− } as any),
1303− }),
1304− [],
1305− ),
1306− {
1307− onSuccess: (context, _payload, result) => {
1308− const listAtom = sourcesAtom(encodeSourcesKey(true, context.workspaceId, context.accountId));
1309− const currentList = getCachedAtomValue(context.registry, listAtom);
1310− if (currentList !== undefined) {
1311− let nextList = currentList;
1312− for (const entry of result.results) {
1313− nextList = upsertSourceInList(nextList, entry.source);
1314− setCachedAtomValue(
1315− context.registry,
1316− sourceAtom(encodeSourceKey(true, context.workspaceId, context.accountId, entry.source.id)),
1317− entry.source,
1318− );
1319− }
1320− setCachedAtomValue(context.registry, listAtom, nextList);
1321− }
1322−
1323− context.invalidateQueries({
1324− workspaceId: context.workspaceId,
1325− accountId: context.accountId,
1326− });
1327− },
1328− },
1329− );
1330−
1331−export const useCreateWorkspaceOauthClient = () =>
1332− useSourceMutation<CreateWorkspaceOauthClientPayload, WorkspaceOauthClient>(
1333− React.useCallback(
1334− ({ workspaceId, accountId, payload }) =>
1335− runControlPlane({
1336− accountId,
1337− execute: (client) => client.sources.createWorkspaceOauthClient({
1338− path: {
1339− workspaceId,
1340− },
1341− payload,
1342− }),
1343− }),
1344− [],
1345− ),
1346− {
1347− onSuccess: (context) => {
1348− context.invalidateQueries({
1349− workspaceId: context.workspaceId,
1350− accountId: context.accountId,
1351− });
1352− },
1353− },
1354− );
1355−
1356−export const useRemoveWorkspaceOauthClient = () =>
1357− useSourceMutation<WorkspaceOauthClient["id"], { removed: boolean }>(
1358− React.useCallback(
1359− ({ workspaceId, accountId, payload }) =>
1360− runControlPlane({
1361− accountId,
1362− execute: (client) => client.sources.removeWorkspaceOauthClient({
1363− path: {
1364− workspaceId,
1365− oauthClientId: payload,
1366− },
1367− }),
1368− }),
1369− [],
1370− ),
1371− {
1372− onSuccess: (context) => {
1373− context.invalidateQueries({
1374− workspaceId: context.workspaceId,
1375− accountId: context.accountId,
1376− });
1377− },
1378− },
1379− );
1380−
1381−export const useRemoveProviderAuthGrant = () =>
1382− useSourceMutation<
1383− Extract<Source["auth"], { kind: "provider_grant_ref" }>["grantId"],
1384− { removed: boolean }
1385− >(
1386− React.useCallback(
1387− ({ workspaceId, accountId, payload }) =>
1388− runControlPlane({
1389− accountId,
1390− execute: (client) => client.sources.removeProviderAuthGrant({
1391− path: {
1392− workspaceId,
1393− grantId: payload,
1394− },
1395− }),
1396− }),
1397− [],
1398− ),
1399− {
1400− onSuccess: (context) => {
1401− context.invalidateQueries({
1402− workspaceId: context.workspaceId,
1403− accountId: context.accountId,
1404− });
1405− },
1406− },
1407− );
140832
33+export {
34+ defineExecutorHttpApiClient,
35+ defineExecutorPluginHttpApiClient,
36+} from "./core/http-client";
37+export {
38+ getExecutorApiBaseUrl,
39+ setExecutorApiBaseUrl,
40+} from "./core/base-url";
140941 export type {
1410− CompleteSourceOAuthResult,
1411− ConnectSourceBatchPayload,
1412− ConnectSourceBatchResult,
1413− ConnectSourcePayload,
1414− ConnectSourceResult,
1415− CreateSecretPayload,
1416− CreateSecretResult,
1417− CreateSourcePayload,
1418− CreateWorkspaceOauthClientPayload,
1419− DeleteSecretResult,
1420− DiscoverSourcePayload,
1421− InstanceConfig,
1422− LocalInstallation,
1423−
1424− SecretListItem,
1425− Source,
1426− SourceDiscoveryResult,
1427− SourceInspection,
1428− SourceInspectionDiscoverResult,
1429− SourceInspectionToolDetail,
1430− StartSourceOAuthPayload,
1431− StartSourceOAuthResult,
1432− UpdateSecretPayload,
1433− UpdateSecretResult,
1434− UpdateSourcePayload,
1435− WorkspaceOauthClient,
1436−};
42+ Loadable,
43+ SourceRemoveResult,
44+} from "./core/types";
45+export {
46+ pendingLoadable,
47+ useWorkspaceId,
48+ useWorkspaceRequestContext,
49+ type WorkspaceContext,
50+} from "./core/workspace";
51+export {
52+ ExecutorReactProvider,
53+} from "./provider";
54+export {
55+ useExecutorMutation,
56+} from "./hooks/mutations";
57+export {
58+ useInstanceConfig,
59+ useLocalInstallation,
60+ useRefreshLocalInstallation,
61+} from "./hooks/local";
62+export {
63+ useCreateSecret,
64+ useDeleteSecret,
65+ useRefreshSecrets,
66+ useSecrets,
67+ useUpdateSecret,
68+} from "./hooks/secrets";
69+export {
70+ useExecution,
71+ useExecutions,
72+} from "./hooks/executions";
73+export {
74+ usePrefetchToolDetail,
75+ useRemoveSource,
76+ useSource,
77+ useSourceDiscovery,
78+ useSourceInspection,
79+ useSourceToolDetail,
80+ useSources,
81+} from "./hooks/sources";
packages/clients/react/src/plugins/components/badge.tsx+44−0
@@ -0,0 +1,44 @@
1+import type { HTMLAttributes } from "react";
2+import { cva, type VariantProps } from "class-variance-authority";
3+
4+import { cn } from "../lib/cn";
5+
6+const badgeVariants = cva(
7+ "inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium tracking-wide uppercase",
8+ {
9+ variants: {
10+ variant: {
11+ default: "border-transparent bg-primary/15 text-primary",
12+ secondary: "border-transparent bg-secondary text-secondary-foreground",
13+ outline: "border-border text-muted-foreground",
14+ muted: "border-transparent bg-muted text-muted-foreground",
15+ get: "border-[var(--method-get)]/20 bg-[var(--method-get)]/10 text-[var(--method-get)]",
16+ post: "border-[var(--method-post)]/20 bg-[var(--method-post)]/10 text-[var(--method-post)]",
17+ put: "border-[var(--method-put)]/20 bg-[var(--method-put)]/10 text-[var(--method-put)]",
18+ delete: "border-[var(--method-delete)]/20 bg-[var(--method-delete)]/10 text-[var(--method-delete)]",
19+ destructive: "border-destructive/20 bg-destructive/10 text-destructive",
20+ },
21+ },
22+ defaultVariants: {
23+ variant: "default",
24+ },
25+ },
26+);
27+
28+export type BadgeProps = HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>;
29+
30+export function Badge({ className, variant, ...props }: BadgeProps) {
31+ return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
32+}
33+
34+export function MethodBadge({ method }: { method: string }) {
35+ const variant = ({
36+ GET: "get",
37+ POST: "post",
38+ PUT: "put",
39+ PATCH: "put",
40+ DELETE: "delete",
41+ } as const)[method.toUpperCase()] ?? "outline";
42+
43+ return <Badge variant={variant}>{method}</Badge>;
44+}
packages/clients/react/src/plugins/components/code-block.tsx+104−0
@@ -0,0 +1,104 @@
1+import { useCallback, useEffect, useRef, useState } from "react";
2+
3+import { codeToHtml, resolveLang } from "../lib/shiki";
4+import { cn } from "../lib/cn";
5+import { IconCheck, IconCopy } from "./icons";
6+
7+const highlightCache = new Map<string, string>();
8+
9+function cacheKey(code: string, lang: string) {
10+ return `${lang}::${code.length}::${code.slice(0, 64)}`;
11+}
12+
13+function detectLanguage(code: string, hint?: string): string {
14+ if (hint) {
15+ return resolveLang(hint) ?? "json";
16+ }
17+
18+ const trimmed = code.trimStart();
19+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
20+ return "json";
21+ }
22+ if (trimmed.startsWith("<")) {
23+ return "xml";
24+ }
25+ if (trimmed.startsWith("---")) {
26+ return "yaml";
27+ }
28+ return "json";
29+}
30+
31+export function CodeBlock(props: {
32+ code: string;
33+ lang?: string;
34+ className?: string;
35+}) {
36+ const { code, lang: langHint, className } = props;
37+ const [html, setHtml] = useState<string | null>(null);
38+ const [copied, setCopied] = useState(false);
39+ const mountedRef = useRef(true);
40+
41+ const language = detectLanguage(code, langHint);
42+ const key = cacheKey(code, language);
43+
44+ useEffect(() => {
45+ mountedRef.current = true;
46+ return () => {
47+ mountedRef.current = false;
48+ };
49+ }, []);
50+
51+ useEffect(() => {
52+ const cached = highlightCache.get(key);
53+ if (cached) {
54+ setHtml(cached);
55+ return;
56+ }
57+
58+ let cancelled = false;
59+ void codeToHtml(code, { lang: language }).then((result) => {
60+ if (cancelled) {
61+ return;
62+ }
63+ highlightCache.set(key, result);
64+ if (mountedRef.current) {
65+ setHtml(result);
66+ }
67+ });
68+
69+ return () => {
70+ cancelled = true;
71+ };
72+ }, [code, key, language]);
73+
74+ const handleCopy = useCallback(() => {
75+ void navigator.clipboard.writeText(code).then(() => {
76+ setCopied(true);
77+ setTimeout(() => setCopied(false), 1500);
78+ });
79+ }, [code]);
80+
81+ return (
82+ <div className={cn("group relative overflow-auto", className)}>
83+ <button
84+ type="button"
85+ onClick={handleCopy}
86+ className="absolute right-2 top-2 z-10 rounded-md border border-border bg-card/90 p-1.5 text-muted-foreground/40 opacity-0 backdrop-blur-sm transition-all hover:text-foreground group-hover:opacity-100"
87+ title="Copy to clipboard"
88+ >
89+ {copied ? <IconCheck className="size-3" /> : <IconCopy className="size-3" />}
90+ </button>
91+
92+ {html ? (
93+ <div
94+ className="shiki-container text-[12px] leading-relaxed [&_pre]:!bg-transparent [&_pre]:p-3 [&_code]:font-mono"
95+ dangerouslySetInnerHTML={{ __html: html }}
96+ />
97+ ) : (
98+ <pre className="p-3 font-mono text-[12px] leading-relaxed text-foreground/60">
99+ {code}
100+ </pre>
101+ )}
102+ </div>
103+ );
104+}
packages/clients/react/src/plugins/components/document-panel.tsx+30−0
@@ -0,0 +1,30 @@
1+import { cn } from "../lib/cn";
2+import { CodeBlock } from "./code-block";
3+
4+export function DocumentPanel(props: {
5+ title: string;
6+ body: string | null;
7+ lang?: string;
8+ empty: string;
9+ compact?: boolean;
10+}) {
11+ return (
12+ <section
13+ className={cn(
14+ "overflow-hidden rounded-lg border border-border bg-card/60",
15+ props.compact && "min-h-48",
16+ )}
17+ >
18+ <div className="border-b border-border px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
19+ {props.title}
20+ </div>
21+ {props.body ? (
22+ <CodeBlock code={props.body} lang={props.lang} className="max-h-[32rem]" />
23+ ) : (
24+ <div className="flex items-center justify-center p-6 text-[13px] text-muted-foreground/40">
25+ {props.empty}
26+ </div>
27+ )}
28+ </section>
29+ );
30+}
packages/clients/react/src/plugins/components/icons.tsx+90−0
@@ -0,0 +1,90 @@
1+import type { SVGProps } from "react";
2+
3+import { cn } from "../lib/cn";
4+
5+type IconProps = SVGProps<SVGSVGElement> & { className?: string };
6+
7+export function IconTool({ className, ...props }: IconProps) {
8+ return (
9+ <svg viewBox="0 0 16 16" fill="none" className={cn("size-4", className)} {...props}>
10+ <path d="M4 8h8M8 4v8" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
11+ </svg>
12+ );
13+}
14+
15+export function IconSearch({ className, ...props }: IconProps) {
16+ return (
17+ <svg viewBox="0 0 16 16" fill="none" className={cn("size-4", className)} {...props}>
18+ <circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.3" />
19+ <path d="M10.5 10.5L14 14" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
20+ </svg>
21+ );
22+}
23+
24+export function IconChevron({ className, ...props }: IconProps) {
25+ return (
26+ <svg viewBox="0 0 16 16" fill="none" className={cn("size-3", className)} {...props}>
27+ <path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
28+ </svg>
29+ );
30+}
31+
32+export function IconFolder({ className, ...props }: IconProps) {
33+ return (
34+ <svg viewBox="0 0 16 16" fill="none" className={cn("size-3", className)} {...props}>
35+ <path d="M2 4h5l2 2h5v7H2V4z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
36+ </svg>
37+ );
38+}
39+
40+export function IconCopy({ className, ...props }: IconProps) {
41+ return (
42+ <svg viewBox="0 0 16 16" fill="none" className={cn("size-3.5", className)} {...props}>
43+ <rect x="5" y="5" width="8" height="8" rx="1" stroke="currentColor" strokeWidth="1.2" />
44+ <path d="M11 3H4a1 1 0 00-1 1v7" stroke="currentColor" strokeWidth="1.2" />
45+ </svg>
46+ );
47+}
48+
49+export function IconCheck({ className, ...props }: IconProps) {
50+ return (
51+ <svg viewBox="0 0 16 16" fill="none" className={cn("size-3.5", className)} {...props}>
52+ <path d="M4 8.5l3 3 5-6" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
53+ </svg>
54+ );
55+}
56+
57+export function IconClose({ className, ...props }: IconProps) {
58+ return (
59+ <svg viewBox="0 0 16 16" fill="none" className={cn("size-3", className)} {...props}>
60+ <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
61+ </svg>
62+ );
63+}
64+
65+export function IconEmpty({ className, ...props }: IconProps) {
66+ return (
67+ <svg viewBox="0 0 48 48" fill="none" className={cn("size-12", className)} {...props}>
68+ <rect x="6" y="6" width="36" height="36" rx="4" stroke="currentColor" strokeWidth="1.5" />
69+ <path d="M16 18h16M16 24h12M16 30h8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
70+ </svg>
71+ );
72+}
73+
74+export function IconSpinner({ className, ...props }: IconProps) {
75+ return (
76+ <svg viewBox="0 0 16 16" fill="none" className={cn("size-4 animate-spin", className)} {...props}>
77+ <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.5" opacity="0.2" />
78+ <path d="M14 8a6 6 0 00-6-6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
79+ </svg>
80+ );
81+}
82+
83+export function IconPencil({ className, ...props }: IconProps) {
84+ return (
85+ <svg viewBox="0 0 16 16" fill="none" className={cn("size-4", className)} {...props}>
86+ <path d="M3 11.75L11.6 3.15a1.2 1.2 0 011.7 0l.55.55a1.2 1.2 0 010 1.7L5.25 14H3v-2.25z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
87+ <path d="M9.75 5l1.25 1.25" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
88+ </svg>
89+ );
90+}
packages/clients/react/src/plugins/components/loadable.tsx+51−0
@@ -0,0 +1,51 @@
1+import type { ReactNode } from "react";
2+
3+import type { Loadable } from "../../core/types";
4+import { IconEmpty, IconSpinner } from "./icons";
5+
6+export function LoadableBlock<T>(props: {
7+ loadable: Loadable<T>;
8+ loading?: string;
9+ children: (data: T) => ReactNode;
10+}) {
11+ if (props.loadable.status === "loading") {
12+ return (
13+ <div className="flex h-full min-h-48 items-center justify-center">
14+ <div className="flex items-center gap-3 text-sm text-muted-foreground">
15+ <IconSpinner />
16+ <span>{props.loading ?? "Loading..."}</span>
17+ </div>
18+ </div>
19+ );
20+ }
21+
22+ if (props.loadable.status === "error") {
23+ return (
24+ <div className="flex h-full min-h-48 items-center justify-center">
25+ <div className="rounded-lg border border-destructive/25 bg-destructive/5 px-4 py-3 text-sm text-destructive">
26+ {props.loadable.error.message}
27+ </div>
28+ </div>
29+ );
30+ }
31+
32+ return <>{props.children(props.loadable.data)}</>;
33+}
34+
35+export function EmptyState(props: {
36+ title: string;
37+ description?: string;
38+ className?: string;
39+}) {
40+ return (
41+ <div className={`flex h-full items-center justify-center ${props.className ?? ""}`}>
42+ <div className="text-center">
43+ <IconEmpty className="mx-auto mb-3 text-muted-foreground/20" />
44+ <p className="text-[13px] text-muted-foreground/60">{props.title}</p>
45+ {props.description && (
46+ <p className="mt-1 text-[11px] text-muted-foreground/40">{props.description}</p>
47+ )}
48+ </div>
49+ </div>
50+ );
51+}
packages/clients/react/src/plugins/components/markdown.tsx+41−0
@@ -0,0 +1,41 @@
1+import { Streamdown } from "streamdown";
2+
3+import { createLimitedCodePlugin } from "../lib/shiki";
4+
5+const codePlugin = createLimitedCodePlugin();
6+
7+const PROSE_CLASSES = [
8+ "text-[13px] leading-relaxed text-muted-foreground",
9+ "[&_p]:mb-[0.4em] [&_p:last-child]:mb-0",
10+ "[&_strong]:text-foreground [&_strong]:font-semibold",
11+ "[&_b]:text-foreground [&_b]:font-semibold",
12+ "[&_code]:font-mono [&_code]:text-xs [&_code]:bg-muted [&_code]:border [&_code]:border-border",
13+ "[&_code]:rounded-sm [&_code]:px-1.5 [&_code]:py-px [&_code]:text-primary",
14+ "[&_pre]:bg-muted [&_pre]:border [&_pre]:border-border [&_pre]:rounded-md",
15+ "[&_pre]:px-3 [&_pre]:py-2 [&_pre]:overflow-x-auto [&_pre]:my-2 [&_pre]:text-xs [&_pre]:leading-relaxed",
16+ "[&_pre_code]:bg-transparent [&_pre_code]:border-0 [&_pre_code]:p-0 [&_pre_code]:text-inherit",
17+ "[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2",
18+ "[&_a]:decoration-primary/30 hover:[&_a]:decoration-primary/80",
19+ "[&_ul]:pl-5 [&_ul]:my-1.5 [&_ol]:pl-5 [&_ol]:my-1.5",
20+ "[&_li]:mb-0.5 [&_li_::marker]:text-muted-foreground",
21+ "[&_table]:w-full [&_table]:border-collapse [&_table]:text-xs [&_table]:my-2",
22+ "[&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:bg-muted [&_th]:font-semibold [&_th]:text-foreground",
23+ "[&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1 [&_td]:text-left [&_td]:bg-background",
24+ "[&_h1]:font-semibold [&_h1]:text-foreground [&_h1]:mt-2 [&_h1]:mb-1 [&_h1]:text-[15px]",
25+ "[&_h2]:font-semibold [&_h2]:text-foreground [&_h2]:mt-2 [&_h2]:mb-1 [&_h2]:text-sm",
26+ "[&_h3]:font-semibold [&_h3]:text-foreground [&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:text-[13px]",
27+ "[&_h4]:font-semibold [&_h4]:text-foreground [&_h4]:mt-2 [&_h4]:mb-1 [&_h4]:text-[13px]",
28+ "[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:my-1.5 [&_blockquote]:text-muted-foreground",
29+ "[&_hr]:border-0 [&_hr]:border-t [&_hr]:border-border [&_hr]:my-2",
30+ "[&_img]:max-w-full [&_img]:rounded",
31+].join(" ");
32+
33+export function Markdown(props: { children: string; className?: string }) {
34+ return (
35+ <div className={props.className ? `${PROSE_CLASSES} ${props.className}` : PROSE_CLASSES}>
36+ <Streamdown plugins={{ code: codePlugin }} controls={{ code: true }} linkSafety={{ enabled: false }}>
37+ {props.children}
38+ </Streamdown>
39+ </div>
40+ );
41+}
packages/clients/react/src/plugins/components/source-tool-explorer-search.ts+24−0
@@ -0,0 +1,24 @@
1+export type SourceToolExplorerSearch = {
2+ tab: "model" | "discover";
3+ tool?: string;
4+ query?: string;
5+};
6+
7+const sourceToolExplorerTabs = ["model", "discover"] as const;
8+
9+export const parseSourceToolExplorerSearch = (
10+ search: Record<string, unknown>,
11+): SourceToolExplorerSearch => ({
12+ tab:
13+ typeof search.tab === "string"
14+ && sourceToolExplorerTabs.includes(
15+ search.tab as SourceToolExplorerSearch["tab"],
16+ )
17+ ? (search.tab as SourceToolExplorerSearch["tab"])
18+ : "model",
19+ tool:
20+ typeof search.tool === "string" && search.tool.length > 0
21+ ? search.tool
22+ : undefined,
23+ query: typeof search.query === "string" ? search.query : undefined,
24+});
packages/clients/react/src/plugins/components/source-tool-explorer.tsx+122−0
@@ -0,0 +1,122 @@
1+import { startTransition, useEffect, type ReactNode } from "react";
2+import type {
3+ SourceInspectionToolDetail,
4+} from "../../index";
5+
6+import {
7+ useSourceInspection,
8+ useSourceToolDetail,
9+} from "../../hooks/sources";
10+import { Badge } from "./badge";
11+import { LoadableBlock } from "./loadable";
12+import {
13+ SourceToolModelWorkbench,
14+} from "./source-tool-workbench";
15+import type { SourceToolExplorerSearch } from "./source-tool-explorer-search";
16+
17+export const SourceToolExplorer = (props: {
18+ sourceId: string;
19+ title: string;
20+ kind: string;
21+ search: SourceToolExplorerSearch;
22+ selectedToolPath?: string | null;
23+ navigate?: (search: {
24+ tab: "model" | "discover";
25+ tool?: string;
26+ query?: string;
27+ }) => void | Promise<void>;
28+ actions?: ReactNode;
29+ summary?: ReactNode;
30+ renderDetail?: (detail: SourceInspectionToolDetail) => ReactNode;
31+}) => {
32+ const inspection = useSourceInspection(props.sourceId);
33+ const selectedToolPath = props.selectedToolPath ?? props.search.tool ?? null;
34+ const detail = useSourceToolDetail(
35+ props.sourceId,
36+ selectedToolPath,
37+ );
38+ const navigate = props.navigate;
39+
40+ useEffect(() => {
41+ if (inspection.status !== "ready" || !navigate) {
42+ return;
43+ }
44+
45+ const firstTool = inspection.data.tools[0]?.path;
46+ const nextToolPath = selectedToolPath ?? firstTool;
47+ if (!nextToolPath) {
48+ return;
49+ }
50+
51+ if (props.search.tab !== "discover" && selectedToolPath) {
52+ return;
53+ }
54+
55+ startTransition(() => {
56+ void navigate({
57+ tab: "model",
58+ tool: nextToolPath,
59+ query: "",
60+ });
61+ });
62+ }, [inspection, navigate, props.search.tab, selectedToolPath]);
63+
64+ return (
65+ <LoadableBlock loadable={inspection} loading="Loading source...">
66+ {(loadedInspection) => {
67+ const selectedTool =
68+ loadedInspection.tools.find((candidate) => candidate.path === selectedToolPath)
69+ ?? loadedInspection.tools[0]
70+ ?? null;
71+
72+ return (
73+ <div className="flex h-full flex-col overflow-hidden">
74+ <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur-sm">
75+ <div className="flex min-w-0 items-center gap-3">
76+ <h2 className="truncate text-sm font-semibold text-foreground">
77+ {props.title}
78+ </h2>
79+ <Badge variant="outline">{props.kind}</Badge>
80+ <span className="hidden text-[11px] tabular-nums text-muted-foreground/50 sm:block">
81+ {loadedInspection.toolCount} {loadedInspection.toolCount === 1 ? "tool" : "tools"}
82+ </span>
83+ </div>
84+ <div className="flex shrink-0 items-center gap-2">
85+ {props.actions}
86+ </div>
87+ </div>
88+
89+ {props.summary && (
90+ <div className="shrink-0 border-b border-border bg-card/30 px-4 py-2">
91+ {props.summary}
92+ </div>
93+ )}
94+
95+ <div className="flex min-h-0 flex-1 overflow-hidden">
96+ <SourceToolModelWorkbench
97+ bundle={loadedInspection}
98+ detail={detail}
99+ selectedToolPath={selectedTool?.path ?? null}
100+ onSelectTool={(nextToolPath) => {
101+ if (!navigate) {
102+ return;
103+ }
104+
105+ startTransition(() => {
106+ void navigate({
107+ tab: "model",
108+ tool: nextToolPath,
109+ query: "",
110+ });
111+ });
112+ }}
113+ sourceId={props.sourceId}
114+ renderDetail={props.renderDetail}
115+ />
116+ </div>
117+ </div>
118+ );
119+ }}
120+ </LoadableBlock>
121+ );
122+};
packages/clients/react/src/plugins/components/source-tool-workbench.tsx+755−0
@@ -0,0 +1,755 @@
1+import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2+
3+import type { Loadable } from "../../core/types";
4+import {
5+ usePrefetchToolDetail,
6+} from "../../hooks/sources";
7+import type {
8+ SourceInspection,
9+ SourceInspectionToolDetail,
10+} from "@executor/platform-sdk/schema";
11+import { MethodBadge } from "./badge";
12+import { CodeBlock } from "./code-block";
13+import { DocumentPanel } from "./document-panel";
14+import { EmptyState, LoadableBlock } from "./loadable";
15+import { Markdown } from "./markdown";
16+import {
17+ IconCheck,
18+ IconChevron,
19+ IconClose,
20+ IconCopy,
21+ IconFolder,
22+ IconSearch,
23+ IconTool,
24+} from "./icons";
25+import { cn } from "../lib/cn";
26+
27+type ToolTreeNode = {
28+ segment: string;
29+ tool?: SourceInspectionToolDetail["summary"] | SourceInspection["tools"][number];
30+ children: Map<string, ToolTreeNode>;
31+};
32+
33+export type SourceToolDiscoveryResult = {
34+ query: string;
35+ results: ReadonlyArray<{
36+ path: string;
37+ score: number;
38+ description?: string | null;
39+ }>;
40+};
41+
42+export type SourceToolDetailPanelProps = {
43+ detail: SourceInspectionToolDetail;
44+ renderHeaderMeta?: (detail: SourceInspectionToolDetail) => ReactNode;
45+ renderSchemaExtras?: (detail: SourceInspectionToolDetail) => ReactNode;
46+};
47+
48+export type SourceToolModelWorkbenchProps = {
49+ bundle: SourceInspection;
50+ detail: Loadable<SourceInspectionToolDetail | null>;
51+ selectedToolPath: string | null;
52+ onSelectTool: (toolPath: string) => void;
53+ sourceId: string;
54+ renderDetail?: (detail: SourceInspectionToolDetail) => ReactNode;
55+};
56+
57+export type SourceToolDiscoveryPanelProps<
58+ TResult extends SourceToolDiscoveryResult = SourceToolDiscoveryResult,
59+> = {
60+ discovery: Loadable<TResult>;
61+ initialQuery: string;
62+ onSubmitQuery: (query: string) => void;
63+ onOpenTool: (toolPath: string) => void;
64+};
65+
66+const buildToolTree = (tools: SourceInspection["tools"]): ToolTreeNode => {
67+ const root: ToolTreeNode = {
68+ segment: "",
69+ children: new Map(),
70+ };
71+
72+ for (const tool of tools) {
73+ const parts = tool.path.split(".");
74+ let node = root;
75+
76+ for (const part of parts) {
77+ const existing = node.children.get(part);
78+ if (existing) {
79+ node = existing;
80+ continue;
81+ }
82+
83+ const next: ToolTreeNode = {
84+ segment: part,
85+ children: new Map(),
86+ };
87+ node.children.set(part, next);
88+ node = next;
89+ }
90+
91+ node.tool = tool;
92+ }
93+
94+ return root;
95+};
96+
97+const countToolLeaves = (node: ToolTreeNode): number => {
98+ let count = node.tool ? 1 : 0;
99+ for (const child of node.children.values()) {
100+ count += countToolLeaves(child);
101+ }
102+ return count;
103+};
104+
105+const highlightMatch = (text: string, search: string) => {
106+ if (!search.trim()) {
107+ return text;
108+ }
109+
110+ const terms = search.trim().toLowerCase().split(/\s+/);
111+ const lowerText = text.toLowerCase();
112+ const ranges: Array<[number, number]> = [];
113+
114+ for (const term of terms) {
115+ let index = 0;
116+ while (index < lowerText.length) {
117+ const found = lowerText.indexOf(term, index);
118+ if (found === -1) {
119+ break;
120+ }
121+ ranges.push([found, found + term.length]);
122+ index = found + 1;
123+ }
124+ }
125+
126+ if (ranges.length === 0) {
127+ return text;
128+ }
129+
130+ ranges.sort((left, right) => left[0] - right[0]);
131+ const merged: Array<[number, number]> = [ranges[0]!];
132+ for (let index = 1; index < ranges.length; index++) {
133+ const last = merged[merged.length - 1]!;
134+ const current = ranges[index]!;
135+ if (current[0] <= last[1]) {
136+ last[1] = Math.max(last[1], current[1]);
137+ } else {
138+ merged.push(current);
139+ }
140+ }
141+
142+ const parts: Array<{ text: string; highlighted: boolean }> = [];
143+ let cursor = 0;
144+ for (const [start, end] of merged) {
145+ if (cursor < start) {
146+ parts.push({ text: text.slice(cursor, start), highlighted: false });
147+ }
148+ parts.push({ text: text.slice(start, end), highlighted: true });
149+ cursor = end;
150+ }
151+ if (cursor < text.length) {
152+ parts.push({ text: text.slice(cursor), highlighted: false });
153+ }
154+
155+ return (
156+ <>
157+ {parts.map((part, index) =>
158+ part.highlighted ? (
159+ <mark key={index} className="rounded-sm bg-primary/20 px-px text-foreground">
160+ {part.text}
161+ </mark>
162+ ) : (
163+ <span key={index}>{part.text}</span>
164+ ),
165+ )}
166+ </>
167+ );
168+};
169+
170+const CopyButton = (props: {
171+ text: string;
172+ field: string;
173+ copiedField: string | null;
174+ onCopy: (text: string, field: string) => void | Promise<void>;
175+}) => (
176+ <button
177+ type="button"
178+ onClick={() => {
179+ void props.onCopy(props.text, props.field);
180+ }}
181+ className="shrink-0 rounded p-1 text-muted-foreground/30 transition-colors hover:text-muted-foreground"
182+ title={`Copy ${props.field}`}
183+ >
184+ {props.copiedField === props.field ? <IconCheck /> : <IconCopy />}
185+ </button>
186+);
187+
188+const SourceToolTree = (props: {
189+ tools: SourceInspection["tools"];
190+ selectedToolPath: string | null;
191+ onSelectTool: (path: string) => void;
192+ search: string;
193+ isFiltered: boolean;
194+ sourceId: string;
195+}) => {
196+ const tree = useMemo(() => buildToolTree(props.tools), [props.tools]);
197+ const prefetch = usePrefetchToolDetail();
198+ const entries = [...tree.children.values()].sort((left, right) =>
199+ left.segment.localeCompare(right.segment)
200+ );
201+
202+ return (
203+ <div className="flex flex-col gap-px">
204+ {entries.map((node) => (
205+ <SourceToolTreeNodeView
206+ key={node.segment}
207+ node={node}
208+ depth={0}
209+ selectedToolPath={props.selectedToolPath}
210+ onSelectTool={props.onSelectTool}
211+ search={props.search}
212+ defaultOpen={props.isFiltered}
213+ sourceId={props.sourceId}
214+ prefetch={prefetch}
215+ />
216+ ))}
217+ </div>
218+ );
219+};
220+
221+const SourceToolTreeNodeView = (props: {
222+ node: ToolTreeNode;
223+ depth: number;
224+ selectedToolPath: string | null;
225+ onSelectTool: (path: string) => void;
226+ search: string;
227+ defaultOpen: boolean;
228+ sourceId: string;
229+ prefetch: ReturnType<typeof usePrefetchToolDetail>;
230+}) => {
231+ const {
232+ node,
233+ depth,
234+ selectedToolPath,
235+ onSelectTool,
236+ search,
237+ defaultOpen,
238+ sourceId,
239+ prefetch,
240+ } = props;
241+ const hasChildren = node.children.size > 0;
242+ const isLeaf = !!node.tool && !hasChildren;
243+
244+ const hasSelectedDescendant = useMemo(() => {
245+ if (!selectedToolPath) {
246+ return false;
247+ }
248+
249+ const check = (candidate: ToolTreeNode): boolean => {
250+ if (candidate.tool?.path === selectedToolPath) {
251+ return true;
252+ }
253+
254+ for (const child of candidate.children.values()) {
255+ if (check(child)) {
256+ return true;
257+ }
258+ }
259+
260+ return false;
261+ };
262+
263+ return check(node);
264+ }, [node, selectedToolPath]);
265+
266+ const [open, setOpen] = useState(defaultOpen || hasSelectedDescendant);
267+
268+ useEffect(() => {
269+ if (defaultOpen || hasSelectedDescendant) {
270+ setOpen(true);
271+ }
272+ }, [defaultOpen, hasSelectedDescendant]);
273+
274+ if (isLeaf) {
275+ return (
276+ <SourceToolListItem
277+ tool={node.tool as SourceInspection["tools"][number]}
278+ active={node.tool?.path === selectedToolPath}
279+ onSelect={() => onSelectTool(node.tool!.path)}
280+ search={search}
281+ depth={depth}
282+ sourceId={sourceId}
283+ prefetch={prefetch}
284+ />
285+ );
286+ }
287+
288+ const paddingLeft = 8 + depth * 16;
289+ const sortedChildren = [...node.children.values()].sort((left, right) =>
290+ left.segment.localeCompare(right.segment)
291+ );
292+ const leafCount = countToolLeaves(node);
293+
294+ return (
295+ <div>
296+ {node.tool ? (
297+ <div className="flex items-center">
298+ <button
299+ type="button"
300+ onClick={() => setOpen((value) => !value)}
301+ className="shrink-0 rounded p-0.5 text-muted-foreground/30 hover:text-muted-foreground"
302+ style={{ marginLeft: paddingLeft }}
303+ >
304+ <IconChevron
305+ className={cn("shrink-0 transition-transform duration-150", open && "rotate-90")}
306+ style={{ width: 8, height: 8 }}
307+ />
308+ </button>
309+ <SourceToolListItem
310+ tool={node.tool as SourceInspection["tools"][number]}
311+ active={node.tool?.path === selectedToolPath}
312+ onSelect={() => onSelectTool(node.tool!.path)}
313+ search={search}
314+ depth={-1}
315+ className="flex-1 pl-1"
316+ sourceId={sourceId}
317+ prefetch={prefetch}
318+ />
319+ </div>
320+ ) : (
321+ <button
322+ type="button"
323+ onClick={() => setOpen((value) => !value)}
324+ className={cn(
325+ "group flex w-full items-center gap-1.5 rounded-md py-1 pr-2.5 text-[12px] transition-colors hover:bg-accent/40",
326+ open ? "text-foreground/80" : "text-muted-foreground/60",
327+ )}
328+ style={{ paddingLeft }}
329+ >
330+ <IconChevron
331+ className={cn(
332+ "shrink-0 text-muted-foreground/30 transition-transform duration-150",
333+ open && "rotate-90",
334+ )}
335+ style={{ width: 8, height: 8 }}
336+ />
337+ <IconFolder
338+ className={cn("shrink-0", open ? "text-primary/60" : "text-muted-foreground/30")}
339+ style={{ width: 12, height: 12 }}
340+ />
341+ <span className="flex-1 truncate text-left font-mono">
342+ {highlightMatch(node.segment, search)}
343+ </span>
344+ <span className="shrink-0 tabular-nums text-[10px] text-muted-foreground/25">
345+ {leafCount}
346+ </span>
347+ </button>
348+ )}
349+
350+ {open && hasChildren && (
351+ <div className="relative flex flex-col gap-px">
352+ <span
353+ className="absolute bottom-1 top-0 w-px bg-border/40"
354+ style={{ left: paddingLeft + 5 }}
355+ aria-hidden
356+ />
357+ {sortedChildren.map((child) => (
358+ <SourceToolTreeNodeView
359+ key={child.segment}
360+ node={child}
361+ depth={depth + 1}
362+ selectedToolPath={selectedToolPath}
363+ onSelectTool={onSelectTool}
364+ search={search}
365+ defaultOpen={defaultOpen}
366+ sourceId={sourceId}
367+ prefetch={prefetch}
368+ />
369+ ))}
370+ </div>
371+ )}
372+ </div>
373+ );
374+};
375+
376+const SourceToolListItem = (props: {
377+ tool: SourceInspection["tools"][number];
378+ active: boolean;
379+ onSelect: () => void;
380+ search: string;
381+ depth: number;
382+ className?: string;
383+ sourceId: string;
384+ prefetch: ReturnType<typeof usePrefetchToolDetail>;
385+}) => {
386+ const ref = useRef<HTMLButtonElement>(null);
387+ const paddingLeft = props.depth >= 0 ? 8 + props.depth * 16 + 8 : undefined;
388+
389+ useEffect(() => {
390+ if (props.active && ref.current) {
391+ ref.current.scrollIntoView({ block: "nearest" });
392+ }
393+ }, [props.active]);
394+
395+ const label = props.depth >= 0
396+ ? props.tool.path.split(".").pop() ?? props.tool.path
397+ : props.tool.path;
398+
399+ return (
400+ <button
401+ ref={ref}
402+ type="button"
403+ onMouseEnter={() => {
404+ props.prefetch(props.sourceId, props.tool.path);
405+ }}
406+ onClick={props.onSelect}
407+ className={cn(
408+ "group flex w-full items-center gap-2 rounded-md py-1.5 pr-2.5 text-left transition-colors",
409+ props.active
410+ ? "border-l-2 border-l-primary bg-primary/10 text-foreground"
411+ : "text-foreground/70 hover:bg-accent/50 hover:text-foreground",
412+ props.className,
413+ )}
414+ style={paddingLeft != null ? { paddingLeft } : undefined}
415+ >
416+ <IconTool className="size-3 shrink-0 text-muted-foreground/40" />
417+ <span className="flex-1 truncate font-mono text-[12px]">
418+ {highlightMatch(label, props.search)}
419+ </span>
420+ {props.tool.method && <MethodBadge method={props.tool.method} />}
421+ </button>
422+ );
423+};
424+
425+export const SourceToolDetailPanel = (props: SourceToolDetailPanelProps) => {
426+ const [copiedField, setCopiedField] = useState<string | null>(null);
427+ const inputType = props.detail.contract.input.typeDeclaration
428+ ?? props.detail.contract.input.typePreview
429+ ?? null;
430+ const outputType = props.detail.contract.output.typeDeclaration
431+ ?? props.detail.contract.output.typePreview
432+ ?? null;
433+
434+ return (
435+ <div className="flex h-full flex-col overflow-hidden">
436+ <div className="shrink-0 border-b border-border bg-background/95 backdrop-blur-sm">
437+ <div className="flex items-start gap-3 px-5 py-3.5">
438+ <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
439+ <IconTool className="size-4" />
440+ </div>
441+ <div className="min-w-0 flex-1">
442+ <div className="flex items-center gap-2">
443+ <h3 className="truncate text-sm font-semibold text-foreground">
444+ {props.detail.summary.path}
445+ </h3>
446+ <CopyButton
447+ text={props.detail.summary.path}
448+ field="path"
449+ copiedField={copiedField}
450+ onCopy={async (text, field) => {
451+ await navigator.clipboard.writeText(text);
452+ setCopiedField(field);
453+ window.setTimeout(() => setCopiedField(null), 1500);
454+ }}
455+ />
456+ </div>
457+ <div className="mt-1 flex flex-wrap items-center gap-1.5">
458+ {props.detail.summary.method && (
459+ <MethodBadge method={props.detail.summary.method} />
460+ )}
461+ {props.renderHeaderMeta?.(props.detail)}
462+ </div>
463+ </div>
464+ </div>
465+ </div>
466+
467+ <div className="flex-1 overflow-y-auto">
468+ <div className="space-y-4 px-5 py-4">
469+ {props.detail.summary.description && (
470+ <Markdown>{props.detail.summary.description}</Markdown>
471+ )}
472+
473+ <div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
474+ <DocumentPanel title="Input" body={inputType} lang="typescript" empty="No input." />
475+ <DocumentPanel title="Output" body={outputType} lang="typescript" empty="No output." />
476+ </div>
477+
478+ <DocumentPanel
479+ title="Call Signature"
480+ body={props.detail.contract.callSignature}
481+ lang="typescript"
482+ empty="No call signature."
483+ />
484+
485+ <div className="grid grid-cols-1 gap-2 xl:grid-cols-2">
486+ <DocumentPanel
487+ title="Input schema"
488+ body={props.detail.contract.input.schemaJson}
489+ empty="No input schema."
490+ compact
491+ />
492+ <DocumentPanel
493+ title="Output schema"
494+ body={props.detail.contract.output.schemaJson}
495+ empty="No output schema."
496+ compact
497+ />
498+ {props.renderSchemaExtras?.(props.detail)}
499+ </div>
500+
501+ {props.detail.sections.map((section, index) => (
502+ <section
503+ key={`${section.title}-${String(index)}`}
504+ className="overflow-hidden rounded-lg border border-border bg-card/60"
505+ >
506+ <div className="border-b border-border px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
507+ {section.title}
508+ </div>
509+ {section.kind === "facts" ? (
510+ <div className="grid gap-2 p-4">
511+ {section.items.map((item) => (
512+ <div key={`${section.title}-${item.label}`} className="text-sm">
513+ <span className="text-muted-foreground">{item.label}:</span>{" "}
514+ <span className={item.mono ? "font-mono text-xs text-foreground" : "text-foreground"}>
515+ {item.value}
516+ </span>
517+ </div>
518+ ))}
519+ </div>
520+ ) : section.kind === "markdown" ? (
521+ <div className="p-4">
522+ <Markdown>{section.body}</Markdown>
523+ </div>
524+ ) : (
525+ section.body ? (
526+ <CodeBlock
527+ code={section.body}
528+ lang={section.language}
529+ className="max-h-[32rem]"
530+ />
531+ ) : (
532+ <div className="flex items-center justify-center p-6 text-[13px] text-muted-foreground/40">
533+ No content.
534+ </div>
535+ )
536+ )}
537+ </section>
538+ ))}
539+ </div>
540+ </div>
541+ </div>
542+ );
543+};
544+
545+export const SourceToolModelWorkbench = (
546+ props: SourceToolModelWorkbenchProps,
547+) => {
548+ const [search, setSearch] = useState("");
549+ const searchRef = useRef<HTMLInputElement>(null);
550+ const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
551+
552+ const filteredTools = useMemo(
553+ () =>
554+ props.bundle.tools.filter((tool) => {
555+ if (terms.length === 0) {
556+ return true;
557+ }
558+
559+ const corpus = [
560+ tool.path,
561+ tool.method ?? "",
562+ tool.inputTypePreview ?? "",
563+ tool.outputTypePreview ?? "",
564+ ]
565+ .join(" ")
566+ .toLowerCase();
567+
568+ return terms.every((term) => corpus.includes(term));
569+ }),
570+ [props.bundle.tools, terms],
571+ );
572+
573+ useEffect(() => {
574+ const handler = (event: KeyboardEvent) => {
575+ if (event.key === "/" && document.activeElement?.tagName !== "INPUT") {
576+ event.preventDefault();
577+ searchRef.current?.focus();
578+ }
579+
580+ if (event.key === "Escape") {
581+ searchRef.current?.blur();
582+ if (search.length > 0) {
583+ setSearch("");
584+ }
585+ }
586+ };
587+
588+ document.addEventListener("keydown", handler);
589+ return () => document.removeEventListener("keydown", handler);
590+ }, [search]);
591+
592+ const renderDetail = props.renderDetail ?? ((detail: SourceInspectionToolDetail) => (
593+ <SourceToolDetailPanel detail={detail} />
594+ ));
595+
596+ return (
597+ <>
598+ <div className="flex w-72 shrink-0 flex-col border-r border-border bg-card/30 lg:w-80 xl:w-[22rem]">
599+ <div className="shrink-0 border-b border-border px-3 py-2">
600+ <div className="flex h-8 items-center gap-2 rounded-md border border-input bg-background px-2.5">
601+ <IconSearch className="size-3.5 shrink-0 text-muted-foreground/40" />
602+ <input
603+ ref={searchRef}
604+ value={search}
605+ onChange={(event) => setSearch(event.target.value)}
606+ placeholder={`Filter ${props.bundle.toolCount} tools...`}
607+ className="min-w-0 flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground/40"
608+ />
609+ {search.length > 0 ? (
610+ <button
611+ type="button"
612+ onClick={() => setSearch("")}
613+ className="shrink-0 rounded p-0.5 text-muted-foreground/40 hover:text-foreground"
614+ >
615+ <IconClose />
616+ </button>
617+ ) : (
618+ <kbd className="shrink-0 rounded border border-border bg-muted px-1 py-px text-[10px] leading-none text-muted-foreground/50">
619+ /
620+ </kbd>
621+ )}
622+ </div>
623+ </div>
624+
625+ <div className="flex-1 overflow-y-auto max-w-[300px]">
626+ {filteredTools.length === 0 ? (
627+ <div className="p-4 text-center text-[13px] text-muted-foreground/50">
628+ {terms.length > 0 ? "No tools match your filter" : "No tools available"}
629+ </div>
630+ ) : (
631+ <div className="p-1.5">
632+ <SourceToolTree
633+ tools={filteredTools}
634+ selectedToolPath={props.selectedToolPath}
635+ onSelectTool={props.onSelectTool}
636+ search={search}
637+ isFiltered={terms.length > 0}
638+ sourceId={props.sourceId}
639+ />
640+ </div>
641+ )}
642+ </div>
643+ </div>
644+
645+ <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
646+ <LoadableBlock loadable={props.detail} loading="Loading tool...">
647+ {(detail) =>
648+ detail ? (
649+ renderDetail(detail)
650+ ) : (
651+ <EmptyState
652+ title={props.bundle.toolCount > 0 ? "Select a tool" : "No tools available"}
653+ description={
654+ props.bundle.toolCount > 0
655+ ? "Choose from the list or press / to search."
656+ : undefined
657+ }
658+ />
659+ )
660+ }
661+ </LoadableBlock>
662+ </div>
663+ </>
664+ );
665+};
666+
667+export const SourceToolDiscoveryPanel = <
668+ TResult extends SourceToolDiscoveryResult = SourceToolDiscoveryResult,
669+>(
670+ props: SourceToolDiscoveryPanelProps<TResult>,
671+) => {
672+ const [draftQuery, setDraftQuery] = useState(props.initialQuery);
673+
674+ useEffect(() => {
675+ setDraftQuery(props.initialQuery);
676+ }, [props.initialQuery]);
677+
678+ return (
679+ <div className="flex flex-1 flex-col overflow-hidden">
680+ <div className="shrink-0 border-b border-border px-4 py-3">
681+ <form
682+ className="flex max-w-2xl items-center gap-2"
683+ onSubmit={(event) => {
684+ event.preventDefault();
685+ props.onSubmitQuery(draftQuery.trim());
686+ }}
687+ >
688+ <div className="relative flex-1">
689+ <IconSearch className="absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/40" />
690+ <input
691+ value={draftQuery}
692+ onChange={(event) => setDraftQuery(event.target.value)}
693+ placeholder="Search tools..."
694+ className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/30"
695+ />
696+ </div>
697+ <button
698+ type="submit"
699+ className="inline-flex h-9 items-center rounded-md border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50"
700+ >
701+ Search
702+ </button>
703+ </form>
704+ </div>
705+
706+ <div className="flex-1 overflow-y-auto p-4">
707+ <LoadableBlock loadable={props.discovery} loading="Searching...">
708+ {(result) =>
709+ result.query.length === 0 ? (
710+ <EmptyState
711+ title="Search your tools"
712+ description="Type a query to find matching tools across this source."
713+ />
714+ ) : result.results.length === 0 ? (
715+ <EmptyState
716+ title="No results"
717+ description="Try different search terms."
718+ />
719+ ) : (
720+ <div className="max-w-3xl space-y-2">
721+ {result.results.map((item, index) => (
722+ <button
723+ key={item.path}
724+ type="button"
725+ onClick={() => props.onOpenTool(item.path)}
726+ className="group w-full rounded-lg border border-border bg-card/60 p-3.5 text-left transition-all hover:border-primary/30 hover:shadow-sm"
727+ >
728+ <div className="mb-1.5 flex items-center justify-between gap-3">
729+ <div className="flex min-w-0 items-center gap-2.5">
730+ <span className="flex size-5 shrink-0 items-center justify-center rounded-md bg-muted text-[10px] font-mono tabular-nums text-muted-foreground/60">
731+ {index + 1}
732+ </span>
733+ <h4 className="truncate font-mono text-[13px] font-medium text-foreground transition-colors group-hover:text-primary">
734+ {item.path}
735+ </h4>
736+ </div>
737+ <span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground/50">
738+ {item.score.toFixed(2)}
739+ </span>
740+ </div>
741+ {item.description && (
742+ <p className="line-clamp-2 text-[12px] leading-relaxed text-muted-foreground">
743+ {item.description}
744+ </p>
745+ )}
746+ </button>
747+ ))}
748+ </div>
749+ )
750+ }
751+ </LoadableBlock>
752+ </div>
753+ </div>
754+ );
755+};
packages/clients/react/src/plugins/index.ts+106−0
@@ -0,0 +1,106 @@
1+export type {
2+ ExecutorFrontendPlugin,
3+ ExecutorPluginNavigation,
4+ ExecutorPluginRouteContextValue,
5+ FrontendPluginRouteDefinition,
6+ FrontendPluginRouteParams,
7+ FrontendPluginRouteSearch,
8+ SourcePluginNavigation,
9+ SourcePluginRouteParams,
10+ SourcePluginRouteSearch,
11+} from "./types";
12+
13+export {
14+ createExecutorPluginPaths,
15+ createSourcePluginPaths,
16+ executorPluginBasePath,
17+ executorPluginRoutePath,
18+ executorPluginRoutePattern,
19+ normalizeExecutorPluginPath,
20+ normalizeSourcePluginPath,
21+ sourcePluginAddPath,
22+ sourcePluginChildPath,
23+ sourcePluginChildPattern,
24+ sourcePluginDetailPath,
25+ sourcePluginDetailPattern,
26+ sourcePluginEditPath,
27+ sourcePluginEditPattern,
28+ sourcePluginsIndexPath,
29+ type ExecutorPluginPaths,
30+ type SourcePluginPaths,
31+} from "./paths";
32+
33+export {
34+ defineExecutorFrontendPlugin,
35+ defineFrontendPluginRoute,
36+ registerExecutorFrontendPlugins,
37+ type RegisteredFrontendPluginRoute,
38+} from "./registry";
39+
40+export {
41+ ExecutorPluginRouteProvider,
42+ useExecutorPlugin,
43+ useExecutorPluginNavigation,
44+ useExecutorPluginPaths,
45+ useExecutorPluginRoute,
46+ useExecutorPluginRouteDefinition,
47+ useExecutorPluginRouteParams,
48+ useExecutorPluginSearch,
49+} from "./plugin-route-context";
50+
51+export {
52+ useSourcePlugin,
53+ useSourcePluginNavigation,
54+ useSourcePluginPaths,
55+ useSourcePluginRoute,
56+ useSourcePluginRouteParams,
57+ useSourcePluginSearch,
58+} from "./route-context";
59+
60+export {
61+ cn,
62+} from "./lib/cn";
63+
64+export {
65+ Badge,
66+ MethodBadge,
67+} from "./components/badge";
68+export {
69+ CodeBlock,
70+} from "./components/code-block";
71+export {
72+ DocumentPanel,
73+} from "./components/document-panel";
74+export {
75+ IconCheck,
76+ IconChevron,
77+ IconClose,
78+ IconCopy,
79+ IconEmpty,
80+ IconFolder,
81+ IconPencil,
82+ IconSearch,
83+ IconSpinner,
84+ IconTool,
85+} from "./components/icons";
86+export {
87+ EmptyState,
88+ LoadableBlock,
89+} from "./components/loadable";
90+export {
91+ Markdown,
92+} from "./components/markdown";
93+export {
94+ SourceToolExplorer,
95+} from "./components/source-tool-explorer";
96+export {
97+ type SourceToolExplorerSearch,
98+ parseSourceToolExplorerSearch,
99+} from "./components/source-tool-explorer-search";
100+export {
101+ SourceToolDetailPanel,
102+ SourceToolDiscoveryPanel,
103+ SourceToolModelWorkbench,
104+ type SourceToolDetailPanelProps,
105+ type SourceToolDiscoveryResult,
106+} from "./components/source-tool-workbench";
packages/clients/react/src/plugins/lib/cn.ts+6−0
@@ -0,0 +1,6 @@
1+import { clsx, type ClassValue } from "clsx";
2+import { twMerge } from "tailwind-merge";
3+
4+export function cn(...inputs: ReadonlyArray<ClassValue>): string {
5+ return twMerge(clsx(inputs));
6+}
packages/clients/react/src/plugins/lib/shiki.ts+211−0
@@ -0,0 +1,211 @@
1+import {
2+ createHighlighterCore,
3+ type HighlighterCore,
4+ type LanguageInput,
5+} from "shiki/core";
6+import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
7+import type { CodeHighlighterPlugin, ThemeInput } from "streamdown";
8+
9+const SUPPORTED_LANGS = [
10+ "json",
11+ "xml",
12+ "yaml",
13+ "shellscript",
14+ "typescript",
15+ "javascript",
16+ "python",
17+ "html",
18+ "css",
19+ "markdown",
20+ "sql",
21+ "graphql",
22+ "go",
23+ "rust",
24+ "java",
25+ "ruby",
26+ "php",
27+ "swift",
28+ "kotlin",
29+ "c",
30+ "cpp",
31+ "csharp",
32+ "tsx",
33+ "jsx",
34+ "toml",
35+ "dockerfile",
36+ "diff",
37+ "http",
38+ "jsonc",
39+ "log",
40+ "proto",
41+] as const;
42+
43+type SupportedLang = (typeof SUPPORTED_LANGS)[number];
44+
45+const LANG_ALIASES: Record<string, SupportedLang> = {
46+ sh: "shellscript",
47+ shell: "shellscript",
48+ bash: "shellscript",
49+ zsh: "shellscript",
50+ ts: "typescript",
51+ js: "javascript",
52+ py: "python",
53+ rb: "ruby",
54+ rs: "rust",
55+ "c++": "cpp",
56+ "c#": "csharp",
57+ cs: "csharp",
58+ kt: "kotlin",
59+ md: "markdown",
60+ gql: "graphql",
61+ yml: "yaml",
62+};
63+
64+const LANG_LOADERS: Record<SupportedLang, () => LanguageInput> = {
65+ json: () => import("@shikijs/langs/json"),
66+ xml: () => import("@shikijs/langs/xml"),
67+ yaml: () => import("@shikijs/langs/yaml"),
68+ shellscript: () => import("@shikijs/langs/shellscript"),
69+ typescript: () => import("@shikijs/langs/typescript"),
70+ javascript: () => import("@shikijs/langs/javascript"),
71+ python: () => import("@shikijs/langs/python"),
72+ html: () => import("@shikijs/langs/html"),
73+ css: () => import("@shikijs/langs/css"),
74+ markdown: () => import("@shikijs/langs/markdown"),
75+ sql: () => import("@shikijs/langs/sql"),
76+ graphql: () => import("@shikijs/langs/graphql"),
77+ go: () => import("@shikijs/langs/go"),
78+ rust: () => import("@shikijs/langs/rust"),
79+ java: () => import("@shikijs/langs/java"),
80+ ruby: () => import("@shikijs/langs/ruby"),
81+ php: () => import("@shikijs/langs/php"),
82+ swift: () => import("@shikijs/langs/swift"),
83+ kotlin: () => import("@shikijs/langs/kotlin"),
84+ c: () => import("@shikijs/langs/c"),
85+ cpp: () => import("@shikijs/langs/cpp"),
86+ csharp: () => import("@shikijs/langs/csharp"),
87+ tsx: () => import("@shikijs/langs/tsx"),
88+ jsx: () => import("@shikijs/langs/jsx"),
89+ toml: () => import("@shikijs/langs/toml"),
90+ dockerfile: () => import("@shikijs/langs/dockerfile"),
91+ diff: () => import("@shikijs/langs/diff"),
92+ http: () => import("@shikijs/langs/http"),
93+ jsonc: () => import("@shikijs/langs/jsonc"),
94+ log: () => import("@shikijs/langs/log"),
95+ proto: () => import("@shikijs/langs/proto"),
96+};
97+
98+const supportedSet = new Set<string>([
99+ ...SUPPORTED_LANGS,
100+ ...Object.keys(LANG_ALIASES),
101+]);
102+
103+export function resolveLang(lang: string): SupportedLang | null {
104+ const value = lang.trim().toLowerCase();
105+ if (value in LANG_LOADERS) {
106+ return value as SupportedLang;
107+ }
108+ if (value in LANG_ALIASES) {
109+ return LANG_ALIASES[value]!;
110+ }
111+ return null;
112+}
113+
114+export function isSupportedLang(lang: string): boolean {
115+ return supportedSet.has(lang.trim().toLowerCase());
116+}
117+
118+const jsEngine = createJavaScriptRegexEngine({ forgiving: true });
119+
120+let highlighter: HighlighterCore | null = null;
121+let highlighterPromise: Promise<HighlighterCore> | null = null;
122+
123+async function getHighlighter(): Promise<HighlighterCore> {
124+ if (highlighter) {
125+ return highlighter;
126+ }
127+ if (highlighterPromise) {
128+ return highlighterPromise;
129+ }
130+
131+ highlighterPromise = createHighlighterCore({
132+ themes: [import("@shikijs/themes/vitesse-dark")],
133+ langs: Object.values(LANG_LOADERS).map((loader) => loader()),
134+ engine: jsEngine,
135+ }).then((instance: HighlighterCore) => {
136+ highlighter = instance;
137+ return instance;
138+ });
139+
140+ return highlighterPromise;
141+}
142+
143+void getHighlighter();
144+
145+export async function codeToHtml(
146+ code: string,
147+ options: { lang: string },
148+): Promise<string> {
149+ const highlighter = await getHighlighter();
150+ const resolved = resolveLang(options.lang) ?? "json";
151+ return highlighter.codeToHtml(code, {
152+ lang: resolved,
153+ theme: "vitesse-dark",
154+ });
155+}
156+
157+const THEME = "vitesse-dark" as ThemeInput;
158+const tokensCache = new Map<string, unknown>();
159+const pendingCallbacks = new Map<string, Set<(result: unknown) => void>>();
160+type CodePluginHighlight = NonNullable<CodeHighlighterPlugin["highlight"]>;
161+type CodeHighlightOptions = Parameters<CodePluginHighlight>[0];
162+type CodeHighlightCallback = Parameters<CodePluginHighlight>[1];
163+
164+export function createLimitedCodePlugin(): CodeHighlighterPlugin {
165+ return {
166+ name: "shiki" as const,
167+ type: "code-highlighter" as const,
168+ getSupportedLanguages: () => [...SUPPORTED_LANGS] as string[] as never,
169+ getThemes: () => [THEME, THEME],
170+ supportsLanguage: (language: string) => isSupportedLang(language),
171+ highlight(
172+ options: CodeHighlightOptions,
173+ callback: CodeHighlightCallback,
174+ ) {
175+ const resolved = resolveLang(options.language);
176+ const lang = resolved ?? "json";
177+ const key = `${lang}:${options.code.length}:${options.code.slice(0, 128)}`;
178+
179+ const cached = tokensCache.get(key);
180+ if (cached) {
181+ return cached as never;
182+ }
183+
184+ if (callback) {
185+ if (!pendingCallbacks.has(key)) {
186+ pendingCallbacks.set(key, new Set());
187+ }
188+ pendingCallbacks.get(key)!.add(callback as (result: unknown) => void);
189+ }
190+
191+ void getHighlighter().then((instance) => {
192+ if (tokensCache.has(key)) {
193+ const result = tokensCache.get(key);
194+ pendingCallbacks.get(key)?.forEach((cb) => cb(result));
195+ pendingCallbacks.delete(key);
196+ return;
197+ }
198+
199+ const result = instance.codeToTokens(options.code, {
200+ lang,
201+ themes: { light: "vitesse-dark", dark: "vitesse-dark" },
202+ });
203+ tokensCache.set(key, result);
204+ pendingCallbacks.get(key)?.forEach((cb) => cb(result));
205+ pendingCallbacks.delete(key);
206+ });
207+
208+ return null;
209+ },
210+ };
211+}
packages/clients/react/src/plugins/paths.ts+105−0
@@ -0,0 +1,105 @@
1+const trimSlashes = (value: string): string =>
2+ value.replace(/^\/+/, "").replace(/\/+$/, "");
3+
4+const joinPath = (...parts: ReadonlyArray<string>): string =>
5+ `/${parts.map(trimSlashes).filter(Boolean).join("/")}`;
6+
7+export type ExecutorPluginPaths = {
8+ base: string;
9+ routePattern: (path?: string) => string;
10+ route: (path?: string) => string;
11+};
12+
13+export type SourcePluginPaths = {
14+ add: string;
15+ detailPattern: string;
16+ editPattern: string;
17+ detail: (sourceId: string) => string;
18+ edit: (sourceId: string) => string;
19+ childPattern: (path: string) => string;
20+ child: (sourceId: string, path: string) => string;
21+};
22+
23+export const sourcePluginsIndexPath = "/sources/add";
24+
25+export const normalizeExecutorPluginPath = (value: string): string =>
26+ trimSlashes(value);
27+
28+export const normalizeSourcePluginPath = normalizeExecutorPluginPath;
29+
30+export const executorPluginBasePath = (key: string): string =>
31+ joinPath("plugins", key);
32+
33+export const executorPluginRoutePattern = (
34+ key: string,
35+ path = "",
36+): string => {
37+ const relativePath = normalizeExecutorPluginPath(path);
38+ return relativePath.length === 0
39+ ? executorPluginBasePath(key)
40+ : joinPath("plugins", key, relativePath);
41+};
42+
43+export const executorPluginRoutePath = (
44+ key: string,
45+ path = "",
46+): string =>
47+ executorPluginRoutePattern(key, path);
48+
49+export const sourcePluginAddPath = (key: string): string =>
50+ executorPluginRoutePath(key, "add");
51+
52+export const sourcePluginDetailPattern = (key: string): string =>
53+ joinPath("plugins", key, "sources", "$sourceId");
54+
55+export const sourcePluginEditPattern = (key: string): string =>
56+ joinPath("plugins", key, "sources", "$sourceId", "edit");
57+
58+export const sourcePluginDetailPath = (
59+ key: string,
60+ sourceId: string,
61+): string =>
62+ joinPath("plugins", key, "sources", sourceId);
63+
64+export const sourcePluginEditPath = (
65+ key: string,
66+ sourceId: string,
67+): string =>
68+ joinPath("plugins", key, "sources", sourceId, "edit");
69+
70+export const sourcePluginChildPattern = (
71+ key: string,
72+ path: string,
73+): string => {
74+ const relativePath = normalizeSourcePluginPath(path);
75+ return relativePath.length === 0
76+ ? sourcePluginDetailPattern(key)
77+ : joinPath("plugins", key, "sources", "$sourceId", relativePath);
78+};
79+
80+export const sourcePluginChildPath = (
81+ key: string,
82+ sourceId: string,
83+ path: string,
84+): string => {
85+ const relativePath = normalizeSourcePluginPath(path);
86+ return relativePath.length === 0
87+ ? sourcePluginDetailPath(key, sourceId)
88+ : joinPath("plugins", key, "sources", sourceId, relativePath);
89+};
90+
91+export const createExecutorPluginPaths = (key: string): ExecutorPluginPaths => ({
92+ base: executorPluginBasePath(key),
93+ routePattern: (path = "") => executorPluginRoutePattern(key, path),
94+ route: (path = "") => executorPluginRoutePath(key, path),
95+});
96+
97+export const createSourcePluginPaths = (key: string): SourcePluginPaths => ({
98+ add: sourcePluginAddPath(key),
99+ detailPattern: sourcePluginDetailPattern(key),
100+ editPattern: sourcePluginEditPattern(key),
101+ detail: (sourceId) => sourcePluginDetailPath(key, sourceId),
102+ edit: (sourceId) => sourcePluginEditPath(key, sourceId),
103+ childPattern: (path) => sourcePluginChildPattern(key, path),
104+ child: (sourceId, path) => sourcePluginChildPath(key, sourceId, path),
105+});
packages/clients/react/src/plugins/plugin-route-context.tsx+55−0
@@ -0,0 +1,55 @@
1+import * as React from "react";
2+
3+import type {
4+ ExecutorPluginRouteContextValue,
5+ FrontendPluginRouteParams,
6+ FrontendPluginRouteSearch,
7+} from "./types";
8+
9+const ExecutorPluginRouteContext =
10+ React.createContext<ExecutorPluginRouteContextValue | null>(null);
11+
12+export function ExecutorPluginRouteProvider(props: {
13+ value: ExecutorPluginRouteContextValue;
14+ children: React.ReactNode;
15+}) {
16+ return (
17+ <ExecutorPluginRouteContext.Provider value={props.value}>
18+ {props.children}
19+ </ExecutorPluginRouteContext.Provider>
20+ );
21+}
22+
23+const useExecutorPluginRouteContext = (): ExecutorPluginRouteContextValue => {
24+ const value = React.useContext(ExecutorPluginRouteContext);
25+ if (value === null) {
26+ throw new Error("Executor plugin route context is unavailable.");
27+ }
28+
29+ return value;
30+};
31+
32+export const useExecutorPluginRoute = (): ExecutorPluginRouteContextValue =>
33+ useExecutorPluginRouteContext();
34+
35+export const useExecutorPlugin = () =>
36+ useExecutorPluginRouteContext().plugin;
37+
38+export const useExecutorPluginRouteDefinition = () =>
39+ useExecutorPluginRouteContext().route;
40+
41+export const useExecutorPluginNavigation = () =>
42+ useExecutorPluginRouteContext().navigation;
43+
44+export const useExecutorPluginSearch = <
45+ TSearch extends FrontendPluginRouteSearch = FrontendPluginRouteSearch,
46+>(): TSearch =>
47+ useExecutorPluginRouteContext().search as TSearch;
48+
49+export const useExecutorPluginRouteParams = <
50+ TParams extends FrontendPluginRouteParams = FrontendPluginRouteParams,
51+>(): TParams =>
52+ useExecutorPluginRouteContext().params as TParams;
53+
54+export const useExecutorPluginPaths = () =>
55+ useExecutorPluginRouteContext().navigation.paths;
packages/clients/react/src/plugins/registry.ts+75−0
@@ -0,0 +1,75 @@
1+import type {
2+ ExecutorFrontendPlugin,
3+ FrontendPluginRouteDefinition,
4+} from "./types";
5+import {
6+ normalizeExecutorPluginPath,
7+} from "./paths";
8+
9+export type RegisteredFrontendPluginRoute = {
10+ plugin: ExecutorFrontendPlugin;
11+ route: FrontendPluginRouteDefinition;
12+};
13+
14+export const defineFrontendPluginRoute = <
15+ TDefinition extends FrontendPluginRouteDefinition,
16+>(
17+ definition: TDefinition,
18+): TDefinition => definition;
19+
20+export const defineExecutorFrontendPlugin = <
21+ TPlugin extends ExecutorFrontendPlugin,
22+>(
23+ input: TPlugin,
24+): TPlugin => input;
25+
26+export const registerExecutorFrontendPlugins = (
27+ plugins: readonly ExecutorFrontendPlugin[],
28+) => {
29+ const pluginsByKey = new Map<string, ExecutorFrontendPlugin>();
30+ const routesByPluginAndKey = new Map<string, RegisteredFrontendPluginRoute>();
31+
32+ for (const plugin of plugins) {
33+ if (pluginsByKey.has(plugin.key)) {
34+ throw new Error(`Duplicate frontend plugin registration: ${plugin.key}`);
35+ }
36+
37+ pluginsByKey.set(plugin.key, plugin);
38+
39+ const pluginRouteKeys = new Set<string>();
40+ const pluginRoutePaths = new Set<string>();
41+
42+ for (const route of plugin.routes ?? []) {
43+ if (pluginRouteKeys.has(route.key)) {
44+ throw new Error(
45+ `Duplicate frontend plugin route key for ${plugin.key}: ${route.key}`,
46+ );
47+ }
48+
49+ pluginRouteKeys.add(route.key);
50+
51+ const normalizedPath = normalizeExecutorPluginPath(route.path ?? "");
52+ if (pluginRoutePaths.has(normalizedPath)) {
53+ throw new Error(
54+ `Duplicate frontend plugin route path for ${plugin.key}: ${normalizedPath || "<root>"}`,
55+ );
56+ }
57+
58+ pluginRoutePaths.add(normalizedPath);
59+ routesByPluginAndKey.set(`${plugin.key}:${route.key}`, {
60+ plugin,
61+ route,
62+ });
63+ }
64+ }
65+
66+ const routes = [...routesByPluginAndKey.values()];
67+
68+ return {
69+ plugins: [...pluginsByKey.values()],
70+ routes,
71+ getPlugin: (key: string) => pluginsByKey.get(key) ?? null,
72+ getRoute: (pluginKey: string, routeKey: string) =>
73+ routesByPluginAndKey.get(`${pluginKey}:${routeKey}`) ?? null,
74+ };
75+};
packages/clients/react/src/plugins/route-context.tsx+64−0
@@ -0,0 +1,64 @@
1+import {
2+ useExecutorPlugin,
3+ useExecutorPluginNavigation,
4+ useExecutorPluginRouteParams,
5+ useExecutorPluginSearch,
6+} from "./plugin-route-context";
7+import {
8+ createSourcePluginPaths,
9+ normalizeSourcePluginPath,
10+} from "./paths";
11+import type {
12+ SourcePluginNavigation,
13+ SourcePluginRouteParams,
14+ SourcePluginRouteSearch,
15+} from "./types";
16+
17+export const useSourcePluginRoute = () => ({
18+ plugin: useExecutorPlugin(),
19+ params: useExecutorPluginRouteParams(),
20+ search: useExecutorPluginSearch(),
21+ navigation: useSourcePluginNavigation(),
22+});
23+
24+export const useSourcePlugin = () =>
25+ useExecutorPlugin();
26+
27+export const useSourcePluginNavigation = (): SourcePluginNavigation => {
28+ const plugin = useExecutorPlugin();
29+ const navigation = useExecutorPluginNavigation();
30+ const paths = createSourcePluginPaths(plugin.key);
31+
32+ return {
33+ paths,
34+ home: () => navigation.home(),
35+ add: () => navigation.route("add"),
36+ detail: (sourceId, search) =>
37+ navigation.route(`sources/${sourceId}`, search),
38+ edit: (sourceId, search) =>
39+ navigation.route(`sources/${sourceId}/edit`, search),
40+ child: ({ sourceId, path, search }) => {
41+ const normalizedPath = normalizeSourcePluginPath(path);
42+ return navigation.route(
43+ normalizedPath.length === 0
44+ ? `sources/${sourceId}`
45+ : `sources/${sourceId}/${normalizedPath}`,
46+ search,
47+ );
48+ },
49+ updateSearch: (search) => navigation.updateSearch(search),
50+ };
51+};
52+
53+export const useSourcePluginSearch = <
54+ TSearch extends SourcePluginRouteSearch = SourcePluginRouteSearch,
55+>(): TSearch =>
56+ useExecutorPluginSearch<TSearch>();
57+
58+export const useSourcePluginRouteParams = <
59+ TParams extends SourcePluginRouteParams = SourcePluginRouteParams,
60+>(): TParams =>
61+ useExecutorPluginRouteParams<TParams>();
62+
63+export const useSourcePluginPaths = () =>
64+ createSourcePluginPaths(useExecutorPlugin().key);
packages/clients/react/src/plugins/types.ts+58−0
@@ -0,0 +1,58 @@
1+import type { ComponentType } from "react";
2+
3+import type {
4+ ExecutorPluginPaths,
5+ SourcePluginPaths,
6+} from "./paths";
7+
8+export type FrontendPluginRouteSearch = Record<string, unknown>;
9+export type FrontendPluginRouteParams = Readonly<Record<string, string | undefined>>;
10+
11+export type ExecutorPluginNavigation = {
12+ paths: ExecutorPluginPaths;
13+ home: () => void | Promise<void>;
14+ route: (path?: string, search?: FrontendPluginRouteSearch) => void | Promise<void>;
15+ updateSearch: (search: FrontendPluginRouteSearch) => void | Promise<void>;
16+};
17+
18+export type FrontendPluginRouteDefinition = {
19+ key: string;
20+ path?: string;
21+ component: ComponentType;
22+ nav?: {
23+ label: string;
24+ section?: string;
25+ };
26+};
27+
28+export type ExecutorPluginRouteContextValue = {
29+ plugin: ExecutorFrontendPlugin;
30+ route: FrontendPluginRouteDefinition;
31+ params: FrontendPluginRouteParams;
32+ search: FrontendPluginRouteSearch;
33+ navigation: ExecutorPluginNavigation;
34+};
35+
36+export type SourcePluginRouteSearch = FrontendPluginRouteSearch;
37+export type SourcePluginRouteParams = FrontendPluginRouteParams;
38+
39+export type SourcePluginNavigation = {
40+ paths: SourcePluginPaths;
41+ home: () => void | Promise<void>;
42+ add: () => void | Promise<void>;
43+ detail: (sourceId: string, search?: SourcePluginRouteSearch) => void | Promise<void>;
44+ edit: (sourceId: string, search?: SourcePluginRouteSearch) => void | Promise<void>;
45+ child: (input: {
46+ sourceId: string;
47+ path: string;
48+ search?: SourcePluginRouteSearch;
49+ }) => void | Promise<void>;
50+ updateSearch: (search: SourcePluginRouteSearch) => void | Promise<void>;
51+};
52+
53+export type ExecutorFrontendPlugin = {
54+ key: string;
55+ displayName?: string;
56+ description?: string;
57+ routes?: readonly FrontendPluginRouteDefinition[];
58+};
packages/clients/react/src/provider.tsx+6−0
@@ -0,0 +1,6 @@
1+import {
2+ RegistryProvider,
3+} from "@effect-atom/atom-react";
4+import * as React from "react";
5+
6+export const ExecutorReactProvider = (props: React.PropsWithChildren) => <RegistryProvider>{props.children}</RegistryProvider>;
packages/clients/react/src/source-plugins/index.ts+1−0
@@ -0,0 +1 @@
1+export * from "../plugins/index";
packages/dev/kitchen-sink/package.json+0−1
@@ -14,7 +14,6 @@ · 13 unmodified lines
1414 "@executor/codemode-core": "workspace:*",
1515 "@executor/platform-sdk": "workspace:*",
1616 "@executor/runtime-ses": "workspace:*",
17− "@executor/source-openapi": "workspace:*",
1817 "effect": "catalog:"
1918 },
2019 "devDependencies": {
packages/dev/kitchen-sink/src/source-runtime.test.ts+0−901
@@ -1,901 +0,0 @@
1−import { createServer } from "node:http";
2−
3−import {
4− HttpApi,
5− HttpApiEndpoint,
6− HttpApiGroup,
7− HttpApiSchema,
8− OpenApi,
9−} from "@effect/platform";
10−import { describe, expect, it } from "@effect/vitest";
11−import * as Effect from "effect/Effect";
12−import * as Schema from "effect/Schema";
13−import { Schema as EffectSchema } from "effect";
14−
15−import {
16− type SecretRef,
17− type Source,
18− type SourceId,
19− type WorkspaceId,
20−} from "@executor/platform-sdk/schema";
21−import {
22− createToolCatalogDiscovery,
23− createToolCatalogFromTools,
24− createSystemToolMap,
25− makeToolInvokerFromTools,
26− mergeToolMaps,
27− type ToolCatalog,
28− type ToolMap,
29− type ToolPath,
30− type ToolInvocationContext,
31− type ToolInvoker,
32−} from "@executor/codemode-core";
33−import { createOpenApiToolsFromSpec } from "@executor/source-openapi";
34−import { makeSesExecutor } from "@executor/runtime-ses";
35−
36−const asToolPath = (value: string): ToolPath => value as ToolPath;
37−const asSourceId = (value: string): SourceId => value as SourceId;
38−const asWorkspaceId = (value: string): WorkspaceId => value as WorkspaceId;
39−
40−const tokenize = (value: string): string[] =>
41− value
42− .trim()
43− .toLowerCase()
44− .split(/\s+/)
45− .filter(Boolean);
46−
47−const normalizeSearchText = (...parts: ReadonlyArray<string | null | undefined>): string =>
48− parts
49− .flatMap((part) => {
50− const normalized = part?.trim();
51− return normalized ? [normalized] : [];
52− })
53− .join(" ")
54− .toLowerCase();
55−
56−const catalogNamespaceFromPath = (path: string): string => {
57− const [first, second] = path.split(".");
58− return second ? `${first}.${second}` : first;
59−};
60−
61−type StoredToolArtifactRecord = {
62− workspaceId: WorkspaceId;
63− path: string;
64− toolId: string;
65− sourceId: SourceId;
66− title: string | null;
67− description: string | null;
68− searchNamespace: string;
69− searchText: string;
70− inputSchema: unknown | null;
71− outputSchema: unknown | null;
72− providerKind: "openapi";
73− openApiMethod: "get" | "put" | "post" | "delete" | "patch" | "head" | "options" | "trace";
74− openApiPathTemplate: string | null;
75− openApiOperationHash: string | null;
76− openApiRawToolId: string | null;
77− openApiOperationId: string | null;
78− openApiTagsJson: string | null;
79− openApiRequestBodyRequired: boolean | null;
80− createdAt: number;
81− updatedAt: number;
82−};
83−
84−const toDescriptor = (
85− artifact: StoredToolArtifactRecord,
86− includeSchemas: boolean,
87−) => ({
88− path: asToolPath(artifact.path),
89− sourceKey: artifact.sourceId,
90− description: artifact.description ?? artifact.title ?? undefined,
91− interaction: "auto" as const,
92− inputTypePreview: includeSchemas && artifact.inputSchema ? "object" : undefined,
93− outputTypePreview: includeSchemas && artifact.outputSchema ? "output" : undefined,
94− inputSchema: includeSchemas ? artifact.inputSchema ?? undefined : undefined,
95− outputSchema: includeSchemas ? artifact.outputSchema ?? undefined : undefined,
96−});
97−
98−type SourceCallContext = {
99− auth:
100− | { kind: "none" }
101− | {
102− kind: "headers";
103− headers: Record<string, string>;
104− };
105−};
106−
107−interface SecretMaterialProvider {
108− providerId: string;
109− get(input: {
110− handle: string;
111− }): Promise<string>;
112−}
113−
114−interface SecretMaterialRegistry {
115− get(input: {
116− ref: SecretRef;
117− }): Promise<string>;
118−}
119−
120−interface ProviderInvoker {
121− invoke(input: {
122− source: Source;
123− artifact: StoredToolArtifactRecord;
124− args: unknown;
125− runtime: SourceCallContext;
126− context?: ToolInvocationContext;
127− }): Promise<unknown>;
128−}
129−
130−type WorkspaceScopedSourceStore = {
131− registerSource(input: {
132− workspaceId: WorkspaceId;
133− source: Source;
134− }): Promise<void>;
135− getById(input: {
136− sourceId: SourceId;
137− }): Promise<Source | null>;
138−};
139−
140−type WorkspaceScopedToolStore = {
141− indexArtifacts(input: {
142− workspaceId: WorkspaceId;
143− artifacts: readonly StoredToolArtifactRecord[];
144− }): Promise<void>;
145− listNamespaces(input: {
146− workspaceId: WorkspaceId;
147− limit?: number;
148− }): Promise<
149− readonly {
150− namespace: string;
151− toolCount: number;
152− }[]
153− >;
154− list(input: {
155− workspaceId: WorkspaceId;
156− query?: string;
157− namespace?: string;
158− limit?: number;
159− }): Promise<readonly StoredToolArtifactRecord[]>;
160− getByPath(input: {
161− workspaceId: WorkspaceId;
162− path: ToolPath;
163− }): Promise<StoredToolArtifactRecord | null>;
164−};
165−
166−const createInMemorySourceStore = (): WorkspaceScopedSourceStore => {
167− const byWorkspace = new Map<string, Map<string, Source>>();
168−
169− const getWorkspaceMap = (workspaceId: WorkspaceId) => {
170− const existing = byWorkspace.get(workspaceId);
171− if (existing) {
172− return existing;
173− }
174−
175− const created = new Map<string, Source>();
176− byWorkspace.set(workspaceId, created);
177− return created;
178− };
179−
180− return {
181− async registerSource({ workspaceId, source }) {
182− getWorkspaceMap(workspaceId).set(source.id, source);
183− },
184− async getById({ sourceId }) {
185− for (const workspace of byWorkspace.values()) {
186− const source = workspace.get(sourceId);
187− if (source) {
188− return source;
189− }
190− }
191− return null;
192− },
193− };
194−};
195−
196−const createInMemoryToolStore = (): WorkspaceScopedToolStore => {
197− const byWorkspace = new Map<string, Map<string, StoredToolArtifactRecord>>();
198−
199− const getWorkspaceMap = (workspaceId: WorkspaceId) => {
200− const existing = byWorkspace.get(workspaceId);
201− if (existing) {
202− return existing;
203− }
204−
205− const created = new Map<string, StoredToolArtifactRecord>();
206− byWorkspace.set(workspaceId, created);
207− return created;
208− };
209−
210− return {
211− async indexArtifacts({ workspaceId, artifacts }) {
212− const workspace = getWorkspaceMap(workspaceId);
213− for (const artifact of artifacts) {
214− workspace.set(artifact.path, artifact);
215− }
216− },
217− async listNamespaces({ workspaceId, limit = 200 }) {
218− const counts = new Map<string, number>();
219− for (const artifact of getWorkspaceMap(workspaceId).values()) {
220− counts.set(
221− artifact.searchNamespace,
222− (counts.get(artifact.searchNamespace) ?? 0) + 1,
223− );
224− }
225−
226− return [...counts.entries()]
227− .map(([namespace, toolCount]) => ({
228− namespace,
229− toolCount,
230− }))
231− .sort((left, right) => left.namespace.localeCompare(right.namespace))
232− .slice(0, limit);
233− },
234− async list({ workspaceId, namespace, query, limit = 200 }) {
235− return [...getWorkspaceMap(workspaceId).values()]
236− .filter((artifact) => !namespace || artifact.searchNamespace === namespace)
237− .filter((artifact) =>
238− !query || tokenize(query).every((token) => artifact.searchText.includes(token))
239− )
240− .slice(0, limit);
241− },
242− async getByPath({ workspaceId, path }) {
243− return getWorkspaceMap(workspaceId).get(path) ?? null;
244− },
245− };
246−};
247−
248−const createStaticSecretProvider = (
249− providerId: string,
250− values: Record<string, string>,
251−): SecretMaterialProvider => {
252− const handles = new Map(Object.entries(values));
253−
254− return {
255− providerId,
256− async get({ handle }) {
257− const value = handles.get(handle);
258− if (!value) {
259− throw new Error(`Unknown secret handle ${providerId}:${handle}`);
260− }
261− return value;
262− },
263− };
264−};
265−
266−const createSecretRegistry = (
267− providers: readonly SecretMaterialProvider[],
268−): SecretMaterialRegistry => {
269− const byId = new Map(providers.map((provider) => [provider.providerId, provider]));
270−
271− return {
272− async get({ ref }) {
273− const provider = byId.get(ref.providerId);
274− if (!provider) {
275− throw new Error(`Unknown secret provider ${ref.providerId}`);
276− }
277− return provider.get({ handle: ref.handle });
278− },
279− };
280−};
281−
282−const resolveSourceCallContext = (input: {
283− secretRegistry: SecretMaterialRegistry;
284−}) =>
285− async (source: Source): Promise<SourceCallContext> => {
286− switch (source.auth.kind) {
287− case "none":
288− return { auth: { kind: "none" } };
289− case "bearer": {
290− const token = await input.secretRegistry.get({ ref: source.auth.token });
291− return {
292− auth: {
293− kind: "headers",
294− headers: {
295− [source.auth.headerName]: `${source.auth.prefix}${token}`,
296− },
297− },
298− };
299− }
300− case "oauth2": {
301− const token = await input.secretRegistry.get({ ref: source.auth.accessToken });
302− return {
303− auth: {
304− kind: "headers",
305− headers: {
306− [source.auth.headerName]: `${source.auth.prefix}${token}`,
307− },
308− },
309− };
310− }
311− case "oauth2_authorized_user":
312− throw new Error("oauth2_authorized_user auth requires persistence-backed runtime resolution");
313− case "provider_grant_ref":
314− throw new Error("provider_grant_ref auth requires persistence-backed runtime resolution");
315− case "mcp_oauth":
316− throw new Error("mcp_oauth auth requires persistence-backed runtime resolution");
317− }
318− };
319−
320−const createProviderInvoker = (): ProviderInvoker => ({
321− async invoke({ source, artifact, args, runtime, context }) {
322− const invocation = {
323− method: artifact.openApiMethod,
324− pathTemplate: artifact.openApiPathTemplate,
325− operationHash: artifact.openApiOperationHash,
326− };
327−
328− return {
329− sourceId: source.id,
330− path: artifact.path,
331− provider: artifact.providerKind,
332− invocation,
333− args,
334− auth: runtime.auth,
335− workspaceId: context?.workspaceId ?? null,
336− runId: context?.runId ?? null,
337− };
338− },
339−});
340−
341−const createWorkspaceToolCatalog = (input: {
342− workspaceId: WorkspaceId;
343− toolStore: WorkspaceScopedToolStore;
344−}): ToolCatalog => ({
345− listNamespaces: ({ limit }) =>
346− Effect.promise(() =>
347− input.toolStore.listNamespaces({
348− workspaceId: input.workspaceId,
349− limit,
350− }).then((namespaces) =>
351− namespaces.map((namespace) => ({
352− namespace: namespace.namespace,
353− toolCount: namespace.toolCount,
354− }))
355− )
356− ),
357− listTools: ({ namespace, query, limit, includeSchemas = false }) =>
358− Effect.promise(() =>
359− input.toolStore.list({
360− workspaceId: input.workspaceId,
361− ...(namespace !== undefined ? { namespace } : {}),
362− ...(query !== undefined ? { query } : {}),
363− limit,
364− }).then((artifacts) => artifacts.map((artifact) => toDescriptor(artifact, includeSchemas)))
365− ),
366− getToolByPath: ({ path, includeSchemas }) =>
367− Effect.promise(() =>
368− input.toolStore.getByPath({
369− workspaceId: input.workspaceId,
370− path,
371− }).then((artifact) => (artifact ? toDescriptor(artifact, includeSchemas) : null))
372− ),
373− searchTools: ({ query, namespace, limit }) =>
374− Effect.promise(() =>
375− input.toolStore.list({
376− workspaceId: input.workspaceId,
377− ...(namespace !== undefined ? { namespace } : {}),
378− query,
379− limit: 500,
380− }).then((artifacts) => {
381− const queryTokens = tokenize(query);
382−
383− return artifacts
384− .map((artifact) => {
385− const score = queryTokens.reduce(
386− (total, token) => total + (artifact.searchText.includes(token) ? 1 : 0),
387− 0,
388− );
389−
390− return {
391− path: asToolPath(artifact.path),
392− score,
393− };
394− })
395− .filter((hit) => hit.score > 0)
396− .sort((left, right) => right.score - left.score)
397− .slice(0, limit);
398− })
399− ),
400−});
401−
402−const createWorkspaceToolInvoker = (input: {
403− workspaceId: WorkspaceId;
404− sourceStore: WorkspaceScopedSourceStore;
405− toolStore: WorkspaceScopedToolStore;
406− resolveSourceCallContext: (source: Source) => Promise<SourceCallContext>;
407− providerInvoker: ProviderInvoker;
408−}): ToolInvoker => ({
409− invoke: (() => {
410− const catalog = createWorkspaceToolCatalog({
411− workspaceId: input.workspaceId,
412− toolStore: input.toolStore,
413− });
414− const systemTools = createSystemToolMap({
415− catalog,
416− sourceKey: "system",
417− });
418− const systemToolPaths = new Set(Object.keys(systemTools));
419− const systemToolInvoker = makeToolInvokerFromTools({
420− tools: systemTools,
421− sourceKey: "system",
422− });
423−
424− return ({ path, args, context }) =>
425− systemToolPaths.has(path)
426− ? systemToolInvoker.invoke({ path, args, context })
427− : Effect.tryPromise({
428− try: async () => {
429− const mergedContext: ToolInvocationContext = {
430− ...context,
431− workspaceId: input.workspaceId,
432− };
433−
434− const artifact = await input.toolStore.getByPath({
435− workspaceId: input.workspaceId,
436− path: asToolPath(path),
437− });
438− if (!artifact) {
439− throw new Error(`Unknown tool path: ${path}`);
440− }
441−
442− const source = await input.sourceStore.getById({
443− sourceId: artifact.sourceId,
444− });
445− if (!source) {
446− throw new Error(`Unknown source for tool path: ${path}`);
447− }
448−
449− const runtime = await input.resolveSourceCallContext(source);
450−
451− return input.providerInvoker.invoke({
452− source,
453− artifact,
454− args,
455− runtime,
456− context: mergedContext,
457− });
458− },
459− catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
460− });
461− })(),
462−});
463−
464−const toolInvokerFromWorkspace = (input: {
465− workspaceId: WorkspaceId;
466− sourceStore: WorkspaceScopedSourceStore;
467− toolStore: WorkspaceScopedToolStore;
468− secretRegistry: SecretMaterialRegistry;
469− providerInvoker?: ProviderInvoker;
470−}): ToolInvoker =>
471− createWorkspaceToolInvoker({
472− workspaceId: input.workspaceId,
473− sourceStore: input.sourceStore,
474− toolStore: input.toolStore,
475− resolveSourceCallContext: resolveSourceCallContext({
476− secretRegistry: input.secretRegistry,
477− }),
478− providerInvoker: input.providerInvoker ?? createProviderInvoker(),
479− });
480−
481−const bearerSourceAuth = (input: {
482− providerId: string;
483− handle: string;
484−}): Source["auth"] => ({
485− kind: "bearer",
486− headerName: "Authorization",
487− prefix: "Bearer ",
488− token: {
489− providerId: input.providerId,
490− handle: input.handle,
491− },
492−});
493−
494−const openApiSource = (input: {
495− workspaceId: WorkspaceId;
496− sourceId: SourceId;
497− displayName: string;
498− baseUrl: string;
499− specUrl?: string;
500− namespace?: string;
501− auth: Source["auth"];
502−}): Source => ({
503− id: input.sourceId,
504− workspaceId: input.workspaceId,
505− name: input.displayName,
506− kind: "openapi",
507− endpoint: input.baseUrl,
508− status: "connected",
509− enabled: true,
510− namespace: input.namespace ?? null,
511− bindingVersion: 1,
512− binding: {
513− specUrl: input.specUrl ?? null,
514− defaultHeaders: null,
515− },
516− importAuthPolicy: "reuse_runtime",
517− importAuth: { kind: "none" },
518− auth: input.auth,
519− sourceHash: null,
520− lastError: null,
521− createdAt: 0,
522− updatedAt: 0,
523−});
524−
525−const openApiArtifact = (input: {
526− workspaceId: WorkspaceId;
527− sourceId: SourceId;
528− path: ToolPath;
529− toolId: string;
530− title: string;
531− description?: string;
532− method: NonNullable<StoredToolArtifactRecord["openApiMethod"]>;
533− pathTemplate: string;
534− operationHash?: string;
535− inputSchema?: unknown | null;
536− outputSchema?: unknown | null;
537−}): StoredToolArtifactRecord => {
538− const path = input.path as string;
539− const description =
540− input.description ?? `${input.method.toUpperCase()} ${input.pathTemplate}`;
541−
542− return {
543− workspaceId: input.workspaceId,
544− path,
545− toolId: input.toolId,
546− sourceId: input.sourceId,
547− title: input.title,
548− description,
549− searchNamespace: catalogNamespaceFromPath(path),
550− searchText: normalizeSearchText(
551− path,
552− catalogNamespaceFromPath(path),
553− input.title,
554− description,
555− input.method.toUpperCase(),
556− input.pathTemplate,
557− ),
558− inputSchema: input.inputSchema ?? null,
559− outputSchema: input.outputSchema ?? null,
560− providerKind: "openapi",
561− openApiMethod: input.method,
562− openApiPathTemplate: input.pathTemplate,
563− openApiOperationHash: input.operationHash ?? input.toolId,
564− openApiRawToolId: input.toolId,
565− openApiOperationId: input.toolId,
566− openApiTagsJson: null,
567− openApiRequestBodyRequired: null,
568− createdAt: 0,
569− updatedAt: 0,
570− };
571−};
572−
573−const numberPairInputSchema = Schema.standardSchemaV1(
574− Schema.Struct({
575− a: Schema.Number,
576− b: Schema.Number,
577− }),
578−);
579−
580−const createDiscoveryBackedToolMap = (input: {
581− tools: ToolMap;
582− namespace: string;
583− sourceKey?: string;
584−}) => {
585− const sourceKey = input.sourceKey ?? "in_memory.tools";
586− const catalog = createToolCatalogFromTools({
587− tools: input.tools,
588− defaultNamespace: input.namespace,
589− });
590− const discovery = createToolCatalogDiscovery({ catalog });
591−
592− return {
593− executeDescription: discovery.executeDescription,
594− tools: mergeToolMaps([
595− input.tools,
596− createSystemToolMap({
597− catalog,
598− sourceKey,
599− }),
600− ]),
601− };
602−};
603−
604−const ownerParam = HttpApiSchema.param("owner", EffectSchema.String);
605−const repoParam = HttpApiSchema.param("repo", EffectSchema.String);
606−
607−class GeneratedReposApi extends HttpApiGroup.make("repos")
608− .add(
609− HttpApiEndpoint.get("getRepo")`/repos/${ownerParam}/${repoParam}`
610− .addSuccess(EffectSchema.Unknown),
611− ) {}
612−
613−class GeneratedApi extends HttpApi.make("generated").add(GeneratedReposApi) {}
614−
615−const generatedOpenApiSpec = OpenApi.fromApi(GeneratedApi);
616−
617−type OpenApiTestServer = {
618− baseUrl: string;
619− requests: Array<{
620− method: string;
621− path: string;
622− authorization: string | null;
623− }>;
624− close: () => Promise<void>;
625−};
626−
627−const makeOpenApiTestServer = Effect.acquireRelease(
628− Effect.promise<OpenApiTestServer>(
629− () =>
630− new Promise<OpenApiTestServer>((resolve, reject) => {
631− const requests: OpenApiTestServer["requests"] = [];
632−
633− const server = createServer((req, res) => {
634− requests.push({
635− method: req.method ?? "GET",
636− path: req.url ?? "/",
637− authorization:
638− typeof req.headers.authorization === "string"
639− ? req.headers.authorization
640− : null,
641− });
642−
643− res.statusCode = 200;
644− res.setHeader("content-type", "application/json");
645− res.end(
646− JSON.stringify({
647− ok: true,
648− path: req.url ?? "/",
649− authorization:
650− typeof req.headers.authorization === "string"
651− ? req.headers.authorization
652− : null,
653− }),
654− );
655− });
656−
657− server.once("error", reject);
658− server.listen(0, "127.0.0.1", () => {
659− const address = server.address();
660− if (!address || typeof address === "string") {
661− reject(new Error("failed to resolve test server address"));
662− return;
663− }
664−
665− resolve({
666− baseUrl: `http://127.0.0.1:${address.port}`,
667− requests,
668− close: async () => {
669− await new Promise<void>((closeResolve, closeReject) => {
670− server.close((error) => {
671− if (error) {
672− closeReject(error);
673− return;
674− }
675− closeResolve();
676− });
677− });
678− },
679− });
680− });
681− }),
682− ),
683− (server) =>
684− Effect.tryPromise({
685− try: () => server.close(),
686− catch: (error: unknown) =>
687− error instanceof Error ? error : new Error(String(error)),
688− }).pipe(Effect.orDie),
689−);
690−
691−describe("source runtime", () => {
692− it.effect("searches serialized workspace tools and calls one", () =>
693− Effect.gen(function* () {
694− const workspaceId = asWorkspaceId("workspace_123");
695− const sourceStore = createInMemorySourceStore();
696− const toolStore = createInMemoryToolStore();
697− const secretRegistry = createSecretRegistry([
698− createStaticSecretProvider("local", {
699− "github-db-token": "ghp_from_db",
700− }),
701− ]);
702−
703− const githubSource = openApiSource({
704− workspaceId,
705− sourceId: asSourceId("github"),
706− displayName: "GitHub API",
707− baseUrl: "https://api.github.com",
708− specUrl: "https://api.github.com/openapi.json",
709− namespace: "github",
710− auth: bearerSourceAuth({
711− providerId: "local",
712− handle: "github-db-token",
713− }),
714− });
715−
716− yield* Effect.promise(() =>
717− sourceStore.registerSource({
718− workspaceId,
719− source: githubSource,
720− })
721− );
722− yield* Effect.promise(() =>
723− toolStore.indexArtifacts({
724− workspaceId,
725− artifacts: [
726− openApiArtifact({
727− workspaceId,
728− sourceId: githubSource.id,
729− path: asToolPath("github.issues.list"),
730− toolId: "issues.list",
731− title: "List issues",
732− description: "Serialized artifact loaded from a database row",
733− method: "get",
734− pathTemplate: "/repos/{owner}/{repo}/issues",
735− }),
736− ],
737− })
738− );
739−
740− const toolInvoker = toolInvokerFromWorkspace({
741− workspaceId,
742− sourceStore,
743− toolStore,
744− secretRegistry,
745− });
746− const workspaceCatalog = createWorkspaceToolCatalog({
747− workspaceId,
748− toolStore,
749− });
750− const executeDescription = yield* createToolCatalogDiscovery({
751− catalog: workspaceCatalog,
752− }).executeDescription;
753−
754− const output = yield* makeSesExecutor().execute(
755− [
756− 'const matches = await tools.discover({ query: "github issues", limit: 3 });',
757− "const result = await tools.github.issues.list({ owner: 'vercel', repo: 'next.js' });",
758− "return { matches, result };",
759− ].join("\n"),
760− toolInvoker,
761− );
762−
763− expect(output.result).toMatchObject({
764− matches: {
765− bestPath: "github.issues.list",
766− results: [
767− {
768− path: "github.issues.list",
769− score: expect.any(Number),
770− description: "Serialized artifact loaded from a database row",
771− interaction: "auto",
772− },
773− ],
774− total: 1,
775− },
776− result: {
777− provider: "openapi",
778− path: "github.issues.list",
779− auth: {
780− kind: "headers",
781− headers: {
782− Authorization: "Bearer ghp_from_db",
783− },
784− },
785− workspaceId,
786− },
787− });
788− expect(executeDescription).toBe(
789− [
790− "Execute TypeScript in sandbox; call tools via discovery workflow.",
791− "Available namespaces:",
792− "- github.issues",
793− "Workflow:",
794− '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',
795− "2) const details = await tools.describe.tool({ path, includeSchemas: true });",
796− "3) Read details.inputSchema/details.outputSchema when you need the projected shape.",
797− "4) Call selected tools.<path>(input).",
798− "Do not use fetch; use tools.* only.",
799− ].join("\n"),
800− );
801− }),
802− );
803−
804− it.scoped("loads an api from inline sources and calls it", () =>
805− Effect.gen(function* () {
806− const server = yield* makeOpenApiTestServer;
807−
808− const extracted = yield* createOpenApiToolsFromSpec({
809− sourceName: "github",
810− openApiSpec: generatedOpenApiSpec,
811− baseUrl: server.baseUrl,
812− namespace: "github",
813− credentialHeaders: {
814− Authorization: "Bearer ghp_from_keychain",
815− },
816− });
817−
818− const discoveryBacked = createDiscoveryBackedToolMap({
819− tools: extracted.tools,
820− namespace: "github",
821− sourceKey: "github.openapi",
822− });
823−
824− const toolInvoker = makeToolInvokerFromTools({
825− tools: discoveryBacked.tools,
826− });
827−
828− const output = yield* makeSesExecutor().execute(
829− [
830− 'const matches = await tools.discover({ query: "github repo", limit: 3 });',
831− "const result = await tools.github.repos.getRepo({ owner: 'vercel', repo: 'ai' });",
832− "return { matches, result };",
833− ].join("\n"),
834− toolInvoker,
835− );
836−
837− expect(output.result).toEqual({
838− matches: {
839− bestPath: "github.repos.getRepo",
840− results: [
841− {
842− path: "github.repos.getRepo",
843− score: expect.any(Number),
844− description: "GET /repos/{owner}/{repo}",
845− interaction: "auto",
846− inputTypePreview: "{ owner: string, repo: string }",
847− outputTypePreview: "unknown",
848− },
849− ],
850− total: 1,
851− },
852− result: {
853− ok: true,
854− path: "/repos/vercel/ai",
855− authorization: "Bearer ghp_from_keychain",
856− },
857− });
858− expect(yield* discoveryBacked.executeDescription).toBe(
859− [
860− "Execute TypeScript in sandbox; call tools via discovery workflow.",
861− "Available namespaces:",
862− "- github",
863− "Workflow:",
864− '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',
865− "2) const details = await tools.describe.tool({ path, includeSchemas: true });",
866− "3) Read details.inputSchema/details.outputSchema when you need the projected shape.",
867− "4) Call selected tools.<path>(input).",
868− "Do not use fetch; use tools.* only.",
869− ].join("\n"),
870− );
871− expect(server.requests).toEqual([
872− {
873− method: "GET",
874− path: "/repos/vercel/ai",
875− authorization: "Bearer ghp_from_keychain",
876− },
877− ]);
878− }),
879− );
880−
881− it.effect("basic calling of tools via codemode", () =>
882− Effect.gen(function* () {
883− const toolInvoker = makeToolInvokerFromTools({
884− tools: {
885− "math.add": {
886− description: "Add two numbers",
887− inputSchema: numberPairInputSchema,
888− execute: ({ a, b }: { a: number; b: number }) => ({ sum: a + b }),
889− },
890− },
891− });
892−
893− const output = yield* makeSesExecutor().execute(
894− "return await tools.math.add({ a: 20, b: 22 });",
895− toolInvoker,
896− );
897−
898− expect(output.result).toEqual({ sum: 42 });
899− }),
900− );
901−});
packages/hosts/mcp/package.json+1−1
@@ -7,7 +7,7 @@ · 6 unmodified lines
77 },
88 "scripts": {
99 "typecheck": "bunx tsc --noEmit -p tsconfig.json",
10− "test": "vitest run --passWithNoTests",
10+ "test": "vitest run",
1111 "test:watch": "vitest"
1212 },
1313 "dependencies": {
packages/hosts/mcp/src/index.ts+5−7
@@ -2,7 +2,7 @@ import type { · 1 unmodified line
22 ExecutionEnvelope,
33 } from "@executor/platform-sdk/schema";
44 import {
5− EXECUTOR_SOURCES_ADD_HELP_LINES,
5+ getExecutorSourcesAddHelpLines,
66 RuntimeSourceCatalogStoreService,
77 createExecution,
88 getExecution,
@@ -172,14 +172,12 @@ const buildExecuteWorkflowText = ( · 163 unmodified lines
172172 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',
173173 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",
174174 "3) Call selected tools.<path>(input).",
175− '4) To connect a source, call tools.executor.sources.add(...) for MCP, OpenAPI, or GraphQL APIs.',
176− ...EXECUTOR_SOURCES_ADD_HELP_LINES,
175+ "4) Use source plugins to inspect or add API sources.",
176+ ...getExecutorSourcesAddHelpLines(),
177177 "5) If execution pauses for interaction, resume it with the returned resumePayload or the available resume flow.",
178178 "Do not use fetch; use tools.* only.",
179179 ].join("\n");
180180
181−const defaultExecuteDescription = buildExecuteWorkflowText();
182−
183181 const loadExecuteDescription = (runtime: ExecutorRuntime): Promise<string> =>
184182 runControlPlane(
185183 runtime,
@@ -203,11 +201,11 @@ const loadExecuteDescription = (runtime: ExecutorRuntime): Promise<string> => · 17 unmodified lines
203201 .filter((catalog) => catalog.toolPaths.length > 0);
204202
205203 if (sourceToolExamples.length === 0) {
206− return defaultExecuteDescription;
204+ return buildExecuteWorkflowText();
207205 }
208206
209207 return buildExecuteWorkflowText(sourceToolExamples);
210− }).pipe(Effect.catchAll(() => Effect.succeed(defaultExecuteDescription))),
208+ }).pipe(Effect.catchAll(() => Effect.succeed(buildExecuteWorkflowText()))),
211209 );
212210
213211 const summarizeExecution = (execution: ExecutionEnvelope["execution"]): string => {
packages/kernel/core/src/tool-map.ts+3−3
@@ -448,9 +448,9 @@ export function toolDescriptorsFromTools(input: { · 447 unmodified lines
448448 interaction: metadata?.interaction,
449449 elicitation: metadata?.elicitation,
450450 contract,
451− ...(metadata?.providerKind ? { providerKind: metadata.providerKind } : {}),
452− ...(metadata?.providerData !== undefined
453− ? { providerData: metadata.providerData }
451+ ...(metadata?.pluginKind ? { pluginKind: metadata.pluginKind } : {}),
452+ ...(metadata?.pluginData !== undefined
453+ ? { pluginData: metadata.pluginData }
454454 : {}),
455455 } satisfies ToolDescriptor;
456456 });
packages/kernel/core/src/types.ts+6−6
@@ -98,8 +98,8 @@ export type ToolMetadata = { · 97 unmodified lines
9898 elicitation?: ElicitationRequest;
9999 contract?: ToolContract;
100100 sourceKey?: string;
101− providerKind?: string;
102− providerData?: unknown;
101+ pluginKind?: string;
102+ pluginData?: unknown;
103103 };
104104
105105 export type ToolExecutionContext = {
@@ -145,8 +145,8 @@ export type ToolDescriptor = { · 39 unmodified lines
145145 interaction?: "auto" | "required";
146146 elicitation?: ElicitationRequest;
147147 contract?: ToolContract;
148− providerKind?: string;
149− providerData?: unknown;
148+ pluginKind?: string;
149+ pluginData?: unknown;
150150 };
151151
152152 export const ToolDescriptorSchema = Schema.Struct({
@@ -158,8 +158,8 @@ export const ToolDescriptorSchema = Schema.Struct({ · 5 unmodified lines
158158 ),
159159 elicitation: Schema.optional(Schema.Unknown),
160160 contract: Schema.optional(ToolContractSchema),
161− providerKind: Schema.optional(Schema.String),
162− providerData: Schema.optional(Schema.Unknown),
161+ pluginKind: Schema.optional(Schema.String),
162+ pluginData: Schema.optional(Schema.Unknown),
163163 });
164164
165165 export type ToolNamespace = {
packages/kernel/ir/src/catalog.test.ts+50−14
@@ -54,6 +54,7 @@ const baseProvenance = (pointer: string): ProvenanceRef[] => [{ · 53 unmodified lines
5454 const createHttpCatalog = (input: {
5555 collideIds?: boolean;
5656 method?: string;
57+ noContentSuccess?: boolean;
5758 } = {}): CatalogV1 => {
5859 const catalog = createEmptyCatalogV1();
5960
@@ -327,16 +328,22 @@ const createHttpCatalog = (input: { · 267 unmodified lines
327328 id: responseId,
328329 kind: "response",
329330 docs: {
330− summary: "Updated event",
331+ summary: input.noContentSuccess ? "No content" : "Updated event",
331332 },
332− contents: [
333− {
334− mediaType: "application/json",
335− shapeId: resultShapeId,
336− },
337− ],
333+ ...(input.noContentSuccess
334+ ? {}
335+ : {
336+ contents: [
337+ {
338+ mediaType: "application/json",
339+ shapeId: resultShapeId,
340+ },
341+ ],
342+ }),
338343 synthetic: false,
339− provenance: baseProvenance("#/paths/~1events/responses/200"),
344+ provenance: baseProvenance(
345+ input.noContentSuccess ? "#/paths/~1events/responses/204" : "#/paths/~1events/responses/200",
346+ ),
340347 });
341348
342349 put(catalog.responseSets as Record<typeof responseSetId, CatalogV1["responseSets"][typeof responseSetId]>, responseSetId, {
@@ -345,7 +352,7 @@ const createHttpCatalog = (input: { · 2 unmodified lines
345352 {
346353 match: {
347354 kind: "exact",
348− status: 200,
355+ status: input.noContentSuccess ? 204 : 200,
349356 },
350357 responseId,
351358 traits: ["success"],
@@ -369,7 +376,7 @@ const createHttpCatalog = (input: { · 17 unmodified lines
369376 id: executableId,
370377 capabilityId,
371378 scopeId,
372− adapterKey: "openapi",
379+ pluginKey: "openapi",
373380 bindingVersion: 1,
374381 binding: {},
375382 projection: {
@@ -606,7 +613,7 @@ const createGraphqlCatalog = (): CatalogV1 => { · 230 unmodified lines
606613 id: executableId,
607614 capabilityId,
608615 scopeId,
609− adapterKey: "graphql",
616+ pluginKey: "graphql",
610617 bindingVersion: 1,
611618 binding: {},
612619 projection: {
@@ -670,7 +677,7 @@ describe("IR catalog", () => { · 57 unmodified lines
670677 const snapshot = createCatalogSnapshotV1({
671678 import: {
672679 sourceKind: "openapi",
673− adapterKey: "openapi",
680+ pluginKey: "openapi",
674681 importerVersion: "1.0.0",
675682 importedAt: "2026-03-14T00:00:00.000Z",
676683 sourceConfigHash: "hash_source",
@@ -713,7 +720,11 @@ describe("IR catalog", () => { · 36 unmodified lines
713720
714721 const merged = mergeCatalogFragments([fragmentA, fragmentB]);
715722 expect(merged.documents[docId]?.title).toBe("A");
716− expect(Object.values(merged.diagnostics).some((diagnostic) => diagnostic.code === "merge_conflict_preserved_first")).toBe(true);
723+ expect(
724+ Object.values(merged.diagnostics).some(
725+ (diagnostic) => diagnostic.code === "merge_conflict_preserved_first",
726+ ),
727+ ).toBe(true);
717728 });
718729
719730 it("reports invariant violations for unresolved shapes without diagnostics", () => {
@@ -801,6 +812,27 @@ describe("IR catalog", () => { · 81 unmodified lines
801812 expect(searchDoc.tags).toEqual(["calendar", "events"]);
802813 });
803814
815+ it("projects no-content HTTP successes as null data", () => {
816+ const projected = projectCatalogForAgentSdk({
817+ catalog: createHttpCatalog({ noContentSuccess: true }),
818+ });
819+
820+ const descriptor = projected.toolDescriptors[CapabilityIdSchema.make("cap_events_update")];
821+ const resultShape = descriptor.resultShapeId
822+ ? projected.catalog.symbols[descriptor.resultShapeId]
823+ : undefined;
824+ if (resultShape?.kind !== "shape" || resultShape.node.type !== "object") {
825+ throw new Error("Expected projected HTTP result envelope");
826+ }
827+
828+ const dataShape = projected.catalog.symbols[resultShape.node.fields.data.shapeId];
829+ if (dataShape?.kind !== "shape" || dataShape.node.type !== "const") {
830+ throw new Error("Expected no-content success data to project as null");
831+ }
832+
833+ expect(dataShape.node.value).toBeNull();
834+ });
835+
804836 it("groups colliding HTTP parameter names into path/query containers", () => {
805837 const projected = projectCatalogForAgentSdk({
806838 catalog: createHttpCatalog({ collideIds: true }),
@@ -815,7 +847,11 @@ describe("IR catalog", () => { · 8 unmodified lines
815847 expect(Object.keys(callShape.node.fields)).toContain("path");
816848 expect(Object.keys(callShape.node.fields)).toContain("query");
817849 expect(Object.keys(callShape.node.fields)).not.toContain("id");
818− expect(Object.values(projected.catalog.diagnostics).some((diagnostic) => diagnostic.code === "projection_collision_grouped_fields")).toBe(true);
850+ expect(
851+ Object.values(projected.catalog.diagnostics).some(
852+ (diagnostic) => diagnostic.code === "projection_collision_grouped_fields",
853+ ),
854+ ).toBe(true);
819855 });
820856
821857 it("keeps GraphQL selection explicit for caller-driven outputs", () => {
packages/kernel/ir/src/catalog.ts+28−4
@@ -538,15 +538,30 @@ const projectResultShapeFromResponses = ( · 537 unmodified lines
538538 catalog: CatalogV1,
539539 capability: Capability,
540540 responseSet: ResponseSet,
541−): ShapeSymbolId | undefined =>
542− contentShapeIdFromResponseEntries(
541+): ShapeSymbolId | undefined => {
542+ const successEntries = responseVariantEntries(catalog, responseSet)
543+ .filter(({ variant }) => isSuccessStatusMatch(variant.match));
544+ const contentShapeId = contentShapeIdFromResponseEntries(
543545 catalog,
544546 capability,
545547 `responseSet:${responseSet.id}`,
546548 `${capability.surface.title ?? capability.id} result`,
547− responseVariantEntries(catalog, responseSet).filter(({ variant }) => isSuccessStatusMatch(variant.match)),
549+ successEntries,
548550 );
549551
552+ if (contentShapeId) {
553+ return contentShapeId;
554+ }
555+
556+ return successEntries.length > 0
557+ ? constNullShape(
558+ catalog,
559+ capability,
560+ `responseSet:${responseSet.id}:success:null`,
561+ )
562+ : undefined;
563+};
564+
550565 const projectErrorShapeFromResponses = (
551566 catalog: CatalogV1,
552567 capability: Capability,
@@ -622,6 +637,15 @@ const nullableShape = ( · 69 unmodified lines
622637 baseShapeId: ShapeSymbolId;
623638 },
624639 ): ShapeSymbolId => {
640+ const baseShape = catalog.symbols[input.baseShapeId];
641+ if (
642+ baseShape?.kind === "shape"
643+ && baseShape.node.type === "const"
644+ && baseShape.node.value === null
645+ ) {
646+ return input.baseShapeId;
647+ }
648+
625649 const nullShapeId = constNullShape(catalog, capability, `${input.label}:null`);
626650 if (input.baseShapeId === nullShapeId) {
627651 return nullShapeId;
@@ -845,7 +869,7 @@ const searchDoc = ( · 217 unmodified lines
845869 capability.executableIds
846870 .map((executableId: ExecutableId) =>
847871 catalog.executables[executableId]?.display?.protocol
848− ?? catalog.executables[executableId]?.adapterKey
872+ ?? catalog.executables[executableId]?.pluginKey
849873 )
850874 .filter((protocol): protocol is string => protocol !== undefined),
851875 ),
packages/kernel/ir/src/model.ts+2−2
@@ -158,7 +158,7 @@ export const EntityBaseSchema = Schema.Struct({ · 157 unmodified lines
158158
159159 export const ImportMetadataSchema = Schema.Struct({
160160 sourceKind: SourceKindSchema,
161− adapterKey: Schema.String,
161+ pluginKey: Schema.String,
162162 importerVersion: Schema.String,
163163 importedAt: Schema.String,
164164 sourceConfigHash: Schema.String,
@@ -696,7 +696,7 @@ export const ExecutableSchema = Schema.extend( · 531 unmodified lines
696696 id: ExecutableIdSchema,
697697 capabilityId: CapabilityIdSchema,
698698 scopeId: ScopeIdSchema,
699− adapterKey: Schema.String,
699+ pluginKey: Schema.String,
700700 bindingVersion: Schema.Number,
701701 binding: Schema.Unknown,
702702 projection: ExecutableProjectionSchema,
packages/platform/api/package.json+2−4
@@ -5,15 +5,13 @@ · 4 unmodified lines
55 "exports": {
66 ".": "./src/index.ts",
77 "./api": "./src/api.ts",
8− "./client": "./src/client.ts",
98 "./effect": "./src/effect.ts",
109 "./http": "./src/http.ts",
10+ "./plugins": "./src/plugins.ts",
1111 "./errors": "./src/errors.ts",
1212 "./local/api": "./src/local/api.ts",
1313 "./local/http": "./src/local/http.ts",
1414 "./local-context": "./src/local-context.ts",
15− "./oauth/api": "./src/oauth/api.ts",
16− "./oauth/http": "./src/oauth/http.ts",
1715 "./policies/api": "./src/policies/api.ts",
1816 "./policies/http": "./src/policies/http.ts",
1917 "./sources/api": "./src/sources/api.ts",
@@ -22,7 +20,7 @@ · 2 unmodified lines
2220 "./executions/http": "./src/executions/http.ts"
2321 },
2422 "scripts": {
25− "test": "vitest run",
23+ "test": "vitest run --passWithNoTests",
2624 "test:watch": "vitest",
2725 "typecheck": "bunx tsc --noEmit -p tsconfig.json"
2826 },
packages/platform/api/src/api.ts+57−5
@@ -1,14 +1,18 @@
11 import { HttpApi, OpenApi } from "@effect/platform";
2+import type { HttpApiGroup } from "@effect/platform";
23
34 import { ExecutionsApi } from "./executions/api";
45 import { LocalApi } from "./local/api";
5−import { OAuthApi } from "./oauth/api";
66 import { PoliciesApi } from "./policies/api";
77 import { SourcesApi } from "./sources/api";
8+import type {
9+ ExecutorHttpApiExtension,
10+ ExecutorHttpPlugin,
11+ ExecutorHttpPluginGroups,
12+} from "./plugins";
813
9−export class ExecutorApi extends HttpApi.make("executor")
14+export const CoreExecutorApi = HttpApi.make("executor")
1015 .add(LocalApi)
11− .add(OAuthApi)
1216 .add(SourcesApi)
1317 .add(PoliciesApi)
1418 .add(ExecutionsApi)
@@ -17,6 +21,54 @@ export class ExecutorApi extends HttpApi.make("executor") · 2 unmodified lines
1721 title: "Executor API",
1822 description: "Local-first API for workspace sources, policies, auth, and execution",
1923 }),
20− ) {}
24+ );
2125
22−export const executorOpenApiSpec = OpenApi.fromApi(ExecutorApi);
26+type HttpApiGroupsOf<TApi> = TApi extends HttpApi.HttpApi<any, infer TGroups, any, any>
27+ ? TGroups
28+ : never;
29+type HttpApiErrorOf<TApi> = TApi extends HttpApi.HttpApi<any, any, infer TError, any>
30+ ? TError
31+ : never;
32+type HttpApiContextOf<TApi> = TApi extends HttpApi.HttpApi<any, any, any, infer TContext>
33+ ? TContext
34+ : never;
35+
36+export type CoreExecutorApiGroups = HttpApiGroupsOf<typeof CoreExecutorApi>;
37+export type ExecutorApiWithPlugins<
38+ TPlugins extends readonly ExecutorHttpApiExtension[] = [],
39+> = HttpApi.HttpApi<
40+ "executor",
41+ CoreExecutorApiGroups | ExecutorHttpPluginGroups<TPlugins>,
42+ HttpApiErrorOf<typeof CoreExecutorApi>,
43+ HttpApiContextOf<typeof CoreExecutorApi>
44+>;
45+
46+export const createExecutorApi = <
47+ const TPlugins extends readonly ExecutorHttpApiExtension[] = [],
48+>(
49+ options: {
50+ plugins?: TPlugins;
51+ } = {},
52+): ExecutorApiWithPlugins<TPlugins> =>
53+ (options.plugins ?? []).reduce(
54+ (api, plugin) =>
55+ api.add(plugin.group as HttpApiGroup.HttpApiGroup.Any),
56+ CoreExecutorApi as HttpApi.HttpApi<
57+ "executor",
58+ CoreExecutorApiGroups | ExecutorHttpPluginGroups<TPlugins>,
59+ HttpApiErrorOf<typeof CoreExecutorApi>,
60+ HttpApiContextOf<typeof CoreExecutorApi>
61+ >,
62+ );
63+
64+export const ExecutorApi = createExecutorApi();
65+
66+export const createExecutorOpenApiSpec = <
67+ const TPlugins extends readonly ExecutorHttpPlugin[] = [],
68+>(
69+ options: {
70+ plugins?: TPlugins;
71+ } = {},
72+) => OpenApi.fromApi(createExecutorApi(options));
73+
74+export const executorOpenApiSpec = createExecutorOpenApiSpec();
packages/platform/api/src/client.ts+0−196
@@ -1,196 +0,0 @@
1−import * as Effect from "effect/Effect";
2−
3−import {
4− createExecutorApiEffectClient,
5− type ExecutorApiEffectClient,
6−} from "./effect";
7−
8−type StripResponse<T> = T extends readonly [infer Value, unknown] ? Value : T;
9−type RemoveWithResponse<T> = T extends object ? Omit<T, "withResponse"> : T;
10−type MethodInput<F extends (input: any) => Effect.Effect<any, any, any>> = RemoveWithResponse<
11− Parameters<F>[0]
12−>;
13−type MethodOutput<F extends (input: any) => Effect.Effect<any, any, any>> = StripResponse<
14− Effect.Effect.Success<ReturnType<F>>
15−>;
16−type PromiseMethod<F extends (input: any) => Effect.Effect<any, any, any>> = (
17− input: MethodInput<F>,
18−) => Promise<MethodOutput<F>>;
19−
20−export type ExecutorApiClient = {
21− local: {
22− installation: PromiseMethod<ExecutorApiEffectClient["local"]["installation"]>;
23− config: PromiseMethod<ExecutorApiEffectClient["local"]["config"]>;
24− listSecrets: PromiseMethod<ExecutorApiEffectClient["local"]["listSecrets"]>;
25− createSecret: PromiseMethod<ExecutorApiEffectClient["local"]["createSecret"]>;
26− updateSecret: PromiseMethod<ExecutorApiEffectClient["local"]["updateSecret"]>;
27− deleteSecret: PromiseMethod<ExecutorApiEffectClient["local"]["deleteSecret"]>;
28− };
29− policies: {
30− list: PromiseMethod<ExecutorApiEffectClient["policies"]["list"]>;
31− create: PromiseMethod<ExecutorApiEffectClient["policies"]["create"]>;
32− get: PromiseMethod<ExecutorApiEffectClient["policies"]["get"]>;
33− update: PromiseMethod<ExecutorApiEffectClient["policies"]["update"]>;
34− remove: PromiseMethod<ExecutorApiEffectClient["policies"]["remove"]>;
35− };
36− sources: {
37− discover: PromiseMethod<ExecutorApiEffectClient["sources"]["discover"]>;
38− connect: PromiseMethod<ExecutorApiEffectClient["sources"]["connect"]>;
39− connectBatch: PromiseMethod<ExecutorApiEffectClient["sources"]["connectBatch"]>;
40− listWorkspaceOauthClients: PromiseMethod<
41− ExecutorApiEffectClient["sources"]["listWorkspaceOauthClients"]
42− >;
43− createWorkspaceOauthClient: PromiseMethod<
44− ExecutorApiEffectClient["sources"]["createWorkspaceOauthClient"]
45− >;
46− removeWorkspaceOauthClient: PromiseMethod<
47− ExecutorApiEffectClient["sources"]["removeWorkspaceOauthClient"]
48− >;
49− removeProviderAuthGrant: PromiseMethod<
50− ExecutorApiEffectClient["sources"]["removeProviderAuthGrant"]
51− >;
52− list: PromiseMethod<ExecutorApiEffectClient["sources"]["list"]>;
53− create: PromiseMethod<ExecutorApiEffectClient["sources"]["create"]>;
54− get: PromiseMethod<ExecutorApiEffectClient["sources"]["get"]>;
55− update: PromiseMethod<ExecutorApiEffectClient["sources"]["update"]>;
56− remove: PromiseMethod<ExecutorApiEffectClient["sources"]["remove"]>;
57− inspection: PromiseMethod<ExecutorApiEffectClient["sources"]["inspection"]>;
58− inspectionTool: PromiseMethod<ExecutorApiEffectClient["sources"]["inspectionTool"]>;
59− inspectionDiscover: PromiseMethod<
60− ExecutorApiEffectClient["sources"]["inspectionDiscover"]
61− >;
62− credentialPage: PromiseMethod<ExecutorApiEffectClient["sources"]["credentialPage"]>;
63− credentialSubmit: PromiseMethod<ExecutorApiEffectClient["sources"]["credentialSubmit"]>;
64− credentialComplete: PromiseMethod<
65− ExecutorApiEffectClient["sources"]["credentialComplete"]
66− >;
67− providerOauthComplete: PromiseMethod<
68− ExecutorApiEffectClient["sources"]["providerOauthComplete"]
69− >;
70− };
71− oauth: {
72− startSourceAuth: PromiseMethod<ExecutorApiEffectClient["oauth"]["startSourceAuth"]>;
73− sourceAuthCallback: PromiseMethod<ExecutorApiEffectClient["oauth"]["sourceAuthCallback"]>;
74− };
75− executions: {
76− create: PromiseMethod<ExecutorApiEffectClient["executions"]["create"]>;
77− get: PromiseMethod<ExecutorApiEffectClient["executions"]["get"]>;
78− resume: PromiseMethod<ExecutorApiEffectClient["executions"]["resume"]>;
79− };
80−};
81−
82−const isResponseTuple = <Value>(
83− value: Value | readonly [Value, unknown],
84−): value is readonly [Value, unknown] =>
85− Array.isArray(value) && value.length === 2;
86−
87−function stripResponse<Value>(value: Value | readonly [Value, unknown]): Value {
88− return isResponseTuple(value) ? value[0] : value;
89−}
90−
91−const wrapMethod = <
92− F extends (input: any) => Effect.Effect<any, unknown, never>,
93−>(
94− effectClientPromise: Promise<ExecutorApiEffectClient>,
95− select: (client: ExecutorApiEffectClient) => F,
96−): PromiseMethod<F> => {
97− const wrapped = (input: MethodInput<F>): Promise<MethodOutput<F>> =>
98− effectClientPromise
99− .then((client) => Effect.runPromise(select(client)(input)))
100− .then(stripResponse);
101−
102− return wrapped;
103−};
104−
105−export const createExecutorApiClient = async (input: {
106− baseUrl: string;
107− accountId?: string;
108−}): Promise<ExecutorApiClient> => {
109− const effectClientPromise = Effect.runPromise(
110− createExecutorApiEffectClient(input),
111− );
112−
113− return {
114− local: {
115− installation: wrapMethod(effectClientPromise, (client) => client.local.installation),
116− config: wrapMethod(effectClientPromise, (client) => client.local.config),
117− listSecrets: wrapMethod(effectClientPromise, (client) => client.local.listSecrets),
118− createSecret: wrapMethod(effectClientPromise, (client) => client.local.createSecret),
119− updateSecret: wrapMethod(effectClientPromise, (client) => client.local.updateSecret),
120− deleteSecret: wrapMethod(effectClientPromise, (client) => client.local.deleteSecret),
121− },
122− policies: {
123− list: wrapMethod(effectClientPromise, (client) => client.policies.list),
124− create: wrapMethod(effectClientPromise, (client) => client.policies.create),
125− get: wrapMethod(effectClientPromise, (client) => client.policies.get),
126− update: wrapMethod(effectClientPromise, (client) => client.policies.update),
127− remove: wrapMethod(effectClientPromise, (client) => client.policies.remove),
128− },
129− sources: {
130− discover: wrapMethod(effectClientPromise, (client) => client.sources.discover),
131− connect: wrapMethod(effectClientPromise, (client) => client.sources.connect),
132− connectBatch: wrapMethod(effectClientPromise, (client) => client.sources.connectBatch),
133− listWorkspaceOauthClients: wrapMethod(
134− effectClientPromise,
135− (client) => client.sources.listWorkspaceOauthClients,
136− ),
137− createWorkspaceOauthClient: wrapMethod(
138− effectClientPromise,
139− (client) => client.sources.createWorkspaceOauthClient,
140− ),
141− removeWorkspaceOauthClient: wrapMethod(
142− effectClientPromise,
143− (client) => client.sources.removeWorkspaceOauthClient,
144− ),
145− removeProviderAuthGrant: wrapMethod(
146− effectClientPromise,
147− (client) => client.sources.removeProviderAuthGrant,
148− ),
149− list: wrapMethod(effectClientPromise, (client) => client.sources.list),
150− create: wrapMethod(effectClientPromise, (client) => client.sources.create),
151− get: wrapMethod(effectClientPromise, (client) => client.sources.get),
152− update: wrapMethod(effectClientPromise, (client) => client.sources.update),
153− remove: wrapMethod(effectClientPromise, (client) => client.sources.remove),
154− inspection: wrapMethod(effectClientPromise, (client) => client.sources.inspection),
155− inspectionTool: wrapMethod(
156− effectClientPromise,
157− (client) => client.sources.inspectionTool,
158− ),
159− inspectionDiscover: wrapMethod(
160− effectClientPromise,
161− (client) => client.sources.inspectionDiscover,
162− ),
163− credentialPage: wrapMethod(
164− effectClientPromise,
165− (client) => client.sources.credentialPage,
166− ),
167− credentialSubmit: wrapMethod(
168− effectClientPromise,
169− (client) => client.sources.credentialSubmit,
170− ),
171− credentialComplete: wrapMethod(
172− effectClientPromise,
173− (client) => client.sources.credentialComplete,
174− ),
175− providerOauthComplete: wrapMethod(
176− effectClientPromise,
177− (client) => client.sources.providerOauthComplete,
178− ),
179− },
180− oauth: {
181− startSourceAuth: wrapMethod(
182− effectClientPromise,
183− (client) => client.oauth.startSourceAuth,
184− ),
185− sourceAuthCallback: wrapMethod(
186− effectClientPromise,
187− (client) => client.oauth.sourceAuthCallback,
188− ),
189− },
190− executions: {
191− create: wrapMethod(effectClientPromise, (client) => client.executions.create),
192− get: wrapMethod(effectClientPromise, (client) => client.executions.get),
193− resume: wrapMethod(effectClientPromise, (client) => client.executions.resume),
194− },
195− };
196−};
packages/platform/api/src/effect.ts+7−4
@@ -4,13 +4,16 @@ import { · 3 unmodified lines
44 } from "@effect/platform";
55 import * as Effect from "effect/Effect";
66
7−import { ExecutorApi } from "./api";
7+import { createExecutorApi } from "./api";
8+import type { ExecutorHttpPlugin } from "./plugins";
89
9−export const createExecutorApiEffectClient = (input: {
10+export const createExecutorApiEffectClient = <
11+ const TPlugins extends readonly ExecutorHttpPlugin[] = [],
12+>(input: {
1013 baseUrl: string;
11− accountId?: string;
14+ plugins?: TPlugins;
1215 }) =>
13− HttpApiClient.make(ExecutorApi, {
16+ HttpApiClient.make(createExecutorApi({ plugins: input.plugins }), {
1417 baseUrl: input.baseUrl,
1518 }).pipe(Effect.provide(FetchHttpClient.layer));
1619
packages/platform/api/src/executions/api.ts+11−0
@@ -1,4 +1,5 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2+import * as Schema from "effect/Schema";
23 import {
34 CreateExecutionPayloadSchema,
45 ResumeExecutionPayloadSchema,
@@ -8,6 +9,7 @@ export type { · 3 unmodified lines
89 ResumeExecutionPayload,
910 } from "@executor/platform-sdk/contracts";
1011 import {
12+ ExecutionSchema,
1113 ExecutionIdSchema,
1214 ExecutionEnvelopeSchema,
1315 ScopeIdSchema as WorkspaceIdSchema,
@@ -30,6 +32,15 @@ const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); · 16 unmodified lines
3032 const executionIdParam = HttpApiSchema.param("executionId", ExecutionIdSchema);
3133
3234 export class ExecutionsApi extends HttpApiGroup.make("executions")
35+ .add(
36+ HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/executions`
37+ .addSuccess(Schema.Array(ExecutionSchema))
38+ .addError(ControlPlaneBadRequestError)
39+ .addError(ControlPlaneUnauthorizedError)
40+ .addError(ControlPlaneForbiddenError)
41+ .addError(ControlPlaneNotFoundError)
42+ .addError(ControlPlaneStorageError),
43+ )
3344 .add(
3445 HttpApiEndpoint.post("create")`/workspaces/${workspaceIdParam}/executions`
3546 .setPayload(CreateExecutionPayloadSchema)
packages/platform/api/src/executions/http.ts+5−0
@@ -9,6 +9,11 @@ export const ExecutorExecutionsLive = HttpApiBuilder.group( · 8 unmodified lines
99 "executions",
1010 (handlers) =>
1111 handlers
12+ .handle("list", ({ path }) =>
13+ resolveRequestedLocalWorkspace("executions.list", path.workspaceId).pipe(
14+ Effect.flatMap((executor) => executor.executions.list()),
15+ ),
16+ )
1217 .handle("create", ({ path, payload }) =>
1318 resolveRequestedLocalWorkspace("executions.create", path.workspaceId).pipe(
1419 Effect.flatMap((executor) => executor.executions.create(payload)),
packages/platform/api/src/executor-context.ts+0−2
@@ -1,9 +1,7 @@
11 import type { ExecutorEffect } from "@executor/platform-sdk/effect";
22 import type { ScopeId as WorkspaceId } from "@executor/platform-sdk/schema";
33 import {
4− ControlPlaneBadRequestError,
54 ControlPlaneForbiddenError,
6− ControlPlaneUnauthorizedError,
75 } from "@executor/platform-sdk/errors";
86 import * as Context from "effect/Context";
97 import * as Effect from "effect/Effect";
packages/platform/api/src/http.ts+32−12
@@ -2,26 +2,46 @@ import { HttpApiBuilder } from "@effect/platform"; · 1 unmodified line
22 import * as Layer from "effect/Layer";
33 import type { ExecutorEffect } from "@executor/platform-sdk/effect";
44
5−import { ExecutorApi } from "./api";
5+import { createExecutorApi } from "./api";
66 import { createControlPlaneExecutorLayer } from "./executor-context";
77 import { ExecutorExecutionsLive } from "./executions/http";
88 import { ExecutorLocalLive } from "./local/http";
9−import { ExecutorOAuthLive } from "./oauth/http";
109 import { ExecutorPoliciesLive } from "./policies/http";
10+import type { ExecutorHttpPlugin } from "./plugins";
1111 import { ExecutorSourcesLive } from "./sources/http";
1212
13−export const ExecutorApiLive = HttpApiBuilder.api(ExecutorApi).pipe(
14− Layer.provide(ExecutorLocalLive),
15− Layer.provide(ExecutorOAuthLive),
16− Layer.provide(ExecutorSourcesLive),
17− Layer.provide(ExecutorPoliciesLive),
18− Layer.provide(ExecutorExecutionsLive),
19−);
13+const createExecutorApiLive = (
14+ executor: ExecutorEffect,
15+ plugins: readonly ExecutorHttpPlugin[] = [],
16+) => {
17+ const api = createExecutorApi({ plugins });
18+ let live: Layer.Layer<any, any, any> = HttpApiBuilder.api(api as any).pipe(
19+ Layer.provide(ExecutorLocalLive),
20+ Layer.provide(ExecutorSourcesLive),
21+ Layer.provide(ExecutorPoliciesLive),
22+ Layer.provide(ExecutorExecutionsLive),
23+ );
24+
25+ for (const plugin of plugins) {
26+ live = live.pipe(
27+ Layer.provide(
28+ plugin.build({
29+ executor,
30+ }),
31+ ),
32+ );
33+ }
2034
21−export type ExecutorApiRuntimeContext = Layer.Layer.Context<typeof ExecutorApiLive>;
35+ return live;
36+};
2237
23−export const createExecutorApiLayer = (executor: ExecutorEffect) =>
24− ExecutorApiLive.pipe(
38+export const createExecutorApiLayer = (
39+ executor: ExecutorEffect,
40+ options: {
41+ plugins?: readonly ExecutorHttpPlugin[];
42+ } = {},
43+) =>
44+ createExecutorApiLive(executor, options.plugins).pipe(
2545 Layer.provide(createControlPlaneExecutorLayer(executor)),
2646 );
2747
packages/platform/api/src/index.ts+8−34
@@ -1,11 +1,15 @@
11 export {
2+ CoreExecutorApi,
3+ createExecutorApi,
4+ createExecutorOpenApiSpec,
25 ExecutorApi,
36 executorOpenApiSpec,
47 } from "./api";
5−export {
6− createExecutorApiClient,
7− type ExecutorApiClient,
8−} from "./client";
8+export type {
9+ ExecutorHttpApiExtension,
10+ ExecutorHttpPlugin,
11+ ExecutorHttpPluginGroups,
12+} from "./plugins";
913
1014 export type { LocalInstallation } from "@executor/platform-sdk/schema";
1115
@@ -38,37 +42,7 @@ export { · 26 unmodified lines
3842 } from "./local/api";
3943
4044 export {
41− OAuthApi,
42− StartSourceOAuthPayloadSchema,
43− StartSourceOAuthResultSchema,
44− CompleteSourceOAuthResultSchema,
45− SourceOAuthPopupFailureResultSchema,
46− SourceOAuthPopupResultSchema,
47− SourceOAuthPopupSuccessResultSchema,
48− type StartSourceOAuthPayload,
49− type StartSourceOAuthResult,
50− type CompleteSourceOAuthResult,
51− type SourceOAuthPopupResult,
52−} from "./oauth/api";
53−
54−export {
55− ConnectSourceBatchPayloadSchema,
56− ConnectSourceBatchResultSchema,
57− ConnectSourcePayloadSchema,
58− ConnectSourceResultSchema,
59− CreateWorkspaceOauthClientPayloadSchema,
60− CreateSourcePayloadSchema,
61− DiscoverSourcePayloadSchema,
6245 SourcesApi,
63− UpdateSourcePayloadSchema,
64− type ConnectSourceBatchPayload,
65− type ConnectSourceBatchResult,
66− type ConnectSourcePayload,
67− type ConnectSourceResult,
68− type CreateWorkspaceOauthClientPayload,
69− type CreateSourcePayload,
70− type DiscoverSourcePayload,
71− type UpdateSourcePayload,
7246 } from "./sources/api";
7347
7448 export {
packages/platform/api/src/oauth/api.ts+0−103
@@ -1,103 +0,0 @@
1−import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2−import {
3− SecretRefSchema,
4− SourceAuthSessionIdSchema,
5− SourceTransportSchema,
6− StringMapSchema,
7− ScopeIdSchema as WorkspaceIdSchema,
8−} from "@executor/platform-sdk/schema";
9−import * as Schema from "effect/Schema";
10−
11−import {
12− ControlPlaneBadRequestError,
13− ControlPlaneForbiddenError,
14− ControlPlaneStorageError,
15− ControlPlaneUnauthorizedError,
16−} from "@executor/platform-sdk/errors";
17−import { TrimmedNonEmptyStringSchema } from "@executor/platform-sdk/string-schemas";
18−
19−const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);
20−
21−const HtmlSchema = HttpApiSchema.Text({
22− contentType: "text/html",
23−});
24−
25−export const StartSourceOAuthPayloadSchema = Schema.Struct({
26− provider: Schema.Literal("mcp"),
27− name: Schema.optional(Schema.NullOr(Schema.String)),
28− endpoint: TrimmedNonEmptyStringSchema,
29− transport: Schema.optional(SourceTransportSchema),
30− queryParams: Schema.optional(Schema.NullOr(StringMapSchema)),
31− headers: Schema.optional(Schema.NullOr(StringMapSchema)),
32−});
33−
34−export type StartSourceOAuthPayload = typeof StartSourceOAuthPayloadSchema.Type;
35−
36−export const StartSourceOAuthResultSchema = Schema.Struct({
37− sessionId: SourceAuthSessionIdSchema,
38− authorizationUrl: Schema.String,
39−});
40−
41−export type StartSourceOAuthResult = typeof StartSourceOAuthResultSchema.Type;
42−
43−export const SourceOAuthAuthSchema = Schema.Struct({
44− kind: Schema.Literal("oauth2"),
45− headerName: Schema.String,
46− prefix: Schema.String,
47− accessToken: SecretRefSchema,
48− refreshToken: Schema.NullOr(SecretRefSchema),
49−});
50−
51−export const CompleteSourceOAuthResultSchema = Schema.Struct({
52− sessionId: SourceAuthSessionIdSchema,
53− auth: SourceOAuthAuthSchema,
54−});
55−
56−export type CompleteSourceOAuthResult = typeof CompleteSourceOAuthResultSchema.Type;
57−
58−export const SourceOAuthPopupSuccessResultSchema = Schema.Struct({
59− type: Schema.Literal("executor:oauth-result"),
60− ok: Schema.Literal(true),
61− sessionId: SourceAuthSessionIdSchema,
62− auth: SourceOAuthAuthSchema,
63−});
64−
65−export const SourceOAuthPopupFailureResultSchema = Schema.Struct({
66− type: Schema.Literal("executor:oauth-result"),
67− ok: Schema.Literal(false),
68− sessionId: Schema.Null,
69− error: Schema.String,
70−});
71−
72−export const SourceOAuthPopupResultSchema = Schema.Union(
73− SourceOAuthPopupSuccessResultSchema,
74− SourceOAuthPopupFailureResultSchema,
75−);
76−
77−export type SourceOAuthPopupResult = typeof SourceOAuthPopupResultSchema.Type;
78−
79−const OAuthCallbackUrlParamsSchema = Schema.Struct({
80− state: Schema.String,
81− code: Schema.optional(Schema.String),
82− error: Schema.optional(Schema.String),
83− error_description: Schema.optional(Schema.String),
84−});
85−
86−export class OAuthApi extends HttpApiGroup.make("oauth")
87− .add(
88− HttpApiEndpoint.post("startSourceAuth")`/workspaces/${workspaceIdParam}/oauth/source-auth/start`
89− .setPayload(StartSourceOAuthPayloadSchema)
90− .addSuccess(StartSourceOAuthResultSchema)
91− .addError(ControlPlaneBadRequestError)
92− .addError(ControlPlaneUnauthorizedError)
93− .addError(ControlPlaneForbiddenError)
94− .addError(ControlPlaneStorageError),
95− )
96− .add(
97− HttpApiEndpoint.get("sourceAuthCallback")`/oauth/source-auth/callback`
98− .setUrlParams(OAuthCallbackUrlParamsSchema)
99− .addSuccess(HtmlSchema)
100− .addError(ControlPlaneBadRequestError)
101− .addError(ControlPlaneStorageError),
102− )
103− .prefix("/v1") {}
packages/platform/api/src/oauth/http.ts+0−321
@@ -1,321 +0,0 @@
1−import {
2− HttpApiBuilder,
3− HttpServerRequest,
4− HttpServerResponse,
5−} from "@effect/platform";
6−import type { ScopeId as WorkspaceId } from "@executor/platform-sdk/schema";
7−import * as Cause from "effect/Cause";
8−import * as Either from "effect/Either";
9−import * as Effect from "effect/Effect";
10−import * as Schema from "effect/Schema";
11−
12−import { ExecutorApi } from "../api";
13−import {
14− ControlPlaneBadRequestError,
15− ControlPlaneStorageError,
16−} from "../errors";
17−import {
18− getControlPlaneExecutor,
19− resolveRequestedLocalWorkspace,
20−} from "../local-context";
21−import {
22− SourceOAuthPopupResultSchema,
23− type SourceOAuthPopupResult,
24−} from "./api";
25−
26−const OAUTH_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";
27−
28−const escapeHtml = (value: string): string =>
29− value
30− .replaceAll("&", "&amp;")
31− .replaceAll("<", "&lt;")
32− .replaceAll(">", "&gt;")
33− .replaceAll('"', "&quot;")
34− .replaceAll("'", "&#39;");
35−
36−const serializeValueForScript = (value: unknown): string =>
37− JSON.stringify(value)
38− .replaceAll("<", "\\u003c")
39− .replaceAll(">", "\\u003e")
40− .replaceAll("&", "\\u0026");
41−
42−const encodeSourceOAuthPopupResultJson = Schema.encodeSync(
43− Schema.parseJson(SourceOAuthPopupResultSchema),
44−);
45−
46−const serializeJsonLiteralForScript = (value: string): string =>
47− value
48− .replaceAll("<", "\\u003c")
49− .replaceAll(">", "\\u003e")
50− .replaceAll("&", "\\u0026");
51−
52−const htmlResponse = (html: string, status = 200) =>
53− HttpServerResponse.html(html).pipe(
54− HttpServerResponse.setStatus(status),
55− );
56−
57−const readHeader = (headers: unknown, name: string): string | null => {
58− if (headers instanceof Headers) {
59− return headers.get(name);
60− }
61−
62− if (headers && typeof headers === "object") {
63− const record = headers as Record<string, unknown>;
64− const direct = record[name];
65− if (typeof direct === "string" && direct.length > 0) {
66− return direct;
67− }
68−
69− const lower = record[name.toLowerCase()];
70− if (typeof lower === "string" && lower.length > 0) {
71− return lower;
72− }
73− }
74−
75− return null;
76−};
77−
78−const resolveRequestOrigin = (request: { url: string; headers: unknown }): string | null => {
79− try {
80− return new URL(request.url).origin;
81− } catch {
82− const forwardedHost = readHeader(request.headers, "x-forwarded-host");
83− const host = forwardedHost ?? readHeader(request.headers, "host");
84− if (!host) {
85− return null;
86− }
87−
88− const forwardedProto = readHeader(request.headers, "x-forwarded-proto");
89− const protocol = forwardedProto && forwardedProto.length > 0 ? forwardedProto : "http";
90− return `${protocol}://${host}`;
91− }
92−};
93−
94−const toStartSourceOAuthError = (input: {
95− workspaceId: WorkspaceId;
96− provider: string;
97− endpoint: string;
98− cause: Cause.Cause<unknown>;
99−}) => {
100− const pretty = Cause.pretty(input.cause);
101− console.error("oauth.start_source_auth failed", {
102− workspaceId: input.workspaceId,
103− provider: input.provider,
104− endpoint: input.endpoint,
105− pretty,
106− });
107−
108− if (pretty.includes("Invalid URL") || pretty.includes("required")) {
109− return new ControlPlaneBadRequestError({
110− operation: "oauth.start_source_auth",
111− message: pretty,
112− details: pretty,
113− });
114− }
115−
116− return new ControlPlaneStorageError({
117− operation: "oauth.start_source_auth",
118− message: pretty,
119− details: pretty,
120− });
121−};
122−
123−const sourceOAuthPopupResultDocument = (input: {
124− title: string;
125− message: string;
126− status: "connected" | "failed";
127− sessionId: string;
128− payload: SourceOAuthPopupResult;
129−}) => `<!doctype html>
130−<html lang="en">
131− <head>
132− <meta charset="utf-8" />
133− <meta name="viewport" content="width=device-width, initial-scale=1" />
134− <title>${escapeHtml(input.title)}</title>
135− <style>
136− :root {
137− --bg: #fafaf9;
138− --surface: #ffffff;
139− --text: #1c1917;
140− --text-secondary: #78716c;
141− --border: #e7e5e4;
142− --green: #16a34a;
143− --green-light: #f0fdf4;
144− --green-border: #bbf7d0;
145− --red: #dc2626;
146− --red-light: #fef2f2;
147− --red-border: #fecaca;
148− }
149−
150− * { box-sizing: border-box; margin: 0; padding: 0; }
151−
152− body {
153− min-height: 100vh;
154− display: flex;
155− align-items: center;
156− justify-content: center;
157− background: var(--bg);
158− color: var(--text);
159− font-family: system-ui, -apple-system, sans-serif;
160− padding: 24px;
161− }
162−
163− .card {
164− width: 100%;
165− max-width: 420px;
166− background: var(--surface);
167− border: 1px solid var(--border);
168− border-radius: 16px;
169− padding: 28px;
170− text-align: center;
171− }
172−
173− .status {
174− display: inline-flex;
175− align-items: center;
176− justify-content: center;
177− padding: 6px 12px;
178− border-radius: 999px;
179− font-size: 12px;
180− font-weight: 600;
181− margin-bottom: 14px;
182− }
183−
184− .status[data-status='connected'] {
185− background: var(--green-light);
186− border: 1px solid var(--green-border);
187− color: var(--green);
188− }
189−
190− .status[data-status='failed'] {
191− background: var(--red-light);
192− border: 1px solid var(--red-border);
193− color: var(--red);
194− }
195−
196− h1 {
197− font-size: 22px;
198− line-height: 1.2;
199− margin-bottom: 10px;
200− }
201−
202− p {
203− color: var(--text-secondary);
204− font-size: 14px;
205− line-height: 1.6;
206− }
207− </style>
208− </head>
209− <body>
210− <main class="card">
211− <div class="status" data-status="${escapeHtml(input.status)}">${escapeHtml(input.status)}</div>
212− <h1>${escapeHtml(input.title)}</h1>
213− <p>${escapeHtml(input.message)}</p>
214− </main>
215− <script>
216− (() => {
217− const payload = ${serializeJsonLiteralForScript(encodeSourceOAuthPopupResultJson(input.payload))};
218− const storageKey = ${serializeValueForScript(`${OAUTH_RESULT_STORAGE_KEY_PREFIX}${input.sessionId}`)};
219− try {
220− window.localStorage.setItem(storageKey, JSON.stringify(payload));
221− } catch {}
222− try {
223− if (window.opener) {
224− window.opener.postMessage(payload, window.location.origin);
225− }
226− } finally {
227− window.setTimeout(() => window.close(), 120);
228− }
229− })();
230− </script>
231− </body>
232−</html>`;
233−
234−export const ExecutorOAuthLive = HttpApiBuilder.group(
235− ExecutorApi,
236− "oauth",
237− (handlers) =>
238− handlers
239− .handle("startSourceAuth", ({ path, payload }) =>
240− resolveRequestedLocalWorkspace("oauth.start_source_auth", path.workspaceId).pipe(
241− Effect.flatMap((executor) =>
242− Effect.gen(function* () {
243− const request = yield* HttpServerRequest.HttpServerRequest;
244− return yield* executor.oauth.startSourceAuth({
245− baseUrl: resolveRequestOrigin(request),
246− displayName: payload.name,
247− provider: {
248− kind: payload.provider,
249− endpoint: payload.endpoint,
250− transport: payload.transport,
251− queryParams: payload.queryParams,
252− headers: payload.headers,
253− },
254− });
255− }).pipe(
256− Effect.catchAllCause((cause) =>
257− Effect.fail(
258− toStartSourceOAuthError({
259− workspaceId: path.workspaceId,
260− provider: payload.provider,
261− endpoint: payload.endpoint,
262− cause,
263− }),
264− ),
265− ),
266− )
267− ),
268− ),
269− )
270− .handle("sourceAuthCallback", ({ urlParams }) =>
271− Effect.flatMap(getControlPlaneExecutor(), (executor) =>
272− Effect.gen(function* () {
273− const completed = yield* Effect.either(
274− executor.oauth.completeSourceAuth({
275− state: urlParams.state,
276− code: urlParams.code,
277− error: urlParams.error,
278− errorDescription: urlParams.error_description,
279− }),
280− );
281−
282− if (Either.isRight(completed)) {
283− return htmlResponse(
284− sourceOAuthPopupResultDocument({
285− title: "OAuth connected",
286− message: "OAuth credentials are ready. Return to the source form to finish saving.",
287− status: "connected",
288− sessionId: completed.right.sessionId,
289− payload: {
290− type: "executor:oauth-result",
291− ok: true,
292− sessionId: completed.right.sessionId,
293− auth: completed.right.auth,
294− },
295− }),
296− );
297− }
298−
299− const message = completed.left instanceof Error
300− ? completed.left.message
301− : "Failed completing OAuth";
302−
303− return htmlResponse(
304− sourceOAuthPopupResultDocument({
305− title: "OAuth failed",
306− message,
307− status: "failed",
308− sessionId: "failed",
309− payload: {
310− type: "executor:oauth-result",
311− ok: false,
312− sessionId: null,
313− error: message,
314− },
315− }),
316− 500,
317− );
318− }),
319− ),
320− ),
321−);
packages/platform/api/src/payload-schemas.test.ts+0−100
@@ -1,100 +0,0 @@
1−import { describe, expect, it } from "@effect/vitest";
2−import { throws } from "@effect/vitest/utils";
3−import * as Schema from "effect/Schema";
4−
5−import {
6− CreateExecutionPayloadSchema,
7−} from "./executions/api";
8−import { CreatePolicyPayloadSchema } from "./policies/api";
9−import {
10− ConnectSourcePayloadSchema,
11− CreateSourcePayloadSchema,
12− UpdateSourcePayloadSchema,
13−} from "./sources/api";
14−
15−describe("control-plane payload schemas", () => {
16− it("normalizes trimmed strings at decode time", () => {
17− expect(
18− Schema.decodeUnknownSync(CreateSourcePayloadSchema)({
19− name: " Github ",
20− kind: "openapi",
21− endpoint: " https://api.github.com ",
22− }),
23− ).toEqual({
24− name: "Github",
25− kind: "openapi",
26− endpoint: "https://api.github.com",
27− });
28−
29− expect(
30− Schema.decodeUnknownSync(CreateExecutionPayloadSchema)({
31− code: " console.log('ok') ",
32− }),
33− ).toEqual({
34− code: "console.log('ok')",
35− });
36−
37− expect(
38− Schema.decodeUnknownSync(ConnectSourcePayloadSchema)({
39− kind: "openapi",
40− endpoint: " https://api.github.com ",
41− specUrl: " https://example.com/openapi.json ",
42− }),
43− ).toEqual({
44− kind: "openapi",
45− endpoint: "https://api.github.com",
46− specUrl: "https://example.com/openapi.json",
47− });
48−
49− expect(
50− Schema.decodeUnknownSync(ConnectSourcePayloadSchema)({
51− kind: "google_discovery",
52− service: " sheets ",
53− version: " v4 ",
54− discoveryUrl: " https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest ",
55− oauthClient: {
56− clientId: " google-client ",
57− clientSecret: " google-secret ",
58− },
59− }),
60− ).toEqual({
61− kind: "google_discovery",
62− service: "sheets",
63− version: "v4",
64− discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest",
65− oauthClient: {
66− clientId: "google-client",
67− clientSecret: "google-secret",
68− },
69− });
70−
71− expect(
72− Schema.decodeUnknownSync(CreatePolicyPayloadSchema)({
73− resourcePattern: " source.github.* ",
74− }),
75− ).toEqual({
76− resourcePattern: "source.github.*",
77− });
78− });
79−
80− it("rejects blank strings for normalized string fields", () => {
81− throws(() =>
82− Schema.decodeUnknownSync(UpdateSourcePayloadSchema)({
83− endpoint: " ",
84− })
85− );
86−
87− throws(() =>
88− Schema.decodeUnknownSync(ConnectSourcePayloadSchema)({
89− kind: "graphql",
90− endpoint: " ",
91− })
92− );
93−
94− throws(() =>
95− Schema.decodeUnknownSync(CreatePolicyPayloadSchema)({
96− resourcePattern: " ",
97− })
98− );
99− });
100−});
packages/platform/api/src/plugins.ts+23−0
@@ -0,0 +1,23 @@
1+import type { HttpApiGroup } from "@effect/platform";
2+import type * as Layer from "effect/Layer";
3+import type { ExecutorEffect } from "@executor/platform-sdk/effect";
4+
5+export type ExecutorHttpApiExtension<
6+ TGroup extends HttpApiGroup.HttpApiGroup.Any = HttpApiGroup.HttpApiGroup.Any,
7+> = {
8+ key: string;
9+ group: TGroup;
10+};
11+
12+export type ExecutorHttpPlugin<
13+ TGroup extends HttpApiGroup.HttpApiGroup.Any = HttpApiGroup.HttpApiGroup.Any,
14+ TExecutorExtension extends object = {},
15+> = ExecutorHttpApiExtension<TGroup> & {
16+ build(input: {
17+ executor: ExecutorEffect & TExecutorExtension;
18+ }): Layer.Layer<any, any, any>;
19+};
20+
21+export type ExecutorHttpPluginGroups<
22+ TPlugins extends readonly ExecutorHttpApiExtension[],
23+> = TPlugins[number]["group"];
packages/platform/api/src/sources/api.ts+0−161
@@ -1,18 +1,4 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2−import {
3− ConnectSourceBatchPayloadSchema,
4− ConnectSourceBatchResultSchema,
5− ConnectSourcePayloadSchema,
6− ConnectSourceResultSchema,
7− CreateSourcePayloadSchema,
8− CreateScopeOauthClientPayloadSchema as CreateWorkspaceOauthClientPayloadSchema,
9− CredentialOauthCompleteUrlParamsSchema,
10− CredentialPageUrlParamsSchema,
11− CredentialSubmitPayloadSchema,
12− DiscoverSourcePayloadSchema,
13− UpdateSourcePayloadSchema,
14− ScopeOauthClientQuerySchema as WorkspaceOauthClientQuerySchema,
15−} from "@executor/platform-sdk/contracts";
162 import {
173 ControlPlaneBadRequestError,
184 ControlPlaneForbiddenError,
@@ -21,116 +7,21 @@ import { · 2 unmodified lines
217 ControlPlaneUnauthorizedError,
228 } from "@executor/platform-sdk/errors";
239 import {
24− ProviderAuthGrantIdSchema,
25− SourceDiscoveryResultSchema,
2610 SourceIdSchema,
2711 SourceInspectionDiscoverPayloadSchema,
2812 SourceInspectionDiscoverResultSchema,
2913 SourceInspectionSchema,
3014 SourceInspectionToolDetailSchema,
3115 SourceSchema,
32− ScopeOauthClientIdSchema as WorkspaceOauthClientIdSchema,
33− ScopeOauthClientSchema as WorkspaceOauthClientSchema,
3416 ScopeIdSchema as WorkspaceIdSchema,
3517 } from "@executor/platform-sdk/schema";
3618 import * as Schema from "effect/Schema";
3719
38−export type {
39− ConnectSourceBatchPayload,
40− ConnectSourceBatchResult,
41− ConnectSourcePayload,
42− ConnectSourceResult,
43− CreateSourcePayload,
44− CreateScopeOauthClientPayload as CreateWorkspaceOauthClientPayload,
45− DiscoverSourcePayload,
46− UpdateSourcePayload,
47−} from "@executor/platform-sdk/contracts";
48−
49−export {
50− ConnectSourceBatchPayloadSchema,
51− ConnectSourceBatchResultSchema,
52− ConnectSourcePayloadSchema,
53− ConnectSourceResultSchema,
54− CreateSourcePayloadSchema,
55− CreateWorkspaceOauthClientPayloadSchema,
56− DiscoverSourcePayloadSchema,
57− UpdateSourcePayloadSchema,
58−};
59−
6020 const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);
6121 const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema);
6222 const toolPathParam = HttpApiSchema.param("toolPath", Schema.String);
63−const oauthClientIdParam = HttpApiSchema.param(
64− "oauthClientId",
65− WorkspaceOauthClientIdSchema,
66−);
67−const grantIdParam = HttpApiSchema.param("grantId", ProviderAuthGrantIdSchema);
68−
69−const HtmlSchema = HttpApiSchema.Text({
70− contentType: "text/html",
71−});
7223
7324 export class SourcesApi extends HttpApiGroup.make("sources")
74− .add(
75− HttpApiEndpoint.post("discover")`/sources/discover`
76− .setPayload(DiscoverSourcePayloadSchema)
77− .addSuccess(SourceDiscoveryResultSchema)
78− .addError(ControlPlaneBadRequestError)
79− .addError(ControlPlaneUnauthorizedError)
80− .addError(ControlPlaneForbiddenError),
81− )
82− .add(
83− HttpApiEndpoint.post("connect")`/workspaces/${workspaceIdParam}/sources/connect`
84− .setPayload(ConnectSourcePayloadSchema)
85− .addSuccess(ConnectSourceResultSchema)
86− .addError(ControlPlaneBadRequestError)
87− .addError(ControlPlaneUnauthorizedError)
88− .addError(ControlPlaneForbiddenError)
89− .addError(ControlPlaneStorageError),
90− )
91− .add(
92− HttpApiEndpoint.post("connectBatch")`/workspaces/${workspaceIdParam}/sources/connect-batch`
93− .setPayload(ConnectSourceBatchPayloadSchema)
94− .addSuccess(ConnectSourceBatchResultSchema)
95− .addError(ControlPlaneBadRequestError)
96− .addError(ControlPlaneUnauthorizedError)
97− .addError(ControlPlaneForbiddenError)
98− .addError(ControlPlaneStorageError),
99− )
100− .add(
101− HttpApiEndpoint.get("listWorkspaceOauthClients")`/workspaces/${workspaceIdParam}/oauth-clients`
102− .setUrlParams(WorkspaceOauthClientQuerySchema)
103− .addSuccess(Schema.Array(WorkspaceOauthClientSchema))
104− .addError(ControlPlaneBadRequestError)
105− .addError(ControlPlaneUnauthorizedError)
106− .addError(ControlPlaneForbiddenError)
107− .addError(ControlPlaneStorageError),
108− )
109− .add(
110− HttpApiEndpoint.post("createWorkspaceOauthClient")`/workspaces/${workspaceIdParam}/oauth-clients`
111− .setPayload(CreateWorkspaceOauthClientPayloadSchema)
112− .addSuccess(WorkspaceOauthClientSchema)
113− .addError(ControlPlaneBadRequestError)
114− .addError(ControlPlaneUnauthorizedError)
115− .addError(ControlPlaneForbiddenError)
116− .addError(ControlPlaneStorageError),
117− )
118− .add(
119− HttpApiEndpoint.del("removeWorkspaceOauthClient")`/workspaces/${workspaceIdParam}/oauth-clients/${oauthClientIdParam}`
120− .addSuccess(Schema.Struct({ removed: Schema.Boolean }))
121− .addError(ControlPlaneBadRequestError)
122− .addError(ControlPlaneUnauthorizedError)
123− .addError(ControlPlaneForbiddenError)
124− .addError(ControlPlaneStorageError),
125− )
126− .add(
127− HttpApiEndpoint.del("removeProviderAuthGrant")`/workspaces/${workspaceIdParam}/provider-grants/${grantIdParam}`
128− .addSuccess(Schema.Struct({ removed: Schema.Boolean }))
129− .addError(ControlPlaneBadRequestError)
130− .addError(ControlPlaneUnauthorizedError)
131− .addError(ControlPlaneForbiddenError)
132− .addError(ControlPlaneStorageError),
133− )
13425 .add(
13526 HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/sources`
13627 .addSuccess(Schema.Array(SourceSchema))
@@ -139,15 +30,6 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 2 unmodified lines
13930 .addError(ControlPlaneForbiddenError)
14031 .addError(ControlPlaneStorageError),
14132 )
142− .add(
143− HttpApiEndpoint.post("create")`/workspaces/${workspaceIdParam}/sources`
144− .setPayload(CreateSourcePayloadSchema)
145− .addSuccess(SourceSchema)
146− .addError(ControlPlaneBadRequestError)
147− .addError(ControlPlaneUnauthorizedError)
148− .addError(ControlPlaneForbiddenError)
149− .addError(ControlPlaneStorageError),
150− )
15133 .add(
15234 HttpApiEndpoint.get("get")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}`
15335 .addSuccess(SourceSchema)
@@ -157,16 +39,6 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 3 unmodified lines
15739 .addError(ControlPlaneNotFoundError)
15840 .addError(ControlPlaneStorageError),
15941 )
160− .add(
161− HttpApiEndpoint.patch("update")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}`
162− .setPayload(UpdateSourcePayloadSchema)
163− .addSuccess(SourceSchema)
164− .addError(ControlPlaneBadRequestError)
165− .addError(ControlPlaneUnauthorizedError)
166− .addError(ControlPlaneForbiddenError)
167− .addError(ControlPlaneNotFoundError)
168− .addError(ControlPlaneStorageError),
169− )
17042 .add(
17143 HttpApiEndpoint.del("remove")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}`
17244 .addSuccess(Schema.Struct({ removed: Schema.Boolean }))
@@ -175,39 +47,6 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 2 unmodified lines
17547 .addError(ControlPlaneForbiddenError)
17648 .addError(ControlPlaneStorageError),
17749 )
178− .add(
179− HttpApiEndpoint.get("credentialPage")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/credentials`
180− .setUrlParams(CredentialPageUrlParamsSchema)
181− .addSuccess(HtmlSchema)
182− .addError(ControlPlaneBadRequestError)
183− .addError(ControlPlaneNotFoundError)
184− .addError(ControlPlaneStorageError),
185− )
186− .add(
187− HttpApiEndpoint.post("credentialSubmit")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/credentials`
188− .setUrlParams(CredentialPageUrlParamsSchema)
189− .setPayload(CredentialSubmitPayloadSchema)
190− .addSuccess(HtmlSchema)
191− .addError(ControlPlaneBadRequestError)
192− .addError(ControlPlaneNotFoundError)
193− .addError(ControlPlaneStorageError),
194− )
195− .add(
196− HttpApiEndpoint.get("credentialComplete")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/credentials/oauth/complete`
197− .setUrlParams(CredentialOauthCompleteUrlParamsSchema)
198− .addSuccess(HtmlSchema)
199− .addError(ControlPlaneBadRequestError)
200− .addError(ControlPlaneNotFoundError)
201− .addError(ControlPlaneStorageError),
202− )
203− .add(
204− HttpApiEndpoint.get("providerOauthComplete")`/workspaces/${workspaceIdParam}/oauth/provider/callback`
205− .setUrlParams(CredentialOauthCompleteUrlParamsSchema)
206− .addSuccess(HtmlSchema)
207− .addError(ControlPlaneBadRequestError)
208− .addError(ControlPlaneNotFoundError)
209− .addError(ControlPlaneStorageError),
210− )
21150 .add(
21251 HttpApiEndpoint.get("inspection")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/inspection`
21352 .addSuccess(SourceInspectionSchema)
packages/platform/api/src/sources/http.ts+1−1147
@@ -1,906 +1,21 @@
1−import {
2− HttpApiBuilder,
3− HttpServerRequest,
4− HttpServerResponse,
5−} from "@effect/platform";
6−import type {
7− ExecutionInteraction,
8− Source,
9− ScopeId as WorkspaceId,
10− ScopeOauthClient as WorkspaceOauthClient,
11−} from "@executor/platform-sdk/schema";
12−import type {
13− ExecutorMcpSourceInput,
14− ExecutorSourceInput,
15−} from "@executor/platform-sdk";
1+import { HttpApiBuilder } from "@effect/platform";
162 import * as Effect from "effect/Effect";
173
18−import {
19− sourceAdapterRequiresInteractiveConnect,
20−} from "@executor/platform-sdk/runtime";
21−import { ScopeOauthClientIdSchema as WorkspaceOauthClientIdSchema } from "@executor/platform-sdk/schema";
22−
23−import {
24− ControlPlaneBadRequestError,
25− ControlPlaneNotFoundError,
26− ControlPlaneStorageError,
27−} from "../errors";
284 import { ExecutorApi } from "../api";
29−import type { ConnectSourcePayload } from "./api";
305 import {
31− getControlPlaneExecutor,
326 resolveRequestedLocalWorkspace,
337 } from "../local-context";
348
35−const readHeader = (headers: unknown, name: string): string | null => {
36− if (headers == null || typeof headers !== "object") {
37− return null;
38− }
39−
40− const record = headers as Record<string, unknown>;
41− const exact = record[name];
42− if (typeof exact === "string" && exact.length > 0) {
43− return exact;
44− }
45−
46− const lower = record[name.toLowerCase()];
47− if (typeof lower === "string" && lower.length > 0) {
48− return lower;
49− }
50−
51− return null;
52−};
53−
54−const resolveRequestOrigin = (request: {
55− url: string;
56− headers: unknown;
57−}): string | null => {
58− try {
59− return new URL(request.url).origin;
60− } catch {
61− const forwardedHost = readHeader(request.headers, "x-forwarded-host");
62− const host = forwardedHost ?? readHeader(request.headers, "host");
63− if (!host) {
64− return null;
65− }
66−
67− const forwardedProto = readHeader(request.headers, "x-forwarded-proto");
68− const protocol =
69− forwardedProto && forwardedProto.length > 0 ? forwardedProto : "http";
70− return `${protocol}://${host}`;
71− }
72−};
73−
74−const toBadRequestError = (operation: string, cause: unknown) => {
75− const message = cause instanceof Error ? cause.message : String(cause);
76− return new ControlPlaneBadRequestError({
77− operation,
78− message,
79− details: message,
80− });
81−};
82−
83−const isInteractiveConnectPayload = (
84− payload: ConnectSourcePayload,
85−): payload is Extract<ConnectSourcePayload, { kind?: "mcp" }> =>
86− payload.kind === undefined ||
87− sourceAdapterRequiresInteractiveConnect(payload.kind);
88−
89−const nullToUndefined = <T>(value: T | null | undefined): T | undefined =>
90− value ?? undefined;
91−
92−const toWorkspaceOauthClientId = (
93− value: string | null | undefined,
94−): WorkspaceOauthClient["id"] | null | undefined =>
95− value == null ? value : WorkspaceOauthClientIdSchema.make(value);
96−
97−const toExecutorSourceInput = (
98− payload: ConnectSourcePayload,
99−): ExecutorSourceInput | null => {
100− if (isInteractiveConnectPayload(payload)) {
101− return null;
102− }
103−
104− switch (payload.kind) {
105− case "openapi":
106− return {
107− kind: "openapi",
108− endpoint: payload.endpoint,
109− specUrl: payload.specUrl,
110− name: payload.name,
111− namespace: payload.namespace,
112− importAuthPolicy: nullToUndefined(payload.importAuthPolicy),
113− importAuth: payload.importAuth,
114− auth: payload.auth,
115− };
116− case "graphql":
117− return {
118− kind: "graphql",
119− endpoint: payload.endpoint,
120− name: payload.name,
121− namespace: payload.namespace,
122− importAuthPolicy: nullToUndefined(payload.importAuthPolicy),
123− importAuth: payload.importAuth,
124− auth: payload.auth,
125− };
126− case "google_discovery":
127− return {
128− kind: "google_discovery",
129− service: payload.service,
130− version: payload.version,
131− discoveryUrl: payload.discoveryUrl,
132− scopes: payload.scopes,
133− scopeOauthClientId: toWorkspaceOauthClientId(
134− payload.scopeOauthClientId,
135− ),
136− oauthClient: payload.oauthClient,
137− name: payload.name,
138− namespace: payload.namespace,
139− importAuthPolicy: nullToUndefined(payload.importAuthPolicy),
140− importAuth: payload.importAuth,
141− auth: payload.auth,
142− };
143− }
144−};
145−
146−const toExecutorMcpSourceInput = (
147− payload: Extract<ConnectSourcePayload, { kind?: "mcp" }>,
148− baseUrl: string | null,
149−): ExecutorMcpSourceInput => ({
150− endpoint: payload.endpoint,
151− name: payload.name,
152− namespace: payload.namespace,
153− transport: payload.transport,
154− queryParams: payload.queryParams,
155− headers: payload.headers,
156− command: payload.command,
157− args: payload.args,
158− env: payload.env,
159− cwd: payload.cwd,
160− baseUrl,
161−});
162−
163−const escapeHtml = (value: string): string =>
164− value
165− .replaceAll("&", "&amp;")
166− .replaceAll("<", "&lt;")
167− .replaceAll(">", "&gt;")
168− .replaceAll('"', "&quot;")
169− .replaceAll("'", "&#39;");
170−
171−const serializeJsonForScript = (value: unknown): string =>
172− JSON.stringify(value)
173− .replaceAll("<", "\\u003c")
174− .replaceAll(">", "\\u003e")
175− .replaceAll("&", "\\u0026");
176−
177−const SOURCE_OAUTH_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";
178−
179−const credentialPageDocument = (input: {
180− title: string;
181− eyebrow: string;
182− message: string;
183− sourceLabel: string;
184− endpoint: string;
185− interactionId: string;
186− error?: string;
187− state?: "pending" | "stored" | "continued" | "cancelled" | "inactive";
188−}) => `<!doctype html>
189−<html lang="en">
190− <head>
191− <meta charset="utf-8" />
192− <meta name="viewport" content="width=device-width, initial-scale=1" />
193− <title>${escapeHtml(input.title)}</title>
194− <link rel="preconnect" href="https://fonts.googleapis.com" />
195− <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
196− <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet" />
197− <style>
198− :root {
199− --bg: #fafaf9;
200− --surface: #ffffff;
201− --text: #1c1917;
202− --text-secondary: #78716c;
203− --text-tertiary: #a8a29e;
204− --border: #e7e5e4;
205− --accent: #1d4ed8;
206− --accent-light: #eff6ff;
207− --green: #16a34a;
208− --green-light: #f0fdf4;
209− --green-border: #bbf7d0;
210− --red: #dc2626;
211− --red-light: #fef2f2;
212− --red-border: #fecaca;
213− --amber: #d97706;
214− --amber-light: #fffbeb;
215− --amber-border: #fde68a;
216− }
217−
218− * { box-sizing: border-box; margin: 0; padding: 0; }
219−
220− body {
221− min-height: 100vh;
222− background: var(--bg);
223− color: var(--text);
224− font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
225− display: flex;
226− justify-content: center;
227− padding: 80px 24px 48px;
228− -webkit-font-smoothing: antialiased;
229− }
230−
231− .page {
232− width: 100%;
233− max-width: 540px;
234− }
235−
236− /* Status pill at the very top */
237− .status-pill {
238− display: inline-flex;
239− align-items: center;
240− gap: 7px;
241− padding: 5px 14px 5px 10px;
242− border-radius: 999px;
243− font-family: 'DM Mono', monospace;
244− font-size: 12px;
245− font-weight: 500;
246− letter-spacing: 0.01em;
247− margin-bottom: 28px;
248− }
249−
250− .status-pill::before {
251− content: '';
252− width: 7px;
253− height: 7px;
254− border-radius: 50%;
255− flex-shrink: 0;
256− }
257−
258− .status-pill[data-state='pending'] {
259− background: var(--amber-light);
260− color: var(--amber);
261− border: 1px solid var(--amber-border);
262− }
263− .status-pill[data-state='pending']::before { background: var(--amber); }
264−
265− .status-pill[data-state='stored'] {
266− background: var(--green-light);
267− color: var(--green);
268− border: 1px solid var(--green-border);
269− }
270− .status-pill[data-state='stored']::before { background: var(--green); }
271−
272− .status-pill[data-state='continued'] {
273− background: var(--green-light);
274− color: var(--green);
275− border: 1px solid var(--green-border);
276− }
277− .status-pill[data-state='continued']::before { background: var(--green); }
278−
279− .status-pill[data-state='cancelled'],
280− .status-pill[data-state='inactive'] {
281− background: #f5f5f4;
282− color: var(--text-secondary);
283− border: 1px solid var(--border);
284− }
285− .status-pill[data-state='cancelled']::before,
286− .status-pill[data-state='inactive']::before { background: var(--text-tertiary); }
287−
288− /* Title area */
289− h1 {
290− font-size: 28px;
291− font-weight: 600;
292− letter-spacing: -0.025em;
293− line-height: 1.2;
294− margin-bottom: 10px;
295− }
296−
297− .subtitle {
298− font-size: 15px;
299− line-height: 1.6;
300− color: var(--text-secondary);
301− }
302−
303− /* Divider */
304− hr {
305− border: none;
306− height: 1px;
307− background: var(--border);
308− margin: 36px 0;
309− }
310−
311− /* Source detail rows */
312− .detail {
313− display: flex;
314− flex-direction: column;
315− gap: 6px;
316− margin-bottom: 24px;
317− }
318−
319− .detail-label {
320− font-size: 13px;
321− font-weight: 500;
322− color: var(--text-secondary);
323− }
324−
325− .detail-value {
326− font-size: 15px;
327− line-height: 1.5;
328− word-break: break-word;
329− }
330−
331− .detail-mono {
332− font-family: 'DM Mono', monospace;
333− font-size: 13px;
334− line-height: 1.5;
335− padding: 10px 14px;
336− background: #f5f5f4;
337− border: 1px solid var(--border);
338− border-radius: 8px;
339− word-break: break-all;
340− color: var(--text);
341− }
342−
343− .detail-id {
344− font-family: 'DM Mono', monospace;
345− font-size: 12px;
346− color: var(--text-tertiary);
347− }
348−
349− /* Result card for stored/cancelled/inactive */
350− .result-card {
351− padding: 32px;
352− border-radius: 12px;
353− text-align: center;
354− }
355−
356− .result-card.success {
357− background: var(--green-light);
358− border: 1px solid var(--green-border);
359− }
360−
361− .result-card.neutral {
362− background: #f5f5f4;
363− border: 1px solid var(--border);
364− }
365−
366− .result-card p {
367− font-size: 15px;
368− line-height: 1.6;
369− color: var(--text-secondary);
370− }
371−
372− .result-card .result-heading {
373− font-weight: 600;
374− font-size: 15px;
375− color: var(--text);
376− margin-bottom: 6px;
377− }
378−
379− /* Form */
380− .alert {
381− padding: 12px 14px;
382− border-radius: 8px;
383− background: var(--red-light);
384− border: 1px solid var(--red-border);
385− color: var(--red);
386− font-size: 14px;
387− line-height: 1.5;
388− margin-bottom: 20px;
389− }
390−
391− .field { margin-bottom: 20px; }
392−
393− label {
394− display: block;
395− font-size: 14px;
396− font-weight: 500;
397− margin-bottom: 6px;
398− }
399−
400− input[type='password'] {
401− width: 100%;
402− padding: 10px 14px;
403− background: var(--surface);
404− border: 1px solid var(--border);
405− border-radius: 8px;
406− font-family: 'DM Mono', monospace;
407− font-size: 14px;
408− color: var(--text);
409− transition: border-color 0.15s, box-shadow 0.15s;
410− }
411−
412− input[type='password']::placeholder { color: var(--text-tertiary); }
413−
414− input[type='password']:focus {
415− outline: none;
416− border-color: var(--accent);
417− box-shadow: 0 0 0 3px rgba(29, 78, 216, 0.1);
418− }
419−
420− .hint {
421− margin-top: 8px;
422− font-size: 13px;
423− line-height: 1.5;
424− color: var(--text-tertiary);
425− }
426−
427− .actions {
428− display: flex;
429− gap: 10px;
430− margin-top: 24px;
431− }
432−
433− button {
434− appearance: none;
435− border: none;
436− border-radius: 8px;
437− padding: 10px 20px;
438− font-family: inherit;
439− font-size: 14px;
440− font-weight: 500;
441− cursor: pointer;
442− transition: background 0.15s;
443− }
444−
445− .primary {
446− background: var(--text);
447− color: var(--surface);
448− }
449− .primary:hover { background: #292524; }
450−
451− .secondary {
452− background: transparent;
453− color: var(--text-secondary);
454− border: 1px solid var(--border);
455− }
456− .secondary:hover { background: #f5f5f4; }
457−
458− @media (max-width: 540px) {
459− body { padding: 48px 20px 32px; }
460− h1 { font-size: 24px; }
461− }
462− </style>
463− </head>
464− <body>
465− <main class="page">
466− <div class="status-pill" data-state="${escapeHtml(input.state ?? "pending")}">${escapeHtml(input.state ?? "pending")}</div>
467−
468− <h1>${escapeHtml(input.title)}</h1>
469− <p class="subtitle">${escapeHtml(input.message)}</p>
470−
471− <hr />
472−
473− <div class="detail">
474− <span class="detail-label">Source</span>
475− <span class="detail-value">${escapeHtml(input.sourceLabel)}</span>
476− </div>
477−
478− <div class="detail">
479− <span class="detail-label">API endpoint</span>
480− <div class="detail-mono">${escapeHtml(input.endpoint)}</div>
481− </div>
482−
483− <div class="detail">
484− <span class="detail-label">Interaction</span>
485− <span class="detail-id">${escapeHtml(input.interactionId)}</span>
486− </div>
487−
488− <hr />
489−
490− ${input.error ? `<p class="alert">${escapeHtml(input.error)}</p>` : ""}
491− ${
492− input.state === "stored"
493− ? `<div class="result-card success">
494− <p class="result-heading">Credential stored</p>
495− <p>Executor resumed the source connection request. You can close this window.</p>
496− </div>`
497− : input.state === "continued"
498− ? `<div class="result-card success">
499− <p class="result-heading">Continuing without auth</p>
500− <p>Executor resumed the source connection without stored credentials. You can close this window.</p>
501− </div>`
502− : input.state === "cancelled"
503− ? `<div class="result-card neutral">
504− <p class="result-heading">Request cancelled</p>
505− <p>Executor was notified. You can close this window.</p>
506− </div>`
507− : input.state === "inactive"
508− ? `<div class="result-card neutral">
509− <p class="result-heading">No longer active</p>
510− <p>This credential request has expired. Start a new source connection flow if needed.</p>
511− </div>`
512− : `<form method="post">
513− <div class="field">
514− <label for="token">Personal access token</label>
515− <input
516− id="token"
517− name="token"
518− type="password"
519− autocomplete="off"
520− spellcheck="false"
521− placeholder="ghp_..."
522− />
523− <p class="hint">
524− Add a token to enable authenticated requests, or continue without auth if this API supports public access.
525− </p>
526− </div>
527− <div class="actions">
528− <button class="primary" type="submit" name="action" value="submit">Connect token</button>
529− <button class="secondary" type="submit" name="action" value="continue" formnovalidate>Continue without auth</button>
530− <button class="secondary" type="submit" name="action" value="cancel" formnovalidate>Cancel</button>
531− </div>

647 more lines aren’t shown here.

packages/platform/internal/src/index.ts+0−1

This diff is large: its 2 lines aren’t shown here.

packages/platform/internal/src/tools/executor-admin.ts+0−11

This diff is large: its 12 lines aren’t shown here.

packages/platform/sdk-file/package.json+0−2

This diff is large: its 9 lines aren’t shown here.

packages/platform/sdk-file/src/capture-release-workspace-fixture.ts+8−22

This diff is large: its 74 lines aren’t shown here.

packages/platform/sdk-file/src/config.test.ts+2−8

This diff is large: its 31 lines aren’t shown here.

packages/platform/sdk-file/src/config.ts+2−3

This diff is large: its 21 lines aren’t shown here.

packages/platform/sdk-file/src/executor-state-store.test.ts+0−166

This diff is large: its 167 lines aren’t shown here.

packages/platform/sdk-file/src/executor-state-store.ts+9−634

This diff is large: its 706 lines aren’t shown here.

packages/platform/sdk-file/src/index.ts+27−24

This diff is large: its 94 lines aren’t shown here.

packages/platform/sdk-file/src/release-upgrade-fixtures.ts+33−13

This diff is large: its 68 lines aren’t shown here.

packages/platform/sdk-file/src/secret-material-providers.ts+6−8

This diff is large: its 32 lines aren’t shown here.

packages/platform/sdk-file/src/source-artifacts.test.ts+0−358

This diff is large: its 359 lines aren’t shown here.

packages/platform/sdk-file/src/source-artifacts.ts+1−3

This diff is large: its 17 lines aren’t shown here.

packages/platform/sdk-file/src/source-type-declarations.test.ts+0−956

This diff is large: its 957 lines aren’t shown here.

packages/platform/sdk-file/src/source-type-declarations.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/platform/sdk-file/src/tools.ts+1−3

This diff is large: its 20 lines aren’t shown here.

packages/platform/sdk-file/src/workspace-state.ts+5−5

This diff is large: its 19 lines aren’t shown here.

packages/platform/sdk/package.json+1−7

This diff is large: its 28 lines aren’t shown here.

packages/platform/sdk/src/backend.ts+1−71

This diff is large: its 114 lines aren’t shown here.

packages/platform/sdk/src/contracts.ts+0−1

This diff is large: its 5 lines aren’t shown here.

packages/platform/sdk/src/executor-effect.ts+177−289

This diff is large: its 602 lines aren’t shown here.

packages/platform/sdk/src/executor.test.ts+0−446

This diff is large: its 447 lines aren’t shown here.

packages/platform/sdk/src/executor.ts+119−63

This diff is large: its 223 lines aren’t shown here.

packages/platform/sdk/src/index.ts+1−0

This diff is large: its 4 lines aren’t shown here.

packages/platform/sdk/src/local/operations.ts+2−382

This diff is large: its 403 lines aren’t shown here.

packages/platform/sdk/src/local/secrets.ts+0−1

This diff is large: its 8 lines aren’t shown here.

packages/platform/sdk/src/operations.ts+0−1

This diff is large: its 7 lines aren’t shown here.

packages/platform/sdk/src/plugins.ts+543−0

This diff is large: its 544 lines aren’t shown here.

packages/platform/sdk/src/policies/operations.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/platform/sdk/src/runtime/auth/auth-artifacts.ts+0−514

This diff is large: its 515 lines aren’t shown here.

packages/platform/sdk/src/runtime/auth/auth-leases.ts+0−507

This diff is large: its 508 lines aren’t shown here.

packages/platform/sdk/src/runtime/auth/mcp-auth-provider.ts+0−262

This diff is large: its 263 lines aren’t shown here.

packages/platform/sdk/src/runtime/auth/mcp-oauth.ts+0−1

This diff is large: its 2 lines aren’t shown here.

packages/platform/sdk/src/runtime/auth/oauth-loopback.test.ts+0−110

This diff is large: its 111 lines aren’t shown here.

packages/platform/sdk/src/runtime/auth/oauth-loopback.ts+0−106

This diff is large: its 107 lines aren’t shown here.

packages/platform/sdk/src/runtime/auth/oauth2-pkce.ts+0−1

This diff is large: its 2 lines aren’t shown here.

packages/platform/sdk/src/runtime/auth/provider-grant-lifecycle.ts+0−94

This diff is large: its 95 lines aren’t shown here.

packages/platform/sdk/src/runtime/auth/source-auth-material.test.ts+0−268

This diff is large: its 269 lines aren’t shown here.

packages/platform/sdk/src/runtime/auth/source-auth-material.ts+0−176

This diff is large: its 177 lines aren’t shown here.

packages/platform/sdk/src/runtime/catalog/source/reconcile.ts+2−6

This diff is large: its 29 lines aren’t shown here.

packages/platform/sdk/src/runtime/catalog/source/runtime.test.ts+0−1160

This diff is large: its 1161 lines aren’t shown here.

packages/platform/sdk/src/runtime/catalog/source/runtime.ts+13−17

This diff is large: its 64 lines aren’t shown here.

packages/platform/sdk/src/runtime/catalog/source/snapshot.test.ts+0−1371

This diff is large: its 1372 lines aren’t shown here.

packages/platform/sdk/src/runtime/catalog/source/sync.ts+21−117

This diff is large: its 274 lines aren’t shown here.

packages/platform/sdk/src/runtime/execution/http.test.ts+42−1

This diff is large: its 55 lines aren’t shown here.

packages/platform/sdk/src/runtime/execution/ir-execution.ts+6−11

This diff is large: its 48 lines aren’t shown here.

packages/platform/sdk/src/runtime/execution/live.ts+6−11

This diff is large: its 52 lines aren’t shown here.

packages/platform/sdk/src/runtime/execution/mcp-resume.test.ts+0−411

This diff is large: its 412 lines aren’t shown here.

packages/platform/sdk/src/runtime/execution/runtime-config.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/platform/sdk/src/runtime/execution/scope/authorization.ts+6−1

This diff is large: its 21 lines aren’t shown here.

packages/platform/sdk/src/runtime/execution/scope/environment.ts+27−48

This diff is large: its 156 lines aren’t shown here.

packages/platform/sdk/src/runtime/execution/scope/source-catalog.test.ts+9−5

This diff is large: its 43 lines aren’t shown here.

packages/platform/sdk/src/runtime/execution/scope/tool-invoker.ts+43−81

This diff is large: its 233 lines aren’t shown here.

packages/platform/sdk/src/runtime/execution/service.ts+8−0

This diff is large: its 22 lines aren’t shown here.

packages/platform/sdk/src/runtime/execution/test-http-client.ts+2−2

This diff is large: its 13 lines aren’t shown here.

packages/platform/sdk/src/runtime/executor-runtime.test.ts+0−2390

This diff is large: its 2391 lines aren’t shown here.

packages/platform/sdk/src/runtime/executor-state-store.ts+3−120

This diff is large: its 148 lines aren’t shown here.

packages/platform/sdk/src/runtime/index.ts+30−79

This diff is large: its 242 lines aren’t shown here.

packages/platform/sdk/src/runtime/source-artifacts.ts+0−10

This diff is large: its 24 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/executor-tools.ts+206−466

This diff is large: its 737 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/graphql-tools.ts+0−1

This diff is large: its 2 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/openapi-type-contract.test.ts+293−0

This diff is large: its 294 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-adapter-fixture-matrix.test.ts+0−1469

This diff is large: its 1470 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-adapters/google-discovery.test.ts+0−139

This diff is large: its 140 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-adapters/google-discovery.ts+0−1

This diff is large: its 2 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-adapters/graphql.test.ts+0−157

This diff is large: its 158 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-adapters/graphql.ts+0−1

This diff is large: its 2 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-adapters/index.ts+0−52

This diff is large: its 53 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-adapters/internal.ts+0−131

This diff is large: its 132 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-adapters/mcp.test.ts+0−1082

This diff is large: its 1083 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-adapters/mcp.ts+0−1

This diff is large: its 2 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-auth-service.ts+0−3815

This diff is large: its 3816 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-credential-interactions.ts+0−34

This diff is large: its 35 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-definitions.test.ts+0−632

This diff is large: its 633 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-definitions.ts+46−430

This diff is large: its 545 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-discovery.test.ts+0−278

This diff is large: its 279 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-inspection.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-plugins/index.ts+29−0

This diff is large: its 30 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-store.test.ts+0−96

This diff is large: its 97 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-store.ts+10−18

This diff is large: its 66 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-store/auth.ts+0−141

This diff is large: its 142 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-store/config.ts+2−143

This diff is large: its 173 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-store/lifecycle.ts+4−165

This diff is large: its 224 lines aren’t shown here.

packages/platform/sdk/src/runtime/sources/source-store/records.ts+17−127

This diff is large: its 242 lines aren’t shown here.

packages/platform/sdk/src/schema/enums.ts+3−38

This diff is large: its 62 lines aren’t shown here.

packages/platform/sdk/src/schema/ids.ts+0−26

This diff is large: its 40 lines aren’t shown here.

packages/platform/sdk/src/schema/index.ts+1−7

This diff is large: its 19 lines aren’t shown here.

packages/platform/sdk/src/schema/models/auth-artifact.ts+0−375

This diff is large: its 376 lines aren’t shown here.

packages/platform/sdk/src/schema/models/auth-lease.ts+0−54

This diff is large: its 55 lines aren’t shown here.

packages/platform/sdk/src/schema/models/local-config.ts+2−73

This diff is large: its 100 lines aren’t shown here.

packages/platform/sdk/src/schema/models/provider-auth-grant.ts+0−37

This diff is large: its 38 lines aren’t shown here.

packages/platform/sdk/src/schema/models/scope-oauth-client.ts+0−26

This diff is large: its 27 lines aren’t shown here.

packages/platform/sdk/src/schema/models/secret-ref.ts+8−0

This diff is large: its 9 lines aren’t shown here.

packages/platform/sdk/src/schema/models/source-auth-session.ts+0−159

This diff is large: its 160 lines aren’t shown here.

packages/platform/sdk/src/schema/models/source-catalog.ts+4−8

This diff is large: its 34 lines aren’t shown here.

packages/platform/sdk/src/schema/models/source-discovery.ts+0−16

This diff is large: its 17 lines aren’t shown here.

packages/platform/sdk/src/schema/models/source-oauth-client.ts+0−48

This diff is large: its 49 lines aren’t shown here.

packages/platform/sdk/src/schema/models/source.ts+0−100

This diff is large: its 187 lines aren’t shown here.

packages/platform/sdk/src/sources/contracts.ts+0−236

This diff is large: its 237 lines aren’t shown here.

packages/platform/sdk/src/sources/discovery.ts+0−56

This diff is large: its 57 lines aren’t shown here.

packages/platform/sdk/src/sources/inspection.ts+2−2

This diff is large: its 18 lines aren’t shown here.

packages/platform/sdk/src/sources/operations.test.ts+157−0

This diff is large: its 158 lines aren’t shown here.

packages/platform/sdk/src/sources/operations.ts+161−117

This diff is large: its 374 lines aren’t shown here.

packages/platform/sdk/vitest.config.ts+20−0

This diff is large: its 27 lines aren’t shown here.

packages/platform/server/package.json+22−4

This diff is large: its 49 lines aren’t shown here.

packages/platform/server/src/env.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/platform/server/src/google-discovery-oauth-session-storage.ts+40−0

This diff is large: its 41 lines aren’t shown here.

packages/platform/server/src/google-discovery-source-storage.ts+43−0

This diff is large: its 44 lines aren’t shown here.

packages/platform/server/src/graphql-source-storage.ts+43−0

This diff is large: its 44 lines aren’t shown here.

packages/platform/server/src/index.test.tsrenamed from apps/executor/src/server/server.test.ts+474−500

This diff is large: its 1393 lines aren’t shown here.

packages/platform/server/src/index.ts+70−14

This diff is large: its 126 lines aren’t shown here.

packages/platform/server/src/json-file-storage.ts+79−0

This diff is large: its 80 lines aren’t shown here.

packages/platform/server/src/mcp-oauth-session-storage.ts+40−0

This diff is large: its 41 lines aren’t shown here.

packages/platform/server/src/mcp-source-storage.ts+43−0

This diff is large: its 44 lines aren’t shown here.

packages/platform/server/src/openapi-source-storage.ts+92−0

This diff is large: its 93 lines aren’t shown here.

packages/sources/builtins/package.json+0−26

This diff is large: its 27 lines aren’t shown here.

packages/sources/builtins/src/index.ts+0−12

This diff is large: its 13 lines aren’t shown here.

packages/sources/core/package.json+0−1

This diff is large: its 8 lines aren’t shown here.

packages/sources/core/src/catalog-fragment.ts+3−3

This diff is large: its 26 lines aren’t shown here.

packages/sources/core/src/catalog-shared.ts+8−25

This diff is large: its 51 lines aren’t shown here.

packages/sources/core/src/composition.ts+0−88

This diff is large: its 89 lines aren’t shown here.

packages/sources/core/src/discovery-models.ts+1−4

This diff is large: its 12 lines aren’t shown here.

packages/sources/core/src/discovery.ts+1−4

This diff is large: its 19 lines aren’t shown here.

packages/sources/core/src/index.ts+0−2

This diff is large: its 11 lines aren’t shown here.

packages/sources/core/src/registry.ts+0−44

This diff is large: its 45 lines aren’t shown here.

packages/sources/core/src/shared.ts+0−253

This diff is large: its 262 lines aren’t shown here.

packages/sources/core/src/source-models.ts+1−98

This diff is large: its 143 lines aren’t shown here.

packages/sources/core/src/types.ts+3−152

This diff is large: its 183 lines aren’t shown here.

packages/sources/google-discovery/CHANGELOG.md+0−7

This diff is large: its 8 lines aren’t shown here.

packages/sources/google-discovery/src/adapter.test.ts+0−202

This diff is large: its 203 lines aren’t shown here.

packages/sources/google-discovery/src/adapter.ts+0−708

This diff is large: its 709 lines aren’t shown here.

packages/sources/google-discovery/src/index.ts+0−34

This diff is large: its 35 lines aren’t shown here.

packages/sources/google-discovery/src/local-config.ts+0−11

This diff is large: its 12 lines aren’t shown here.

packages/sources/graphql/src/adapter.ts+0−539

This diff is large: its 540 lines aren’t shown here.

packages/sources/graphql/src/index.ts+0−13

This diff is large: its 14 lines aren’t shown here.

packages/sources/graphql/src/local-config.ts+0−7

This diff is large: its 8 lines aren’t shown here.

packages/sources/mcp/CHANGELOG.md+0−8

This diff is large: its 9 lines aren’t shown here.

packages/sources/mcp/src/adapter.ts+0−627

This diff is large: its 628 lines aren’t shown here.

packages/sources/mcp/src/index.ts+0−39

This diff is large: its 40 lines aren’t shown here.

packages/sources/mcp/src/local-config.ts+0−17

This diff is large: its 18 lines aren’t shown here.

packages/sources/mcp/vitest.config.ts+0−7

This diff is large: its 8 lines aren’t shown here.

packages/sources/openapi/CHANGELOG.md+0−7

This diff is large: its 8 lines aren’t shown here.

packages/sources/openapi/openapi-extractor-rs/.gitignore+0−1

This diff is large: its 2 lines aren’t shown here.

packages/sources/openapi/openapi-extractor-rs/Cargo.lock+0−445

This diff is large: its 446 lines aren’t shown here.

packages/sources/openapi/openapi-extractor-rs/Cargo.toml+0−17

This diff is large: its 18 lines aren’t shown here.

packages/sources/openapi/openapi-extractor-rs/build-wasm.sh+0−20

This diff is large: its 21 lines aren’t shown here.

packages/sources/openapi/openapi-extractor-rs/src/lib.rs+0−1016

This diff is large: its 1017 lines aren’t shown here.

packages/sources/openapi/openapi-extractor-rs/src/main.rs+0−58

This diff is large: its 59 lines aren’t shown here.

packages/sources/openapi/src/adapter.ts+0−684

This diff is large: its 685 lines aren’t shown here.

packages/sources/openapi/src/discovery.ts+0−487

This diff is large: its 488 lines aren’t shown here.

packages/sources/openapi/src/document.test.ts+0−52

This diff is large: its 53 lines aren’t shown here.

packages/sources/openapi/src/document.ts+0−49

This diff is large: its 50 lines aren’t shown here.

packages/sources/openapi/src/extraction.real-specs.test.ts+0−126

This diff is large: its 127 lines aren’t shown here.

packages/sources/openapi/src/extraction.ts+0−893

This diff is large: its 894 lines aren’t shown here.

packages/sources/openapi/src/extractor-wasm.ts+0−69

This diff is large: its 70 lines aren’t shown here.

packages/sources/openapi/src/index.ts+0−100

This diff is large: its 101 lines aren’t shown here.

packages/sources/openapi/src/local-config.ts+0−8

This diff is large: its 9 lines aren’t shown here.

packages/sources/openapi/src/openapi-extractor-wasm/openapi_extractor.d.ts+0−39

This diff is large: its 40 lines aren’t shown here.

packages/sources/openapi/src/openapi-extractor-wasm/openapi_extractor.js+0−230

This diff is large: its 231 lines aren’t shown here.

packages/sources/openapi/src/openapi-extractor-wasm/openapi_extractor_bg.wasm+0−0

Binary file not shown.

packages/sources/openapi/src/openapi-extractor-wasm/openapi_extractor_bg.wasm.d.ts+0−10

This diff is large: its 11 lines aren’t shown here.

packages/sources/openapi/src/real-spec-coverage.test.ts+0−351

This diff is large: its 352 lines aren’t shown here.

packages/sources/openapi/src/schema-refs.ts+0−131

This diff is large: its 132 lines aren’t shown here.

packages/sources/openapi/src/tool-presentation.test.ts+0−360

This diff is large: its 361 lines aren’t shown here.

packages/sources/openapi/src/tool-presentation.ts+0−333

This diff is large: its 334 lines aren’t shown here.

packages/sources/openapi/src/tools.test.ts+0−1316

This diff is large: its 1317 lines aren’t shown here.

packages/sources/openapi/src/tools.ts+0−625

This diff is large: its 626 lines aren’t shown here.

packages/sources/openapi/vitest.config.ts+0−7

This diff is large: its 8 lines aren’t shown here.

plugins/execution-history/react/components.tsx+301−0

This diff is large: its 302 lines aren’t shown here.

plugins/execution-history/react/index.ts+29−0

This diff is large: its 30 lines aren’t shown here.

plugins/execution-history/react/package.json+25−0

This diff is large: its 26 lines aren’t shown here.

plugins/execution-history/react/tsconfig.json+17−0

This diff is large: its 18 lines aren’t shown here.

plugins/google-discovery/http/index.ts+356−0

This diff is large: its 357 lines aren’t shown here.

plugins/google-discovery/http/package.json+26−0

This diff is large: its 27 lines aren’t shown here.

plugins/google-discovery/http/tsconfig.jsonrenamed from packages/sources/openapi/tsconfig.json+1−1

This diff is large: its 8 lines aren’t shown here.

plugins/google-discovery/react/components.tsx+1461−0

This diff is large: its 1462 lines aren’t shown here.

plugins/google-discovery/react/icons.ts+44−0

This diff is large: its 45 lines aren’t shown here.

plugins/google-discovery/react/index.ts+35−0

This diff is large: its 36 lines aren’t shown here.

plugins/google-discovery/react/package.json+28−0

This diff is large: its 29 lines aren’t shown here.

plugins/google-discovery/react/tsconfig.json+17−0

This diff is large: its 18 lines aren’t shown here.

plugins/google-discovery/sdk/catalog.tsrenamed from packages/sources/google-discovery/src/catalog.ts+3−2

This diff is large: its 26 lines aren’t shown here.

plugins/google-discovery/sdk/discovery.tsrenamed from packages/sources/google-discovery/src/discovery.ts+1−1

This diff is large: its 9 lines aren’t shown here.

plugins/google-discovery/sdk/document.tsrenamed from packages/sources/google-discovery/src/document.ts+0−0

File renamed without changes.

plugins/google-discovery/sdk/index.ts+1011−0

This diff is large: its 1012 lines aren’t shown here.

plugins/google-discovery/sdk/oauth2.tsrenamed from packages/auth/oauth2/src/index.ts+29−9

This diff is large: its 88 lines aren’t shown here.

plugins/google-discovery/sdk/package.jsonrenamed from packages/sources/google-discovery/package.json+8−6

This diff is large: its 32 lines aren’t shown here.

plugins/google-discovery/sdk/tools.test.tsrenamed from packages/sources/google-discovery/src/tools.test.ts+4−2

This diff is large: its 15 lines aren’t shown here.

plugins/google-discovery/sdk/tools.tsrenamed from packages/sources/google-discovery/src/tools.ts+10−5

This diff is large: its 50 lines aren’t shown here.

plugins/google-discovery/sdk/tsconfig.jsonrenamed from packages/sources/google-discovery/tsconfig.json+1−1

This diff is large: its 8 lines aren’t shown here.

plugins/google-discovery/sdk/types.tsrenamed from packages/sources/google-discovery/src/types.ts+2−1

This diff is large: its 15 lines aren’t shown here.

plugins/google-discovery/shared/index.ts+223−0

This diff is large: its 224 lines aren’t shown here.

plugins/google-discovery/shared/package.jsonrenamed from packages/platform/internal/package.json+7−5

This diff is large: its 30 lines aren’t shown here.

plugins/google-discovery/shared/tsconfig.jsonrenamed from packages/sources/builtins/tsconfig.json+1−1

This diff is large: its 8 lines aren’t shown here.

plugins/graphql/http/index.ts+180−0

This diff is large: its 181 lines aren’t shown here.

plugins/graphql/http/package.json+26−0

This diff is large: its 27 lines aren’t shown here.

plugins/graphql/http/tsconfig.jsonrenamed from packages/sources/graphql/tsconfig.json+1−1

This diff is large: its 8 lines aren’t shown here.

plugins/graphql/react/components.tsx+840−0

This diff is large: its 841 lines aren’t shown here.

plugins/graphql/react/index.ts+36−0

This diff is large: its 37 lines aren’t shown here.

plugins/graphql/react/package.json+27−0

This diff is large: its 28 lines aren’t shown here.

plugins/graphql/react/tsconfig.json+17−0

This diff is large: its 18 lines aren’t shown here.

plugins/graphql/sdk/catalog.tsrenamed from packages/sources/graphql/src/catalog.ts+2−2

This diff is large: its 18 lines aren’t shown here.

plugins/graphql/sdk/discovery.tsrenamed from packages/sources/graphql/src/discovery.ts+11−3

This diff is large: its 42 lines aren’t shown here.

plugins/graphql/sdk/graphql-tools.test.tsrenamed from packages/platform/sdk/src/runtime/sources/graphql-tools.test.ts+0−0

File renamed without changes.

plugins/graphql/sdk/graphql-tools.tsrenamed from packages/sources/graphql/src/graphql-tools.ts+4−4

This diff is large: its 22 lines aren’t shown here.

plugins/graphql/sdk/index.ts+542−0

This diff is large: its 543 lines aren’t shown here.

plugins/graphql/sdk/package.json+32−0

This diff is large: its 33 lines aren’t shown here.

plugins/graphql/sdk/provider-data.tsrenamed from packages/sources/graphql/src/provider-data.ts+0−0

File renamed without changes.

plugins/graphql/sdk/tsconfig.json+15−0

This diff is large: its 16 lines aren’t shown here.

plugins/graphql/shared/index.ts+66−0

This diff is large: its 67 lines aren’t shown here.

plugins/graphql/shared/package.jsonrenamed from packages/auth/oauth2/package.json+5−3

This diff is large: its 23 lines aren’t shown here.

plugins/graphql/shared/tsconfig.json+15−0

This diff is large: its 16 lines aren’t shown here.

plugins/mcp/http/index.ts+333−0

This diff is large: its 334 lines aren’t shown here.

plugins/mcp/http/package.json+26−0

This diff is large: its 27 lines aren’t shown here.

plugins/mcp/http/tsconfig.jsonrenamed from packages/sources/mcp/tsconfig.json+1−1

This diff is large: its 8 lines aren’t shown here.

plugins/mcp/react/components.tsx+1285−0

This diff is large: its 1286 lines aren’t shown here.

plugins/mcp/react/index.ts+30−0

This diff is large: its 31 lines aren’t shown here.

plugins/mcp/react/json.ts+56−0

This diff is large: its 57 lines aren’t shown here.

plugins/mcp/react/package.json+27−0

This diff is large: its 28 lines aren’t shown here.

plugins/mcp/react/transport.tsrenamed from apps/web/src/views/mcp-transport-state.ts+0−0

File renamed without changes.

plugins/mcp/react/tsconfig.json+17−0

This diff is large: its 18 lines aren’t shown here.

plugins/mcp/sdk/catalog.tsrenamed from packages/sources/mcp/src/catalog.ts+2−2

This diff is large: its 18 lines aren’t shown here.

plugins/mcp/sdk/connection-pool.tsrenamed from packages/sources/mcp/src/connection-pool.ts+0−0

File renamed without changes.

plugins/mcp/sdk/connection.tsrenamed from packages/sources/mcp/src/connection.ts+0−0

File renamed without changes.

plugins/mcp/sdk/discovery.tsrenamed from packages/sources/mcp/src/discovery.ts+3−3

This diff is large: its 31 lines aren’t shown here.

plugins/mcp/sdk/elicitation-bridge.tsrenamed from packages/sources/mcp/src/elicitation-bridge.ts+0−0

File renamed without changes.

plugins/mcp/sdk/elicitation-form.test.tsrenamed from packages/sources/mcp/src/elicitation-form.test.ts+0−0

File renamed without changes.

plugins/mcp/sdk/elicitation.url.test.tsrenamed from packages/sources/mcp/src/elicitation.url.test.ts+9−7

This diff is large: its 33 lines aren’t shown here.

plugins/mcp/sdk/index.ts+991−0

This diff is large: its 992 lines aren’t shown here.

plugins/mcp/sdk/manifest.tsrenamed from packages/sources/mcp/src/manifest.ts+0−0

File renamed without changes.

plugins/mcp/sdk/oauth-effect-errors.tsrenamed from packages/auth/mcp-oauth/src/effect-errors.ts+0−0

File renamed without changes.

plugins/mcp/sdk/oauth.tsrenamed from packages/auth/mcp-oauth/src/index.ts+4−3

This diff is large: its 28 lines aren’t shown here.

plugins/mcp/sdk/package.jsonrenamed from packages/sources/mcp/package.json+11−7

This diff is large: its 38 lines aren’t shown here.

plugins/mcp/sdk/tools.test.tsrenamed from packages/sources/mcp/src/tools.test.ts+1−2

This diff is large: its 17 lines aren’t shown here.

plugins/mcp/sdk/tools.tsrenamed from packages/sources/mcp/src/tools.ts+0−0

File renamed without changes.

plugins/mcp/sdk/tsconfig.jsonrenamed from packages/platform/internal/tsconfig.json+2−2

This diff is large: its 11 lines aren’t shown here.

plugins/mcp/shared/index.ts+174−0

This diff is large: its 175 lines aren’t shown here.

plugins/mcp/shared/package.jsonrenamed from packages/auth/mcp-oauth/package.json+5−4

This diff is large: its 24 lines aren’t shown here.

plugins/mcp/shared/tsconfig.json+15−0

This diff is large: its 16 lines aren’t shown here.

plugins/openapi/http/index.ts+223−0

This diff is large: its 224 lines aren’t shown here.

plugins/openapi/http/package.json+26−0

This diff is large: its 27 lines aren’t shown here.

plugins/openapi/http/tsconfig.json+15−0

This diff is large: its 16 lines aren’t shown here.

plugins/openapi/react/components.tsx+996−0

This diff is large: its 997 lines aren’t shown here.

plugins/openapi/react/index.ts+29−0

This diff is large: its 30 lines aren’t shown here.

plugins/openapi/react/package.json+27−0

This diff is large: its 28 lines aren’t shown here.

plugins/openapi/react/tsconfig.json+17−0

This diff is large: its 18 lines aren’t shown here.

plugins/openapi/sdk/catalog.tsrenamed from packages/sources/openapi/src/catalog.ts+196−263

This diff is large: its 705 lines aren’t shown here.

plugins/openapi/sdk/definitions.tsrenamed from packages/sources/openapi/src/definitions.ts+53−78

This diff is large: its 201 lines aren’t shown here.

plugins/openapi/sdk/document.test.ts+32−0

This diff is large: its 33 lines aren’t shown here.

plugins/openapi/sdk/document.ts+32−0

This diff is large: its 33 lines aren’t shown here.

plugins/openapi/sdk/extraction.ts+1525−0

This diff is large: its 1526 lines aren’t shown here.

plugins/openapi/sdk/fixtures/openapi-external/components/parameters/widgetId.yaml+6−0

This diff is large: its 7 lines aren’t shown here.

plugins/openapi/sdk/fixtures/openapi-external/components/schemas/widget.yaml+14−0

This diff is large: its 15 lines aren’t shown here.

plugins/openapi/sdk/fixtures/openapi-external/operations/widgets.get.yaml+17−0

This diff is large: its 18 lines aren’t shown here.

plugins/openapi/sdk/fixtures/openapi-external/root.yaml+18−0

This diff is large: its 19 lines aren’t shown here.

plugins/openapi/sdk/fixtures/resend-openapi.yaml+3715−0

This diff is large: its 3716 lines aren’t shown here.

plugins/openapi/sdk/fixtures/slack-openapi.json+23500−0

This diff is large: its 23501 lines aren’t shown here.

plugins/openapi/sdk/http-serialization.tsrenamed from packages/sources/openapi/src/http-serialization.ts+193−241

This diff is large: its 621 lines aren’t shown here.

plugins/openapi/sdk/index.ts+741−0

This diff is large: its 742 lines aren’t shown here.

plugins/openapi/sdk/package.jsonrenamed from packages/sources/openapi/package.json+10−10

This diff is large: its 41 lines aren’t shown here.

plugins/openapi/sdk/refresh.test.ts+216−0

This diff is large: its 217 lines aren’t shown here.

plugins/openapi/sdk/smoke.test.ts+119−0

This diff is large: its 120 lines aren’t shown here.

plugins/openapi/sdk/test-harness.ts+164−0

This diff is large: its 165 lines aren’t shown here.

plugins/openapi/sdk/tsconfig.json+13−0

This diff is large: its 14 lines aren’t shown here.

plugins/openapi/sdk/types.tsrenamed from packages/sources/openapi/src/types.ts+102−110

This diff is large: its 270 lines aren’t shown here.

plugins/openapi/shared/index.ts+276−0

This diff is large: its 277 lines aren’t shown here.

plugins/openapi/shared/package.jsonrenamed from packages/sources/graphql/package.json+7−5

This diff is large: its 27 lines aren’t shown here.

plugins/openapi/shared/tsconfig.json+15−0

This diff is large: its 16 lines aren’t shown here.

ralph.ts+22−0

This diff is large: its 23 lines aren’t shown here.