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 @@
1{1{
2 "sources": {2 "sources": {
3 "github": {3 "github-v3-rest-api": {
4 "kind": "openapi",4 "name": "GitHub v3 REST API",
5 "name": "GitHub",5 "kind": "openapi"
6 "connection": {
7 "endpoint": "https://api.github.com"
8 },
9 "binding": {
10 "specUrl": "https://example.com/github-openapi.yaml",
11 "defaultHeaders": null
12 }
13 },6 },
14 "google-gmail": {7 "axiom-mcp": {
15 "name": "Gmail",8 "kind": "mcp",
16 "namespace": "google.gmail",9 "name": "Axiom MCP",
17 "connection": {10 "namespace": "axiom.mcp"
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 }
62 }11 }
63 }12 }
64}13}
.gitignore+2−1
@@ -83,4 +83,5 @@ sources/composio-scrape/data/ · 82 unmodified lines
83personal-notes/83personal-notes/
84.tmp/84.tmp/
85executor/.env.executor-push85executor/.env.executor-push
86ignored/86ignored/
87agent-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
79 "no-unsafe-finally": "off",79 "no-unsafe-finally": "off",
80 },80 },
81 },81 },
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 },
91 ],82 ],
92}83}
ARCHITECTURE.md+0−411
@@ -1,411 +0,0 @@
1# Architecture
2
3This document explains the active v3 architecture at a high level.
4
5If `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
52The 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
65This is the main user entrypoint.
66
67Responsibilities:
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
75Conceptually, 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
79This package hosts the actual local server.
80
81Responsibilities:
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
89This 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
93This is the center of the system.
94
95It 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
106If 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
110This package provides the TypeScript execution environment used by the local product.
111
112At a high level it receives:
113
114- an executor implementation
115- a tool catalog
116- a tool invoker
117
118and runs user-authored code against that environment.
119
120The default runtime executes code inside a QuickJS WebAssembly sandbox so tool calls stay proxied through the control plane.
121
122The workspace can override that in `.executor/executor.jsonc` with `runtime: "quickjs" | "ses" | "deno"`.
123
124### Adapter packages
125
126Several 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
134These 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
140On first startup, the control plane provisions a local installation automatically.
141
142That 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
149This 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
153All 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
159Because 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
168The persistence layer is local-file-backed.
169
170Default behavior:
171
172- workspace config and state are stored in local files
173
174Optional behavior:
175
176- future cloud backends can plug in behind Effect service boundaries
177
178At 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
187This 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
191The source lifecycle is one of the defining architectural paths in the system.
192
193### 1. Discovery
194
195The Add Source flow starts with URL discovery.
196
197The 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
204Discovery 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
215Once a source is identified, the auth service drives the connection flow.
216
217Depending 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
224The 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
228After a source is connected, `executor` materializes a workspace-visible tool model.
229
230The 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
236This indexed representation is what powers search, inspection, and execution without re-parsing everything in the UI.
237
238### 4. Inspection
239
240The source inspection service reconstructs a rich inspection bundle for the UI.
241
242That 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
251The UI uses this to show both a tree view of tools and a search-oriented discovery view.
252
253## Execution architecture
254
255Execution is the other defining path in the system.
256
257### 1. Create execution
258
259An execution begins when the CLI, API, or MCP bridge submits TypeScript code.
260
261The 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
271Before code runs, the runtime builds the execution environment for the current workspace.
272
273That 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
279The resolver returns three things:
280
281- the SES code executor
282- the workspace tool catalog
283- the tool invoker that actually dispatches calls
284
285This resolver is the composition point where the whole product becomes one callable tool surface.
286
287### 3. Run TypeScript
288
289The code then runs inside the local SES sandbox runtime.
290
291The intended calling pattern is:
292
2931. discover a tool by intent
2942. inspect its schema if needed
2953. call the selected `tools.*` path
296
297The 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
301When code calls a tool, the control plane decides how to handle it.
302
303Possible 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
311This is also the point where policy checks and source auth resolution happen.
312
313### 5. Persist results and surface interactions
314
315The execution service updates persistent execution state as the run progresses.
316
317If 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
324Once the response arrives, the manager moves the execution back to `running` and the code continues.
325
326## Human interaction model
327
328A major architectural feature of `executor` is that interactions are first-class runtime state.
329
330This is handled by the live execution manager and the execution interaction tables.
331
332That gives the product a clean pause/resume loop instead of forcing every host to improvise its own half-finished approval flow.
333
334Interaction 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
339This 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
345The control-plane HTTP API is mounted under `/v1`.
346
347Major groups include:
348
349- local installation and secret management
350- OAuth
351- organizations and memberships
352- workspaces
353- sources
354- policies
355- executions
356
357The 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
363The bridge registers two main tools:
364
365- `execute`: run TypeScript against the local runtime
366- `resume`: continue a paused execution
367
368When 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
372The web app lives in `apps/web` and is a React app built with Vite.
373
374At 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
382The 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
386The 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
393That 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
399A 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
229 229
230```ts230```ts
231return await tools.executor.sources.add({231return await tools.executor.sources.add({
232 endpoint: "https://example.com/mcp",232 kind: "mcp",
233 name: "Example",233 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" },
235});243});
236```244```
237 245
238```ts246```ts
239return await tools.executor.sources.add({247return await tools.executor.sources.add({
240 kind: "openapi",248 kind: "openapi",
241 endpoint: "https://api.github.com",249 name: "GitHub",
242 specUrl:250 specUrl:
243 "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json",251 "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json",
244 name: "GitHub",252 baseUrl: null,
245 namespace: "github",253 auth: { kind: "none" },
246});254});
247```255```
248 256
249For HTTP-style sources, `executor` can drive the credential flow for you.257`executor.sources.add(...)` accepts the final plugin-specific source config.
258Browser OAuth and popup-driven auth flows live on the plugin-owned HTTP and web surfaces.
250 259
251## How execution works260## How execution works
252 261
@@ -382,5 +391,3 @@ This repository is explicitly on its third major architecture iteration. · 129 unmodified lines
382- `apps/` and `packages/` are the active implementation391- `apps/` and `packages/` are the active implementation
383- `legacy/` is the original codebase392- `legacy/` is the original codebase
384- `legacy2/` is the second generation393- `legacy2/` is the second generation
385
386If 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---
2title: "Adapters And Formats"
3description: "How executor supports multiple integration types without hardwiring the core to one protocol."
4---
5
6Adapters are the main extensibility boundary in executor.
7
8They 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
12An adapter owns the format-specific or protocol-specific behavior for a source.
13
14That 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
25The control plane owns the generic lifecycle around that.
26
27## Why adapters exist
28
29Executor 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
38If the core storage model were shaped around one of those directly, every new integration type would cause another redesign.
39
40Adapters prevent that.
41
42## Adapter vs format
43
44It is important not to confuse the adapter with the import format.
45
46Examples:
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
51This lets executor share a thin catalog model without pretending every source has the same runtime behavior.
52
53## What gets stored
54
55Adapters do **not** get to invent arbitrary top-level storage models.
56
57They compile into the same canonical layers:
58
59- recipe
60- revision
61- raw documents
62- schema bundles
63- operations
64
65That means the control plane can stay generic while adapters remain flexible.
66
67## Why binding config is adapter-owned
68
69Adapter-owned binding config is one of the most important anti-churn decisions in the codebase.
70
71Instead of adding more and more provider columns to the source model, the adapter carries its own binding payload and version.
72
73That 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
77Executor does still store some provider-shaped detail in operation metadata and bundle/document kinds.
78
79That 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
84The goal is not to erase differences between formats. The goal is to stop those differences from dominating the core architecture.
85
86For 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
90A 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
98If 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---
2title: "CLI"
3description: "How the executor CLI fits into the system and why it stays thin."
4---
5
6The CLI lives in `apps/executor`.
7
8Its job is to be the installed user-facing shell around the local runtime, not the place where product logic is implemented.
9
10The 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
14The 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
25The CLI should not own:
26
27- source persistence rules
28- auth workflows
29- tool discovery
30- execution state machines
31- tool invocation behavior
32
33Those belong in the control plane.
34
35## Why this boundary matters
36
37This split is intentional.
38
39It 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
43At a high level, the CLI:
44
451. starts a foreground local session or ensures the detached daemon is available
462. calls the local HTTP API exposed by `packages/platform/server`
473. hands off real work to `packages/platform/sdk` and `packages/platform/api`
48
49That makes the CLI a stable shell over a shared local runtime rather than a second product core.
50
51## Why this is flexible
52
53Because the CLI is thin, new product capabilities usually do not require redesigning the CLI architecture.
54
55Most new work lands in:
56
57- control-plane runtime logic
58- source adapters
59- codemode packages
60- server/API surfaces
61
62The 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---
2title: "Code Mode"
3description: "The shared tool abstractions and adapter packages that connect sources to execution."
4---
5
6The codemode packages are the bridge between imported integrations and executable tools.
7
8They provide the shared tool abstractions that both the control plane and runtime can depend on.
9
10## Core role
11
12The codemode layer is where executor turns "some external thing" into a discoverable and invokable tool surface.
13
14That 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
20This 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
29This is the main abstraction boundary that other layers build on.
30
31### `packages/sources/openapi`
32
33This package handles OpenAPI-specific extraction and tool generation.
34
35Its 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
39This 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
43Without the codemode layer, the control plane would have to understand every source format and execution detail directly.
44
45Instead, executor keeps the core model broad and pushes format-specific transformation logic into focused packages.
46
47That 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
55The control plane owns:
56
57- bindings
58- credentials
59- revisions and materialization
60- tool indexing
61- policy and invocation lifecycle
62
63The 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
69That 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---
2title: "Core Model"
3description: "The main data and runtime concepts in executor today."
4---
5
6The most important thing to understand about executor is that it does **not** model the system as one flat `source` row.
7
8The current model has a few separate concepts that each exist for a different reason.
9
10## The primary concepts
11
12### Source binding
13
14A source is the workspace-local installation record.
15
16It 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
23This is why a source carries local concerns like name, namespace, enabled state, binding config, and status.
24
25### Recipe
26
27A recipe is the shareable integration identity.
28
29It answers:
30
31- what integration definition is this?
32- what adapter produced it?
33- what broad catalog kind does it belong to?
34
35A recipe is meant to converge across workspaces when the underlying integration definition is the same.
36
37### Revision / materialization
38
39A recipe revision is the compiled result of an import.
40
41It answers:
42
43- what source config produced this compiled shape?
44- what manifest, documents, schema bundles, and operations belong to it?
45
46This is the durable execution artifact layer.
47
48### Operation IR
49
50Operations are the canonical callable tool index.
51
52They 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
58The operation layer exists because raw upstream documents are not a good direct query surface for search, policy, and invocation lookup.
59
60### Credential
61
62Credentials are private auth bindings.
63
64They answer:
65
66- who can authenticate for this binding?
67- for which slot?
68- using what auth kind and secret refs?
69
70Credentials 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
74This 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
83Source-specific connection config now lives in adapter-owned binding payloads:
84
85- `bindingVersion`
86- `binding`
87
88That means the core model does not need built-in fields forever for every provider-specific connection knob.
89
90Examples:
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
96The adapter owns serialization, decoding, defaults, and validation for that payload.
97
98## Catalog kinds vs adapters
99
100Executor separates broad catalog kinds from adapter keys.
101
102Catalog kinds:
103
104- `imported`
105- `internal`
106
107Adapters are more specific:
108
109- `openapi`
110- `graphql`
111- `mcp`
112- `google_discovery`
113- future `postman_collection`
114- future `snippet_bundle`
115
116The 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
120The 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
125and not:
126
127- redesign how source persistence fundamentally works
128
129That is the main goal of the current architecture.
apps/docs/developer/credentials-and-auth.mdx+0−113
@@ -1,113 +0,0 @@
1---
2title: "Credentials And Auth"
3description: "How executor models auth, interactive flows, and actor-scoped credential ownership."
4---
5
6Auth in executor is not treated as a side detail on the source row.
7
8It 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
12Credentials are scoped to:
13
14- workspace
15- source binding
16- actor
17- slot
18
19This is important because different users in the same workspace may connect different identities to the same source.
20
21## Auth slots
22
23Executor distinguishes where auth is used.
24
25Important current slots:
26
27- `runtime`
28- `import`
29
30Why 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
36This is why source auth is more than a single token field.
37
38## Static credentials
39
40The common case today is still static auth backed by secret references.
41
42Examples:
43
44- bearer token
45- OAuth access token
46- OAuth refresh token
47
48The control plane stores secret refs, not raw secret values inline on the source or credential rows.
49
50## Interactive auth sessions
51
52Interactive auth, such as OAuth flows, is modeled separately from stored credentials.
53
54That 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
60This is why executor has auth sessions and credentials as separate concepts.
61
62## Shareable config vs private state
63
64Project config under `.executor/executor.jsonc` is intended to stay portable and committable.
65
66Private auth state does not belong in the workspace tree:
67
68- actor-scoped credentials
69- OAuth sessions
70- refresh leases
71- secret material
72
73That 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
77The 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
82Actor-scoped ownership is the durable fix for that.
83
84## The next auth primitive
85
86One important extension area is dynamic credentials.
87
88Static 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
94The 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
100That 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
104Recipes are shareable integration definitions.
105Credentials are private installation-specific state.
106
107That 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
113This 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---
2title: "Extending Executor"
3description: "How to add new source support without breaking the current architecture."
4---
5
6The 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
10Not every new integration should be modeled as a source.
11
12Use 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
19If the new thing is really:
20
21- a local tool library
22- a package bundle
23- a browser automation runtime
24
25then 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
29The happy path for a new integration is:
30
311. define the adapter key, catalog kind, and strategies
322. define the connect and binding config shape
333. materialize into documents, schema bundles, and operations
344. implement invocation
355. add tests against the canonical model
36
37The adapter should absorb provider-specific logic so the core does not have to.
38
39## What a new adapter should not do
40
41Avoid 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
48Those are the patterns that lead back to churn.
49
50## The standard for a good extension
51
52A good new adapter should make the core system feel boring.
53
54That 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
61If 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
65The 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
72Those should mostly be adapter and auth-layer work, not another persistence redesign.
73
74## Why this is the best setup
75
76The current setup is not trying to be abstract for its own sake.
77It 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
83That 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---
2title: "Import Fidelity"
3description: "What executor preserves, normalizes, and currently loses when importing external formats."
4---
5
6Executor should normalize syntax, not meaning.
7
8That 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
10The 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
18This 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
25Current 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
48GraphQL 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
60Google 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
72MCP 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
76Even when data is preserved natively, the current inspection UI often does not show it directly.
77
78Today inspection primarily exposes:
79
80- projected call and result schemas
81- capability summary text
82- the first executable native blob
83
84That means "preserved in native data" and "visible to a user" are still different states.
85
86## What should be first-class in IR
87
88These 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
97By 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
124The 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
130If executor keeps that line clear, a standardized representation is a strength.
131
132If 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---
2title: "MCP"
3description: "How executor uses MCP both as an adapter and as an external interface."
4---
5
6MCP shows up in executor in two different ways.
7
8## 1. Executor can connect to MCP servers
9
10This is the source side of MCP.
11
12When 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
14This 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
18This 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
22That 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
31These 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
36Keeping that distinction clear avoids collapsing very different responsibilities into one vague "MCP integration" concept.
37
38## Where the logic lives
39
40Broadly:
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
49Because 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
51That 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---
2title: "Control Plane Overview"
3description: "How the control plane is structured today and how the major pieces fit together."
4---
5
6Executor 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
14Executor is a local control plane that:
15
161. stores connected integrations as sources
172. materializes those sources into callable tools
183. exposes those tools to the runtime, the UI, and MCP clients
194. resolves auth and policy at invocation time
20
21## Major packages
22
23### `apps/executor`
24
25The CLI and daemon manager.
26
27Responsibilities:
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
36The local server shell around the control plane.
37
38Responsibilities:
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
46The product core.
47
48Responsibilities:
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
59These 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
69The 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
75That 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
83Those 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
87Executor 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
96This 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
100The 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
108The 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---
2title: "Persistence And Migrations"
3description: "How executor stores source state today and how migration logic is intended to work."
4---
5
6Executor persistence is designed around durable runtime state, not just CRUD storage.
7
8That 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
21The storage model is layered because these concerns have different reuse and ownership semantics.
22
23Examples:
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
31Trying to collapse those into one table or one document shape causes churn quickly.
32
33## What is normalized vs serialized
34
35Executor intentionally uses both relational columns and serialized JSON.
36
37Normalized:
38
39- ownership
40- ids and references
41- search and lookup fields
42- status and visibility
43- auth/session ownership
44
45Serialized:
46
47- adapter-owned binding config
48- source config blobs
49- provider metadata
50- some session payloads
51
52This is deliberate.
53The rule is to normalize what the core system queries and enforces, and serialize what belongs to adapter-specific contracts.
54
55## Migrations philosophy
56
57The 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
63That last point matters.
64Compatibility logic belongs in upgrade code, not in normal reads and writes.
65
66## What is still acceptable in migration code
67
68One-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
74What 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
81This 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
86That 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---
2title: "Tool Catalog And Execution"
3description: "How source materialization becomes callable tools and how invocation works."
4---
5
6Executor turns connected integrations into a workspace tool catalog.
7
8That catalog is the thing the runtime actually executes against.
9
10The execution sandbox is selected from `.executor/executor.jsonc`:
11
12```jsonc
13{
14 "runtime": "quickjs"
15}
16```
17
18Supported values are `quickjs` (default), `ses`, and `deno`.
19
20## How tools are produced
21
22At a high level:
23
241. an adapter materializes a source
252. the materialization writes documents, schema bundles, and operations
263. the control plane loads those persisted operations into a workspace tool index
274. the runtime invokes one tool at a time through the control plane
28
29This means executor does not rebuild every integration from scratch on every execution.
30
31## Why operations exist as a separate layer
32
33The 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
41Raw upstream documents are still kept, but they are not the best hot-path query surface.
42
43## Schema transport
44
45Executor uses compact operation-root schemas plus shared schema bundles.
46
47This 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
53The result is:
54
55- smaller payloads
56- more reusable schema graphs
57- cleaner tool detail responses
58
59## Tool catalog shape
60
61The tool catalog is built from:
62
63- persisted source tools
64- built-in executor tools
65
66That lets executor present one unified discovery and invocation surface even though the underlying tools come from different places.
67
68## Invocation flow
69
70At a high level, invocation works like this:
71
721. resolve the persisted operation by tool path
732. run policy and approval checks
743. resolve auth material for the correct slot
754. load the manifest and schema bundle needed for the call
765. delegate actual invocation to the adapter
77
78This 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
85This 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
91That 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
11 "navigation": {11 "navigation": {
12 "tabs": [12 "tabs": [
13 {13 {
14 "tab": "Product",14 "tab": "Docs",
15 "groups": [15 "groups": [
16 {16 {
17 "group": "Introduction",17 "group": "Status",
18 "pages": [18 "pages": [
19 "introduction"19 "introduction"
20 ]20 ]
21 }21 }
22 ]22 ]
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 ]
58 }23 }
59 ]24 ]
60 },25 },
apps/docs/introduction.mdx+2−12
@@ -5,16 +5,6 @@ description: "A local-first execution environment for AI agents." · 4 unmodified lines
5 5
6`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.6`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.
7 7
8This docs site is split into two documentation surfaces:8The docs set has been intentionally removed for now while the plugin architecture is still shifting.
9 9
10- product-facing docs for how to install and use executor10Use the repository source directly if you need current implementation details.
11- developer-facing docs for how executor is built and how to extend it
12
13The product-facing section will come later. The current docs start with the developer-facing architecture and implementation model.
14
15If 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)
apps/executor/package.json+4−1
@@ -38,8 +38,11 @@ · 37 unmodified lines
38 "@effect/platform-node": "catalog:",38 "@effect/platform-node": "catalog:",
39 "@executor/codemode-core": "workspace:*",39 "@executor/codemode-core": "workspace:*",
40 "@executor/executor-mcp": "workspace:*",40 "@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:*",
41 "@executor/platform-api": "workspace:*",45 "@executor/platform-api": "workspace:*",
42 "@executor/platform-internal": "workspace:*",
43 "@executor/platform-sdk": "workspace:*",46 "@executor/platform-sdk": "workspace:*",
44 "@executor/platform-sdk-file": "workspace:*",47 "@executor/platform-sdk-file": "workspace:*",
45 "@executor/server": "workspace:*",48 "@executor/server": "workspace:*",
apps/executor/src/cli/dev.ts+0−231
@@ -1,231 +0,0 @@
1import type { ExecutorApiEffectClient as ExecutorApiClient } from "@executor/platform-api/effect";
2import { ScopeIdSchema } from "@executor/platform-sdk/schema";
3import * as Effect from "effect/Effect";
4
5const readBindingString = (binding: Record<string, unknown>, key: string): string | null =>
6 typeof binding[key] === "string" ? String(binding[key]) : null;
7
8type SeedDemoMcpSourceInput = {
9 client: ExecutorApiClient;
10 workspaceId: string;
11 endpoint: string;
12 name: string;
13 namespace: string;
14};
15
16type 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
30type SeedGithubOpenApiSourceInput = {
31 client: ExecutorApiClient;
32 workspaceId: string;
33 endpoint: string;
34 specUrl: string;
35 name: string;
36 namespace: string;
37 credentialEnvVar?: string;
38};
39
40export 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
137export 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
14 type ExecutorApiEffectClient as ExecutorApiClient,14 type ExecutorApiEffectClient as ExecutorApiClient,
15} from "@executor/platform-api/effect";15} from "@executor/platform-api/effect";
16import { runExecutorMcpStdioServer } from "@executor/executor-mcp";16import { runExecutorMcpStdioServer } from "@executor/executor-mcp";
17import { createWorkspaceExecutorAdminToolMap } from "@executor/platform-internal";
18import {17import {
19 EXECUTOR_SOURCES_ADD_HELP_LINES,18 googleDiscoverySdkPlugin,
19} from "@executor/plugin-google-discovery-sdk";
20import {
21 graphqlSdkPlugin,
22} from "@executor/plugin-graphql-sdk";
23import {
24 mcpSdkPlugin,
25} from "@executor/plugin-mcp-sdk";
26import {
27 openApiSdkPlugin,
28} from "@executor/plugin-openapi-sdk";
29import {
30 getExecutorSourcesAddHelpLines,
20 RuntimeExecutionResolverService,31 RuntimeExecutionResolverService,
21} from "@executor/platform-sdk/runtime";32} from "@executor/platform-sdk/runtime";
22import {33import {
@@ -37,10 +48,15 @@ import * as Option from "effect/Option"; · 14 unmodified lines
37import * as Cause from "effect/Cause";48import * as Cause from "effect/Cause";
38 49
39import {50import {
51 createFileGoogleDiscoveryOAuthSessionStorage,
52 createFileGoogleDiscoverySourceStorage,
53 createFileGraphqlSourceStorage,
54 createFileMcpOAuthSessionStorage,
55 createFileMcpSourceStorage,
56 createFileOpenApiSourceStorage,
40 createLocalExecutorServer,57 createLocalExecutorServer,
41 DEFAULT_SERVER_BASE_URL,58 DEFAULT_SERVER_BASE_URL,
42 DEFAULT_SERVER_HOST,59 DEFAULT_SERVER_HOST,
43 DEFAULT_LOCAL_DATA_DIR,
44 DEFAULT_SERVER_LOG_FILE,60 DEFAULT_SERVER_LOG_FILE,
45 DEFAULT_SERVER_PID_FILE,61 DEFAULT_SERVER_PID_FILE,
46 DEFAULT_SERVER_PORT,62 DEFAULT_SERVER_PORT,
@@ -49,10 +65,7 @@ import { · 2 unmodified lines
49 runLocalExecutorServer,65 runLocalExecutorServer,
50} from "@executor/server";66} from "@executor/server";
51import {67import {
52 seedDemoMcpSourceInWorkspace,68 resolveCliLocalDataDir,
53 seedGithubOpenApiSourceInWorkspace,
54} from "./dev";
55import {
56 resolveRuntimeWebAssetsDir,69 resolveRuntimeWebAssetsDir,
57 resolveSelfCommand,70 resolveSelfCommand,
58} from "./runtime-paths";71} from "./runtime-paths";
@@ -74,7 +87,6 @@ import { · 15 unmodified lines
74 87
75const toError = (cause: unknown): Error =>88const toError = (cause: unknown): Error =>
76 cause instanceof Error ? cause : new Error(String(cause));89 cause instanceof Error ? cause : new Error(String(cause));
77
78const sleep = (ms: number) =>90const sleep = (ms: number) =>
79 Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms)));91 Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms)));
80 92
@@ -166,6 +178,7 @@ const getBootstrapClient = (baseUrl: string = DEFAULT_SERVER_BASE_URL) => · 85 unmodified lines
166const decodeExecutionId = Schema.decodeUnknown(ExecutionIdSchema);178const decodeExecutionId = Schema.decodeUnknown(ExecutionIdSchema);
167const require = createRequire(import.meta.url);179const require = createRequire(import.meta.url);
168const CLI_NAME = "executor";180const CLI_NAME = "executor";
181const CLI_LOCAL_DATA_DIR = resolveCliLocalDataDir();
169const CLI_VERSION = (() => {182const CLI_VERSION = (() => {
170 const candidatePaths = [183 const candidatePaths = [
171 "../package.json",184 "../package.json",
@@ -217,13 +230,13 @@ const buildWorkflowText = (namespaces: readonly string[] = []): string => · 45 unmodified lines
217 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',230 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',
218 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",231 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",
219 "3) Call selected tools.<path>(input).",232 "3) Call selected tools.<path>(input).",
220 '4) To connect a source, call tools.executor.sources.add(...) for MCP, OpenAPI, or GraphQL APIs.',233 "4) Use source plugins to inspect or add API sources.",
221 ...EXECUTOR_SOURCES_ADD_HELP_LINES,234 ...getExecutorSourcesAddHelpLines(),
222 "5) If execution pauses for interaction, resume it with `executor resume --execution-id ...`.",235 "5) If execution pauses for interaction, resume it with `executor resume --execution-id ...`.",
223 "Do not use fetch; use tools.* only.",236 "Do not use fetch; use tools.* only.",
224 ].join("\n");237 ].join("\n");
225 238
226const DEFAULT_RUN_WORKFLOW = buildWorkflowText();239const getDefaultRunWorkflow = () => buildWorkflowText();
227 240
228const indentBlock = (value: string, prefix: string = " "): string =>241const indentBlock = (value: string, prefix: string = " "): string =>
229 value242 value
@@ -268,7 +281,7 @@ const buildRunWorkflowText = ( · 38 unmodified lines
268 catalog?: ToolCatalog,281 catalog?: ToolCatalog,
269): Effect.Effect<string, Error, never> => {282): Effect.Effect<string, Error, never> => {
270 if (!catalog) {283 if (!catalog) {
271 return Effect.succeed(DEFAULT_RUN_WORKFLOW);284 return Effect.succeed(getDefaultRunWorkflow());
272 }285 }
273 286
274 return catalog.listNamespaces({ limit: 200 }).pipe(287 return catalog.listNamespaces({ limit: 200 }).pipe(
@@ -287,9 +300,36 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 12 unmodified lines
287 Effect.acquireUseRelease(300 Effect.acquireUseRelease(
288 createExecutorEffect({301 createExecutorEffect({
289 backend: createLocalExecutorBackend({302 backend: createLocalExecutorBackend({
290 localDataDir: DEFAULT_LOCAL_DATA_DIR,303 localDataDir: CLI_LOCAL_DATA_DIR,
291 }),304 }),
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,
293 }).pipe(Effect.mapError(toError)),333 }).pipe(Effect.mapError(toError)),
294 (executor) =>334 (executor) =>
295 Effect.gen(function* () {335 Effect.gen(function* () {
@@ -312,7 +352,7 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 16 unmodified lines
312 Effect.catchAllCause((cause) =>352 Effect.catchAllCause((cause) =>
313 Effect.succeed(353 Effect.succeed(
314 [354 [
315 DEFAULT_RUN_WORKFLOW,355 getDefaultRunWorkflow(),
316 "",356 "",
317 formatCatalogUnavailableMessage(cause),357 formatCatalogUnavailableMessage(cause),
318 ].join("\n"),358 ].join("\n"),
@@ -389,8 +429,6 @@ const printCallHelp = (workflow: string) => · 70 unmodified lines
389 "",429 "",
390 ' executor call \'const matches = await tools.discover({ query: "github issues", limit: 5 }); return matches;\'',430 ' executor call \'const matches = await tools.discover({ query: "github issues", limit: 5 }); return matches;\'',
391 ' executor call \'const matches = await tools.discover({ query: "repo details", limit: 1 }); const path = matches.bestPath; return await tools.describe.tool({ path, includeSchemas: true });\'',431 ' 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" });\'',
394 " cat script.ts | executor call --stdin",432 " cat script.ts | executor call --stdin",
395 " executor call --file script.ts",433 " executor call --file script.ts",
396 " executor call --no-open --file script.ts",434 " executor call --no-open --file script.ts",
@@ -418,7 +456,6 @@ const getLocalAuthedClient = (baseUrl: string = DEFAULT_SERVER_BASE_URL) => · 21 unmodified lines
418 const installation = yield* bootstrapClient.local.installation({});456 const installation = yield* bootstrapClient.local.installation({});
419 const client = yield* createExecutorApiClient({457 const client = yield* createExecutorApiClient({
420 baseUrl,458 baseUrl,
421 accountId: installation.actorScopeId,
422 });459 });
423 460
424 return {461 return {
@@ -440,7 +477,7 @@ const getDefaultServerOptions = (port: number = DEFAULT_SERVER_PORT) => { · 15 unmodified lines
440 return {477 return {
441 host: DEFAULT_SERVER_HOST,478 host: DEFAULT_SERVER_HOST,
442 port,479 port,
443 localDataDir: DEFAULT_LOCAL_DATA_DIR,480 localDataDir: CLI_LOCAL_DATA_DIR,
444 pidFile: DEFAULT_SERVER_PID_FILE,481 pidFile: DEFAULT_SERVER_PID_FILE,
445 ui: assetsDir ? { assetsDir } : undefined,482 ui: assetsDir ? { assetsDir } : undefined,
446 };483 };
@@ -602,7 +639,7 @@ const getServerStatus = ( · 155 unmodified lines
602 pid,639 pid,
603 pidRunning,640 pidRunning,
604 logFile,641 logFile,
605 localDataDir: DEFAULT_LOCAL_DATA_DIR,642 localDataDir: CLI_LOCAL_DATA_DIR,
606 webAssetsDir: resolveRuntimeWebAssetsDir(),643 webAssetsDir: resolveRuntimeWebAssetsDir(),
607 installation,644 installation,
608 } satisfies LocalServerStatus;645 } satisfies LocalServerStatus;
@@ -1036,54 +1073,6 @@ const printExecution = (envelope: ExecutionEnvelope) => · 427 unmodified lines
1036 }));1073 }));
1037 });1074 });
1038 1075
1039const 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
1061const 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
1087const driveExecution = (input: {1076const driveExecution = (input: {
1088 client: ExecutorApiClient;1077 client: ExecutorApiClient;
1089 workspaceId: ExecutionEnvelope["execution"]["scopeId"];1078 workspaceId: ExecutionEnvelope["execution"]["scopeId"];
@@ -1359,64 +1348,7 @@ const resumeCommand = Command.make( · 269 unmodified lines
1359 }),1348 }),
1360).pipe(Command.withDescription("Resume a paused execution"));1349).pipe(Command.withDescription("Resume a paused execution"));
1361 1350
1362const 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
1385const 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
1418const devCommand = Command.make("dev").pipe(1351const devCommand = Command.make("dev").pipe(
1419 Command.withSubcommands([devSeedMcpDemoCommand, devSeedGithubCommand] as const),
1420 Command.withDescription("Development helpers"),1352 Command.withDescription("Development helpers"),
1421);1353);
1422 1354
apps/executor/src/cli/pending-interaction-output.test.ts+0−113
@@ -1,113 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import type { ExecutionInteraction } from "@executor/platform-sdk/schema";
3
4import {
5 buildPausedExecutionOutput,
6 parseInteractionPayload,
7} from "./pending-interaction-output";
8
9const 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
39describe("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
55 switch (interaction.purpose) {55 switch (interaction.purpose) {
56 case "tool_execution_gate":56 case "tool_execution_gate":
57 return "this tool call requires approval before executor can continue";57 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";
62 default:58 default:
63 return "executor needs additional input before it can continue";59 return "executor needs additional input before it can continue";
64 }60 }
apps/executor/src/cli/runtime-paths.test.ts+47−0
@@ -0,0 +1,47 @@
1import { join } from "node:path";
2import { describe, expect, it } from "@effect/vitest";
3
4import {
5 DEFAULT_EXECUTOR_DATA_DIR,
6 DEFAULT_LOCAL_DATA_DIR,
7} from "@executor/server";
8
9import {
10 resolveCliLocalDataDir,
11} from "./runtime-paths";
12
13describe("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 @@
1import { existsSync } from "node:fs";1import { existsSync } from "node:fs";
2import { dirname, extname, resolve } from "node:path";2import { dirname, extname, join, resolve } from "node:path";
3import { fileURLToPath } from "node:url";3import { fileURLToPath } from "node:url";
4 4
5import {5import {
6 DEFAULT_EXECUTOR_DATA_DIR,
7 DEFAULT_LOCAL_DATA_DIR,
8 EXECUTOR_LOCAL_DATA_DIR_ENV,
6 EXECUTOR_WEB_ASSETS_DIR_ENV,9 EXECUTOR_WEB_ASSETS_DIR_ENV,
7} from "@executor/server";10} from "@executor/server";
8 11
@@ -36,6 +39,8 @@ const getSourceEntrypoint = (): string | null => { · 27 unmodified lines
36 : null;39 : null;
37};40};
38 41
42const isSourceCliLaunch = (): boolean => getSourceEntrypoint() !== null;
43
39const resolveBundledNodeLauncher = (): string | null => {44const resolveBundledNodeLauncher = (): string | null => {
40 const candidate = resolve(sourceDir, "executor.js");45 const candidate = resolve(sourceDir, "executor.js");
41 return existsSync(candidate) ? candidate : null;46 return existsSync(candidate) ? candidate : null;
@@ -76,6 +81,16 @@ export const resolveSelfCommand = (args: readonly string[]): readonly string[] = · 34 unmodified lines
76 : [process.execPath, sourceEntrypoint, ...args];81 : [process.execPath, sourceEntrypoint, ...args];
77};82};
78 83
84export 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
79export const resolveRuntimeWebAssetsDir = (): string | null => {94export const resolveRuntimeWebAssetsDir = (): string | null => {
80 const explicit = resolveIfExists(process.env[EXECUTOR_WEB_ASSETS_DIR_ENV]);95 const explicit = resolveIfExists(process.env[EXECUTOR_WEB_ASSETS_DIR_ENV]);
81 if (explicit) {96 if (explicit) {
apps/executor/src/distribution/artifact.ts+0−6
@@ -187,12 +187,6 @@ export const buildDistributionPackage = async ( · 186 unmodified lines
187 187
188 await cp(webDistDir, webDir, { recursive: true });188 await cp(webDistDir, webDir, { recursive: true });
189 await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm"));189 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 );
196 await cp(190 await cp(
197 join(repoRoot, "packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs"),191 join(repoRoot, "packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs"),
198 join(binDir, "deno-subprocess-worker.mjs"),192 join(binDir, "deno-subprocess-worker.mjs"),
apps/executor/src/server/server.real-ingestion.test.ts+0−262
@@ -1,262 +0,0 @@
1import { FileSystem } from "@effect/platform";
2import { NodeFileSystem } from "@effect/platform-node";
3import { describe, expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5
6import {
7 createExecutorApiEffectClient as createExecutorApiClient,
8} from "@executor/platform-api/effect";
9import { type SourceDiscoveryKind } from "@executor/platform-sdk/schema";
10import { createLocalExecutorServer } from "@executor/server";
11
12const REAL_VERCEL_SPEC_URL = "https://openapi.vercel.sh";
13const REAL_VERCEL_API_ENDPOINT = "https://api.vercel.com";
14const REAL_LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql";
15const REAL_DEEPWIKI_MCP_ENDPOINT = "https://mcp.deepwiki.com/mcp";
16
17const makeTempWorkspaceRoot = () =>
18 FileSystem.FileSystem.pipe(
19 Effect.flatMap((fs) => fs.makeTempDirectory({ prefix: "executor-real-ingestion-" })),
20 Effect.provide(NodeFileSystem.layer),
21 );
22
23const 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
33const 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
51const canonicalUrl = (value: string): string =>
52 new URL(value).toString();
53
54const expectDiscoveredKind = <T extends { detectedKind: SourceDiscoveryKind }>(
55 result: T,
56 expectedKind: SourceDiscoveryKind,
57) => {
58 expect(result.detectedKind).toBe(expectedKind);
59};
60
61describe("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
9 "start": "NODE_ENV=production bun src/server.ts"9 "start": "NODE_ENV=production bun src/server.ts"
10 },10 },
11 "dependencies": {11 "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:*",
12 "@executor/react": "workspace:*",17 "@executor/react": "workspace:*",
13 "@executor/server": "workspace:*",18 "@executor/server": "workspace:*",
14 "@shikijs/langs": "^4.0.1",19 "@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
4 4
5type LocalMcpInstallMode = "stdio" | "http";5type LocalMcpInstallMode = "stdio" | "http";
6 6
7const isDevBuild = import.meta.env.DEV;
8
7export function LocalMcpInstallCard(props: {9export function LocalMcpInstallCard(props: {
8 title?: string;10 title?: string;
9 description?: string;11 description?: string;
@@ -17,7 +19,9 @@ export function LocalMcpInstallCard(props: { · 7 unmodified lines
17 }, []);19 }, []);
18 20
19 const command = mode === "stdio"21 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"'
21 : origin25 : origin
22 ? `npx add-mcp "${origin}/mcp" --transport http --name "executor"`26 ? `npx add-mcp "${origin}/mcp" --transport http --name "executor"`
23 : 'npx add-mcp "<this-server>/mcp" --transport http --name "executor"';27 : 'npx add-mcp "<this-server>/mcp" --transport http --name "executor"';
@@ -57,9 +61,22 @@ export function LocalMcpInstallCard(props: { · 33 unmodified lines
57 </div>61 </div>
58 <CodeBlock code={command} lang="bash" className="rounded-xl border border-border bg-background/70" />62 <CodeBlock code={command} lang="bash" className="rounded-xl border border-border bg-background/70" />
59 {mode === "stdio" ? (63 {mode === "stdio" ? (
60 <p className="mt-3 text-[12px] text-muted-foreground">64 <div className="mt-3 space-y-1 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.65 {!isDevBuild ? (
62 </p>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>
63 ) : null}80 ) : null}
64 </section>81 </section>
65 );82 );
apps/web/src/components/shell.tsx+76−18
@@ -1,10 +1,15 @@
1import { Link, Outlet, useMatchRoute } from "@tanstack/react-router";1import { Link, Outlet, useLocation, useMatchRoute } from "@tanstack/react-router";
2import { useCallback, useEffect, useState } from "react";2import { useCallback, useEffect, useState } from "react";
3import { useSources, type Source } from "@executor/react";3import { useSources, type Source } from "@executor/react";
4import { sourcePluginsIndexPath } from "@executor/react/plugins";
4import { cn } from "../lib/utils";5import { cn } from "../lib/utils";
5import { IconPlus, IconCopy, IconCheck } from "./icons";6import { IconPlus, IconCopy, IconCheck } from "./icons";
6import { LoadableBlock } from "./loadable";7import { LoadableBlock } from "./loadable";
7import { SourceFavicon } from "./source-favicon";8import { SourceFavicon } from "./source-favicon";
9import {
10 getSourceFrontendPaths,
11 registeredFrontendPluginNavRoutes,
12} from "../plugins";
8 13
9// ── Status dot color ─────────────────────────────────────────────────────14// ── Status dot color ─────────────────────────────────────────────────────
10 15
@@ -217,10 +222,18 @@ function UpdateCard(props: { latestVersion: string; channel: UpdateChannel }) { · 206 unmodified lines
217// ── AppShell ─────────────────────────────────────────────────────────────222// ── AppShell ─────────────────────────────────────────────────────────────
218export function AppShell() {223export function AppShell() {
219 const sources = useSources();224 const sources = useSources();
225 const location = useLocation();
220 const matchRoute = useMatchRoute();226 const matchRoute = useMatchRoute();
221 const isHome = matchRoute({ to: "/" });227 const isHome = matchRoute({ to: "/" });
222 const isSecrets = matchRoute({ to: "/secrets" });228 const isSecrets = matchRoute({ to: "/secrets" });
223 const { latestVersion, updateAvailable, channel } = useLatestVersion(VITE_APP_VERSION);229 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
224 return (237 return (
225 <div className="flex h-screen overflow-hidden">238 <div className="flex h-screen overflow-hidden">
226 {/* Sidebar */}239 {/* Sidebar */}
@@ -241,13 +254,21 @@ export function AppShell() { · 14 unmodified lines
241 <nav className="flex flex-1 flex-col p-2 overflow-y-auto">254 <nav className="flex flex-1 flex-col p-2 overflow-y-auto">
242 <NavItem to="/" label="Dashboard" active={!!isHome} />255 <NavItem to="/" label="Dashboard" active={!!isHome} />
243 <NavItem to="/secrets" label="Secrets" active={!!isSecrets} />256 <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 ))}
244 265
245 {/* Sources */}266 {/* Sources */}
246 <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50">267 <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50">
247 <div className="flex items-center justify-between gap-2">268 <div className="flex items-center justify-between gap-2">
248 <span>Sources</span>269 <span>Sources</span>
249 <Link270 <Link
250 to="/sources/add"271 to={sourcePluginsIndexPath}
251 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"272 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"
252 >273 >
253 <IconPlus className="size-3" />274 <IconPlus className="size-3" />
@@ -255,16 +276,39 @@ export function AppShell() { · 1 unmodified line
255 </Link>276 </Link>
256 </div>277 </div>
257 </div>278 </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 )}
258 <LoadableBlock loadable={sources} loading="Loading...">294 <LoadableBlock loadable={sources} loading="Loading...">
259 {(items) =>295 {(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 ? (
261 <div className="px-2.5 py-2 text-[11px] leading-relaxed text-muted-foreground/40">301 <div className="px-2.5 py-2 text-[11px] leading-relaxed text-muted-foreground/40">
262 No sources yet302 No sources yet
263 </div>303 </div>
264 ) : (304 ) : (
265 <div className="flex flex-col gap-px">305 <div className="flex flex-col gap-px">
266 {items.map((source) => (306 {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 />
268 ))}312 ))}
269 </div>313 </div>
270 )314 )
@@ -294,7 +338,7 @@ export function AppShell() { · 23 unmodified lines
294 </aside>338 </aside>
295 339
296 {/* Main content */}340 {/* 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">
298 <Outlet />342 <Outlet />
299 </main>343 </main>
300 </div>344 </div>
@@ -304,20 +348,34 @@ export function AppShell() { · 3 unmodified lines
304// ── SourceItem ───────────────────────────────────────────────────────────348// ── SourceItem ───────────────────────────────────────────────────────────
305 349
306function SourceItem(props: {350function SourceItem(props: {
351 pathname: string;
307 source: Source;352 source: Source;
308 matchRoute: ReturnType<typeof useMatchRoute>;
309}) {353}) {
310 const { source, matchRoute } = props;354 const paths = getSourceFrontendPaths(props.source.kind);
311 const active = matchRoute({355
312 to: "/sources/$sourceId",356 if (!paths) {
313 params: { sourceId: source.id },357 return (
314 fuzzy: true,358 <div className="group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] text-sidebar-foreground/60">
315 });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}/`);
316 375
317 return (376 return (
318 <Link377 <Link
319 to="/sources/$sourceId"378 to={detailPath}
320 params={{ sourceId: source.id }}
321 search={{ tab: "model" }}379 search={{ tab: "model" }}
322 className={cn(380 className={cn(
323 "group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors",381 "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
327 )}385 )}
328 >386 >
329 <div className="flex size-3 shrink-0 items-center justify-center text-muted-foreground/50">387 <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" />
331 </div>389 </div>
332 <span className="flex-1 truncate">{source.name}</span>390 <span className="flex-1 truncate">{props.source.name}</span>
333 <span391 <span
334 className={cn("size-1.5 shrink-0 rounded-full", statusColor[source.status] ?? "bg-muted-foreground/30")}392 className={cn("size-1.5 shrink-0 rounded-full", statusColor[props.source.status] ?? "bg-muted-foreground/30")}
335 title={source.status}393 title={props.source.status}
336 />394 />
337 </Link>395 </Link>
338 );396 );
apps/web/src/components/source-favicon.tsx+13−61
@@ -1,34 +1,25 @@
1import { useMemo, useState } from "react";1import { useState } from "react";
2import type { Source } from "@executor/react";2import type { Source } from "@executor/react";
3import { getGoogleProductIconUrl, getSourceFaviconUrl } from "../lib/source-favicon";3import { getSourceFrontendIconUrl } from "../plugins";
4import { cn } from "../lib/utils";4import { cn } from "../lib/utils";
5 5
6type SourceKind = Source["kind"] | string;6type SourceKind = Source["kind"] | string;
7 7
8export function SourceFavicon({8export function SourceFavicon({
9 endpoint,9 source,
10 kind,
11 className,10 className,
12 size = 16,11 size = 16,
13}: {12}: {
14 endpoint?: string | null;13 source: Source;
15 kind: SourceKind;
16 className?: string;14 className?: string;
17 size?: number;15 size?: number;
18}) {16}) {
19 const faviconUrl = useMemo(() => {17 const faviconUrl = getSourceFrontendIconUrl(source);
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
27 const [failedUrl, setFailedUrl] = useState<string | null>(null);18 const [failedUrl, setFailedUrl] = useState<string | null>(null);
28 const isFailed = Boolean(faviconUrl && failedUrl === faviconUrl);19 const isFailed = Boolean(faviconUrl && failedUrl === faviconUrl);
29 20
30 if (!faviconUrl || isFailed) {21 if (!faviconUrl || isFailed) {
31 return <DefaultSourceIcon kind={kind} className={className} />;22 return <DefaultSourceIcon kind={source.kind} className={className} />;
32 }23 }
33 24
34 return (25 return (
@@ -47,7 +38,7 @@ export function SourceFavicon({ · 12 unmodified lines
47}38}
48 39
49export function DefaultSourceIcon({40export function DefaultSourceIcon({
50 kind,41 kind: _kind,
51 className,42 className,
52}: {43}: {
53 kind: SourceKind;44 kind: SourceKind;
@@ -55,49 +46,10 @@ export function DefaultSourceIcon({ · 1 unmodified line
55}) {46}) {
56 const base = cn("shrink-0", className);47 const base = cn("shrink-0", className);
57 48
58 switch (kind) {49 return (
59 case "mcp":50 <svg viewBox="0 0 16 16" fill="none" className={base}>
60 return (51 <rect x="2.5" y="2.5" width="11" height="11" rx="2" stroke="currentColor" strokeWidth="1.2" />
61 <svg viewBox="0 0 16 16" fill="none" className={base}>52 <path d="M5 6h6M5 8h4M5 10h3" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" />
62 <rect x="2" y="3" width="12" height="10" rx="1.5" stroke="currentColor" strokeWidth="1.2" />53 </svg>
63 <path d="M5 7h1M5 9h4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />54 );
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 }
103}55}
apps/web/src/components/source-not-found-state.tsx+2−1
@@ -1,4 +1,5 @@
1import { Link } from "@tanstack/react-router";1import { Link } from "@tanstack/react-router";
2import { sourcePluginsIndexPath } from "@executor/react/plugins";
2 3
3import { IconEmpty } from "./icons";4import { IconEmpty } from "./icons";
4import { cn } from "../lib/utils";5import { cn } from "../lib/utils";
@@ -23,7 +24,7 @@ export function SourceNotFoundState() { · 18 unmodified lines
23 Back to dashboard24 Back to dashboard
24 </Link>25 </Link>
25 <Link26 <Link
26 to="/sources/add"27 to={sourcePluginsIndexPath}
27 className={cn(28 className={cn(
28 "inline-flex h-7 items-center justify-center rounded-md bg-primary px-2.5 text-xs font-medium text-primary-foreground transition-all",29 "inline-flex h-7 items-center justify-center rounded-md bg-primary px-2.5 text-xs font-medium text-primary-foreground transition-all",
29 "hover:bg-primary/90",30 "hover:bg-primary/90",
apps/web/src/components/source-plugins-reset-state.tsx+27−0
@@ -0,0 +1,27 @@
1import type { ReactNode } from "react";
2
3type SourcePluginsResetStateProps = {
4 title: string;
5 message: string;
6 action?: ReactNode;
7};
8
9export 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 @@
1import { parse as parseDomain } from "tldts";
2
3const 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
14const 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 */
39const 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
68const 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
80const 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
105export const getGoogleProductIconUrl = getGoogleServiceIconUrl;
106
107export 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 @@
1import * as React from "react";
2import {1import {
3 RouterProvider,2 RouterProvider,
4 createRootRoute,3 createRootRoute,
@@ -7,31 +6,25 @@ import { · 2 unmodified lines
7 useNavigate,6 useNavigate,
8} from "@tanstack/react-router";7} from "@tanstack/react-router";
9import { ExecutorReactProvider } from "@executor/react";8import { ExecutorReactProvider } from "@executor/react";
9import {
10 createExecutorPluginPaths,
11 sourcePluginsIndexPath,
12 type ExecutorPluginNavigation,
13 type FrontendPluginRouteSearch,
14} from "@executor/react/plugins";
10 15
11import "./globals.css";16import "./globals.css";
12 17
13import { AppShell } from "./components/shell";18import { AppShell } from "./components/shell";
19import {
20 registeredFrontendPluginRoutes,
21} from "./plugins";
22import {
23 ExecutorPluginRoutePage,
24 SourcePluginsIndexPage,
25} from "./plugins/pages";
14import { HomePage } from "./views/home";26import { HomePage } from "./views/home";
15import { EditSourcePage, NewSourcePage } from "./views/source-editor";
16import { SourceDetailPage } from "./views/source-detail";
17import { SecretsPage } from "./views/secrets";27import { SecretsPage } from "./views/secrets";
18import { AddSourcePage } from "./views/add-source";
19
20// ---------------------------------------------------------------------------
21// Route search schema
22// ---------------------------------------------------------------------------
23
24type SourceRouteSearch = {
25 tab: "model" | "discover";
26 tool?: string;
27 query?: string;
28};
29
30const sourceTabs = ["model", "discover"] as const;
31
32// ---------------------------------------------------------------------------
33// Routes
34// ---------------------------------------------------------------------------
35 28
36const rootRoute = createRootRoute({29const rootRoute = createRootRoute({
37 component: AppShell,30 component: AppShell,
@@ -43,36 +36,10 @@ const homeRoute = createRoute({ · 5 unmodified lines
43 component: HomePage,36 component: HomePage,
44});37});
45 38
46const newSourceRoute = createRoute({39const sourcePluginsIndexRoute = createRoute({
47 getParentRoute: () => rootRoute,
48 path: "/sources/new",
49 component: NewSourcePage,
50});
51
52const addSourceRoute = createRoute({
53 getParentRoute: () => rootRoute,40 getParentRoute: () => rootRoute,
54 path: "/sources/add",41 path: sourcePluginsIndexPath,
55 component: AddSourcePage,42 component: SourcePluginsIndexPage,
56});
57
58const 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
72const editSourceRoute = createRoute({
73 getParentRoute: () => rootRoute,
74 path: "/sources/$sourceId/edit",
75 component: EditSourcePageWrapper,
76});43});
77 44
78const secretsRoute = createRoute({45const secretsRoute = createRoute({
@@ -81,35 +48,73 @@ const secretsRoute = createRoute({ · 2 unmodified lines
81 component: SecretsPage,48 component: SecretsPage,
82});49});
83 50
51const 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};
84 72
85function SourceDetailPageWrapper() {73const frontendPluginRoutes = registeredFrontendPluginRoutes.map(({ plugin, route }) => {
86 const { sourceId } = sourceRoute.useParams();74 const paths = createExecutorPluginPaths(plugin.key);
87 const search = sourceRoute.useSearch();75
88 const navigate = useNavigate({ from: sourceRoute.fullPath });76 const PluginRouteComponent = () => {
89 77 const params = pluginRoute.useParams() as Record<string, string | undefined>;
90 return (78 const search = pluginRoute.useSearch();
91 <SourceDetailPage79 const navigate = useNavigate();
92 sourceId={sourceId}80 const navigateFromRoute = useNavigate({ from: pluginRoute.fullPath });
93 search={search}81 const navigation = createExecutorPluginNavigation(plugin.key, {
94 navigate={navigate as any}82 navigateTo: (to, nextSearch) =>
95 />83 nextSearch === undefined ? navigate({ to }) : navigate({ to, search: nextSearch }),
96 );84 updateSearch: (nextSearch) => navigateFromRoute({ search: nextSearch }),
97}85 });
98 86
99function EditSourcePageWrapper() {87 return (
100 const { sourceId } = editSourceRoute.useParams();88 <ExecutorPluginRoutePage
101 return <EditSourcePage sourceId={sourceId} />;89 pluginKey={plugin.key}
102}90 routeKey={route.key}
103 91 params={params}
104// ---------------------------------------------------------------------------92 search={search}
105// Router93 navigation={navigation}
106// ---------------------------------------------------------------------------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});
107 106
108const routeTree = rootRoute.addChildren([homeRoute, newSourceRoute, addSourceRoute, sourceRoute, editSourceRoute, secretsRoute]);107const routeTree = rootRoute.addChildren([
108 homeRoute,
109 secretsRoute,
110 sourcePluginsIndexRoute,
111 ...frontendPluginRoutes,
112]);
109 113
110const router = createRouter({114const router = createRouter({
111 routeTree,115 routeTree,
112 defaultPreload: "intent",116 defaultPreload: "intent",
117 defaultPreloadStaleTime: 0,
113});118});
114 119
115declare module "@tanstack/react-router" {120declare module "@tanstack/react-router" {
@@ -118,16 +123,8 @@ declare module "@tanstack/react-router" { · 2 unmodified lines
118 }123 }
119}124}
120 125
121// ---------------------------------------------------------------------------126export const App = () => (
122// App127 <ExecutorReactProvider>
123// ---------------------------------------------------------------------------128 <RouterProvider router={router} />
124 129 </ExecutorReactProvider>
125export function App() {130);
126 return (
127 <React.StrictMode>
128 <ExecutorReactProvider>
129 <RouterProvider router={router} />
130 </ExecutorReactProvider>
131 </React.StrictMode>
132 );
133}
apps/web/src/plugins/index.ts+83−0
@@ -0,0 +1,83 @@
1import type {
2 Source,
3} from "@executor/react";
4import {
5 ExecutionHistoryReactPlugin,
6} from "@executor/plugin-execution-history-react";
7import {
8 GoogleDiscoveryReactPlugin,
9 getGoogleDiscoveryIconUrl,
10} from "@executor/plugin-google-discovery-react";
11import {
12 GraphqlReactPlugin,
13} from "@executor/plugin-graphql-react";
14import {
15 McpReactPlugin,
16} from "@executor/plugin-mcp-react";
17import {
18 OpenApiReactPlugin,
19} from "@executor/plugin-openapi-react";
20import {
21 createExecutorPluginPaths,
22 createSourcePluginPaths,
23 registerExecutorFrontendPlugins,
24 type ExecutorFrontendPlugin,
25} from "@executor/react/plugins";
26
27const frontendPlugins = [
28 ExecutionHistoryReactPlugin,
29 McpReactPlugin,
30 GraphqlReactPlugin,
31 GoogleDiscoveryReactPlugin,
32 OpenApiReactPlugin,
33] as const satisfies readonly ExecutorFrontendPlugin[];
34
35const frontendPluginRegistry = registerExecutorFrontendPlugins(frontendPlugins);
36
37const hasRouteKey = (
38 plugin: ExecutorFrontendPlugin,
39 routeKey: string,
40): boolean =>
41 (plugin.routes ?? []).some((route) => route.key === routeKey);
42
43const isSourceFrontendPlugin = (
44 plugin: ExecutorFrontendPlugin,
45): boolean =>
46 hasRouteKey(plugin, "add")
47 && hasRouteKey(plugin, "detail");
48
49export const registeredFrontendPlugins = frontendPluginRegistry.plugins;
50export const registeredFrontendPluginRoutes = frontendPluginRegistry.routes;
51export 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 }));
59export const registeredSourceFrontendPlugins =
60 registeredFrontendPlugins.filter(isSourceFrontendPlugin);
61
62export const getFrontendPlugin = (key: string) =>
63 frontendPluginRegistry.getPlugin(key);
64
65export const getFrontendPluginRoute = (
66 pluginKey: string,
67 routeKey: string,
68) => frontendPluginRegistry.getRoute(pluginKey, routeKey);
69
70export const getSourceFrontendPlugin = (kind: string) => {
71 const plugin = getFrontendPlugin(kind);
72 return plugin && isSourceFrontendPlugin(plugin) ? plugin : null;
73};
74
75export const getSourceFrontendPaths = (kind: string) => {
76 const plugin = getSourceFrontendPlugin(kind);
77 return plugin ? createSourcePluginPaths(plugin.key) : null;
78};
79
80export 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 @@
1import { useState, type FormEvent } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";
3import {
4 ExecutorPluginRouteProvider,
5 createSourcePluginPaths,
6 type ExecutorPluginNavigation,
7 type FrontendPluginRouteDefinition,
8 type FrontendPluginRouteParams,
9 type FrontendPluginRouteSearch,
10} from "@executor/react/plugins";
11
12import { DefaultSourceIcon } from "../components/source-favicon";
13import { LocalMcpInstallCard } from "../components/local-mcp-install-card";
14import { SourcePluginsResetState } from "../components/source-plugins-reset-state";
15import {
16 getFrontendPlugin,
17 getFrontendPluginRoute,
18 registeredSourceFrontendPlugins,
19} from "./index";
20import {
21 buildSourcePresetSearch,
22 resolveQuickSourceInput,
23 sourcePresets,
24} from "./source-presets";
25
26const FrontendPluginUnavailableState = () => (
27 <SourcePluginsResetState
28 title="Plugins unavailable"
29 message="No plugins are available in this build."
30 />
31);
32
33const SourcePluginUnavailableState = () => (
34 <SourcePluginsResetState
35 title="No source plugins available"
36 message="No source plugins are available in this build."
37 />
38);
39
40const 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
98export 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
139export 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 @@
1const GOOGLE_DISCOVERY_PLUGIN_KEY = "google-discovery" as const;
2const GOOGLE_DISCOVERY_SOURCE_KIND = "google_discovery" as const;
3
4export 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
50const 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
69export 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
360export 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
399const 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
412const tryParseUrl = (value: string): URL | null => {
413 try {
414 return new URL(value.trim());
415 } catch {
416 return null;
417 }
418};
419
420const 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
434const 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
477const 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
488const 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
494const 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
505const 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
537export type ResolvedQuickSourceInput = {
538 pluginKey: SourcePreset["pluginKey"];
539 search: Record<string, string>;
540};
541
542export 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 @@
1import { useEffect, useState, type ReactNode } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";
3import {
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";
27import { Badge } from "../components/ui/badge";
28import { Button } from "../components/ui/button";
29import { LocalMcpInstallCard } from "../components/local-mcp-install-card";
30import { SourceFavicon } from "../components/source-favicon";
31import {
32 IconArrowLeft,
33 IconCheck,
34 IconDiscover,
35 IconPlus,
36 IconSpinner,
37} from "../components/icons";
38import { cn } from "../lib/utils";
39import {
40 isStdioMcpSourceTemplate,
41 sourceTemplates,
42 type SourceTemplate,
43} from "./source-templates";
44import {
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";
54import { parseJsonStringArray, parseJsonStringMap } from "./json-form";
55import { getDomain } from "tldts";
56
57// ---------------------------------------------------------------------------
58// State machine
59// ---------------------------------------------------------------------------
60
61type FlowPhase =
62 | "idle"
63 | "discovering"
64 | "editing"
65 | "connecting"
66 | "connected"
67 | "credential_required"
68 | "oauth_required";
69
70type ProbeAuthKind = "none" | "bearer" | "basic" | "headers";
71
72type ProbeAuthState = {
73 kind: ProbeAuthKind;
74 token: string;
75 headerName: string;
76 prefix: string;
77 username: string;
78 password: string;
79 headersText: string;
80};
81
82type 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
102type ConnectFormState = ConnectFormBase & McpTransportFields;
103
104type OAuthRequiredInfo = {
105 source: Source;
106 sessionId: string;
107 authorizationUrl: string;
108};
109
110type BatchOAuthRequiredInfo = {
111 sessionId: string;
112 authorizationUrl: string;
113 sourceIds: ReadonlyArray<string>;
114};
115
116// ---------------------------------------------------------------------------
117// Constants
118// ---------------------------------------------------------------------------
119
120const kindOptions: ReadonlyArray<ConnectFormState["kind"]> = [
121 "mcp",
122 "openapi",
123 "graphql",
124 "google_discovery",
125];
126
127const transportOptions: ReadonlyArray<Exclude<McpTransportValue, "">> = [
128 "auto",
129 "streamable-http",
130 "sse",
131 "stdio",
132];
133
134const authOptions: ReadonlyArray<ConnectFormState["authKind"]> = [
135 "none",
136 "bearer",
137 "oauth2",
138];
139
140const probeAuthOptions: ReadonlyArray<ProbeAuthKind> = [
141 "none",
142 "bearer",
143 "basic",
144 "headers",
145];
146
147const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000;
148const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";
149
150// ---------------------------------------------------------------------------
151// Helpers
152// ---------------------------------------------------------------------------
153
154const trimToNull = (value: string): string | null => {
155 const trimmed = value.trim();
156 return trimmed.length > 0 ? trimmed : null;
157};
158
159const 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
171const googleDiscoveryNamespace = (service: string): string =>
172 `google.${service
173 .trim()
174 .toLowerCase()
175 .replace(/[^a-z0-9]+/g, ".")
176 .replace(/^\.+|\.+$/g, "")}`;
177
178const 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
218const 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
225const stringifyStringArray = (
226 value: ReadonlyArray<string> | null | undefined,
227): string =>
228 !value || value.length === 0 ? "" : JSON.stringify(value, null, 2);
229
230const 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
252const 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
333const 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
365const authKindForSourceKind = (
366 currentAuthKind: ConnectFormState["authKind"],
367 nextKind: ConnectFormState["kind"],
368): ConnectFormState["authKind"] =>
369 nextKind === "google_discovery" && currentAuthKind === "none"
370 ? "oauth2"
371 : currentAuthKind;
372
373const 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
403const 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
515const 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
569type 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
593const 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
608const 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
615const 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
728export 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
2158function 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
2178function 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
2193function 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
2214function 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
2234function 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
2250function 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
2311const CREATE_NEW_VALUE = "__create_new__";
2312const INLINE_TOKEN_VALUE = "__inline_token__";
2313
2314function 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 @@
1import { Link } from "@tanstack/react-router";1import { Link } from "@tanstack/react-router";
2import { useSources, type Source } from "@executor/react";2import { useSources } from "@executor/react";
3import { sourcePluginsIndexPath } from "@executor/react/plugins";
3import { LoadableBlock } from "../components/loadable";4import { LoadableBlock } from "../components/loadable";
4import { LocalMcpInstallCard } from "../components/local-mcp-install-card";5import { LocalMcpInstallCard } from "../components/local-mcp-install-card";
5import { SourceFavicon } from "../components/source-favicon";6import { SourceFavicon } from "../components/source-favicon";
6import { Badge } from "../components/ui/badge";7import { Badge } from "../components/ui/badge";
7import { Button } from "../components/ui/button";8import { Button } from "../components/ui/button";
8import { IconSources, IconPlus } from "../components/icons";9import { IconSources, IconPlus } from "../components/icons";
10import {
11 getSourceFrontendPaths,
12} from "../plugins";
13
14const statusVariant = (status: string) =>
15 status === "connected"
16 ? "default" as const
17 : status === "error"
18 ? "destructive" as const
19 : "muted" as const;
9 20
10export function HomePage() {21export function HomePage() {
11 const sources = useSources();22 const sources = useSources();
12 23
13 return (24 return (
14 <div className="flex-1 overflow-y-auto">25 <div className="min-h-0 flex-1 overflow-y-auto">
15 <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">26 <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">
16 {/* Header */}27 {/* Header */}
17 <div className="flex items-end justify-between mb-8">28 <div className="flex items-end justify-between mb-8">
@@ -23,7 +34,7 @@ export function HomePage() { · 5 unmodified lines
23 Connected tool providers in this workspace.34 Connected tool providers in this workspace.
24 </p>35 </p>
25 </div>36 </div>
26 <Link to="/sources/add">37 <Link to={sourcePluginsIndexPath}>
27 <Button size="sm">38 <Button size="sm">
28 <IconPlus className="size-3.5" />39 <IconPlus className="size-3.5" />
29 Add source40 Add source
@@ -36,7 +47,11 @@ export function HomePage() { · 6 unmodified lines
36 {/* Source list */}47 {/* Source list */}
37 <LoadableBlock loadable={sources} loading="Loading sources...">48 <LoadableBlock loadable={sources} loading="Loading sources...">
38 {(items) =>49 {(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 ? (
40 <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20">55 <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20">
41 <div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground mb-4">56 <div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground mb-4">
42 <IconSources className="size-5" />57 <IconSources className="size-5" />
@@ -47,7 +62,7 @@ export function HomePage() { · 4 unmodified lines
47 <p className="text-[13px] text-muted-foreground/60 mb-5">62 <p className="text-[13px] text-muted-foreground/60 mb-5">
48 Add a source to get started.63 Add a source to get started.
49 </p>64 </p>
50 <Link to="/sources/add">65 <Link to={sourcePluginsIndexPath}>
51 <Button size="sm">66 <Button size="sm">
52 <IconPlus className="size-3.5" />67 <IconPlus className="size-3.5" />
53 Add source68 Add source
@@ -56,9 +71,49 @@ export function HomePage() { · 2 unmodified lines
56 </div>71 </div>
57 ) : (72 ) : (
58 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">73 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
59 {items.map((source) => (74 {items.map((source) => {
60 <SourceCard key={source.id} source={source} />75 const paths = getSourceFrontendPaths(source.kind);
61 ))}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 })}
62 </div>117 </div>
63 )118 )
64 }119 }
@@ -67,29 +122,3 @@ export function HomePage() { · 2 unmodified lines
67 </div>122 </div>
68 );123 );
69}124}
70
71function 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 @@
1import * as Either from "effect/Either";
2import * as ParseResult from "effect/ParseResult";
3import * as Schema from "effect/Schema";
4
5const JsonStringMapSchema = Schema.parseJson(
6 Schema.Record({
7 key: Schema.String,
8 value: Schema.String,
9 }),
10);
11
12const JsonStringArraySchema = Schema.parseJson(
13 Schema.Array(Schema.String),
14);
15
16const formatJsonFieldError = (
17 label: string,
18 error: ParseResult.ParseError,
19): Error =>
20 new Error(
21 `${label} is invalid: ${ParseResult.TreeFormatter.formatErrorSync(error)}`,
22 );
23
24const 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
42export 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
55export 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 @@
1import { useEffect, useState, type ReactNode } from "react";1import { useEffect, useState, type ReactNode } from "react";
2import { Link } from "@tanstack/react-router";
3import {2import {
4 type InstanceConfig,3 type InstanceConfig,
5 type SecretListItem,4 type SecretListItem,
@@ -42,7 +41,7 @@ export function SecretsPage() { · 36 unmodified lines
42 : null;41 : null;
43 42
44 return (43 return (
45 <div className="flex-1 overflow-y-auto">44 <div className="min-h-0 flex-1 overflow-y-auto">
46 <div className="mx-auto max-w-3xl px-6 py-10 lg:px-10 lg:py-14">45 <div className="mx-auto max-w-3xl px-6 py-10 lg:px-10 lg:py-14">
47 {/* Header */}46 {/* Header */}
48 <div className="flex items-end justify-between mb-8">47 <div className="flex items-end justify-between mb-8">
@@ -259,19 +258,16 @@ function SecretRow(props: { · 210 unmodified lines
259 const { secret, providerLabel, isEditing } = props;258 const { secret, providerLabel, isEditing } = props;
260 const deleteSecret = useDeleteSecret();259 const deleteSecret = useDeleteSecret();
261 const [isDeleting, setIsDeleting] = useState(false);260 const [isDeleting, setIsDeleting] = useState(false);
261 const [confirmDelete, setConfirmDelete] = useState(false);
262 262
263 const handleDelete = async () => {263 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
269 setIsDeleting(true);264 setIsDeleting(true);
270 try {265 try {
271 await deleteSecret.mutateAsync(secret.id);266 await deleteSecret.mutateAsync(secret.id);
272 } catch {267 } catch {
273 // refresh will show the secret still there268 // refresh will show the secret still there
274 } finally {269 } finally {
270 setConfirmDelete(false);
275 setIsDeleting(false);271 setIsDeleting(false);
276 }272 }
277 };273 };
@@ -313,15 +309,12 @@ function SecretRow(props: { · 35 unmodified lines
313 <div className="flex items-center gap-1.5 mt-1 flex-wrap">309 <div className="flex items-center gap-1.5 mt-1 flex-wrap">
314 <span className="text-[10px] text-muted-foreground/50">Used by</span>310 <span className="text-[10px] text-muted-foreground/50">Used by</span>
315 {secret.linkedSources.map((ls) => (311 {secret.linkedSources.map((ls) => (
316 <Link312 <span
317 key={ls.sourceId}313 key={ls.sourceId}
318 to="/sources/$sourceId"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"
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"
322 >315 >
323 {ls.sourceName}316 {ls.sourceName}
324 </Link>317 </span>
325 ))}318 ))}
326 </div>319 </div>
327 )}320 )}
@@ -341,15 +334,42 @@ function SecretRow(props: { · 13 unmodified lines
341 <IconPencil className="size-3" />334 <IconPencil className="size-3" />
342 Edit335 Edit
343 </button>336 </button>
344 <button337 {confirmDelete ? (
345 type="button"338 <>
346 onClick={handleDelete}339 <span className="text-[10px] font-medium text-destructive">
347 disabled={isDeleting}340 Confirm delete?
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"341 </span>
349 >342 <button
350 {isDeleting ? <IconSpinner className="size-3" /> : <IconTrash className="size-3" />}343 type="button"
351 Delete344 onClick={() => setConfirmDelete(false)}
352 </button>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 )}
353 </div>373 </div>
354 </div>374 </div>
355 375
apps/web/src/views/source-detail.tsx+0−1172
@@ -1,1172 +0,0 @@
1import { useState, useEffect, useCallback, useRef, useMemo } from "react";
2import { Link } from "@tanstack/react-router";
3import {
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";
17import { cn } from "../lib/utils";
18import { Badge, MethodBadge } from "../components/ui/badge";
19import { Button } from "../components/ui/button";
20import { LoadableBlock, EmptyState } from "../components/loadable";
21import { DocumentPanel } from "../components/document-panel";
22import { SourceNotFoundState } from "../components/source-not-found-state";
23import { SourceRecoveryState } from "../components/source-recovery-state";
24import {
25 IconSearch,
26 IconChevron,
27 IconTool,
28 IconFolder,
29 IconCopy,
30 IconCheck,
31 IconClose,
32 IconPencil,
33 IconSpinner,
34} from "../components/icons";
35import { Markdown } from "../components/markdown";
36
37// ---------------------------------------------------------------------------
38// Types
39// ---------------------------------------------------------------------------
40
41type SourceRouteSearch = {
42 tab: "model" | "discover";
43 tool?: string;
44 query?: string;
45};
46
47const visibleTabs: Array<{ id: SourceRouteSearch["tab"]; label: string }> = [
48 { id: "model", label: "Tools" },
49 { id: "discover", label: "Search" },
50];
51
52const isSourceNotFoundError = (loadable: Loadable<unknown>): boolean =>
53 loadable.status === "error"
54 && loadable.error.message.toLowerCase().includes("source not found");
55
56const isMissingCatalogArtifactError = (loadable: Loadable<unknown>): boolean =>
57 loadable.status === "error"
58 && loadable.error.message.toLowerCase().includes("catalog artifact missing for source");
59
60const listExcludesSource = (
61 sources: Loadable<ReadonlyArray<Source>>,
62 sourceId: string,
63): boolean => sources.status === "ready" && !sources.data.some((source) => source.id === sourceId);
64
65type SourceDetailPageState =
66 | { kind: "source_not_found" }
67 | { kind: "tools_missing"; source: Source }
68 | { kind: "ready" };
69
70const 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
94const 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
99const 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
111const 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
118const 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
125type 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
144const 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
170const 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
243export 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
456function 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
570type ToolTreeNode = {
571 segment: string;
572 tool?: SourceInspection["tools"][number];
573 children: Map<string, ToolTreeNode>;
574};
575
576function 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
592function 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
621function 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
749function 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
761function 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
809function 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
985function 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
1102function 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
1120function 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 @@
1import { useEffect, useState, type ReactNode } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";
3import {
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";
24import { Badge } from "../components/ui/badge";
25import { Button } from "../components/ui/button";
26import { LoadableBlock } from "../components/loadable";
27import { SourceNotFoundState } from "../components/source-not-found-state";
28import { SourceFavicon } from "../components/source-favicon";
29import {
30 IconArrowLeft,
31 IconPencil,
32 IconPlus,
33 IconSpinner,
34 IconTrash,
35} from "../components/icons";
36import { cn } from "../lib/utils";
37import {
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";
47import { parseJsonStringArray, parseJsonStringMap } from "./json-form";
48import { sourceTemplates, type SourceTemplate } from "./source-templates";
49import { getDomain } from "tldts";
50
51type StatusBannerState = {
52 tone: "info" | "success" | "error";
53 text: string;
54};
55
56type 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
70const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000;
71const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";
72
73const isSourceNotFoundLoadable = (loadable: Loadable<unknown>): boolean =>
74 loadable.status === "error" &&
75 loadable.error.message.toLowerCase().includes("source not found");
76
77type 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
102type SourceFormState = SourceFormBase & McpTransportFields;
103
104const kindOptions: ReadonlyArray<Source["kind"]> = [
105 "mcp",
106 "openapi",
107 "graphql",
108 "google_discovery",
109 "internal",
110];
111
112const transportOptions: ReadonlyArray<Exclude<McpTransportValue, "">> = [
113 "auto",
114 "streamable-http",
115 "sse",
116 "stdio",
117];
118
119const authOptions: ReadonlyArray<Source["auth"]["kind"]> = [
120 "none",
121 "bearer",
122 "oauth2",
123 "provider_grant_ref",
124 "mcp_oauth",
125];
126
127const trimToNull = (value: string): string | null => {
128 const trimmed = value.trim();
129 return trimmed.length > 0 ? trimmed : null;
130};
131
132const 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
143const googleDiscoveryNamespace = (service: string): string =>
144 `google.${service
145 .trim()
146 .toLowerCase()
147 .replace(/[^a-z0-9]+/g, ".")
148 .replace(/^\.+|\.+$/g, "")}`;
149
150const 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
171const 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
181const 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
308const stringMapToEditor = (value: Record<string, string> | null): string =>
309 value === null ? "" : JSON.stringify(value, null, 2);
310
311const stringArrayToEditor = (
312 value: ReadonlyArray<string> | null | undefined,
313): string =>
314 !value || value.length === 0 ? "" : JSON.stringify(value, null, 2);
315
316const 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
336const 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
347const readBindingString = (source: Source, key: string): string =>
348 typeof source.binding[key] === "string" ? String(source.binding[key]) : "";
349
350const 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
361const 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
379const 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
418const 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
481const 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
553const 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
567const 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
693const buildUpdatePayload = (state: SourceFormState): UpdateSourcePayload => ({
694 ...buildSourcePayload(state),
695});
696
697const 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
722export function NewSourcePage() {
723 return <SourceEditor key="create" mode="create" />;
724}
725
726export 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
751function 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
1581function 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
1601function 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
1616function 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
1635function 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
1657function 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
1673function 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
1699function 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
1718const CREATE_NEW_VALUE = "__create_new__";
1719
1720function 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 @@
1type 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
12export 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
22export type OpenApiSourceTemplate = SourceTemplateBase & {
23 kind: "openapi";
24 specUrl: string;
25};
26
27export type GoogleDiscoverySourceTemplate = SourceTemplateBase & {
28 kind: "google_discovery";
29 service: string;
30 version: string;
31 discoveryUrl: string;
32};
33
34export type GraphqlSourceTemplate = SourceTemplateBase & {
35 kind: "graphql";
36};
37
38export type SourceTemplate =
39 | McpSourceTemplate
40 | OpenApiSourceTemplate
41 | GoogleDiscoverySourceTemplate
42 | GraphqlSourceTemplate;
43
44export const isStdioMcpSourceTemplate = (
45 template: SourceTemplate,
46): template is McpSourceTemplate & { transport: "stdio" } =>
47 template.kind === "mcp" &&
48 template.connectionType === "command" &&
49 template.transport === "stdio";
50
51const googleDiscoveryUrl = (service: string, version: string): string =>
52 `https://www.googleapis.com/discovery/v1/apis/${encodeURIComponent(service)}/${encodeURIComponent(version)}/rest`;
53
54const 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
79export 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
23 .replace(/^git\+/, "")23 .replace(/^git\+/, "")
24 .replace(/\.git$/, "");24 .replace(/\.git$/, "");
25 25
26const webPackage = JSON.parse(
27 readFileSync(new URL("./package.json", import.meta.url), "utf8"),
28) as {
29 dependencies?: Record<string, string>;
30};
31
32const workspaceExecutorPackages = Object.keys(webPackage.dependencies ?? {}).filter(
33 (name) => name.startsWith("@executor/"),
34);
35
26export default defineConfig({36export default defineConfig({
27 root: "src",37 root: "src",
28 plugins: [38 plugins: [
@@ -47,7 +57,15 @@ export default defineConfig({ · 18 unmodified lines
47 outDir: "../dist",57 outDir: "../dist",
48 emptyOutDir: true,58 emptyOutDir: true,
49 },59 },
60 optimizeDeps: {
61 exclude: workspaceExecutorPackages,
62 },
50 server: {63 server: {
51 port: 8788,64 port: 8788,
65 watch: {
66 ignored: [
67 "!**/node_modules/@executor/**",
68 ],
69 },
52 },70 },
53});71});
bun.lock+276−92
@@ -18,7 +18,7 @@ · 17 unmodified lines
18 },18 },
19 "apps/executor": {19 "apps/executor": {
20 "name": "executor",20 "name": "executor",
21 "version": "1.2.4-beta.4",21 "version": "1.2.6",
22 "bin": {22 "bin": {
23 "executor": "bin/executor",23 "executor": "bin/executor",
24 },24 },
@@ -27,10 +27,14 @@ · 2 unmodified lines
27 "@effect/platform": "catalog:",27 "@effect/platform": "catalog:",
28 "@effect/platform-node": "catalog:",28 "@effect/platform-node": "catalog:",
29 "@executor/codemode-core": "workspace:*",29 "@executor/codemode-core": "workspace:*",
30 "@executor/executor-mcp": "workspace:*",
30 "@executor/platform-api": "workspace:*",31 "@executor/platform-api": "workspace:*",
31 "@executor/platform-internal": "workspace:*",
32 "@executor/platform-sdk": "workspace:*",32 "@executor/platform-sdk": "workspace:*",
33 "@executor/platform-sdk-file": "workspace:*",33 "@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:*",
34 "@executor/server": "workspace:*",38 "@executor/server": "workspace:*",
35 "effect": "catalog:",39 "effect": "catalog:",
36 },40 },
@@ -50,6 +54,11 @@ · 13 unmodified lines
50 "name": "@executor/web",54 "name": "@executor/web",
51 "version": "0.0.0",55 "version": "0.0.0",
52 "dependencies": {56 "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:*",
53 "@executor/react": "workspace:*",62 "@executor/react": "workspace:*",
54 "@executor/server": "workspace:*",63 "@executor/server": "workspace:*",
55 "@shikijs/langs": "^4.0.1",64 "@shikijs/langs": "^4.0.1",
@@ -114,41 +123,25 @@ · 58 unmodified lines
114 "bun-types": "catalog:",123 "bun-types": "catalog:",
115 },124 },
116 },125 },
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 },
140 "packages/clients/react": {126 "packages/clients/react": {
141 "name": "@executor/react",127 "name": "@executor/react",
142 "version": "0.0.0",128 "version": "0.0.0",
143 "dependencies": {129 "dependencies": {
144 "@effect-atom/atom": "0.5.3",130 "@effect-atom/atom": "0.5.3",
145 "@effect-atom/atom-react": "0.5.0",131 "@effect-atom/atom-react": "0.5.0",
132 "@effect/platform": "catalog:",
146 "@executor/platform-api": "workspace:*",133 "@executor/platform-api": "workspace:*",
147 "@executor/platform-sdk": "workspace:*",134 "@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",
148 "effect": "catalog:",139 "effect": "catalog:",
140 "shiki": "^4.0.1",
141 "streamdown": "^2.3.0",
142 "tailwind-merge": "^3.5.0",
149 },143 },
150 "devDependencies": {144 "devDependencies": {
151 "@effect/platform": "catalog:",
152 "@effect/platform-node": "catalog:",145 "@effect/platform-node": "catalog:",
153 "@effect/vitest": "catalog:",146 "@effect/vitest": "catalog:",
154 "@executor/effect-test-utils": "workspace:*",147 "@executor/effect-test-utils": "workspace:*",
@@ -189,7 +182,6 @@ · 34 unmodified lines
189 "@executor/codemode-core": "workspace:*",182 "@executor/codemode-core": "workspace:*",
190 "@executor/platform-sdk": "workspace:*",183 "@executor/platform-sdk": "workspace:*",
191 "@executor/runtime-ses": "workspace:*",184 "@executor/runtime-ses": "workspace:*",
192 "@executor/source-openapi": "workspace:*",
193 "effect": "catalog:",185 "effect": "catalog:",
194 },186 },
195 "devDependencies": {187 "devDependencies": {
@@ -316,39 +308,18 @@ · 120 unmodified lines
316 "vitest": "catalog:",308 "vitest": "catalog:",
317 },309 },
318 },310 },
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 },
333 "packages/platform/sdk": {311 "packages/platform/sdk": {
334 "name": "@executor/platform-sdk",312 "name": "@executor/platform-sdk",
335 "version": "0.0.0",313 "version": "0.0.0",
336 "dependencies": {314 "dependencies": {
337 "@effect/platform": "catalog:",315 "@effect/platform": "catalog:",
338 "@effect/platform-node": "catalog:",316 "@effect/platform-node": "catalog:",
339 "@executor/auth-mcp-oauth": "workspace:*",
340 "@executor/auth-oauth2": "workspace:*",
341 "@executor/codemode-core": "workspace:*",317 "@executor/codemode-core": "workspace:*",
342 "@executor/ir": "workspace:*",318 "@executor/ir": "workspace:*",
343 "@executor/runtime-deno-subprocess": "workspace:*",319 "@executor/runtime-deno-subprocess": "workspace:*",
344 "@executor/runtime-quickjs": "workspace:*",320 "@executor/runtime-quickjs": "workspace:*",
345 "@executor/runtime-ses": "workspace:*",321 "@executor/runtime-ses": "workspace:*",
346 "@executor/source-builtins": "workspace:*",
347 "@executor/source-core": "workspace:*",322 "@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:*",
352 "effect": "catalog:",323 "effect": "catalog:",
353 },324 },
354 "devDependencies": {325 "devDependencies": {
@@ -374,8 +345,6 @@ · 19 unmodified lines
374 "@executor/ir": "workspace:*",345 "@executor/ir": "workspace:*",
375 "@executor/platform-sdk": "workspace:*",346 "@executor/platform-sdk": "workspace:*",
376 "@executor/source-core": "workspace:*",347 "@executor/source-core": "workspace:*",
377 "@executor/source-graphql": "workspace:*",
378 "@executor/source-openapi": "workspace:*",
379 "effect": "catalog:",348 "effect": "catalog:",
380 "jsonc-parser": "3.3.1",349 "jsonc-parser": "3.3.1",
381 },350 },
@@ -396,9 +365,20 @@ · 14 unmodified lines
396 "@effect/platform-node": "catalog:",365 "@effect/platform-node": "catalog:",
397 "@executor/executor-mcp": "workspace:*",366 "@executor/executor-mcp": "workspace:*",
398 "@executor/platform-api": "workspace:*",367 "@executor/platform-api": "workspace:*",
399 "@executor/platform-internal": "workspace:*",
400 "@executor/platform-sdk": "workspace:*",368 "@executor/platform-sdk": "workspace:*",
401 "@executor/platform-sdk-file": "workspace:*",369 "@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:*",
402 "@opentelemetry/api": "^1.9.0",382 "@opentelemetry/api": "^1.9.0",
403 "@opentelemetry/exporter-trace-otlp-grpc": "^0.213.0",383 "@opentelemetry/exporter-trace-otlp-grpc": "^0.213.0",
404 "@opentelemetry/resources": "^2.6.0",384 "@opentelemetry/resources": "^2.6.0",
@@ -408,33 +388,52 @@ · 3 unmodified lines
408 "effect": "catalog:",388 "effect": "catalog:",
409 },389 },
410 "devDependencies": {390 "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",
411 "@types/node": "catalog:",396 "@types/node": "catalog:",
412 "bun-types": "catalog:",397 "bun-types": "catalog:",
398 "vitest": "catalog:",
399 "zod": "catalog:",
413 },400 },
414 },401 },
415 "packages/sources/builtins": {402 "packages/sources/core": {
416 "name": "@executor/source-builtins",403 "name": "@executor/source-core",
417 "version": "0.0.0",404 "version": "0.0.0",
418 "dependencies": {405 "dependencies": {
419 "@executor/source-core": "workspace:*",406 "@executor/codemode-core": "workspace:*",
420 "@executor/source-google-discovery": "workspace:*",407 "@executor/ir": "workspace:*",
421 "@executor/source-graphql": "workspace:*",408 "@modelcontextprotocol/sdk": "^1.26.0",
422 "@executor/source-mcp": "workspace:*",409 "effect": "catalog:",
423 "@executor/source-openapi": "workspace:*",
424 },410 },
425 "devDependencies": {411 "devDependencies": {
426 "@types/node": "catalog:",412 "@types/node": "catalog:",
427 "bun-types": "catalog:",413 "bun-types": "catalog:",
428 },414 },
429 },415 },
430 "packages/sources/core": {416 "plugins/execution-history/react": {
431 "name": "@executor/source-core",417 "name": "@executor/plugin-execution-history-react",
432 "version": "0.0.0",418 "version": "0.0.0",
433 "dependencies": {419 "dependencies": {
434 "@executor/auth-oauth2": "workspace:*",420 "@executor/react": "workspace:*",
435 "@executor/codemode-core": "workspace:*",421 "react": "^19.2.0",
436 "@executor/ir": "workspace:*",422 },
437 "@modelcontextprotocol/sdk": "^1.26.0",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:*",
438 "effect": "catalog:",437 "effect": "catalog:",
439 },438 },
440 "devDependencies": {439 "devDependencies": {
@@ -442,13 +441,30 @@ · 1 unmodified line
442 "bun-types": "catalog:",441 "bun-types": "catalog:",
443 },442 },
444 },443 },
445 "packages/sources/google-discovery": {444 "plugins/google-discovery/react": {
446 "name": "@executor/source-google-discovery",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",
447 "version": "0.0.0",461 "version": "0.0.0",
448 "dependencies": {462 "dependencies": {
449 "@effect/platform": "catalog:",463 "@effect/platform": "catalog:",
450 "@executor/codemode-core": "workspace:*",464 "@executor/codemode-core": "workspace:*",
451 "@executor/ir": "workspace:*",465 "@executor/ir": "workspace:*",
466 "@executor/platform-sdk": "workspace:*",
467 "@executor/plugin-google-discovery-shared": "workspace:*",
452 "@executor/source-core": "workspace:*",468 "@executor/source-core": "workspace:*",
453 "effect": "catalog:",469 "effect": "catalog:",
454 },470 },
@@ -459,60 +475,210 @@ · 4 unmodified lines
459 "vitest": "catalog:",475 "vitest": "catalog:",
460 },476 },
461 },477 },
462 "packages/sources/graphql": {478 "plugins/google-discovery/shared": {
463 "name": "@executor/source-graphql",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",
464 "version": "0.0.0",523 "version": "0.0.0",
465 "dependencies": {524 "dependencies": {
466 "@executor/codemode-core": "workspace:*",525 "@executor/codemode-core": "workspace:*",
467 "@executor/ir": "workspace:*",526 "@executor/ir": "workspace:*",
527 "@executor/platform-sdk": "workspace:*",
528 "@executor/plugin-graphql-shared": "workspace:*",
468 "@executor/source-core": "workspace:*",529 "@executor/source-core": "workspace:*",
469 "effect": "catalog:",530 "effect": "catalog:",
470 "graphql": "^16.12.0",531 "graphql": "^16.12.0",
471 },532 },
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 },
472 "devDependencies": {548 "devDependencies": {
473 "@types/node": "catalog:",549 "@types/node": "catalog:",
474 "bun-types": "catalog:",550 "bun-types": "catalog:",
475 },551 },
476 },552 },
477 "packages/sources/mcp": {553 "plugins/mcp/http": {
478 "name": "@executor/source-mcp",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",
479 "version": "0.0.0",585 "version": "0.0.0",
480 "dependencies": {586 "dependencies": {
481 "@executor/auth-mcp-oauth": "workspace:*",
482 "@executor/codemode-core": "workspace:*",587 "@executor/codemode-core": "workspace:*",
483 "@executor/ir": "workspace:*",588 "@executor/ir": "workspace:*",
484 "@executor/runtime-ses": "workspace:*",589 "@executor/platform-sdk": "workspace:*",
590 "@executor/plugin-mcp-shared": "workspace:*",
485 "@executor/source-core": "workspace:*",591 "@executor/source-core": "workspace:*",
486 "@modelcontextprotocol/sdk": "^1.26.0",592 "@modelcontextprotocol/sdk": "^1.26.0",
487 "effect": "catalog:",593 "effect": "catalog:",
488 },594 },
489 "devDependencies": {595 "devDependencies": {
490 "@effect/vitest": "catalog:",596 "@effect/vitest": "catalog:",
597 "@executor/runtime-ses": "workspace:*",
491 "@types/node": "catalog:",598 "@types/node": "catalog:",
492 "bun-types": "catalog:",599 "bun-types": "catalog:",
493 "vitest": "catalog:",600 "vitest": "catalog:",
494 "zod": "catalog:",601 "zod": "catalog:",
495 },602 },
496 },603 },
497 "packages/sources/openapi": {604 "plugins/mcp/shared": {
498 "name": "@executor/source-openapi",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",
499 "version": "0.0.0",619 "version": "0.0.0",
500 "dependencies": {620 "dependencies": {
501 "@effect/platform": "catalog:",621 "@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": {
502 "@executor/codemode-core": "workspace:*",651 "@executor/codemode-core": "workspace:*",
503 "@executor/ir": "workspace:*",652 "@executor/ir": "workspace:*",
653 "@executor/platform-sdk": "workspace:*",
654 "@executor/plugin-openapi-shared": "workspace:*",
504 "@executor/source-core": "workspace:*",655 "@executor/source-core": "workspace:*",
505 "effect": "catalog:",656 "effect": "catalog:",
506 "yaml": "^2.8.1",657 "yaml": "^2.8.1",
507 },658 },
508 "devDependencies": {659 "devDependencies": {
509 "@effect/platform-node": "^0.104.1",660 "@effect/platform": "catalog:",
510 "@effect/vitest": "catalog:",661 "@effect/platform-node": "catalog:",
662 "@executor/effect-test-utils": "workspace:*",
511 "@types/node": "catalog:",663 "@types/node": "catalog:",
512 "bun-types": "catalog:",664 "bun-types": "catalog:",
513 "vitest": "catalog:",665 "vitest": "catalog:",
514 },666 },
515 },667 },
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 },
516 },682 },
517 "catalog": {683 "catalog": {
518 "@effect/cli": "^0.73.2",684 "@effect/cli": "^0.73.2",
@@ -718,10 +884,6 @@ · 199 unmodified lines
718 884
719 "@executor/ai-sdk-adapter": ["@executor/ai-sdk-adapter@workspace:packages/hosts/ai-sdk"],885 "@executor/ai-sdk-adapter": ["@executor/ai-sdk-adapter@workspace:packages/hosts/ai-sdk"],
720 886
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
725 "@executor/codemode-core": ["@executor/codemode-core@workspace:packages/kernel/core"],887 "@executor/codemode-core": ["@executor/codemode-core@workspace:packages/kernel/core"],
726 888
727 "@executor/docs": ["@executor/docs@workspace:apps/docs"],889 "@executor/docs": ["@executor/docs@workspace:apps/docs"],
@@ -738,12 +900,44 @@ · 10 unmodified lines
738 900
739 "@executor/platform-api": ["@executor/platform-api@workspace:packages/platform/api"],901 "@executor/platform-api": ["@executor/platform-api@workspace:packages/platform/api"],
740 902
741 "@executor/platform-internal": ["@executor/platform-internal@workspace:packages/platform/internal"],
742
743 "@executor/platform-sdk": ["@executor/platform-sdk@workspace:packages/platform/sdk"],903 "@executor/platform-sdk": ["@executor/platform-sdk@workspace:packages/platform/sdk"],
744 904
745 "@executor/platform-sdk-file": ["@executor/platform-sdk-file@workspace:packages/platform/sdk-file"],905 "@executor/platform-sdk-file": ["@executor/platform-sdk-file@workspace:packages/platform/sdk-file"],
746 906
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
747 "@executor/react": ["@executor/react@workspace:packages/clients/react"],941 "@executor/react": ["@executor/react@workspace:packages/clients/react"],
748 942
749 "@executor/runtime-deno-subprocess": ["@executor/runtime-deno-subprocess@workspace:packages/kernel/runtime-deno-subprocess"],943 "@executor/runtime-deno-subprocess": ["@executor/runtime-deno-subprocess@workspace:packages/kernel/runtime-deno-subprocess"],
@@ -756,18 +950,8 @@ · 6 unmodified lines
756 950
757 "@executor/server": ["@executor/server@workspace:packages/platform/server"],951 "@executor/server": ["@executor/server@workspace:packages/platform/server"],
758 952
759 "@executor/source-builtins": ["@executor/source-builtins@workspace:packages/sources/builtins"],
760
761 "@executor/source-core": ["@executor/source-core@workspace:packages/sources/core"],953 "@executor/source-core": ["@executor/source-core@workspace:packages/sources/core"],
762 954
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
771 "@executor/sqlite-sdk-consumer-example": ["@executor/sqlite-sdk-consumer-example@workspace:examples/sqlite-sdk-consumer"],955 "@executor/sqlite-sdk-consumer-example": ["@executor/sqlite-sdk-consumer-example@workspace:examples/sqlite-sdk-consumer"],
772 956
773 "@executor/web": ["@executor/web@workspace:apps/web"],957 "@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
20 type ExecutorWorkspaceSourceArtifactRepository,20 type ExecutorWorkspaceSourceArtifactRepository,
21} from "@executor/platform-sdk";21} from "@executor/platform-sdk";
22import type {22import type {
23 AuthArtifact,
24 AuthLease,
25 Execution,23 Execution,
26 ExecutionInteraction,24 ExecutionInteraction,
27 ExecutionStep,25 ExecutionStep,
28 LocalExecutorConfig,26 LocalExecutorConfig,
29 LocalInstallation,27 LocalInstallation,
30 ProviderAuthGrant,
31 ScopeOauthClient,
32 ScopedSourceOauthClient,
33 SecretMaterial,28 SecretMaterial,
34 SecretRef,29 SecretRef,
35 SourceAuthSession,
36} from "@executor/platform-sdk/schema";30} from "@executor/platform-sdk/schema";
37import {31import {
38 ScopeIdSchema,32 ScopeIdSchema,
@@ -94,54 +88,6 @@ const sourceArtifacts = sqliteTable("source_artifacts", { · 55 unmodified lines
94 artifactJson: text("artifact_json").notNull(),88 artifactJson: text("artifact_json").notNull(),
95});89});
96 90
97const 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
106const authLeases = sqliteTable("auth_leases", {
107 authArtifactId: text("auth_artifact_id").primaryKey(),
108 json: text("json").notNull(),
109});
110
111const 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
119const 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
126const 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
134const 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
145const secretMaterials = sqliteTable("secret_materials", {91const secretMaterials = sqliteTable("secret_materials", {
146 id: text("id").primaryKey(),92 id: text("id").primaryKey(),
147 providerId: text("provider_id").notNull(),93 providerId: text("provider_id").notNull(),
@@ -175,25 +121,15 @@ const executionSteps = sqliteTable("execution_steps", { · 27 unmodified lines
175const makeHash = (value: string): string =>121const makeHash = (value: string): string =>
176 createHash("sha256").update(value).digest("hex").slice(0, 24);122 createHash("sha256").update(value).digest("hex").slice(0, 24);
177 123
178const sameActor = (
179 left: string | null | undefined,
180 right: string | null | undefined,
181): boolean => (left ?? null) === (right ?? null);
182
183const parseJson = <T>(value: string): T => JSON.parse(value) as T;124const parseJson = <T>(value: string): T => JSON.parse(value) as T;
184 125
185const createSourceArtifact = (input: SourceArtifactBuildInput): SourceArtifact => {126const createSourceArtifact = (input: SourceArtifactBuildInput): SourceArtifact => {
186 const snapshot = snapshotFromSourceCatalogSyncResult(input.syncResult);127 const snapshot = snapshotFromSourceCatalogSyncResult(input.syncResult);
187 const sourceConfigJson = JSON.stringify({128 const sourceConfigJson = JSON.stringify({
188 kind: input.source.kind,129 kind: input.source.kind,
189 endpoint: input.source.endpoint,
190 namespace: input.source.namespace,130 namespace: input.source.namespace,
191 name: input.source.name,131 name: input.source.name,
192 enabled: input.source.enabled,132 enabled: input.source.enabled,
193 binding: input.source.binding,
194 auth: input.source.auth,
195 importAuth: input.source.importAuth,
196 importAuthPolicy: input.source.importAuthPolicy,
197 });133 });
198 const importMetadataJson = JSON.stringify(snapshot.import);134 const importMetadataJson = JSON.stringify(snapshot.import);
199 const catalogId = SourceCatalogIdSchema.make(`src_catalog_${makeHash(sourceConfigJson)}`);135 const catalogId = SourceCatalogIdSchema.make(`src_catalog_${makeHash(sourceConfigJson)}`);
@@ -273,54 +209,6 @@ const openSqliteStore = (databasePath: string) => { · 73 unmodified lines
273 artifact_json TEXT NOT NULL209 artifact_json TEXT NOT NULL
274 );210 );
275 211
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
324 CREATE TABLE IF NOT EXISTS secret_materials (212 CREATE TABLE IF NOT EXISTS secret_materials (
325 id TEXT PRIMARY KEY NOT NULL,213 id TEXT PRIMARY KEY NOT NULL,
326 provider_id TEXT NOT NULL,214 provider_id TEXT NOT NULL,
@@ -363,358 +251,6 @@ const openSqliteStore = (databasePath: string) => { · 36 unmodified lines
363type SqliteStore = ReturnType<typeof openSqliteStore>;251type SqliteStore = ReturnType<typeof openSqliteStore>;
364 252
365const createStorageDomains = (store: SqliteStore) => ({253const 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 },
718 secrets: {254 secrets: {
719 getById: (id: SecretMaterial["id"]) => {255 getById: (id: SecretMaterial["id"]) => {
720 const row = store.db.select().from(secretMaterials).where(eq(secretMaterials.id, id)).get();256 const row = store.db.select().from(secretMaterials).where(eq(secretMaterials.id, id)).get();
@@ -790,6 +326,12 @@ const createStorageDomains = (store: SqliteStore) => ({ · 69 unmodified lines
790 },326 },
791 executions: {327 executions: {
792 runs: {328 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),
793 getById: (executionId: Execution["id"]) => {335 getById: (executionId: Execution["id"]) => {
794 const row = store.db.select().from(executions).where(eq(executions.id, executionId)).get();336 const row = store.db.select().from(executions).where(eq(executions.id, executionId)).get();
795 return row ? parseJson<Execution>(row.json) : null;337 return row ? parseJson<Execution>(row.json) : null;
@@ -929,7 +471,7 @@ export const createSqliteExecutorBackend = ( · 133 unmodified lines
929 return createExecutorBackend({471 return createExecutorBackend({
930 loadRepositories: () => {472 loadRepositories: () => {
931 const store = openSqliteStore(databasePath);473 const store = openSqliteStore(databasePath);
932 const { auth, secrets, executions } = createStorageDomains(store);474 const { secrets, executions } = createStorageDomains(store);
933 475
934 return {476 return {
935 scope: {477 scope: {
@@ -1042,7 +584,6 @@ export const createSqliteExecutorBackend = ( · 106 unmodified lines
1042 store.db.delete(sourceArtifacts).where(eq(sourceArtifacts.sourceId, sourceId)).run();584 store.db.delete(sourceArtifacts).where(eq(sourceArtifacts.sourceId, sourceId)).run();
1043 },585 },
1044 },586 },
1045 sourceAuth: auth,
1046 },587 },
1047 secrets: {588 secrets: {
1048 ...secrets,589 ...secrets,
knip.json+0−3
@@ -9,9 +9,6 @@ · 8 unmodified lines
9 "ignoreIssues": {9 "ignoreIssues": {
10 "packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs": [10 "packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs": [
11 "files"11 "files"
12 ],
13 "packages/sources/openapi/src/openapi-extractor-wasm/openapi_extractor.js": [
14 "exports"
15 ]12 ]
16 },13 },
17 "workspaces": {14 "workspaces": {
opencode.json+11−4
@@ -1,10 +1,17 @@
1{1{
2 "$schema": "https://opencode.ai/config.json",2 "$schema": "https://opencode.ai/config.json",
3 "mcp": {3 "mcp": {
4 "executor": {4 "executor-stdio": {
5 "type": "remote",5 "type": "local",
6 "url": "http://localhost:8788/mcp",6 "command": [
7 "enabled": true7 "bun",
8 "run",
9 "executor",
10 "mcp",
11 "--stdio"
12 ],
13 "enabled": true,
14 "environment": {}
8 }15 }
9 }16 }
10}17}
package.json+1−0
@@ -5,6 +5,7 @@ · 4 unmodified lines
5 "packageManager": "bun@1.2.22",5 "packageManager": "bun@1.2.22",
6 "workspaces": [6 "workspaces": [
7 "packages/*/*",7 "packages/*/*",
8 "plugins/*/*",
8 "apps/*",9 "apps/*",
9 "examples/*",10 "examples/*",
10 "apps/docs"11 "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
3 "private": true,3 "private": true,
4 "type": "module",4 "type": "module",
5 "exports": {5 "exports": {
6 ".": "./src/index.ts"6 ".": "./src/index.ts",
7 "./plugins": "./src/plugins/index.ts",
8 "./source-plugins": "./src/source-plugins/index.ts"
7 },9 },
8 "scripts": {10 "scripts": {
9 "typecheck": "bunx tsc --noEmit -p tsconfig.json",11 "typecheck": "bunx tsc --noEmit -p tsconfig.json",
@@ -11,17 +13,24 @@ · 1 unmodified line
11 "test:watch": "bunx vitest"13 "test:watch": "bunx vitest"
12 },14 },
13 "dependencies": {15 "dependencies": {
16 "@shikijs/langs": "^4.0.1",
17 "@shikijs/themes": "^4.0.1",
14 "@effect-atom/atom": "0.5.3",18 "@effect-atom/atom": "0.5.3",
15 "@effect-atom/atom-react": "0.5.0",19 "@effect-atom/atom-react": "0.5.0",
20 "@effect/platform": "catalog:",
16 "@executor/platform-api": "workspace:*",21 "@executor/platform-api": "workspace:*",
17 "@executor/platform-sdk": "workspace:*",22 "@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"
19 },29 },
20 "peerDependencies": {30 "peerDependencies": {
21 "react": ">=18 <20"31 "react": ">=18 <20"
22 },32 },
23 "devDependencies": {33 "devDependencies": {
24 "@effect/platform": "catalog:",
25 "@effect/platform-node": "catalog:",34 "@effect/platform-node": "catalog:",
26 "@effect/vitest": "catalog:",35 "@effect/vitest": "catalog:",
27 "@executor/effect-test-utils": "workspace:*",36 "@executor/effect-test-utils": "workspace:*",
packages/clients/react/src/core/api-atoms.ts+191−0
@@ -0,0 +1,191 @@
1import {
2 Atom,
3} from "@effect-atom/atom-react";
4import type {
5 InstanceConfig,
6 LocalInstallation,
7 SecretListItem,
8} from "@executor/platform-api";
9import type {
10 Execution,
11 ExecutionEnvelope,
12 Source,
13 SourceInspection,
14 SourceInspectionDiscoverResult,
15 SourceInspectionToolDetail,
16} from "@executor/platform-sdk/schema";
17import * as Effect from "effect/Effect";
18
19import { getExecutorApiBaseUrl } from "./base-url";
20import { getExecutorApiHttpClient } from "./http-client";
21import {
22 executionReactivityKey,
23 executionsReactivityKey,
24 instanceConfigReactivityKey,
25 localInstallationReactivityKey,
26 secretsReactivityKey,
27 sourceDiscoveryReactivityKey,
28 sourceInspectionReactivityKey,
29 sourceInspectionToolReactivityKey,
30 sourceReactivityKey,
31 sourcesReactivityKey,
32} from "./reactivity";
33import { disabledAtom } from "./loadable";
34
35export const localInstallationAtom = (baseUrl: string = getExecutorApiBaseUrl()) =>
36 getExecutorApiHttpClient(baseUrl).query("local", "installation", {
37 reactivityKeys: localInstallationReactivityKey(),
38 timeToLive: "5 minutes",
39 });
40
41export const instanceConfigAtom = (baseUrl: string = getExecutorApiBaseUrl()) =>
42 getExecutorApiHttpClient(baseUrl).query("local", "config", {
43 reactivityKeys: instanceConfigReactivityKey(),
44 timeToLive: "5 minutes",
45 });
46
47export const secretsAtom = (baseUrl: string = getExecutorApiBaseUrl()) =>
48 getExecutorApiHttpClient(baseUrl).query("local", "listSecrets", {
49 reactivityKeys: secretsReactivityKey(),
50 timeToLive: "1 minute",
51 });
52
53export 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
62export 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
75export 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
84export 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
97export 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
110export 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
131const emptyDiscoveryResult: SourceInspectionDiscoverResult = {
132 query: "",
133 queryTokens: [],
134 bestPath: null,
135 total: 0,
136 results: [],
137};
138
139export 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
167export 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
180export 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 @@
1const DEFAULT_EXECUTOR_API_BASE_URL = "http://127.0.0.1:8788";
2
3let apiBaseUrl =
4 typeof window !== "undefined" && typeof window.location?.origin === "string"
5 ? window.location.origin
6 : DEFAULT_EXECUTOR_API_BASE_URL;
7
8export const getExecutorApiBaseUrl = (): string => apiBaseUrl;
9
10export const setExecutorApiBaseUrl = (baseUrl: string): void => {
11 apiBaseUrl = baseUrl;
12};
packages/clients/react/src/core/http-client.ts+71−0
@@ -0,0 +1,71 @@
1import {
2 AtomHttpApi,
3} from "@effect-atom/atom-react";
4import {
5 FetchHttpClient,
6} from "@effect/platform";
7import type * as HttpApi from "@effect/platform/HttpApi";
8import type * as HttpApiGroup from "@effect/platform/HttpApiGroup";
9import {
10 ExecutorApi,
11 createExecutorApi,
12 type ExecutorHttpApiExtension,
13} from "@executor/platform-api";
14
15import { getExecutorApiBaseUrl } from "./base-url";
16
17export 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
51export 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
67export const getExecutorApiHttpClient =
68 defineExecutorHttpApiClient<"ExecutorReactHttpClient">()(
69 "ExecutorReactHttpClient",
70 ExecutorApi,
71 );
packages/clients/react/src/core/loadable.ts+61−0
@@ -0,0 +1,61 @@
1import {
2 Atom,
3 Result,
4 useAtomValue,
5} from "@effect-atom/atom-react";
6import * as Cause from "effect/Cause";
7import * as Effect from "effect/Effect";
8import * as React from "react";
9
10import type { Loadable } from "./types";
11
12const causeMessage = (cause: Cause.Cause<unknown>): Error =>
13 new Error(Cause.pretty(cause));
14
15export 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
35export const pendingResultAtom = Atom.make(
36 Effect.never as Effect.Effect<never, Error>,
37).pipe(Atom.keepAlive);
38
39export 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
53export 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
60export 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 @@
1import type { Execution, Source } from "@executor/platform-sdk/schema";
2
3import type { ReactivityKeys } from "./types";
4
5export const localInstallationReactivityKey = (): ReactivityKeys => ({
6 localInstallation: [],
7});
8
9export const instanceConfigReactivityKey = (): ReactivityKeys => ({
10 instanceConfig: [],
11});
12
13export const secretsReactivityKey = (): ReactivityKeys => ({
14 secrets: [],
15});
16
17export const sourcesReactivityKey = (
18 workspaceId: Source["scopeId"],
19): ReactivityKeys => ({
20 sources: [workspaceId],
21});
22
23export const executionsReactivityKey = (
24 workspaceId: Execution["scopeId"],
25): ReactivityKeys => ({
26 executions: [workspaceId],
27});
28
29export const executionReactivityKey = (
30 workspaceId: Execution["scopeId"],
31 executionId: Execution["id"],
32): ReactivityKeys => ({
33 execution: [workspaceId, executionId],
34});
35
36export const sourceReactivityKey = (
37 workspaceId: Source["scopeId"],
38 sourceId: Source["id"],
39): ReactivityKeys => ({
40 source: [workspaceId, sourceId],
41});
42
43export const sourceInspectionReactivityKey = (
44 workspaceId: Source["scopeId"],
45 sourceId: Source["id"],
46): ReactivityKeys => ({
47 sourceInspection: [workspaceId, sourceId],
48});
49
50export 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
61export 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 @@
1export type SourceRemoveResult = {
2 removed: boolean;
3};
4
5export type Loadable<T> =
6 | { status: "loading" }
7 | { status: "error"; error: Error }
8 | { status: "ready"; data: T };
9
10export type MutationState<T> = {
11 status: "idle" | "pending" | "success" | "error";
12 data: T | null;
13 error: Error | null;
14};
15
16export type ReactivityKeys = Readonly<Record<string, ReadonlyArray<unknown>>>;
packages/clients/react/src/core/workspace.ts+62−0
@@ -0,0 +1,62 @@
1import type { LocalInstallation } from "@executor/platform-api";
2import type { Source } from "@executor/platform-sdk/schema";
3import * as React from "react";
4
5import { getExecutorApiBaseUrl } from "./base-url";
6import { localInstallationAtom } from "./api-atoms";
7import { pendingLoadable, useLoadableAtom } from "./loadable";
8import type { Loadable } from "./types";
9
10const PLACEHOLDER_WORKSPACE_ID = "ws_placeholder" as Source["scopeId"];
11
12export type WorkspaceContext = {
13 installation: LocalInstallation;
14 workspaceId: Source["scopeId"];
15};
16
17const 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
37export 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
53export 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
62export { pendingLoadable };
packages/clients/react/src/hooks/executions.ts+46−0
@@ -0,0 +1,46 @@
1import type {
2 Execution,
3 ExecutionEnvelope,
4} from "@executor/platform-sdk/schema";
5
6import {
7 executionAtom,
8 executionsAtom,
9} from "../core/api-atoms";
10import {
11 disabledAtom,
12 useLoadableAtom,
13} from "../core/loadable";
14import type {
15 Loadable,
16} from "../core/types";
17import {
18 pendingLoadable,
19 useWorkspaceRequestContext,
20} from "../core/workspace";
21
22export 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
34export 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 @@
1import {
2 useAtomRefresh,
3} from "@effect-atom/atom-react";
4import type {
5 InstanceConfig,
6 LocalInstallation,
7} from "@executor/platform-api";
8
9import { getExecutorApiBaseUrl } from "../core/base-url";
10import { instanceConfigAtom, localInstallationAtom } from "../core/api-atoms";
11import { useLoadableAtom } from "../core/loadable";
12import type { Loadable } from "../core/types";
13
14export const useLocalInstallation = (): Loadable<LocalInstallation> =>
15 useLoadableAtom(localInstallationAtom(getExecutorApiBaseUrl()));
16
17export const useInstanceConfig = (): Loadable<InstanceConfig> =>
18 useLoadableAtom(instanceConfigAtom(getExecutorApiBaseUrl()));
19
20export const useRefreshLocalInstallation = (): (() => void) =>
21 useAtomRefresh(localInstallationAtom(getExecutorApiBaseUrl()));
packages/clients/react/src/hooks/mutations.ts+44−0
@@ -0,0 +1,44 @@
1import * as React from "react";
2
3import type { MutationState } from "../core/types";
4
5export 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 @@
1import {
2 useAtomRefresh,
3 useAtomSet,
4} from "@effect-atom/atom-react";
5import type {
6 CreateSecretPayload,
7 CreateSecretResult,
8 DeleteSecretResult,
9 SecretListItem,
10 UpdateSecretPayload,
11 UpdateSecretResult,
12} from "@executor/platform-api";
13import * as React from "react";
14
15import { getExecutorApiBaseUrl } from "../core/base-url";
16import { getExecutorApiHttpClient } from "../core/http-client";
17import { secretsAtom } from "../core/api-atoms";
18import { useLoadableAtom } from "../core/loadable";
19import { secretsReactivityKey } from "../core/reactivity";
20import type { Loadable } from "../core/types";
21import { useExecutorMutation } from "./mutations";
22
23export const useSecrets = (): Loadable<ReadonlyArray<SecretListItem>> =>
24 useLoadableAtom(secretsAtom(getExecutorApiBaseUrl()));
25
26export const useRefreshSecrets = (): (() => void) =>
27 useAtomRefresh(secretsAtom(getExecutorApiBaseUrl()));
28
29export 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
47export 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
69export 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 @@
1import {
2 RegistryContext,
3 useAtomSet,
4} from "@effect-atom/atom-react";
5import type {
6 Source,
7 SourceInspection,
8 SourceInspectionDiscoverResult,
9 SourceInspectionToolDetail,
10} from "@executor/platform-sdk/schema";
11import * as React from "react";
12
13import {
14 sourceAtom,
15 sourceDiscoveryAtom,
16 sourceInspectionAtom,
17 sourceInspectionToolAtom,
18 sourcesAtom,
19} from "../core/api-atoms";
20import { disabledAtom, useLoadableAtom } from "../core/loadable";
21import {
22 sourceDiscoveryReactivityKey,
23 sourceInspectionReactivityKey,
24 sourceInspectionToolReactivityKey,
25 sourceReactivityKey,
26 sourcesReactivityKey,
27} from "../core/reactivity";
28import type { Loadable, SourceRemoveResult } from "../core/types";
29import { pendingLoadable, useWorkspaceRequestContext } from "../core/workspace";
30import { getExecutorApiHttpClient } from "../core/http-client";
31import { useExecutorMutation } from "./mutations";
32
33export 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
43export 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
53export 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
65export 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
82export 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
101export 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
123export 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 @@
1import { createServer } from "node:http";
2
3import {1import {
4 FileSystem,2 FileSystem,
5 HttpApi,3 HttpApi,
6 HttpApiBuilder,4 HttpApiBuilder,
7 HttpApiEndpoint,5 HttpApiEndpoint,
8 HttpApiGroup,6 HttpApiGroup,
9 HttpServer,
10 OpenApi,7 OpenApi,
11} from "@effect/platform";8} from "@effect/platform";
12import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node";9import { NodeFileSystem } from "@effect/platform-node";
13import { describe, expect, it } from "@effect/vitest";10import { startOpenApiTestServer } from "@executor/effect-test-utils";
14import {
15 startOpenApiTestServer,
16} from "@executor/effect-test-utils";
17import {
18 createExecutorApiLayer,
19} from "@executor/platform-api/http";
20import { createExecutorEffect } from "@executor/platform-sdk/effect";
21import type { ExecutorRuntime } from "@executor/platform-sdk/runtime";
22import { createLocalExecutorRuntime as createExecutorRuntime } from "@executor/platform-sdk-file/runtime";
23import type {11import type {
24 LocalInstallation,12 LocalInstallation,
25 Source,13 Source,
26} from "@executor/platform-sdk/schema";14} from "@executor/platform-sdk/schema";
27import * as Context from "effect/Context";
28import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
29import * as Exit from "effect/Exit";16import * as Exit from "effect/Exit";
30import * as Layer from "effect/Layer";17import * as Layer from "effect/Layer";
@@ -33,18 +20,19 @@ import * as Scope from "effect/Scope"; · 2 unmodified lines
33import { JSDOM } from "jsdom";20import { JSDOM } from "jsdom";
34import * as React from "react";21import * as React from "react";
35import { createRoot } from "react-dom/client";22import { createRoot } from "react-dom/client";
23import { describe, expect, it } from "vitest";
36 24
37import {25import {
38 ExecutorReactProvider,26 ExecutorReactProvider,
39 setExecutorApiBaseUrl,27 setExecutorApiBaseUrl,
40 type Loadable,28 type Loadable,
41 useCreateSource,29 useLocalInstallation,
42 useRemoveSource,30 useRemoveSource,
43 useSource,31 useSource,
44 useSourceInspection,32 useSourceInspection,
45 useSources,33 useSources,
46 useUpdateSource,
47} from "./index";34} from "./index";
35import { createLocalExecutorServer } from "../../../platform/server/src/index";
48 36
49const dom = new JSDOM("<!doctype html><html><body></body></html>", {37const dom = new JSDOM("<!doctype html><html><body></body></html>", {
50 url: "http://127.0.0.1/",38 url: "http://127.0.0.1/",
@@ -61,7 +49,6 @@ globalThis.Node = dom.window.Node; · 10 unmodified lines
61globalThis.MutationObserver = dom.window.MutationObserver;49globalThis.MutationObserver = dom.window.MutationObserver;
62globalThis.Event = dom.window.Event;50globalThis.Event = dom.window.Event;
63globalThis.EventTarget = dom.window.EventTarget;51globalThis.EventTarget = dom.window.EventTarget;
64
65globalThis.requestAnimationFrame = (callback: FrameRequestCallback) =>52globalThis.requestAnimationFrame = (callback: FrameRequestCallback) =>
66 setTimeout(() => callback(Date.now()), 0) as unknown as number;53 setTimeout(() => callback(Date.now()), 0) as unknown as number;
67globalThis.cancelAnimationFrame = (handle: number) => {54globalThis.cancelAnimationFrame = (handle: number) => {
@@ -70,173 +57,62 @@ globalThis.cancelAnimationFrame = (handle: number) => { · 2 unmodified lines
70 57
71(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;58(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
72 59
73type DelayRule = {
74 method: string;
75 pathname: RegExp;
76 durationMs: number;
77};
78
79type RunningServer = {60type RunningServer = {
80 baseUrl: string;61 baseUrl: string;
81 close: () => Promise<void>;62 close: () => Promise<void>;
82};63};
83 64
84type ApiServer = RunningServer & {
85 runtime: ExecutorRuntime;
86};
87
88type HookHarness<T> = {65type HookHarness<T> = {
89 current: T | null;66 current: T | null;
90 unmount: () => Promise<void>;67 unmount: () => Promise<void>;
91};68};
92 69
93type CreateHarnessState = {70type WorkspaceHarnessState = {
71 installation: Loadable<LocalInstallation>;
94 sources: Loadable<ReadonlyArray<Source>>;72 sources: Loadable<ReadonlyArray<Source>>;
95 createSource: ReturnType<typeof useCreateSource>;
96};73};
97 74
98type SourceHarnessState = {75type SourceHarnessState = {
99 sources: Loadable<ReadonlyArray<Source>>;76 sources: Loadable<ReadonlyArray<Source>>;
100 source: Loadable<Source>;77 source: Loadable<Source>;
101 inspection: ReturnType<typeof useSourceInspection>;78 inspection: ReturnType<typeof useSourceInspection>;
102 updateSource: ReturnType<typeof useUpdateSource>;
103 removeSource: ReturnType<typeof useRemoveSource>;79 removeSource: ReturnType<typeof useRemoveSource>;
104};80};
105 81
106const closeScope = (scope: Scope.CloseableScope) =>82const closeScope = (scope: Scope.CloseableScope) =>
107 Scope.close(scope, Exit.void).pipe(Effect.orDie);83 Scope.close(scope, Exit.void).pipe(Effect.orDie);
108 84
109const startControlPlaneServer = async (): Promise<ApiServer> => {85const startControlPlaneServer = async (): Promise<RunningServer> => {
110 const workspaceRoot = await Effect.runPromise(86 const workspaceRoot = await Effect.runPromise(
111 FileSystem.FileSystem.pipe(87 FileSystem.FileSystem.pipe(
112 Effect.flatMap((fs) => fs.makeTempDirectory({ prefix: "executor-react-test-" })),88 Effect.flatMap((fs) =>
89 fs.makeTempDirectory({ prefix: "executor-react-test-" })),
113 Effect.provide(NodeFileSystem.layer),90 Effect.provide(NodeFileSystem.layer),
114 ),91 ),
115 );92 );
116 const runtime = await Effect.runPromise(
117 createExecutorRuntime({
118 workspaceRoot,
119 }),
120 );
121 const scope = await Effect.runPromise(Scope.make());93 const scope = await Effect.runPromise(Scope.make());
122 94
123 try {95 try {
124 const executor = await Effect.runPromise(96 const server = await Effect.runPromise(
125 createExecutorEffect({97 createLocalExecutorServer({
126 backend: {98 port: 0,
127 createRuntime: () => Effect.succeed(runtime),99 localDataDir: ":memory:",
128 },100 workspaceRoot,
129 }),101 }).pipe(Effect.provideService(Scope.Scope, scope)),
130 );
131 const serverLayer = HttpApiBuilder.serve().pipe(
132 Layer.provide(createExecutorApiLayer(executor)),
133 Layer.provideMerge(NodeHttpServer.layerTest),
134 );102 );
135 const context = await Effect.runPromise(Layer.buildWithScope(serverLayer, scope));
136 const server = Context.get(context, HttpServer.HttpServer);
137 103
138 return {104 return {
139 runtime,105 baseUrl: server.baseUrl,
140 baseUrl: HttpServer.formatAddress(server.address),
141 close: async () => {106 close: async () => {
142 await Effect.runPromise(closeScope(scope));107 await Effect.runPromise(closeScope(scope));
143 await runtime.close();
144 },108 },
145 };109 };
146 } catch (error) {110 } catch (error) {
147 await Effect.runPromise(closeScope(scope));111 await Effect.runPromise(closeScope(scope));
148 await runtime.close();
149 throw error;112 throw error;
150 }113 }
151};114};
152 115
153const 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
236type OpenApiSpecServer = RunningServer & {
237 specUrl: string;
238};
239
240class HooksTestPingApi extends HttpApiGroup.make("ping")116class HooksTestPingApi extends HttpApiGroup.make("ping")
241 .add(117 .add(
242 HttpApiEndpoint.get("ping")`/ping`118 HttpApiEndpoint.get("ping")`/ping`
@@ -251,30 +127,26 @@ class HooksTestPingApi extends HttpApiGroup.make("ping") · 8 unmodified lines
251class HooksTestOpenApi extends HttpApi.make("hooksTest").add(HooksTestPingApi) {}127class HooksTestOpenApi extends HttpApi.make("hooksTest").add(HooksTestPingApi) {}
252 128
253const hooksTestOpenApiSpec = OpenApi.fromApi(HooksTestOpenApi);129const hooksTestOpenApiSpec = OpenApi.fromApi(HooksTestOpenApi);
254const hooksTestOpenApiLive = HttpApiBuilder.group(
255 HooksTestOpenApi,
256 "ping",
257 (handlers) =>
258 handlers.handle("ping", () =>
259 Effect.succeed({
260 ok: true,
261 }),
262 ),
263);
264const hooksTestOpenApiLayer = HttpApiBuilder.api(HooksTestOpenApi).pipe(130const 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 ),
266);139);
267 140
268const startOpenApiSpecServer = async (): Promise<OpenApiSpecServer> => {141const startOpenApiSpecServer = async (): Promise<OpenApiSpecServer> =>
269 return startOpenApiTestServer({142 startOpenApiTestServer({
270 apiLayer: hooksTestOpenApiLayer,143 apiLayer: hooksTestOpenApiLayer,
271 });144 });
272};
273 145
274async function requestJson<T>(input: {146async function requestJson<T>(input: {
275 baseUrl: string;147 baseUrl: string;
276 path: string;148 path: string;
277 method?: "GET" | "POST" | "PATCH" | "DELETE";149 method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
278 payload?: unknown;150 payload?: unknown;
279 accountId?: string;151 accountId?: string;
280}): Promise<T> {152}): Promise<T> {
@@ -305,29 +177,24 @@ const getInstallation = (baseUrl: string) => · 24 unmodified lines
305 });177 });
306 178
307const seedStoredOpenApiSource = async (input: {179const seedStoredOpenApiSource = async (input: {
308 server: ApiServer;180 server: RunningServer;
309 installation: LocalInstallation;181 installation: LocalInstallation;
310 name: string;182 name: string;
311 specUrl?: string;183 specUrl?: string;
312}): Promise<Source> => {184}): Promise<Source> => {
313 const sourceDocumentText = JSON.stringify(hooksTestOpenApiSpec);185 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)}`;
315 188
316 return requestJson<Source>({189 return requestJson<Source>({
317 baseUrl: input.server.baseUrl,190 baseUrl: input.server.baseUrl,
318 path: `/v1/workspaces/${input.installation.scopeId}/sources`,191 path: `/v1/workspaces/${input.installation.scopeId}/plugins/openapi/sources`,
319 method: "POST",192 method: "POST",
320 accountId: input.installation.actorScopeId,193 accountId: input.installation.actorScopeId,
321 payload: {194 payload: {
322 name: input.name,195 name: input.name,
323 kind: "openapi",196 specUrl,
324 endpoint: "https://example.com/api",197 baseUrl: "https://example.com/api",
325 status: "connected",
326 enabled: true,
327 namespace: "hooks-test",
328 binding: {
329 specUrl,
330 },
331 auth: {198 auth: {
332 kind: "none",199 kind: "none",
333 },200 },
@@ -372,10 +239,10 @@ async function renderExecutorHarness<T>(useValue: () => T): Promise<HookHarness< · 38 unmodified lines
372 };239 };
373}240}
374 241
375const renderCreateHarness = () =>242const renderWorkspaceHarness = () =>
376 renderExecutorHarness<CreateHarnessState>(() => ({243 renderExecutorHarness<WorkspaceHarnessState>(() => ({
244 installation: useLocalInstallation(),
377 sources: useSources(),245 sources: useSources(),
378 createSource: useCreateSource(),
379 }));246 }));
380 247
381const renderSourceHarness = (sourceId: string) =>248const renderSourceHarness = (sourceId: string) =>
@@ -383,7 +250,6 @@ const renderSourceHarness = (sourceId: string) => · 1 unmodified line
383 sources: useSources(),250 sources: useSources(),
384 source: useSource(sourceId),251 source: useSource(sourceId),
385 inspection: useSourceInspection(sourceId),252 inspection: useSourceInspection(sourceId),
386 updateSource: useUpdateSource(),
387 removeSource: useRemoveSource(),253 removeSource: useRemoveSource(),
388 }));254 }));
389 255
@@ -414,190 +280,74 @@ function isReady<T>( · 24 unmodified lines
414 return loadable.status === "ready";280 return loadable.status === "ready";
415}281}
416 282
417function getReadyData<T>(loadable: Loadable<T>): T {283type OpenApiSpecServer = RunningServer & {
418 if (!isReady(loadable)) {284 specUrl: string;
419 throw new Error("Expected loadable data to be ready");285};
420 }
421
422 return loadable.data;
423}
424
425 286
426describe("executor-react source hooks", () => {287describe("executor-react source hooks", () => {
427 it.effect("rejects invalid source creation before applying optimistic state", () =>288 it("loads the local installation, sources, and inspection data", async () => {
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 () => {
494 const apiServer = await startControlPlaneServer();289 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 });
505 const specServer = await startOpenApiSpecServer();290 const specServer = await startOpenApiSpecServer();
506 291
507 setExecutorApiBaseUrl(proxyServer.baseUrl);292 setExecutorApiBaseUrl(apiServer.baseUrl);
508 293
509 try {294 try {
510 const installation = await getInstallation(apiServer.baseUrl);295 const installation = await getInstallation(apiServer.baseUrl);
511 const source = await seedStoredOpenApiSource({296 const source = await seedStoredOpenApiSource({
512 server: apiServer,297 server: apiServer,
513 installation,298 installation,
514 name: "Original Source",299 name: "Hooks Test Source",
515 specUrl: specServer.specUrl,300 specUrl: specServer.specUrl,
516 });301 });
517 const harness = await renderSourceHarness(source.id);
518 302
519 try {303 const workspaceHarness = await renderWorkspaceHarness();
520 await waitForValue(304 const sourceHarness = await renderSourceHarness(source.id);
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 );
529 305
530 let mutationPromise!: Promise<Source>;306 try {
531 await React.act(async () => {307 const workspace = await waitForValue(
532 mutationPromise = harness.current!.updateSource.mutateAsync({308 () => workspaceHarness.current,
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,
543 (value) =>309 (value) =>
544 value.updateSource.status === "pending"310 isReady(value.installation)
545 && isReady(value.sources)311 && isReady(value.sources)
546 && isReady(value.source)312 && value.installation.data.scopeId === installation.scopeId
547 && isReady(value.inspection)313 && value.sources.data.some((item) => item.id === source.id),
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",
551 );314 );
552 315
553 expect(getReadyData(optimistic.source).name).toBe("Renamed Source");316 expect(workspace.installation.status).toBe("ready");
554 expect(getReadyData(optimistic.inspection).source.name).toBe("Original Source");317 expect(workspace.sources.status).toBe("ready");
555
556 let updated!: Source;
557 await React.act(async () => {
558 updated = await mutationPromise;
559 });
560 318
561 expect(updated.name).toBe("Renamed Source");319 const loaded = await waitForValue(
562 320 () => sourceHarness.current,
563 const refreshed = await waitForValue(
564 () => harness.current,
565 (value) =>321 (value) =>
566 value.updateSource.status === "success"322 isReady(value.source)
567 && isReady(value.source)
568 && isReady(value.inspection)323 && isReady(value.inspection)
569 && value.source.data.name === "Renamed Source"324 && value.source.data.name === "Hooks Test Source"
570 && value.inspection.data.source.name === "Renamed Source",325 && value.inspection.data.source.id === source.id
326 && value.inspection.data.toolCount > 0,
571 );327 );
572 328
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");
574 } finally {336 } finally {
575 await harness.unmount();337 await sourceHarness.unmount();
338 await workspaceHarness.unmount();
576 }339 }
577 } finally {340 } finally {
578 await specServer.close();341 await specServer.close();
579 await proxyServer.close();
580 await apiServer.close();342 await apiServer.close();
581 }343 }
582 }),344 }, 60_000);
583 60_000,
584 );
585 345
586 it.effect("optimistically removes deleted sources and invalidates mounted source queries", () =>346 it("invalidates mounted source and inspection queries after removal", async () => {
587 Effect.promise(async () => {
588 const apiServer = await startControlPlaneServer();347 const apiServer = await startControlPlaneServer();
589 const proxyServer = await startProxyServer({348 const specServer = await startOpenApiSpecServer();
590 targetBaseUrl: apiServer.baseUrl,
591 delays: [
592 {
593 method: "DELETE",
594 pathname: /^\/v1\/workspaces\/[^/]+\/sources\/[^/]+$/,
595 durationMs: 150,
596 },
597 ],
598 });
599 349
600 setExecutorApiBaseUrl(proxyServer.baseUrl);350 setExecutorApiBaseUrl(apiServer.baseUrl);
601 351
602 try {352 try {
603 const installation = await getInstallation(apiServer.baseUrl);353 const installation = await getInstallation(apiServer.baseUrl);
@@ -605,6 +355,7 @@ describe("executor-react source hooks", () => { · 1 unmodified line
605 server: apiServer,355 server: apiServer,
606 installation,356 installation,
607 name: "Disposable Source",357 name: "Disposable Source",
358 specUrl: specServer.specUrl,
608 });359 });
609 const harness = await renderSourceHarness(source.id);360 const harness = await renderSourceHarness(source.id);
610 361
@@ -618,36 +369,14 @@ describe("executor-react source hooks", () => { · 7 unmodified lines
618 && value.sources.data.some((item) => item.id === source.id),369 && value.sources.data.some((item) => item.id === source.id),
619 );370 );
620 371
621 let mutationPromise!: Promise<{ removed: boolean }>;372 const removed = await React.act(async () =>
622 await React.act(async () => {373 harness.current!.removeSource.mutateAsync(source.id));
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
644 expect(removed.removed).toBe(true);374 expect(removed.removed).toBe(true);
645 375
646 const invalidated = await waitForValue(376 const invalidated = await waitForValue(
647 () => harness.current,377 () => harness.current,
648 (value) =>378 (value) =>
649 value.removeSource.status === "success"379 isReady(value.sources)
650 && isReady(value.sources)
651 && value.sources.data.length === 0380 && value.sources.data.length === 0
652 && value.source.status === "error"381 && value.source.status === "error"
653 && value.inspection.status === "error",382 && value.inspection.status === "error",
@@ -659,46 +388,41 @@ describe("executor-react source hooks", () => { · 5 unmodified lines
659 await harness.unmount();388 await harness.unmount();
660 }389 }
661 } finally {390 } finally {
662 await proxyServer.close();391 await specServer.close();
663 await apiServer.close();392 await apiServer.close();
664 }393 }
665 }),394 }, 60_000);
666 60_000,
667 );
668 395
669 it.effect("surfaces missing sources as errors instead of staying loading", () =>396 it("surfaces missing sources as errors instead of staying loading", async () => {
670 Effect.promise(async () => {397 const apiServer = await startControlPlaneServer();
671 const apiServer = await startControlPlaneServer();398 setExecutorApiBaseUrl(apiServer.baseUrl);
672 setExecutorApiBaseUrl(apiServer.baseUrl);399
400 try {
401 const harness = await renderSourceHarness("src_missing");
673 402
674 try {403 try {
675 const harness = await renderSourceHarness("src_missing");404 const missing = await waitForValue(
676 405 () => harness.current,
677 try {406 (value) =>
678 const missing = await waitForValue(407 isReady(value.sources)
679 () => harness.current,408 && value.sources.data.length === 0
680 (value) =>409 && value.source.status === "error"
681 isReady(value.sources)410 && value.inspection.status === "error",
682 && value.sources.data.length === 0411 );
683 && value.source.status === "error"412
684 && value.inspection.status === "error",413 expect(missing.source.status).toBe("error");
685 );414 expect(missing.inspection.status).toBe("error");
686 415 if (missing.source.status === "error") {
687 expect(missing.source.status).toBe("error");416 expect(missing.source.error.message).toContain("Source not found");
688 expect(missing.inspection.status).toBe("error");417 }
689 if (missing.source.status === "error") {418 if (missing.inspection.status === "error") {
690 expect(missing.source.error.message).toContain("Source not found");419 expect(missing.inspection.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();
697 }420 }
698 } finally {421 } finally {
699 await apiServer.close();422 await harness.unmount();
700 }423 }
701 }),424 } finally {
702 60_000,425 await apiServer.close();
703 );426 }
427 }, 60_000);
704});428});
packages/clients/react/src/index.ts+65−1420
@@ -1,1436 +1,81 @@
1import { Atom, Result } from "@effect-atom/atom";1export {
2import type * as Registry from "@effect-atom/atom/Registry";2 Atom,
3import { RegistryContext, RegistryProvider, useAtomValue } from "@effect-atom/atom-react";3 AtomHttpApi,
4import {4 RegistryContext,
5 createExecutorApiClient,5 RegistryProvider,
6 type ExecutorApiClient,6 Result,
7} from "@executor/platform-api/client";7 useAtomRefresh,
8import type {8 useAtomSet,
9 CompleteSourceOAuthResult,9 useAtomValue,
10 ConnectSourceBatchPayload,10} from "@effect-atom/atom-react";
11 ConnectSourceBatchResult,11
12 ConnectSourcePayload,12export type {
13 ConnectSourceResult,
14 CreateSecretPayload,13 CreateSecretPayload,
15 CreateSecretResult,14 CreateSecretResult,
16 CreateSourcePayload,
17 CreateWorkspaceOauthClientPayload,
18 DeleteSecretResult,15 DeleteSecretResult,
19 DiscoverSourcePayload,
20 InstanceConfig,16 InstanceConfig,
21 LocalInstallation,17 LocalInstallation,
22 SecretListItem,18 SecretListItem,
23 StartSourceOAuthPayload,
24 StartSourceOAuthResult,
25 UpdateSecretPayload,19 UpdateSecretPayload,
26 UpdateSecretResult,20 UpdateSecretResult,
27 UpdateSourcePayload,
28} from "@executor/platform-api";21} from "@executor/platform-api";
29import type {22
23export type {
24 Execution,
25 ExecutionEnvelope,
26 ExecutionStatus,
30 Source,27 Source,
31 SourceDiscoveryResult,
32 SourceInspection,28 SourceInspection,
33 SourceInspectionDiscoverResult,29 SourceInspectionDiscoverResult,
34 SourceInspectionToolDetail,30 SourceInspectionToolDetail,
35 ScopeOauthClient as WorkspaceOauthClient,
36} from "@executor/platform-sdk/schema";31} from "@executor/platform-sdk/schema";
37import * as Cause from "effect/Cause";
38import * as Effect from "effect/Effect";
39import * as Option from "effect/Option";
40import * as Runtime from "effect/Runtime";
41import * as React from "react";
42
43const DEFAULT_EXECUTOR_API_BASE_URL = "http://127.0.0.1:8788";
44const PLACEHOLDER_WORKSPACE_ID = "ws_placeholder" as Source["scopeId"];
45const PLACEHOLDER_ACCOUNT_ID = "acc_placeholder";
46const PLACEHOLDER_SOURCE_ID = "src_placeholder" as Source["id"];
47
48type SourceMutationState<T> = {
49 status: "idle" | "pending" | "success" | "error";
50 data: T | null;
51 error: Error | null;
52};
53
54export type SourceRemoveResult = {
55 removed: boolean;
56};
57
58type AtomKeyPart = string | number | boolean | null | undefined;
59
60type SourcesKeyParts = readonly [boolean, Source["scopeId"], string];
61type SourceKeyParts = readonly [boolean, Source["scopeId"], string, Source["id"]];
62type SourceToolDetailKeyParts = readonly [
63 boolean,
64 Source["scopeId"],
65 string,
66 Source["id"],
67 string | null,
68];
69type SourceDiscoveryKeyParts = readonly [
70 boolean,
71 Source["scopeId"],
72 string,
73 Source["id"],
74 string,
75 number | null,
76];
77type WorkspaceOauthClientsKeyParts = readonly [
78 boolean,
79 Source["scopeId"],
80 string,
81 string,
82];
83
84type InvalidationTarget = {
85 workspaceId?: Source["scopeId"];
86 accountId?: string;
87 sourceId?: Source["id"];
88};
89
90type 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
99type ExecutorQueryContextValue = {
100 registry: Registry.Registry;
101 activeQueries: ActiveQueryCollections;
102 invalidateQueries: (target?: InvalidationTarget) => void;
103};
104
105type MutationExecutionContext = {
106 workspaceId: Source["scopeId"];
107 accountId: string;
108 registry: Registry.Registry;
109 invalidateQueries: (target?: InvalidationTarget) => void;
110};
111
112type OptimisticMutationResult<T> =
113 | void
114 | (() => void)
115 | {
116 rollback?: () => void;
117 value?: T;
118 };
119
120type 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
133type InternalNode<A> = {
134 setValue: (value: A) => void;
135 valueOption?: () => Option.Option<A>;
136};
137
138let apiBaseUrl =
139 typeof window !== "undefined" && typeof window.location?.origin === "string"
140 ? window.location.origin
141 : DEFAULT_EXECUTOR_API_BASE_URL;
142
143const ExecutorQueryContext = React.createContext<ExecutorQueryContextValue | null>(null);
144
145const encodeAtomKey = (parts: ReadonlyArray<AtomKeyPart>): string => JSON.stringify(parts);
146
147const decodeAtomKey = <T extends ReadonlyArray<AtomKeyPart>>(key: string): T => JSON.parse(key) as T;
148
149const encodeSourcesKey = (
150 enabled: boolean,
151 workspaceId: Source["scopeId"],
152 accountId: string,
153): string => encodeAtomKey([enabled, workspaceId, accountId] satisfies SourcesKeyParts);
154
155const 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
162const 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
171const 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
181const 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
191const toError = (cause: unknown): Error =>
192 cause instanceof Error ? cause : new Error(String(cause));
193
194const causeMessage = (cause: Cause.Cause<unknown>): Error =>
195 new Error(Cause.pretty(cause));
196
197const 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
206const 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
229const logExecutorDevError = (label: string, details: Record<string, unknown>): void => {
230 if (!shouldLogExecutorDevErrors()) {
231 return;
232 }
233
234 console.error(`[executor react] ${label}`, details);
235};
236
237const 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
262const 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
272const 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
281const 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
290const 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
299const 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
316const 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
334const 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
352const 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
371const 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
401const 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
421export type Loadable<T> =
422 | { status: "loading" }
423 | { status: "error"; error: Error }
424 | { status: "ready"; data: T };
425
426type WorkspaceContext = {
427 installation: LocalInstallation;
428 workspaceId: Source["scopeId"];
429 accountId: string;
430};
431
432const 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
452const 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
464const 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
469const 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
488const 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
510const 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
531const 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
546const 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
555const 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
573const 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
621const 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
629const 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
649const 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
663const removeSourceFromList = (
664 sources: ReadonlyArray<Source>,
665 sourceId: Source["id"],
666): ReadonlyArray<Source> => sources.filter((source) => source.id !== sourceId);
667
668const 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
695const 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
709const 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
791const 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
807export const setExecutorApiBaseUrl = (baseUrl: string): void => {
808 apiBaseUrl = baseUrl;
809};
810
811export const ExecutorReactProvider = (props: React.PropsWithChildren) =>
812 React.createElement(
813 RegistryProvider,
814 null,
815 React.createElement(ExecutorReactProviderInner, null, props.children),
816 );
817
818export const useInvalidateExecutorQueries = (): (() => void) => {
819 const { invalidateQueries } = useExecutorQueryContext();
820 return React.useCallback(() => {
821 invalidateQueries();
822 }, [invalidateQueries]);
823};
824
825export const useLocalInstallation = (): Loadable<LocalInstallation> =>
826 useLoadableAtom(localInstallationAtom(apiBaseUrl));
827
828export const useInstanceConfig = (): Loadable<InstanceConfig> =>
829 useLoadableAtom(instanceConfigAtom(apiBaseUrl));
830
831export const useSecrets = (): Loadable<ReadonlyArray<SecretListItem>> =>
832 useLoadableAtom(secretsAtom(apiBaseUrl));
833
834export const useRefreshSecrets = (): (() => void) => {
835 const registry = React.useContext(RegistryContext);
836 return React.useCallback(() => {
837 registry.refresh(secretsAtom(apiBaseUrl));
838 }, [registry]);
839};
840
841type SecretMutationState<T> = {
842 status: "idle" | "pending" | "success" | "error";
843 data: T | null;
844 error: Error | null;
845};
846
847const 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
890export 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
903export 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
917export 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
930export 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
939export 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
956export 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
987export 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
1004export 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
1025export 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
1048export 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
1071export 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
1129export 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
1186export 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
1203export 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
1241export 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
1255export 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
1291export 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
1331export 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
1356export 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
1381export 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 );
1408 32
33export {
34 defineExecutorHttpApiClient,
35 defineExecutorPluginHttpApiClient,
36} from "./core/http-client";
37export {
38 getExecutorApiBaseUrl,
39 setExecutorApiBaseUrl,
40} from "./core/base-url";
1409export type {41export type {
1410 CompleteSourceOAuthResult,42 Loadable,
1411 ConnectSourceBatchPayload,43 SourceRemoveResult,
1412 ConnectSourceBatchResult,44} from "./core/types";
1413 ConnectSourcePayload,45export {
1414 ConnectSourceResult,46 pendingLoadable,
1415 CreateSecretPayload,47 useWorkspaceId,
1416 CreateSecretResult,48 useWorkspaceRequestContext,
1417 CreateSourcePayload,49 type WorkspaceContext,
1418 CreateWorkspaceOauthClientPayload,50} from "./core/workspace";
1419 DeleteSecretResult,51export {
1420 DiscoverSourcePayload,52 ExecutorReactProvider,
1421 InstanceConfig,53} from "./provider";
1422 LocalInstallation,54export {
1423 55 useExecutorMutation,
1424 SecretListItem,56} from "./hooks/mutations";
1425 Source,57export {
1426 SourceDiscoveryResult,58 useInstanceConfig,
1427 SourceInspection,59 useLocalInstallation,
1428 SourceInspectionDiscoverResult,60 useRefreshLocalInstallation,
1429 SourceInspectionToolDetail,61} from "./hooks/local";
1430 StartSourceOAuthPayload,62export {
1431 StartSourceOAuthResult,63 useCreateSecret,
1432 UpdateSecretPayload,64 useDeleteSecret,
1433 UpdateSecretResult,65 useRefreshSecrets,
1434 UpdateSourcePayload,66 useSecrets,
1435 WorkspaceOauthClient,67 useUpdateSecret,
1436};68} from "./hooks/secrets";
69export {
70 useExecution,
71 useExecutions,
72} from "./hooks/executions";
73export {
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 @@
1import type { HTMLAttributes } from "react";
2import { cva, type VariantProps } from "class-variance-authority";
3
4import { cn } from "../lib/cn";
5
6const 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
28export type BadgeProps = HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>;
29
30export function Badge({ className, variant, ...props }: BadgeProps) {
31 return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
32}
33
34export 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 @@
1import { useCallback, useEffect, useRef, useState } from "react";
2
3import { codeToHtml, resolveLang } from "../lib/shiki";
4import { cn } from "../lib/cn";
5import { IconCheck, IconCopy } from "./icons";
6
7const highlightCache = new Map<string, string>();
8
9function cacheKey(code: string, lang: string) {
10 return `${lang}::${code.length}::${code.slice(0, 64)}`;
11}
12
13function 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
31export 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 @@
1import { cn } from "../lib/cn";
2import { CodeBlock } from "./code-block";
3
4export 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 @@
1import type { SVGProps } from "react";
2
3import { cn } from "../lib/cn";
4
5type IconProps = SVGProps<SVGSVGElement> & { className?: string };
6
7export 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
15export 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
24export 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
32export 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
40export 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
49export 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
57export 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
65export 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
74export 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
83export 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 @@
1import type { ReactNode } from "react";
2
3import type { Loadable } from "../../core/types";
4import { IconEmpty, IconSpinner } from "./icons";
5
6export 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
35export 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 @@
1import { Streamdown } from "streamdown";
2
3import { createLimitedCodePlugin } from "../lib/shiki";
4
5const codePlugin = createLimitedCodePlugin();
6
7const 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
33export 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 @@
1export type SourceToolExplorerSearch = {
2 tab: "model" | "discover";
3 tool?: string;
4 query?: string;
5};
6
7const sourceToolExplorerTabs = ["model", "discover"] as const;
8
9export 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 @@
1import { startTransition, useEffect, type ReactNode } from "react";
2import type {
3 SourceInspectionToolDetail,
4} from "../../index";
5
6import {
7 useSourceInspection,
8 useSourceToolDetail,
9} from "../../hooks/sources";
10import { Badge } from "./badge";
11import { LoadableBlock } from "./loadable";
12import {
13 SourceToolModelWorkbench,
14} from "./source-tool-workbench";
15import type { SourceToolExplorerSearch } from "./source-tool-explorer-search";
16
17export 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 @@
1import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
3import type { Loadable } from "../../core/types";
4import {
5 usePrefetchToolDetail,
6} from "../../hooks/sources";
7import type {
8 SourceInspection,
9 SourceInspectionToolDetail,
10} from "@executor/platform-sdk/schema";
11import { MethodBadge } from "./badge";
12import { CodeBlock } from "./code-block";
13import { DocumentPanel } from "./document-panel";
14import { EmptyState, LoadableBlock } from "./loadable";
15import { Markdown } from "./markdown";
16import {
17 IconCheck,
18 IconChevron,
19 IconClose,
20 IconCopy,
21 IconFolder,
22 IconSearch,
23 IconTool,
24} from "./icons";
25import { cn } from "../lib/cn";
26
27type ToolTreeNode = {
28 segment: string;
29 tool?: SourceInspectionToolDetail["summary"] | SourceInspection["tools"][number];
30 children: Map<string, ToolTreeNode>;
31};
32
33export type SourceToolDiscoveryResult = {
34 query: string;
35 results: ReadonlyArray<{
36 path: string;
37 score: number;
38 description?: string | null;
39 }>;
40};
41
42export type SourceToolDetailPanelProps = {
43 detail: SourceInspectionToolDetail;
44 renderHeaderMeta?: (detail: SourceInspectionToolDetail) => ReactNode;
45 renderSchemaExtras?: (detail: SourceInspectionToolDetail) => ReactNode;
46};
47
48export 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
57export 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
66const 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
97const 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
105const 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
170const 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
188const 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
221const 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
376const 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
425export 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
545export 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
667export 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 @@
1export type {
2 ExecutorFrontendPlugin,
3 ExecutorPluginNavigation,
4 ExecutorPluginRouteContextValue,
5 FrontendPluginRouteDefinition,
6 FrontendPluginRouteParams,
7 FrontendPluginRouteSearch,
8 SourcePluginNavigation,
9 SourcePluginRouteParams,
10 SourcePluginRouteSearch,
11} from "./types";
12
13export {
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
33export {
34 defineExecutorFrontendPlugin,
35 defineFrontendPluginRoute,
36 registerExecutorFrontendPlugins,
37 type RegisteredFrontendPluginRoute,
38} from "./registry";
39
40export {
41 ExecutorPluginRouteProvider,
42 useExecutorPlugin,
43 useExecutorPluginNavigation,
44 useExecutorPluginPaths,
45 useExecutorPluginRoute,
46 useExecutorPluginRouteDefinition,
47 useExecutorPluginRouteParams,
48 useExecutorPluginSearch,
49} from "./plugin-route-context";
50
51export {
52 useSourcePlugin,
53 useSourcePluginNavigation,
54 useSourcePluginPaths,
55 useSourcePluginRoute,
56 useSourcePluginRouteParams,
57 useSourcePluginSearch,
58} from "./route-context";
59
60export {
61 cn,
62} from "./lib/cn";
63
64export {
65 Badge,
66 MethodBadge,
67} from "./components/badge";
68export {
69 CodeBlock,
70} from "./components/code-block";
71export {
72 DocumentPanel,
73} from "./components/document-panel";
74export {
75 IconCheck,
76 IconChevron,
77 IconClose,
78 IconCopy,
79 IconEmpty,
80 IconFolder,
81 IconPencil,
82 IconSearch,
83 IconSpinner,
84 IconTool,
85} from "./components/icons";
86export {
87 EmptyState,
88 LoadableBlock,
89} from "./components/loadable";
90export {
91 Markdown,
92} from "./components/markdown";
93export {
94 SourceToolExplorer,
95} from "./components/source-tool-explorer";
96export {
97 type SourceToolExplorerSearch,
98 parseSourceToolExplorerSearch,
99} from "./components/source-tool-explorer-search";
100export {
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 @@
1import { clsx, type ClassValue } from "clsx";
2import { twMerge } from "tailwind-merge";
3
4export 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 @@
1import {
2 createHighlighterCore,
3 type HighlighterCore,
4 type LanguageInput,
5} from "shiki/core";
6import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
7import type { CodeHighlighterPlugin, ThemeInput } from "streamdown";
8
9const 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
43type SupportedLang = (typeof SUPPORTED_LANGS)[number];
44
45const 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
64const 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
98const supportedSet = new Set<string>([
99 ...SUPPORTED_LANGS,
100 ...Object.keys(LANG_ALIASES),
101]);
102
103export 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
114export function isSupportedLang(lang: string): boolean {
115 return supportedSet.has(lang.trim().toLowerCase());
116}
117
118const jsEngine = createJavaScriptRegexEngine({ forgiving: true });
119
120let highlighter: HighlighterCore | null = null;
121let highlighterPromise: Promise<HighlighterCore> | null = null;
122
123async 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
143void getHighlighter();
144
145export 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
157const THEME = "vitesse-dark" as ThemeInput;
158const tokensCache = new Map<string, unknown>();
159const pendingCallbacks = new Map<string, Set<(result: unknown) => void>>();
160type CodePluginHighlight = NonNullable<CodeHighlighterPlugin["highlight"]>;
161type CodeHighlightOptions = Parameters<CodePluginHighlight>[0];
162type CodeHighlightCallback = Parameters<CodePluginHighlight>[1];
163
164export 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 @@
1const trimSlashes = (value: string): string =>
2 value.replace(/^\/+/, "").replace(/\/+$/, "");
3
4const joinPath = (...parts: ReadonlyArray<string>): string =>
5 `/${parts.map(trimSlashes).filter(Boolean).join("/")}`;
6
7export type ExecutorPluginPaths = {
8 base: string;
9 routePattern: (path?: string) => string;
10 route: (path?: string) => string;
11};
12
13export 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
23export const sourcePluginsIndexPath = "/sources/add";
24
25export const normalizeExecutorPluginPath = (value: string): string =>
26 trimSlashes(value);
27
28export const normalizeSourcePluginPath = normalizeExecutorPluginPath;
29
30export const executorPluginBasePath = (key: string): string =>
31 joinPath("plugins", key);
32
33export 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
43export const executorPluginRoutePath = (
44 key: string,
45 path = "",
46): string =>
47 executorPluginRoutePattern(key, path);
48
49export const sourcePluginAddPath = (key: string): string =>
50 executorPluginRoutePath(key, "add");
51
52export const sourcePluginDetailPattern = (key: string): string =>
53 joinPath("plugins", key, "sources", "$sourceId");
54
55export const sourcePluginEditPattern = (key: string): string =>
56 joinPath("plugins", key, "sources", "$sourceId", "edit");
57
58export const sourcePluginDetailPath = (
59 key: string,
60 sourceId: string,
61): string =>
62 joinPath("plugins", key, "sources", sourceId);
63
64export const sourcePluginEditPath = (
65 key: string,
66 sourceId: string,
67): string =>
68 joinPath("plugins", key, "sources", sourceId, "edit");
69
70export 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
80export 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
91export const createExecutorPluginPaths = (key: string): ExecutorPluginPaths => ({
92 base: executorPluginBasePath(key),
93 routePattern: (path = "") => executorPluginRoutePattern(key, path),
94 route: (path = "") => executorPluginRoutePath(key, path),
95});
96
97export 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 @@
1import * as React from "react";
2
3import type {
4 ExecutorPluginRouteContextValue,
5 FrontendPluginRouteParams,
6 FrontendPluginRouteSearch,
7} from "./types";
8
9const ExecutorPluginRouteContext =
10 React.createContext<ExecutorPluginRouteContextValue | null>(null);
11
12export 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
23const 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
32export const useExecutorPluginRoute = (): ExecutorPluginRouteContextValue =>
33 useExecutorPluginRouteContext();
34
35export const useExecutorPlugin = () =>
36 useExecutorPluginRouteContext().plugin;
37
38export const useExecutorPluginRouteDefinition = () =>
39 useExecutorPluginRouteContext().route;
40
41export const useExecutorPluginNavigation = () =>
42 useExecutorPluginRouteContext().navigation;
43
44export const useExecutorPluginSearch = <
45 TSearch extends FrontendPluginRouteSearch = FrontendPluginRouteSearch,
46>(): TSearch =>
47 useExecutorPluginRouteContext().search as TSearch;
48
49export const useExecutorPluginRouteParams = <
50 TParams extends FrontendPluginRouteParams = FrontendPluginRouteParams,
51>(): TParams =>
52 useExecutorPluginRouteContext().params as TParams;
53
54export const useExecutorPluginPaths = () =>
55 useExecutorPluginRouteContext().navigation.paths;
packages/clients/react/src/plugins/registry.ts+75−0
@@ -0,0 +1,75 @@
1import type {
2 ExecutorFrontendPlugin,
3 FrontendPluginRouteDefinition,
4} from "./types";
5import {
6 normalizeExecutorPluginPath,
7} from "./paths";
8
9export type RegisteredFrontendPluginRoute = {
10 plugin: ExecutorFrontendPlugin;
11 route: FrontendPluginRouteDefinition;
12};
13
14export const defineFrontendPluginRoute = <
15 TDefinition extends FrontendPluginRouteDefinition,
16>(
17 definition: TDefinition,
18): TDefinition => definition;
19
20export const defineExecutorFrontendPlugin = <
21 TPlugin extends ExecutorFrontendPlugin,
22>(
23 input: TPlugin,
24): TPlugin => input;
25
26export 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 @@
1import {
2 useExecutorPlugin,
3 useExecutorPluginNavigation,
4 useExecutorPluginRouteParams,
5 useExecutorPluginSearch,
6} from "./plugin-route-context";
7import {
8 createSourcePluginPaths,
9 normalizeSourcePluginPath,
10} from "./paths";
11import type {
12 SourcePluginNavigation,
13 SourcePluginRouteParams,
14 SourcePluginRouteSearch,
15} from "./types";
16
17export const useSourcePluginRoute = () => ({
18 plugin: useExecutorPlugin(),
19 params: useExecutorPluginRouteParams(),
20 search: useExecutorPluginSearch(),
21 navigation: useSourcePluginNavigation(),
22});
23
24export const useSourcePlugin = () =>
25 useExecutorPlugin();
26
27export 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
53export const useSourcePluginSearch = <
54 TSearch extends SourcePluginRouteSearch = SourcePluginRouteSearch,
55>(): TSearch =>
56 useExecutorPluginSearch<TSearch>();
57
58export const useSourcePluginRouteParams = <
59 TParams extends SourcePluginRouteParams = SourcePluginRouteParams,
60>(): TParams =>
61 useExecutorPluginRouteParams<TParams>();
62
63export const useSourcePluginPaths = () =>
64 createSourcePluginPaths(useExecutorPlugin().key);
packages/clients/react/src/plugins/types.ts+58−0
@@ -0,0 +1,58 @@
1import type { ComponentType } from "react";
2
3import type {
4 ExecutorPluginPaths,
5 SourcePluginPaths,
6} from "./paths";
7
8export type FrontendPluginRouteSearch = Record<string, unknown>;
9export type FrontendPluginRouteParams = Readonly<Record<string, string | undefined>>;
10
11export 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
18export type FrontendPluginRouteDefinition = {
19 key: string;
20 path?: string;
21 component: ComponentType;
22 nav?: {
23 label: string;
24 section?: string;
25 };
26};
27
28export type ExecutorPluginRouteContextValue = {
29 plugin: ExecutorFrontendPlugin;
30 route: FrontendPluginRouteDefinition;
31 params: FrontendPluginRouteParams;
32 search: FrontendPluginRouteSearch;
33 navigation: ExecutorPluginNavigation;
34};
35
36export type SourcePluginRouteSearch = FrontendPluginRouteSearch;
37export type SourcePluginRouteParams = FrontendPluginRouteParams;
38
39export 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
53export 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 @@
1import {
2 RegistryProvider,
3} from "@effect-atom/atom-react";
4import * as React from "react";
5
6export const ExecutorReactProvider = (props: React.PropsWithChildren) => <RegistryProvider>{props.children}</RegistryProvider>;
packages/clients/react/src/source-plugins/index.ts+1−0
@@ -0,0 +1 @@
1export * from "../plugins/index";
packages/dev/kitchen-sink/package.json+0−1
@@ -14,7 +14,6 @@ · 13 unmodified lines
14 "@executor/codemode-core": "workspace:*",14 "@executor/codemode-core": "workspace:*",
15 "@executor/platform-sdk": "workspace:*",15 "@executor/platform-sdk": "workspace:*",
16 "@executor/runtime-ses": "workspace:*",16 "@executor/runtime-ses": "workspace:*",
17 "@executor/source-openapi": "workspace:*",
18 "effect": "catalog:"17 "effect": "catalog:"
19 },18 },
20 "devDependencies": {19 "devDependencies": {
packages/dev/kitchen-sink/src/source-runtime.test.ts+0−901
@@ -1,901 +0,0 @@
1import { createServer } from "node:http";
2
3import {
4 HttpApi,
5 HttpApiEndpoint,
6 HttpApiGroup,
7 HttpApiSchema,
8 OpenApi,
9} from "@effect/platform";
10import { describe, expect, it } from "@effect/vitest";
11import * as Effect from "effect/Effect";
12import * as Schema from "effect/Schema";
13import { Schema as EffectSchema } from "effect";
14
15import {
16 type SecretRef,
17 type Source,
18 type SourceId,
19 type WorkspaceId,
20} from "@executor/platform-sdk/schema";
21import {
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";
33import { createOpenApiToolsFromSpec } from "@executor/source-openapi";
34import { makeSesExecutor } from "@executor/runtime-ses";
35
36const asToolPath = (value: string): ToolPath => value as ToolPath;
37const asSourceId = (value: string): SourceId => value as SourceId;
38const asWorkspaceId = (value: string): WorkspaceId => value as WorkspaceId;
39
40const tokenize = (value: string): string[] =>
41 value
42 .trim()
43 .toLowerCase()
44 .split(/\s+/)
45 .filter(Boolean);
46
47const 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
56const catalogNamespaceFromPath = (path: string): string => {
57 const [first, second] = path.split(".");
58 return second ? `${first}.${second}` : first;
59};
60
61type 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
84const 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
98type SourceCallContext = {
99 auth:
100 | { kind: "none" }
101 | {
102 kind: "headers";
103 headers: Record<string, string>;
104 };
105};
106
107interface SecretMaterialProvider {
108 providerId: string;
109 get(input: {
110 handle: string;
111 }): Promise<string>;
112}
113
114interface SecretMaterialRegistry {
115 get(input: {
116 ref: SecretRef;
117 }): Promise<string>;
118}
119
120interface ProviderInvoker {
121 invoke(input: {
122 source: Source;
123 artifact: StoredToolArtifactRecord;
124 args: unknown;
125 runtime: SourceCallContext;
126 context?: ToolInvocationContext;
127 }): Promise<unknown>;
128}
129
130type 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
140type 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
166const 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
196const 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
248const 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
266const 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
282const 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
320const 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
341const 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
402const 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
464const 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
481const 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
494const 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
525const 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
573const numberPairInputSchema = Schema.standardSchemaV1(
574 Schema.Struct({
575 a: Schema.Number,
576 b: Schema.Number,
577 }),
578);
579
580const 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
604const ownerParam = HttpApiSchema.param("owner", EffectSchema.String);
605const repoParam = HttpApiSchema.param("repo", EffectSchema.String);
606
607class GeneratedReposApi extends HttpApiGroup.make("repos")
608 .add(
609 HttpApiEndpoint.get("getRepo")`/repos/${ownerParam}/${repoParam}`
610 .addSuccess(EffectSchema.Unknown),
611 ) {}
612
613class GeneratedApi extends HttpApi.make("generated").add(GeneratedReposApi) {}
614
615const generatedOpenApiSpec = OpenApi.fromApi(GeneratedApi);
616
617type OpenApiTestServer = {
618 baseUrl: string;
619 requests: Array<{
620 method: string;
621 path: string;
622 authorization: string | null;
623 }>;
624 close: () => Promise<void>;
625};
626
627const 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
691describe("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
7 },7 },
8 "scripts": {8 "scripts": {
9 "typecheck": "bunx tsc --noEmit -p tsconfig.json",9 "typecheck": "bunx tsc --noEmit -p tsconfig.json",
10 "test": "vitest run --passWithNoTests",10 "test": "vitest run",
11 "test:watch": "vitest"11 "test:watch": "vitest"
12 },12 },
13 "dependencies": {13 "dependencies": {
packages/hosts/mcp/src/index.ts+5−7
@@ -2,7 +2,7 @@ import type { · 1 unmodified line
2 ExecutionEnvelope,2 ExecutionEnvelope,
3} from "@executor/platform-sdk/schema";3} from "@executor/platform-sdk/schema";
4import {4import {
5 EXECUTOR_SOURCES_ADD_HELP_LINES,5 getExecutorSourcesAddHelpLines,
6 RuntimeSourceCatalogStoreService,6 RuntimeSourceCatalogStoreService,
7 createExecution,7 createExecution,
8 getExecution,8 getExecution,
@@ -172,14 +172,12 @@ const buildExecuteWorkflowText = ( · 163 unmodified lines
172 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',172 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',
173 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",173 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",
174 "3) Call selected tools.<path>(input).",174 "3) Call selected tools.<path>(input).",
175 '4) To connect a source, call tools.executor.sources.add(...) for MCP, OpenAPI, or GraphQL APIs.',175 "4) Use source plugins to inspect or add API sources.",
176 ...EXECUTOR_SOURCES_ADD_HELP_LINES,176 ...getExecutorSourcesAddHelpLines(),
177 "5) If execution pauses for interaction, resume it with the returned resumePayload or the available resume flow.",177 "5) If execution pauses for interaction, resume it with the returned resumePayload or the available resume flow.",
178 "Do not use fetch; use tools.* only.",178 "Do not use fetch; use tools.* only.",
179 ].join("\n");179 ].join("\n");
180 180
181const defaultExecuteDescription = buildExecuteWorkflowText();
182
183const loadExecuteDescription = (runtime: ExecutorRuntime): Promise<string> =>181const loadExecuteDescription = (runtime: ExecutorRuntime): Promise<string> =>
184 runControlPlane(182 runControlPlane(
185 runtime,183 runtime,
@@ -203,11 +201,11 @@ const loadExecuteDescription = (runtime: ExecutorRuntime): Promise<string> => · 17 unmodified lines
203 .filter((catalog) => catalog.toolPaths.length > 0);201 .filter((catalog) => catalog.toolPaths.length > 0);
204 202
205 if (sourceToolExamples.length === 0) {203 if (sourceToolExamples.length === 0) {
206 return defaultExecuteDescription;204 return buildExecuteWorkflowText();
207 }205 }
208 206
209 return buildExecuteWorkflowText(sourceToolExamples);207 return buildExecuteWorkflowText(sourceToolExamples);
210 }).pipe(Effect.catchAll(() => Effect.succeed(defaultExecuteDescription))),208 }).pipe(Effect.catchAll(() => Effect.succeed(buildExecuteWorkflowText()))),
211 );209 );
212 210
213const summarizeExecution = (execution: ExecutionEnvelope["execution"]): string => {211const 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
448 interaction: metadata?.interaction,448 interaction: metadata?.interaction,
449 elicitation: metadata?.elicitation,449 elicitation: metadata?.elicitation,
450 contract,450 contract,
451 ...(metadata?.providerKind ? { providerKind: metadata.providerKind } : {}),451 ...(metadata?.pluginKind ? { pluginKind: metadata.pluginKind } : {}),
452 ...(metadata?.providerData !== undefined452 ...(metadata?.pluginData !== undefined
453 ? { providerData: metadata.providerData }453 ? { pluginData: metadata.pluginData }
454 : {}),454 : {}),
455 } satisfies ToolDescriptor;455 } satisfies ToolDescriptor;
456 });456 });
packages/kernel/core/src/types.ts+6−6
@@ -98,8 +98,8 @@ export type ToolMetadata = { · 97 unmodified lines
98 elicitation?: ElicitationRequest;98 elicitation?: ElicitationRequest;
99 contract?: ToolContract;99 contract?: ToolContract;
100 sourceKey?: string;100 sourceKey?: string;
101 providerKind?: string;101 pluginKind?: string;
102 providerData?: unknown;102 pluginData?: unknown;
103};103};
104 104
105export type ToolExecutionContext = {105export type ToolExecutionContext = {
@@ -145,8 +145,8 @@ export type ToolDescriptor = { · 39 unmodified lines
145 interaction?: "auto" | "required";145 interaction?: "auto" | "required";
146 elicitation?: ElicitationRequest;146 elicitation?: ElicitationRequest;
147 contract?: ToolContract;147 contract?: ToolContract;
148 providerKind?: string;148 pluginKind?: string;
149 providerData?: unknown;149 pluginData?: unknown;
150};150};
151 151
152export const ToolDescriptorSchema = Schema.Struct({152export const ToolDescriptorSchema = Schema.Struct({
@@ -158,8 +158,8 @@ export const ToolDescriptorSchema = Schema.Struct({ · 5 unmodified lines
158 ),158 ),
159 elicitation: Schema.optional(Schema.Unknown),159 elicitation: Schema.optional(Schema.Unknown),
160 contract: Schema.optional(ToolContractSchema),160 contract: Schema.optional(ToolContractSchema),
161 providerKind: Schema.optional(Schema.String),161 pluginKind: Schema.optional(Schema.String),
162 providerData: Schema.optional(Schema.Unknown),162 pluginData: Schema.optional(Schema.Unknown),
163});163});
164 164
165export type ToolNamespace = {165export type ToolNamespace = {
packages/kernel/ir/src/catalog.test.ts+50−14
@@ -54,6 +54,7 @@ const baseProvenance = (pointer: string): ProvenanceRef[] => [{ · 53 unmodified lines
54const createHttpCatalog = (input: {54const createHttpCatalog = (input: {
55 collideIds?: boolean;55 collideIds?: boolean;
56 method?: string;56 method?: string;
57 noContentSuccess?: boolean;
57} = {}): CatalogV1 => {58} = {}): CatalogV1 => {
58 const catalog = createEmptyCatalogV1();59 const catalog = createEmptyCatalogV1();
59 60
@@ -327,16 +328,22 @@ const createHttpCatalog = (input: { · 267 unmodified lines
327 id: responseId,328 id: responseId,
328 kind: "response",329 kind: "response",
329 docs: {330 docs: {
330 summary: "Updated event",331 summary: input.noContentSuccess ? "No content" : "Updated event",
331 },332 },
332 contents: [333 ...(input.noContentSuccess
333 {334 ? {}
334 mediaType: "application/json",335 : {
335 shapeId: resultShapeId,336 contents: [
336 },337 {
337 ],338 mediaType: "application/json",
339 shapeId: resultShapeId,
340 },
341 ],
342 }),
338 synthetic: false,343 synthetic: false,
339 provenance: baseProvenance("#/paths/~1events/responses/200"),344 provenance: baseProvenance(
345 input.noContentSuccess ? "#/paths/~1events/responses/204" : "#/paths/~1events/responses/200",
346 ),
340 });347 });
341 348
342 put(catalog.responseSets as Record<typeof responseSetId, CatalogV1["responseSets"][typeof responseSetId]>, responseSetId, {349 put(catalog.responseSets as Record<typeof responseSetId, CatalogV1["responseSets"][typeof responseSetId]>, responseSetId, {
@@ -345,7 +352,7 @@ const createHttpCatalog = (input: { · 2 unmodified lines
345 {352 {
346 match: {353 match: {
347 kind: "exact",354 kind: "exact",
348 status: 200,355 status: input.noContentSuccess ? 204 : 200,
349 },356 },
350 responseId,357 responseId,
351 traits: ["success"],358 traits: ["success"],
@@ -369,7 +376,7 @@ const createHttpCatalog = (input: { · 17 unmodified lines
369 id: executableId,376 id: executableId,
370 capabilityId,377 capabilityId,
371 scopeId,378 scopeId,
372 adapterKey: "openapi",379 pluginKey: "openapi",
373 bindingVersion: 1,380 bindingVersion: 1,
374 binding: {},381 binding: {},
375 projection: {382 projection: {
@@ -606,7 +613,7 @@ const createGraphqlCatalog = (): CatalogV1 => { · 230 unmodified lines
606 id: executableId,613 id: executableId,
607 capabilityId,614 capabilityId,
608 scopeId,615 scopeId,
609 adapterKey: "graphql",616 pluginKey: "graphql",
610 bindingVersion: 1,617 bindingVersion: 1,
611 binding: {},618 binding: {},
612 projection: {619 projection: {
@@ -670,7 +677,7 @@ describe("IR catalog", () => { · 57 unmodified lines
670 const snapshot = createCatalogSnapshotV1({677 const snapshot = createCatalogSnapshotV1({
671 import: {678 import: {
672 sourceKind: "openapi",679 sourceKind: "openapi",
673 adapterKey: "openapi",680 pluginKey: "openapi",
674 importerVersion: "1.0.0",681 importerVersion: "1.0.0",
675 importedAt: "2026-03-14T00:00:00.000Z",682 importedAt: "2026-03-14T00:00:00.000Z",
676 sourceConfigHash: "hash_source",683 sourceConfigHash: "hash_source",
@@ -713,7 +720,11 @@ describe("IR catalog", () => { · 36 unmodified lines
713 720
714 const merged = mergeCatalogFragments([fragmentA, fragmentB]);721 const merged = mergeCatalogFragments([fragmentA, fragmentB]);
715 expect(merged.documents[docId]?.title).toBe("A");722 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);
717 });728 });
718 729
719 it("reports invariant violations for unresolved shapes without diagnostics", () => {730 it("reports invariant violations for unresolved shapes without diagnostics", () => {
@@ -801,6 +812,27 @@ describe("IR catalog", () => { · 81 unmodified lines
801 expect(searchDoc.tags).toEqual(["calendar", "events"]);812 expect(searchDoc.tags).toEqual(["calendar", "events"]);
802 });813 });
803 814
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
804 it("groups colliding HTTP parameter names into path/query containers", () => {836 it("groups colliding HTTP parameter names into path/query containers", () => {
805 const projected = projectCatalogForAgentSdk({837 const projected = projectCatalogForAgentSdk({
806 catalog: createHttpCatalog({ collideIds: true }),838 catalog: createHttpCatalog({ collideIds: true }),
@@ -815,7 +847,11 @@ describe("IR catalog", () => { · 8 unmodified lines
815 expect(Object.keys(callShape.node.fields)).toContain("path");847 expect(Object.keys(callShape.node.fields)).toContain("path");
816 expect(Object.keys(callShape.node.fields)).toContain("query");848 expect(Object.keys(callShape.node.fields)).toContain("query");
817 expect(Object.keys(callShape.node.fields)).not.toContain("id");849 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);
819 });855 });
820 856
821 it("keeps GraphQL selection explicit for caller-driven outputs", () => {857 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
538 catalog: CatalogV1,538 catalog: CatalogV1,
539 capability: Capability,539 capability: Capability,
540 responseSet: ResponseSet,540 responseSet: ResponseSet,
541): ShapeSymbolId | undefined =>541): ShapeSymbolId | undefined => {
542 contentShapeIdFromResponseEntries(542 const successEntries = responseVariantEntries(catalog, responseSet)
543 .filter(({ variant }) => isSuccessStatusMatch(variant.match));
544 const contentShapeId = contentShapeIdFromResponseEntries(
543 catalog,545 catalog,
544 capability,546 capability,
545 `responseSet:${responseSet.id}`,547 `responseSet:${responseSet.id}`,
546 `${capability.surface.title ?? capability.id} result`,548 `${capability.surface.title ?? capability.id} result`,
547 responseVariantEntries(catalog, responseSet).filter(({ variant }) => isSuccessStatusMatch(variant.match)),549 successEntries,
548 );550 );
549 551
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
550const projectErrorShapeFromResponses = (565const projectErrorShapeFromResponses = (
551 catalog: CatalogV1,566 catalog: CatalogV1,
552 capability: Capability,567 capability: Capability,
@@ -622,6 +637,15 @@ const nullableShape = ( · 69 unmodified lines
622 baseShapeId: ShapeSymbolId;637 baseShapeId: ShapeSymbolId;
623 },638 },
624): ShapeSymbolId => {639): 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
625 const nullShapeId = constNullShape(catalog, capability, `${input.label}:null`);649 const nullShapeId = constNullShape(catalog, capability, `${input.label}:null`);
626 if (input.baseShapeId === nullShapeId) {650 if (input.baseShapeId === nullShapeId) {
627 return nullShapeId;651 return nullShapeId;
@@ -845,7 +869,7 @@ const searchDoc = ( · 217 unmodified lines
845 capability.executableIds869 capability.executableIds
846 .map((executableId: ExecutableId) =>870 .map((executableId: ExecutableId) =>
847 catalog.executables[executableId]?.display?.protocol871 catalog.executables[executableId]?.display?.protocol
848 ?? catalog.executables[executableId]?.adapterKey872 ?? catalog.executables[executableId]?.pluginKey
849 )873 )
850 .filter((protocol): protocol is string => protocol !== undefined),874 .filter((protocol): protocol is string => protocol !== undefined),
851 ),875 ),
packages/kernel/ir/src/model.ts+2−2
@@ -158,7 +158,7 @@ export const EntityBaseSchema = Schema.Struct({ · 157 unmodified lines
158 158
159export const ImportMetadataSchema = Schema.Struct({159export const ImportMetadataSchema = Schema.Struct({
160 sourceKind: SourceKindSchema,160 sourceKind: SourceKindSchema,
161 adapterKey: Schema.String,161 pluginKey: Schema.String,
162 importerVersion: Schema.String,162 importerVersion: Schema.String,
163 importedAt: Schema.String,163 importedAt: Schema.String,
164 sourceConfigHash: Schema.String,164 sourceConfigHash: Schema.String,
@@ -696,7 +696,7 @@ export const ExecutableSchema = Schema.extend( · 531 unmodified lines
696 id: ExecutableIdSchema,696 id: ExecutableIdSchema,
697 capabilityId: CapabilityIdSchema,697 capabilityId: CapabilityIdSchema,
698 scopeId: ScopeIdSchema,698 scopeId: ScopeIdSchema,
699 adapterKey: Schema.String,699 pluginKey: Schema.String,
700 bindingVersion: Schema.Number,700 bindingVersion: Schema.Number,
701 binding: Schema.Unknown,701 binding: Schema.Unknown,
702 projection: ExecutableProjectionSchema,702 projection: ExecutableProjectionSchema,
packages/platform/api/package.json+2−4
@@ -5,15 +5,13 @@ · 4 unmodified lines
5 "exports": {5 "exports": {
6 ".": "./src/index.ts",6 ".": "./src/index.ts",
7 "./api": "./src/api.ts",7 "./api": "./src/api.ts",
8 "./client": "./src/client.ts",
9 "./effect": "./src/effect.ts",8 "./effect": "./src/effect.ts",
10 "./http": "./src/http.ts",9 "./http": "./src/http.ts",
10 "./plugins": "./src/plugins.ts",
11 "./errors": "./src/errors.ts",11 "./errors": "./src/errors.ts",
12 "./local/api": "./src/local/api.ts",12 "./local/api": "./src/local/api.ts",
13 "./local/http": "./src/local/http.ts",13 "./local/http": "./src/local/http.ts",
14 "./local-context": "./src/local-context.ts",14 "./local-context": "./src/local-context.ts",
15 "./oauth/api": "./src/oauth/api.ts",
16 "./oauth/http": "./src/oauth/http.ts",
17 "./policies/api": "./src/policies/api.ts",15 "./policies/api": "./src/policies/api.ts",
18 "./policies/http": "./src/policies/http.ts",16 "./policies/http": "./src/policies/http.ts",
19 "./sources/api": "./src/sources/api.ts",17 "./sources/api": "./src/sources/api.ts",
@@ -22,7 +20,7 @@ · 2 unmodified lines
22 "./executions/http": "./src/executions/http.ts"20 "./executions/http": "./src/executions/http.ts"
23 },21 },
24 "scripts": {22 "scripts": {
25 "test": "vitest run",23 "test": "vitest run --passWithNoTests",
26 "test:watch": "vitest",24 "test:watch": "vitest",
27 "typecheck": "bunx tsc --noEmit -p tsconfig.json"25 "typecheck": "bunx tsc --noEmit -p tsconfig.json"
28 },26 },
packages/platform/api/src/api.ts+57−5
@@ -1,14 +1,18 @@
1import { HttpApi, OpenApi } from "@effect/platform";1import { HttpApi, OpenApi } from "@effect/platform";
2import type { HttpApiGroup } from "@effect/platform";
2 3
3import { ExecutionsApi } from "./executions/api";4import { ExecutionsApi } from "./executions/api";
4import { LocalApi } from "./local/api";5import { LocalApi } from "./local/api";
5import { OAuthApi } from "./oauth/api";
6import { PoliciesApi } from "./policies/api";6import { PoliciesApi } from "./policies/api";
7import { SourcesApi } from "./sources/api";7import { SourcesApi } from "./sources/api";
8import type {
9 ExecutorHttpApiExtension,
10 ExecutorHttpPlugin,
11 ExecutorHttpPluginGroups,
12} from "./plugins";
8 13
9export class ExecutorApi extends HttpApi.make("executor")14export const CoreExecutorApi = HttpApi.make("executor")
10 .add(LocalApi)15 .add(LocalApi)
11 .add(OAuthApi)
12 .add(SourcesApi)16 .add(SourcesApi)
13 .add(PoliciesApi)17 .add(PoliciesApi)
14 .add(ExecutionsApi)18 .add(ExecutionsApi)
@@ -17,6 +21,54 @@ export class ExecutorApi extends HttpApi.make("executor") · 2 unmodified lines
17 title: "Executor API",21 title: "Executor API",
18 description: "Local-first API for workspace sources, policies, auth, and execution",22 description: "Local-first API for workspace sources, policies, auth, and execution",
19 }),23 }),
20 ) {}24 );
21 25
22export const executorOpenApiSpec = OpenApi.fromApi(ExecutorApi);26type HttpApiGroupsOf<TApi> = TApi extends HttpApi.HttpApi<any, infer TGroups, any, any>
27 ? TGroups
28 : never;
29type HttpApiErrorOf<TApi> = TApi extends HttpApi.HttpApi<any, any, infer TError, any>
30 ? TError
31 : never;
32type HttpApiContextOf<TApi> = TApi extends HttpApi.HttpApi<any, any, any, infer TContext>
33 ? TContext
34 : never;
35
36export type CoreExecutorApiGroups = HttpApiGroupsOf<typeof CoreExecutorApi>;
37export 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
46export 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
64export const ExecutorApi = createExecutorApi();
65
66export const createExecutorOpenApiSpec = <
67 const TPlugins extends readonly ExecutorHttpPlugin[] = [],
68>(
69 options: {
70 plugins?: TPlugins;
71 } = {},
72) => OpenApi.fromApi(createExecutorApi(options));
73
74export const executorOpenApiSpec = createExecutorOpenApiSpec();
packages/platform/api/src/client.ts+0−196
@@ -1,196 +0,0 @@
1import * as Effect from "effect/Effect";
2
3import {
4 createExecutorApiEffectClient,
5 type ExecutorApiEffectClient,
6} from "./effect";
7
8type StripResponse<T> = T extends readonly [infer Value, unknown] ? Value : T;
9type RemoveWithResponse<T> = T extends object ? Omit<T, "withResponse"> : T;
10type MethodInput<F extends (input: any) => Effect.Effect<any, any, any>> = RemoveWithResponse<
11 Parameters<F>[0]
12>;
13type MethodOutput<F extends (input: any) => Effect.Effect<any, any, any>> = StripResponse<
14 Effect.Effect.Success<ReturnType<F>>
15>;
16type PromiseMethod<F extends (input: any) => Effect.Effect<any, any, any>> = (
17 input: MethodInput<F>,
18) => Promise<MethodOutput<F>>;
19
20export 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
82const isResponseTuple = <Value>(
83 value: Value | readonly [Value, unknown],
84): value is readonly [Value, unknown] =>
85 Array.isArray(value) && value.length === 2;
86
87function stripResponse<Value>(value: Value | readonly [Value, unknown]): Value {
88 return isResponseTuple(value) ? value[0] : value;
89}
90
91const 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
105export 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
4} from "@effect/platform";4} from "@effect/platform";
5import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
6 6
7import { ExecutorApi } from "./api";7import { createExecutorApi } from "./api";
8import type { ExecutorHttpPlugin } from "./plugins";
8 9
9export const createExecutorApiEffectClient = (input: {10export const createExecutorApiEffectClient = <
11 const TPlugins extends readonly ExecutorHttpPlugin[] = [],
12>(input: {
10 baseUrl: string;13 baseUrl: string;
11 accountId?: string;14 plugins?: TPlugins;
12}) =>15}) =>
13 HttpApiClient.make(ExecutorApi, {16 HttpApiClient.make(createExecutorApi({ plugins: input.plugins }), {
14 baseUrl: input.baseUrl,17 baseUrl: input.baseUrl,
15 }).pipe(Effect.provide(FetchHttpClient.layer));18 }).pipe(Effect.provide(FetchHttpClient.layer));
16 19
packages/platform/api/src/executions/api.ts+11−0
@@ -1,4 +1,5 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import * as Schema from "effect/Schema";
2import {3import {
3 CreateExecutionPayloadSchema,4 CreateExecutionPayloadSchema,
4 ResumeExecutionPayloadSchema,5 ResumeExecutionPayloadSchema,
@@ -8,6 +9,7 @@ export type { · 3 unmodified lines
8 ResumeExecutionPayload,9 ResumeExecutionPayload,
9} from "@executor/platform-sdk/contracts";10} from "@executor/platform-sdk/contracts";
10import {11import {
12 ExecutionSchema,
11 ExecutionIdSchema,13 ExecutionIdSchema,
12 ExecutionEnvelopeSchema,14 ExecutionEnvelopeSchema,
13 ScopeIdSchema as WorkspaceIdSchema,15 ScopeIdSchema as WorkspaceIdSchema,
@@ -30,6 +32,15 @@ const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); · 16 unmodified lines
30const executionIdParam = HttpApiSchema.param("executionId", ExecutionIdSchema);32const executionIdParam = HttpApiSchema.param("executionId", ExecutionIdSchema);
31 33
32export class ExecutionsApi extends HttpApiGroup.make("executions")34export 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 )
33 .add(44 .add(
34 HttpApiEndpoint.post("create")`/workspaces/${workspaceIdParam}/executions`45 HttpApiEndpoint.post("create")`/workspaces/${workspaceIdParam}/executions`
35 .setPayload(CreateExecutionPayloadSchema)46 .setPayload(CreateExecutionPayloadSchema)
packages/platform/api/src/executions/http.ts+5−0
@@ -9,6 +9,11 @@ export const ExecutorExecutionsLive = HttpApiBuilder.group( · 8 unmodified lines
9 "executions",9 "executions",
10 (handlers) =>10 (handlers) =>
11 handlers11 handlers
12 .handle("list", ({ path }) =>
13 resolveRequestedLocalWorkspace("executions.list", path.workspaceId).pipe(
14 Effect.flatMap((executor) => executor.executions.list()),
15 ),
16 )
12 .handle("create", ({ path, payload }) =>17 .handle("create", ({ path, payload }) =>
13 resolveRequestedLocalWorkspace("executions.create", path.workspaceId).pipe(18 resolveRequestedLocalWorkspace("executions.create", path.workspaceId).pipe(
14 Effect.flatMap((executor) => executor.executions.create(payload)),19 Effect.flatMap((executor) => executor.executions.create(payload)),
packages/platform/api/src/executor-context.ts+0−2
@@ -1,9 +1,7 @@
1import type { ExecutorEffect } from "@executor/platform-sdk/effect";1import type { ExecutorEffect } from "@executor/platform-sdk/effect";
2import type { ScopeId as WorkspaceId } from "@executor/platform-sdk/schema";2import type { ScopeId as WorkspaceId } from "@executor/platform-sdk/schema";
3import {3import {
4 ControlPlaneBadRequestError,
5 ControlPlaneForbiddenError,4 ControlPlaneForbiddenError,
6 ControlPlaneUnauthorizedError,
7} from "@executor/platform-sdk/errors";5} from "@executor/platform-sdk/errors";
8import * as Context from "effect/Context";6import * as Context from "effect/Context";
9import * as Effect from "effect/Effect";7import * 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
2import * as Layer from "effect/Layer";2import * as Layer from "effect/Layer";
3import type { ExecutorEffect } from "@executor/platform-sdk/effect";3import type { ExecutorEffect } from "@executor/platform-sdk/effect";
4 4
5import { ExecutorApi } from "./api";5import { createExecutorApi } from "./api";
6import { createControlPlaneExecutorLayer } from "./executor-context";6import { createControlPlaneExecutorLayer } from "./executor-context";
7import { ExecutorExecutionsLive } from "./executions/http";7import { ExecutorExecutionsLive } from "./executions/http";
8import { ExecutorLocalLive } from "./local/http";8import { ExecutorLocalLive } from "./local/http";
9import { ExecutorOAuthLive } from "./oauth/http";
10import { ExecutorPoliciesLive } from "./policies/http";9import { ExecutorPoliciesLive } from "./policies/http";
10import type { ExecutorHttpPlugin } from "./plugins";
11import { ExecutorSourcesLive } from "./sources/http";11import { ExecutorSourcesLive } from "./sources/http";
12 12
13export const ExecutorApiLive = HttpApiBuilder.api(ExecutorApi).pipe(13const createExecutorApiLive = (
14 Layer.provide(ExecutorLocalLive),14 executor: ExecutorEffect,
15 Layer.provide(ExecutorOAuthLive),15 plugins: readonly ExecutorHttpPlugin[] = [],
16 Layer.provide(ExecutorSourcesLive),16) => {
17 Layer.provide(ExecutorPoliciesLive),17 const api = createExecutorApi({ plugins });
18 Layer.provide(ExecutorExecutionsLive),18 let live: Layer.Layer<any, any, any> = HttpApiBuilder.api(api as any).pipe(
19);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 }
20 34
21export type ExecutorApiRuntimeContext = Layer.Layer.Context<typeof ExecutorApiLive>;35 return live;
36};
22 37
23export const createExecutorApiLayer = (executor: ExecutorEffect) =>38export const createExecutorApiLayer = (
24 ExecutorApiLive.pipe(39 executor: ExecutorEffect,
40 options: {
41 plugins?: readonly ExecutorHttpPlugin[];
42 } = {},
43) =>
44 createExecutorApiLive(executor, options.plugins).pipe(
25 Layer.provide(createControlPlaneExecutorLayer(executor)),45 Layer.provide(createControlPlaneExecutorLayer(executor)),
26 );46 );
27 47
packages/platform/api/src/index.ts+8−34
@@ -1,11 +1,15 @@
1export {1export {
2 CoreExecutorApi,
3 createExecutorApi,
4 createExecutorOpenApiSpec,
2 ExecutorApi,5 ExecutorApi,
3 executorOpenApiSpec,6 executorOpenApiSpec,
4} from "./api";7} from "./api";
5export {8export type {
6 createExecutorApiClient,9 ExecutorHttpApiExtension,
7 type ExecutorApiClient,10 ExecutorHttpPlugin,
8} from "./client";11 ExecutorHttpPluginGroups,
12} from "./plugins";
9 13
10export type { LocalInstallation } from "@executor/platform-sdk/schema";14export type { LocalInstallation } from "@executor/platform-sdk/schema";
11 15
@@ -38,37 +42,7 @@ export { · 26 unmodified lines
38} from "./local/api";42} from "./local/api";
39 43
40export {44export {
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
54export {
55 ConnectSourceBatchPayloadSchema,
56 ConnectSourceBatchResultSchema,
57 ConnectSourcePayloadSchema,
58 ConnectSourceResultSchema,
59 CreateWorkspaceOauthClientPayloadSchema,
60 CreateSourcePayloadSchema,
61 DiscoverSourcePayloadSchema,
62 SourcesApi,45 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,
72} from "./sources/api";46} from "./sources/api";
73 47
74export {48export {
packages/platform/api/src/oauth/api.ts+0−103
@@ -1,103 +0,0 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import {
3 SecretRefSchema,
4 SourceAuthSessionIdSchema,
5 SourceTransportSchema,
6 StringMapSchema,
7 ScopeIdSchema as WorkspaceIdSchema,
8} from "@executor/platform-sdk/schema";
9import * as Schema from "effect/Schema";
10
11import {
12 ControlPlaneBadRequestError,
13 ControlPlaneForbiddenError,
14 ControlPlaneStorageError,
15 ControlPlaneUnauthorizedError,
16} from "@executor/platform-sdk/errors";
17import { TrimmedNonEmptyStringSchema } from "@executor/platform-sdk/string-schemas";
18
19const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);
20
21const HtmlSchema = HttpApiSchema.Text({
22 contentType: "text/html",
23});
24
25export 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
34export type StartSourceOAuthPayload = typeof StartSourceOAuthPayloadSchema.Type;
35
36export const StartSourceOAuthResultSchema = Schema.Struct({
37 sessionId: SourceAuthSessionIdSchema,
38 authorizationUrl: Schema.String,
39});
40
41export type StartSourceOAuthResult = typeof StartSourceOAuthResultSchema.Type;
42
43export 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
51export const CompleteSourceOAuthResultSchema = Schema.Struct({
52 sessionId: SourceAuthSessionIdSchema,
53 auth: SourceOAuthAuthSchema,
54});
55
56export type CompleteSourceOAuthResult = typeof CompleteSourceOAuthResultSchema.Type;
57
58export const SourceOAuthPopupSuccessResultSchema = Schema.Struct({
59 type: Schema.Literal("executor:oauth-result"),
60 ok: Schema.Literal(true),
61 sessionId: SourceAuthSessionIdSchema,
62 auth: SourceOAuthAuthSchema,
63});
64
65export 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
72export const SourceOAuthPopupResultSchema = Schema.Union(
73 SourceOAuthPopupSuccessResultSchema,
74 SourceOAuthPopupFailureResultSchema,
75);
76
77export type SourceOAuthPopupResult = typeof SourceOAuthPopupResultSchema.Type;
78
79const 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
86export 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 @@
1import {
2 HttpApiBuilder,
3 HttpServerRequest,
4 HttpServerResponse,
5} from "@effect/platform";
6import type { ScopeId as WorkspaceId } from "@executor/platform-sdk/schema";
7import * as Cause from "effect/Cause";
8import * as Either from "effect/Either";
9import * as Effect from "effect/Effect";
10import * as Schema from "effect/Schema";
11
12import { ExecutorApi } from "../api";
13import {
14 ControlPlaneBadRequestError,
15 ControlPlaneStorageError,
16} from "../errors";
17import {
18 getControlPlaneExecutor,
19 resolveRequestedLocalWorkspace,
20} from "../local-context";
21import {
22 SourceOAuthPopupResultSchema,
23 type SourceOAuthPopupResult,
24} from "./api";
25
26const OAUTH_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";
27
28const escapeHtml = (value: string): string =>
29 value
30 .replaceAll("&", "&amp;")
31 .replaceAll("<", "&lt;")
32 .replaceAll(">", "&gt;")
33 .replaceAll('"', "&quot;")
34 .replaceAll("'", "&#39;");
35
36const serializeValueForScript = (value: unknown): string =>
37 JSON.stringify(value)
38 .replaceAll("<", "\\u003c")
39 .replaceAll(">", "\\u003e")
40 .replaceAll("&", "\\u0026");
41
42const encodeSourceOAuthPopupResultJson = Schema.encodeSync(
43 Schema.parseJson(SourceOAuthPopupResultSchema),
44);
45
46const serializeJsonLiteralForScript = (value: string): string =>
47 value
48 .replaceAll("<", "\\u003c")
49 .replaceAll(">", "\\u003e")
50 .replaceAll("&", "\\u0026");
51
52const htmlResponse = (html: string, status = 200) =>
53 HttpServerResponse.html(html).pipe(
54 HttpServerResponse.setStatus(status),
55 );
56
57const 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
78const 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
94const 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
123const 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
234export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { throws } from "@effect/vitest/utils";
3import * as Schema from "effect/Schema";
4
5import {
6 CreateExecutionPayloadSchema,
7} from "./executions/api";
8import { CreatePolicyPayloadSchema } from "./policies/api";
9import {
10 ConnectSourcePayloadSchema,
11 CreateSourcePayloadSchema,
12 UpdateSourcePayloadSchema,
13} from "./sources/api";
14
15describe("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 @@
1import type { HttpApiGroup } from "@effect/platform";
2import type * as Layer from "effect/Layer";
3import type { ExecutorEffect } from "@executor/platform-sdk/effect";
4
5export type ExecutorHttpApiExtension<
6 TGroup extends HttpApiGroup.HttpApiGroup.Any = HttpApiGroup.HttpApiGroup.Any,
7> = {
8 key: string;
9 group: TGroup;
10};
11
12export 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
21export type ExecutorHttpPluginGroups<
22 TPlugins extends readonly ExecutorHttpApiExtension[],
23> = TPlugins[number]["group"];
packages/platform/api/src/sources/api.ts+0−161
@@ -1,18 +1,4 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import {
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";
16import {2import {
17 ControlPlaneBadRequestError,3 ControlPlaneBadRequestError,
18 ControlPlaneForbiddenError,4 ControlPlaneForbiddenError,
@@ -21,116 +7,21 @@ import { · 2 unmodified lines
21 ControlPlaneUnauthorizedError,7 ControlPlaneUnauthorizedError,
22} from "@executor/platform-sdk/errors";8} from "@executor/platform-sdk/errors";
23import {9import {
24 ProviderAuthGrantIdSchema,
25 SourceDiscoveryResultSchema,
26 SourceIdSchema,10 SourceIdSchema,
27 SourceInspectionDiscoverPayloadSchema,11 SourceInspectionDiscoverPayloadSchema,
28 SourceInspectionDiscoverResultSchema,12 SourceInspectionDiscoverResultSchema,
29 SourceInspectionSchema,13 SourceInspectionSchema,
30 SourceInspectionToolDetailSchema,14 SourceInspectionToolDetailSchema,
31 SourceSchema,15 SourceSchema,
32 ScopeOauthClientIdSchema as WorkspaceOauthClientIdSchema,
33 ScopeOauthClientSchema as WorkspaceOauthClientSchema,
34 ScopeIdSchema as WorkspaceIdSchema,16 ScopeIdSchema as WorkspaceIdSchema,
35} from "@executor/platform-sdk/schema";17} from "@executor/platform-sdk/schema";
36import * as Schema from "effect/Schema";18import * as Schema from "effect/Schema";
37 19
38export 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
49export {
50 ConnectSourceBatchPayloadSchema,
51 ConnectSourceBatchResultSchema,
52 ConnectSourcePayloadSchema,
53 ConnectSourceResultSchema,
54 CreateSourcePayloadSchema,
55 CreateWorkspaceOauthClientPayloadSchema,
56 DiscoverSourcePayloadSchema,
57 UpdateSourcePayloadSchema,
58};
59
60const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);20const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);
61const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema);21const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema);
62const toolPathParam = HttpApiSchema.param("toolPath", Schema.String);22const toolPathParam = HttpApiSchema.param("toolPath", Schema.String);
63const oauthClientIdParam = HttpApiSchema.param(
64 "oauthClientId",
65 WorkspaceOauthClientIdSchema,
66);
67const grantIdParam = HttpApiSchema.param("grantId", ProviderAuthGrantIdSchema);
68
69const HtmlSchema = HttpApiSchema.Text({
70 contentType: "text/html",
71});
72 23
73export class SourcesApi extends HttpApiGroup.make("sources")24export 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 )
134 .add(25 .add(
135 HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/sources`26 HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/sources`
136 .addSuccess(Schema.Array(SourceSchema))27 .addSuccess(Schema.Array(SourceSchema))
@@ -139,15 +30,6 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 2 unmodified lines
139 .addError(ControlPlaneForbiddenError)30 .addError(ControlPlaneForbiddenError)
140 .addError(ControlPlaneStorageError),31 .addError(ControlPlaneStorageError),
141 )32 )
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 )
151 .add(33 .add(
152 HttpApiEndpoint.get("get")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}`34 HttpApiEndpoint.get("get")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}`
153 .addSuccess(SourceSchema)35 .addSuccess(SourceSchema)
@@ -157,16 +39,6 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 3 unmodified lines
157 .addError(ControlPlaneNotFoundError)39 .addError(ControlPlaneNotFoundError)
158 .addError(ControlPlaneStorageError),40 .addError(ControlPlaneStorageError),
159 )41 )
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 )
170 .add(42 .add(
171 HttpApiEndpoint.del("remove")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}`43 HttpApiEndpoint.del("remove")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}`
172 .addSuccess(Schema.Struct({ removed: Schema.Boolean }))44 .addSuccess(Schema.Struct({ removed: Schema.Boolean }))
@@ -175,39 +47,6 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 2 unmodified lines
175 .addError(ControlPlaneForbiddenError)47 .addError(ControlPlaneForbiddenError)
176 .addError(ControlPlaneStorageError),48 .addError(ControlPlaneStorageError),
177 )49 )
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 )
211 .add(50 .add(
212 HttpApiEndpoint.get("inspection")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/inspection`51 HttpApiEndpoint.get("inspection")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/inspection`
213 .addSuccess(SourceInspectionSchema)52 .addSuccess(SourceInspectionSchema)
packages/platform/api/src/sources/http.ts+1−1147
@@ -1,906 +1,21 @@
1import {1import { HttpApiBuilder } from "@effect/platform";
2 HttpApiBuilder,
3 HttpServerRequest,
4 HttpServerResponse,
5} from "@effect/platform";
6import type {
7 ExecutionInteraction,
8 Source,
9 ScopeId as WorkspaceId,
10 ScopeOauthClient as WorkspaceOauthClient,
11} from "@executor/platform-sdk/schema";
12import type {
13 ExecutorMcpSourceInput,
14 ExecutorSourceInput,
15} from "@executor/platform-sdk";
16import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
17 3
18import {
19 sourceAdapterRequiresInteractiveConnect,
20} from "@executor/platform-sdk/runtime";
21import { ScopeOauthClientIdSchema as WorkspaceOauthClientIdSchema } from "@executor/platform-sdk/schema";
22
23import {
24 ControlPlaneBadRequestError,
25 ControlPlaneNotFoundError,
26 ControlPlaneStorageError,
27} from "../errors";
28import { ExecutorApi } from "../api";4import { ExecutorApi } from "../api";
29import type { ConnectSourcePayload } from "./api";
30import {5import {
31 getControlPlaneExecutor,
32 resolveRequestedLocalWorkspace,6 resolveRequestedLocalWorkspace,
33} from "../local-context";7} from "../local-context";
34 8
35const 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
54const 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
74const 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
83const isInteractiveConnectPayload = (
84 payload: ConnectSourcePayload,
85): payload is Extract<ConnectSourcePayload, { kind?: "mcp" }> =>
86 payload.kind === undefined ||
87 sourceAdapterRequiresInteractiveConnect(payload.kind);
88
89const nullToUndefined = <T>(value: T | null | undefined): T | undefined =>
90 value ?? undefined;
91
92const toWorkspaceOauthClientId = (
93 value: string | null | undefined,
94): WorkspaceOauthClient["id"] | null | undefined =>
95 value == null ? value : WorkspaceOauthClientIdSchema.make(value);
96
97const 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
146const 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
163const escapeHtml = (value: string): string =>
164 value
165 .replaceAll("&", "&amp;")
166 .replaceAll("<", "&lt;")
167 .replaceAll(">", "&gt;")
168 .replaceAll('"', "&quot;")
169 .replaceAll("'", "&#39;");
170
171const serializeJsonForScript = (value: unknown): string =>
172 JSON.stringify(value)
173 .replaceAll("<", "\\u003c")
174 .replaceAll(">", "\\u003e")
175 .replaceAll("&", "\\u0026");
176
177const SOURCE_OAUTH_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";
178
179const 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.