Unified · Split
| @@ -1,64 +1,13 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "sources": { |
| 3 | − "github": { | |
| 4 | − "kind": "openapi", | |
| 5 | − "name": "GitHub", | |
| 6 | − "connection": { | |
| 7 | − "endpoint": "https://api.github.com" | |
| 8 | − }, | |
| 9 | − "binding": { | |
| 10 | − "specUrl": "https://example.com/github-openapi.yaml", | |
| 11 | − "defaultHeaders": null | |
| 12 | − } | |
| 3 | + "github-v3-rest-api": { | |
| 4 | + "name": "GitHub v3 REST API", | |
| 5 | + "kind": "openapi" | |
| 13 | 6 | }, |
| 14 | − "google-gmail": { | |
| 15 | − "name": "Gmail", | |
| 16 | − "namespace": "google.gmail", | |
| 17 | − "connection": { | |
| 18 | − "endpoint": "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest" | |
| 19 | − }, | |
| 20 | − "kind": "google_discovery", | |
| 21 | − "binding": { | |
| 22 | − "service": "gmail", | |
| 23 | − "version": "v1", | |
| 24 | − "discoveryUrl": "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", | |
| 25 | − "defaultHeaders": null, | |
| 26 | − "scopes": [ | |
| 27 | − "scope:gmail.readonly" | |
| 28 | − ] | |
| 29 | − } | |
| 30 | − }, | |
| 31 | − "google-calendar": { | |
| 32 | − "name": "Google Calendar", | |
| 33 | − "namespace": "google.calendar", | |
| 34 | − "connection": { | |
| 35 | − "endpoint": "https://calendar-json.googleapis.com/$discovery/rest?version=v3" | |
| 36 | − }, | |
| 37 | − "kind": "google_discovery", | |
| 38 | − "binding": { | |
| 39 | − "service": "calendar", | |
| 40 | − "version": "v3", | |
| 41 | − "discoveryUrl": "https://calendar-json.googleapis.com/$discovery/rest?version=v3", | |
| 42 | − "defaultHeaders": null, | |
| 43 | − "scopes": [ | |
| 44 | − "scope:calendar.readonly" | |
| 45 | − ] | |
| 46 | − } | |
| 47 | − }, | |
| 48 | − "google-drive": { | |
| 49 | − "name": "Drive", | |
| 50 | − "namespace": "google.drive", | |
| 51 | − "connection": { | |
| 52 | − "endpoint": "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest" | |
| 53 | − }, | |
| 54 | − "kind": "google_discovery", | |
| 55 | − "binding": { | |
| 56 | − "service": "drive", | |
| 57 | − "version": "v3", | |
| 58 | − "discoveryUrl": "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest", | |
| 59 | − "defaultHeaders": null, | |
| 60 | − "scopes": [] | |
| 61 | − } | |
| 7 | + "axiom-mcp": { | |
| 8 | + "kind": "mcp", | |
| 9 | + "name": "Axiom MCP", | |
| 10 | + "namespace": "axiom.mcp" | |
| 62 | 11 | } |
| 63 | 12 | } |
| 64 | 13 | } |
| @@ -83,4 +83,5 @@ sources/composio-scrape/data/ · 82 unmodified lines | ||
| 83 | 83 | personal-notes/ |
| 84 | 84 | .tmp/ |
| 85 | 85 | executor/.env.executor-push |
| 86 | −ignored/ | |
| 86 | +ignored/ | |
| 87 | +agent-notes/ | |
| @@ -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 | +} | |
| @@ -79,14 +79,5 @@ · 78 unmodified lines | ||
| 79 | 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 | } |
| @@ -1,411 +0,0 @@ | ||
| 1 | −# Architecture | |
| 2 | − | |
| 3 | −This document explains the active v3 architecture at a high level. | |
| 4 | − | |
| 5 | −If `README.md` answers "what is this product and how do I use it?", this file answers "what are the major moving parts and how do they fit together?" | |
| 6 | − | |
| 7 | −## One-line view | |
| 8 | − | |
| 9 | −`executor` is a local daemonized control plane that turns connected sources into a workspace tool catalog and then runs TypeScript against that catalog, pausing for user interaction whenever a tool or auth flow needs it. | |
| 10 | − | |
| 11 | −## System shape | |
| 12 | − | |
| 13 | −```text | |
| 14 | − +-----------------------+ | |
| 15 | − | CLI / executor | | |
| 16 | − | apps/executor | | |
| 17 | − +-----------+-----------+ | |
| 18 | − | | |
| 19 | − | HTTP | |
| 20 | − v | |
| 21 | −+-------------+ +---------+---------+ +----------------+ | |
| 22 | −| Browser UI |-->| local server |<--| MCP clients | | |
| 23 | −| apps/web | | packages/platform | | via /mcp | | |
| 24 | −| | | /server | | | | |
| 25 | −+-------------+ +---------+---------+ +----------------+ | |
| 26 | − | | |
| 27 | − | provides runtime layer | |
| 28 | − v | |
| 29 | − +---------+---------+ | |
| 30 | − | control plane | | |
| 31 | − | packages/platform/ | | |
| 32 | − | control-plane | | |
| 33 | − +----+-----+----+----+ | |
| 34 | − | | | | |
| 35 | − persistence --+ | +-- source auth / discovery / inspection | |
| 36 | − | | |
| 37 | − +-- execution environment resolver | |
| 38 | − | | |
| 39 | − +-- live execution manager | |
| 40 | − v | |
| 41 | − +---------+---------+ | |
| 42 | − | QuickJS sandboxed | | |
| 43 | − | executor runtime | | |
| 44 | − | packages/kernel/ | | |
| 45 | − | runtime-quickjs | | |
| 46 | − | default executor | | |
| 47 | − +--------------------+ | |
| 48 | −``` | |
| 49 | − | |
| 50 | −## Design goals reflected in the code | |
| 51 | − | |
| 52 | −The current architecture optimizes for a few specific ideas: | |
| 53 | − | |
| 54 | −- local-first operation with one daemon instead of many disconnected tools | |
| 55 | −- one shared runtime for CLI, browser UI, and MCP access | |
| 56 | −- schema-rich tool usage instead of raw HTTP from prompts | |
| 57 | −- reusable source connections that become workspace-scoped tools | |
| 58 | −- human-in-the-loop execution that can pause and resume cleanly | |
| 59 | −- adapters for multiple source kinds without hardwiring product logic to one protocol | |
| 60 | − | |
| 61 | −## Major components | |
| 62 | − | |
| 63 | −### `apps/executor`: installed CLI and daemon manager | |
| 64 | − | |
| 65 | −This is the main user entrypoint. | |
| 66 | − | |
| 67 | −Responsibilities: | |
| 68 | − | |
| 69 | −- exposes commands such as `up`, `down`, `status`, `doctor`, `call`, and `resume` | |
| 70 | −- ensures the local daemon is running before execution | |
| 71 | −- submits TypeScript executions to the local control plane | |
| 72 | −- handles paused interaction flows, including opening browser URLs when needed | |
| 73 | −- includes a few dev-only seed helpers for demo sources | |
| 74 | − | |
| 75 | −Conceptually, the CLI is not the business logic. It is a thin user-facing shell over the local runtime. | |
| 76 | − | |
| 77 | −### `packages/platform/server`: one local process for API, MCP, and UI | |
| 78 | − | |
| 79 | −This package hosts the actual local server. | |
| 80 | − | |
| 81 | −Responsibilities: | |
| 82 | − | |
| 83 | −- creates the control-plane runtime | |
| 84 | −- mounts the control-plane HTTP API at `/v1` | |
| 85 | −- mounts the `executor` MCP endpoint at `/mcp` | |
| 86 | −- serves the web UI assets for normal browser routes | |
| 87 | −- writes PID metadata for daemon lifecycle management | |
| 88 | − | |
| 89 | −This is an important architectural choice: the API and UI are served by the same local process, so the product behaves like one install rather than a pile of separate services. | |
| 90 | − | |
| 91 | −### `packages/platform/sdk`: product core | |
| 92 | − | |
| 93 | −This is the center of the system. | |
| 94 | − | |
| 95 | −It contains the runtime layer, persistence integration, and the business logic for: | |
| 96 | − | |
| 97 | −- local installation bootstrap | |
| 98 | −- accounts, organizations, and workspaces | |
| 99 | −- source discovery and connection | |
| 100 | −- source auth and credential flows | |
| 101 | −- source inspection and tool indexing | |
| 102 | −- secret and credential handling | |
| 103 | −- execution creation, resumption, and state tracking | |
| 104 | −- policy-aware tool invocation | |
| 105 | − | |
| 106 | −If you want to understand the behavior of the product, this is the most important package. | |
| 107 | − | |
| 108 | −### `packages/kernel/runtime-quickjs`: default code execution runtime | |
| 109 | − | |
| 110 | −This package provides the TypeScript execution environment used by the local product. | |
| 111 | − | |
| 112 | −At a high level it receives: | |
| 113 | − | |
| 114 | −- an executor implementation | |
| 115 | −- a tool catalog | |
| 116 | −- a tool invoker | |
| 117 | − | |
| 118 | −and runs user-authored code against that environment. | |
| 119 | − | |
| 120 | −The default runtime executes code inside a QuickJS WebAssembly sandbox so tool calls stay proxied through the control plane. | |
| 121 | − | |
| 122 | −The workspace can override that in `.executor/executor.jsonc` with `runtime: "quickjs" | "ses" | "deno"`. | |
| 123 | − | |
| 124 | −### Adapter packages | |
| 125 | − | |
| 126 | −Several packages exist to turn external systems into callable tools: | |
| 127 | − | |
| 128 | −- `packages/kernel/core`: shared tool abstractions, discovery, schemas, and system tools | |
| 129 | −- `packages/sources/mcp`: MCP tool loading and invocation | |
| 130 | −- `packages/sources/openapi`: OpenAPI extraction, manifests, and tool generation | |
| 131 | −- `packages/hosts/mcp`: exposes the local runtime itself as an MCP server | |
| 132 | −- `packages/clients/react`: React hooks and client state wrappers for the local UI | |
| 133 | − | |
| 134 | −These packages are what let the control plane treat multiple source kinds as one logical tool catalog. | |
| 135 | − | |
| 136 | −## Runtime model | |
| 137 | − | |
| 138 | −### Local installation bootstrap | |
| 139 | − | |
| 140 | −On first startup, the control plane provisions a local installation automatically. | |
| 141 | − | |
| 142 | −That bootstrap creates: | |
| 143 | − | |
| 144 | −- one local account | |
| 145 | −- one personal organization | |
| 146 | −- one default workspace | |
| 147 | −- one local installation record that points at them | |
| 148 | − | |
| 149 | −This means the product can work out of the box as a local single-user system without requiring an external identity or tenant setup step. | |
| 150 | − | |
| 151 | −### Single daemon, shared state | |
| 152 | − | |
| 153 | −All main entrypoints talk to the same local daemon: | |
| 154 | − | |
| 155 | −- CLI commands call the local HTTP API | |
| 156 | −- the web app calls the same local HTTP API | |
| 157 | −- MCP hosts talk to the `/mcp` handler exposed by that same process | |
| 158 | − | |
| 159 | −Because of that, these surfaces share: | |
| 160 | − | |
| 161 | −- the same workspace | |
| 162 | −- the same connected sources | |
| 163 | −- the same secrets and credentials | |
| 164 | −- the same execution history and interaction state | |
| 165 | − | |
| 166 | −## Persistence model | |
| 167 | − | |
| 168 | −The persistence layer is local-file-backed. | |
| 169 | − | |
| 170 | −Default behavior: | |
| 171 | − | |
| 172 | −- workspace config and state are stored in local files | |
| 173 | − | |
| 174 | −Optional behavior: | |
| 175 | − | |
| 176 | −- future cloud backends can plug in behind Effect service boundaries | |
| 177 | − | |
| 178 | −At a high level, the local control plane stores these domains: | |
| 179 | − | |
| 180 | −- installation identity: local installation identity for the workspace | |
| 181 | −- source state: sources, auth sessions, source credential bindings | |
| 182 | −- tool model: tool artifacts and related metadata extracted from sources | |
| 183 | −- secret state: credentials and secret materials | |
| 184 | −- execution state: executions and execution interactions | |
| 185 | −- governance state: policies | |
| 186 | − | |
| 187 | −This is why `executor` can reconnect sources once, inspect them later, and run multiple executions over time without rebuilding everything from scratch on every prompt. | |
| 188 | − | |
| 189 | −## Source lifecycle | |
| 190 | − | |
| 191 | −The source lifecycle is one of the defining architectural paths in the system. | |
| 192 | − | |
| 193 | −### 1. Discovery | |
| 194 | − | |
| 195 | −The Add Source flow starts with URL discovery. | |
| 196 | − | |
| 197 | −The discovery service probes a URL and tries, in order, to determine whether it looks like: | |
| 198 | − | |
| 199 | −- an OpenAPI source | |
| 200 | −- a GraphQL source | |
| 201 | −- an MCP source | |
| 202 | −- or an unknown endpoint | |
| 203 | − | |
| 204 | −Discovery also produces metadata such as: | |
| 205 | − | |
| 206 | −- inferred source kind | |
| 207 | −- confidence level | |
| 208 | −- probable auth style | |
| 209 | −- optional spec URL | |
| 210 | −- warnings | |
| 211 | −- potential namespace and transport hints | |
| 212 | − | |
| 213 | −### 2. Connection and auth | |
| 214 | − | |
| 215 | −Once a source is identified, the auth service drives the connection flow. | |
| 216 | − | |
| 217 | −Depending on the source, that may involve: | |
| 218 | − | |
| 219 | −- connecting immediately with no auth | |
| 220 | −- asking for a bearer token or other credential material | |
| 221 | −- creating an OAuth session and handing the user into a browser flow | |
| 222 | −- persisting auth bindings back to the source | |
| 223 | − | |
| 224 | −The important architecture point is that source connection is not just a CRUD write. It is a runtime workflow that can branch into interactive auth. | |
| 225 | − | |
| 226 | −### 3. Tool indexing | |
| 227 | − | |
| 228 | −After a source is connected, `executor` materializes a workspace-visible tool model. | |
| 229 | − | |
| 230 | −The exact extraction path depends on the source kind: | |
| 231 | − | |
| 232 | −- OpenAPI sources are transformed from the OpenAPI document into tool manifests and typed operations | |
| 233 | −- GraphQL sources are introspected into callable query and mutation tools | |
| 234 | −- MCP sources are represented through persisted tool artifacts and runtime invocation metadata | |
| 235 | − | |
| 236 | −This indexed representation is what powers search, inspection, and execution without re-parsing everything in the UI. | |
| 237 | − | |
| 238 | −### 4. Inspection | |
| 239 | − | |
| 240 | −The source inspection service reconstructs a rich inspection bundle for the UI. | |
| 241 | − | |
| 242 | −That bundle includes: | |
| 243 | − | |
| 244 | −- source metadata | |
| 245 | −- namespace and pipeline kind | |
| 246 | −- tool summaries | |
| 247 | −- tool detail payloads | |
| 248 | −- raw document text when available | |
| 249 | −- manifest and definitions JSON when available | |
| 250 | − | |
| 251 | −The UI uses this to show both a tree view of tools and a search-oriented discovery view. | |
| 252 | − | |
| 253 | −## Execution architecture | |
| 254 | − | |
| 255 | −Execution is the other defining path in the system. | |
| 256 | − | |
| 257 | −### 1. Create execution | |
| 258 | − | |
| 259 | −An execution begins when the CLI, API, or MCP bridge submits TypeScript code. | |
| 260 | − | |
| 261 | −The control plane creates an execution record with status such as: | |
| 262 | − | |
| 263 | −- `pending` | |
| 264 | −- `running` | |
| 265 | −- `waiting_for_interaction` | |
| 266 | −- `completed` | |
| 267 | −- `failed` | |
| 268 | − | |
| 269 | −### 2. Resolve the workspace execution environment | |
| 270 | − | |
| 271 | −Before code runs, the runtime builds the execution environment for the current workspace. | |
| 272 | − | |
| 273 | −That environment combines: | |
| 274 | − | |
| 275 | −- built-in system tools from `codemode-core` | |
| 276 | −- `executor` internal tools such as `executor.sources.add` | |
| 277 | −- persisted tools generated from connected OpenAPI, GraphQL, and MCP sources | |
| 278 | − | |
| 279 | −The resolver returns three things: | |
| 280 | − | |
| 281 | −- the SES code executor | |
| 282 | −- the workspace tool catalog | |
| 283 | −- the tool invoker that actually dispatches calls | |
| 284 | − | |
| 285 | −This resolver is the composition point where the whole product becomes one callable tool surface. | |
| 286 | − | |
| 287 | −### 3. Run TypeScript | |
| 288 | − | |
| 289 | −The code then runs inside the local SES sandbox runtime. | |
| 290 | − | |
| 291 | −The intended calling pattern is: | |
| 292 | − | |
| 293 | −1. discover a tool by intent | |
| 294 | −2. inspect its schema if needed | |
| 295 | −3. call the selected `tools.*` path | |
| 296 | − | |
| 297 | −The runtime is deliberately opinionated here: the product is built around tool calls, not ad hoc `fetch` requests from user code. | |
| 298 | − | |
| 299 | −### 4. Invoke tools through the control plane | |
| 300 | − | |
| 301 | −When code calls a tool, the control plane decides how to handle it. | |
| 302 | − | |
| 303 | −Possible paths include: | |
| 304 | − | |
| 305 | −- built-in system tool invocation | |
| 306 | −- internal `executor` tool invocation | |
| 307 | −- OpenAPI tool invocation with resolved auth headers | |
| 308 | −- GraphQL tool invocation with resolved auth headers | |
| 309 | −- MCP tool invocation through the MCP connector | |
| 310 | − | |
| 311 | −This is also the point where policy checks and source auth resolution happen. | |
| 312 | − | |
| 313 | −### 5. Persist results and surface interactions | |
| 314 | − | |
| 315 | −The execution service updates persistent execution state as the run progresses. | |
| 316 | − | |
| 317 | −If a tool call or auth flow needs user interaction, the live execution manager: | |
| 318 | − | |
| 319 | −- creates an execution interaction record | |
| 320 | −- marks the execution as `waiting_for_interaction` | |
| 321 | −- stores enough payload to resume later | |
| 322 | −- waits for a structured elicitation response | |
| 323 | − | |
| 324 | −Once the response arrives, the manager moves the execution back to `running` and the code continues. | |
| 325 | − | |
| 326 | −## Human interaction model | |
| 327 | − | |
| 328 | −A major architectural feature of `executor` is that interactions are first-class runtime state. | |
| 329 | − | |
| 330 | −This is handled by the live execution manager and the execution interaction tables. | |
| 331 | − | |
| 332 | −That gives the product a clean pause/resume loop instead of forcing every host to improvise its own half-finished approval flow. | |
| 333 | − | |
| 334 | −Interaction types include: | |
| 335 | − | |
| 336 | −- URL-based flows, such as opening a secure credential page or OAuth URL | |
| 337 | −- form-like elicitation where a host can provide a structured response | |
| 338 | − | |
| 339 | −This same idea is used both for direct CLI execution and for the MCP-facing `execute` and `resume` tools. | |
| 340 | − | |
| 341 | −## API and MCP surfaces | |
| 342 | − | |
| 343 | −### HTTP API | |
| 344 | − | |
| 345 | −The control-plane HTTP API is mounted under `/v1`. | |
| 346 | − | |
| 347 | −Major groups include: | |
| 348 | − | |
| 349 | −- local installation and secret management | |
| 350 | −- OAuth | |
| 351 | −- organizations and memberships | |
| 352 | −- workspaces | |
| 353 | −- sources | |
| 354 | −- policies | |
| 355 | −- executions | |
| 356 | − | |
| 357 | −The web UI is a client of this API. | |
| 358 | − | |
| 359 | −### MCP bridge | |
| 360 | − | |
| 361 | −`packages/hosts/mcp` exposes the local runtime as an MCP server. | |
| 362 | − | |
| 363 | −The bridge registers two main tools: | |
| 364 | − | |
| 365 | −- `execute`: run TypeScript against the local runtime | |
| 366 | −- `resume`: continue a paused execution | |
| 367 | − | |
| 368 | −When the MCP host supports managed elicitation, the bridge can drive the interaction loop directly through MCP instead of requiring the caller to implement custom resume handling. | |
| 369 | − | |
| 370 | −## Frontend architecture | |
| 371 | − | |
| 372 | −The web app lives in `apps/web` and is a React app built with Vite. | |
| 373 | − | |
| 374 | −At a high level: | |
| 375 | − | |
| 376 | −- routes are managed with TanStack Router | |
| 377 | −- data access is wrapped by `@executor/react` | |
| 378 | −- the UI is focused on sources, source inspection, and secrets | |
| 379 | −- production assets are served by the local server | |
| 380 | −- development runs Vite separately while still embedding the same backend behavior | |
| 381 | − | |
| 382 | −The frontend is intentionally thin. It is mostly a presentation layer over the control-plane API. | |
| 383 | − | |
| 384 | −## Why the architecture is shaped this way | |
| 385 | − | |
| 386 | −The system is trying to solve a specific product problem: | |
| 387 | − | |
| 388 | −- give agents a better way to use tools than pasting giant manifests into prompts | |
| 389 | −- keep auth, credentials, and user interactions in a durable runtime | |
| 390 | −- let many protocols look like one workspace tool surface | |
| 391 | −- make CLI, UI, and MCP hosts all operate on the same local state | |
| 392 | − | |
| 393 | −That is why the architecture keeps converging on the same central idea: | |
| 394 | − | |
| 395 | −`executor` is not just a CLI and not just an MCP server. It is a local runtime that owns source connection, tool indexing, execution, and interaction state. | |
| 396 | − | |
| 397 | −## Current boundaries | |
| 398 | − | |
| 399 | −A few practical boundaries are worth calling out: | |
| 400 | − | |
| 401 | −- the active implementation is local-first and single-daemon | |
| 402 | −- the current web app is React/Vite, not the older architectures mentioned in planning notes | |
| 403 | −- `legacy/` and `legacy2/` are historical context, not the active runtime | |
| 404 | −- policy infrastructure exists in the core, but the central product loop today is source connection plus execution | |
| 405 | − | |
| 406 | −## Read next | |
| 407 | − | |
| 408 | −- `README.md` for the product view and usage guidance | |
| 409 | −- `apps/executor/src/cli/main.ts` for the CLI surface | |
| 410 | −- `packages/platform/server/src/index.ts` for how the local server is assembled | |
| 411 | −- `packages/platform/sdk/src/runtime/` for the core runtime flows | |
| @@ -229,24 +229,33 @@ Examples: · 228 unmodified lines | ||
| 229 | 229 | |
| 230 | 230 | ```ts |
| 231 | 231 | return await tools.executor.sources.add({ |
| 232 | − endpoint: "https://example.com/mcp", | |
| 232 | + kind: "mcp", | |
| 233 | 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 | 246 | ```ts |
| 239 | 247 | return await tools.executor.sources.add({ |
| 240 | 248 | kind: "openapi", |
| 241 | − endpoint: "https://api.github.com", | |
| 249 | + name: "GitHub", | |
| 242 | 250 | specUrl: |
| 243 | 251 | "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json", |
| 244 | − name: "GitHub", | |
| 245 | − namespace: "github", | |
| 252 | + baseUrl: null, | |
| 253 | + auth: { kind: "none" }, | |
| 246 | 254 | }); |
| 247 | 255 | ``` |
| 248 | 256 | |
| 249 | −For HTTP-style sources, `executor` can drive the credential flow for you. | |
| 257 | +`executor.sources.add(...)` accepts the final plugin-specific source config. | |
| 258 | +Browser OAuth and popup-driven auth flows live on the plugin-owned HTTP and web surfaces. | |
| 250 | 259 | |
| 251 | 260 | ## How execution works |
| 252 | 261 | |
| @@ -382,5 +391,3 @@ This repository is explicitly on its third major architecture iteration. · 129 unmodified lines | ||
| 382 | 391 | - `apps/` and `packages/` are the active implementation |
| 383 | 392 | - `legacy/` is the original codebase |
| 384 | 393 | - `legacy2/` is the second generation |
| 385 | − | |
| 386 | −If you want the system view instead of the product view, read [`ARCHITECTURE.md`](./ARCHITECTURE.md). | |
| @@ -1,98 +0,0 @@ | ||
| 1 | −--- | |
| 2 | −title: "Adapters And Formats" | |
| 3 | −description: "How executor supports multiple integration types without hardwiring the core to one protocol." | |
| 4 | −--- | |
| 5 | − | |
| 6 | −Adapters are the main extensibility boundary in executor. | |
| 7 | − | |
| 8 | −They let executor support multiple integration styles without turning the control plane into a long chain of provider-specific branches. | |
| 9 | − | |
| 10 | −## What an adapter does | |
| 11 | − | |
| 12 | −An adapter owns the format-specific or protocol-specific behavior for a source. | |
| 13 | − | |
| 14 | −That includes: | |
| 15 | − | |
| 16 | −- connect payload shape | |
| 17 | −- binding config serialization and decoding | |
| 18 | −- source validation | |
| 19 | −- source config extraction | |
| 20 | −- materialization | |
| 21 | −- manifest parsing | |
| 22 | −- operation description | |
| 23 | −- invocation | |
| 24 | − | |
| 25 | −The control plane owns the generic lifecycle around that. | |
| 26 | − | |
| 27 | −## Why adapters exist | |
| 28 | − | |
| 29 | −Executor needs to support integrations that do not all look the same at the source boundary: | |
| 30 | − | |
| 31 | −- OpenAPI specs | |
| 32 | −- GraphQL APIs | |
| 33 | −- MCP servers | |
| 34 | −- future Google Discovery imports | |
| 35 | −- future Postman collections | |
| 36 | −- future snippet bundles | |
| 37 | − | |
| 38 | −If the core storage model were shaped around one of those directly, every new integration type would cause another redesign. | |
| 39 | − | |
| 40 | −Adapters prevent that. | |
| 41 | − | |
| 42 | −## Adapter vs format | |
| 43 | − | |
| 44 | −It is important not to confuse the adapter with the import format. | |
| 45 | − | |
| 46 | −Examples: | |
| 47 | − | |
| 48 | −- OpenAPI and GraphQL are different adapters even though both import HTTP-shaped API descriptions | |
| 49 | −- MCP is its own adapter because it has distinct session, discovery, and resume semantics | |
| 50 | − | |
| 51 | −This lets executor share a thin catalog model without pretending every source has the same runtime behavior. | |
| 52 | − | |
| 53 | −## What gets stored | |
| 54 | − | |
| 55 | −Adapters do **not** get to invent arbitrary top-level storage models. | |
| 56 | − | |
| 57 | −They compile into the same canonical layers: | |
| 58 | − | |
| 59 | −- recipe | |
| 60 | −- revision | |
| 61 | −- raw documents | |
| 62 | −- schema bundles | |
| 63 | −- operations | |
| 64 | − | |
| 65 | −That means the control plane can stay generic while adapters remain flexible. | |
| 66 | − | |
| 67 | −## Why binding config is adapter-owned | |
| 68 | − | |
| 69 | −Adapter-owned binding config is one of the most important anti-churn decisions in the codebase. | |
| 70 | − | |
| 71 | −Instead of adding more and more provider columns to the source model, the adapter carries its own binding payload and version. | |
| 72 | − | |
| 73 | −That makes it much cheaper to evolve formats without reopening the core schema for every field addition. | |
| 74 | − | |
| 75 | −## Why provider-specific detail still exists | |
| 76 | − | |
| 77 | −Executor does still store some provider-shaped detail in operation metadata and bundle/document kinds. | |
| 78 | − | |
| 79 | −That is intentional, but the direction is: | |
| 80 | − | |
| 81 | −- keep the generic IR thin | |
| 82 | −- keep provider-specific detail in adapter code or typed provider metadata | |
| 83 | − | |
| 84 | −The goal is not to erase differences between formats. The goal is to stop those differences from dominating the core architecture. | |
| 85 | − | |
| 86 | −For the current fidelity tradeoffs and known lossy areas across adapters, see [Import Fidelity](/developer/import-fidelity). | |
| 87 | − | |
| 88 | −## What a good new adapter should do | |
| 89 | − | |
| 90 | −A good new adapter should: | |
| 91 | − | |
| 92 | −- compile into the canonical recipe/revision/operation model | |
| 93 | −- use adapter-owned binding config | |
| 94 | −- avoid requiring new generic tables or provider columns unless truly necessary | |
| 95 | −- expose stable tool paths and search text | |
| 96 | −- make auth needs explicit through the existing auth slot model | |
| 97 | − | |
| 98 | −If an integration needs a new primitive outside the source/tool model entirely, that is a sign it may not belong as just another adapter. | |
| @@ -1,62 +0,0 @@ | ||
| 1 | −--- | |
| 2 | −title: "CLI" | |
| 3 | −description: "How the executor CLI fits into the system and why it stays thin." | |
| 4 | −--- | |
| 5 | − | |
| 6 | −The CLI lives in `apps/executor`. | |
| 7 | − | |
| 8 | −Its job is to be the installed user-facing shell around the local runtime, not the place where product logic is implemented. | |
| 9 | − | |
| 10 | −The main interactive entrypoints are foreground session commands such as `executor web` and `executor mcp`, plus `executor mcp --stdio` for local process-spawned MCP integrations. | |
| 11 | − | |
| 12 | −## Responsibilities | |
| 13 | − | |
| 14 | −The CLI is responsible for: | |
| 15 | − | |
| 16 | −- starting foreground web and MCP sessions | |
| 17 | −- optionally starting and stopping the detached local daemon for compatibility | |
| 18 | −- checking daemon health and installation state | |
| 19 | −- submitting executions | |
| 20 | −- resuming paused interactions | |
| 21 | −- packaging and distribution concerns for the installed binary | |
| 22 | − | |
| 23 | −## What it should not own | |
| 24 | − | |
| 25 | −The CLI should not own: | |
| 26 | − | |
| 27 | −- source persistence rules | |
| 28 | −- auth workflows | |
| 29 | −- tool discovery | |
| 30 | −- execution state machines | |
| 31 | −- tool invocation behavior | |
| 32 | − | |
| 33 | −Those belong in the control plane. | |
| 34 | − | |
| 35 | −## Why this boundary matters | |
| 36 | − | |
| 37 | −This split is intentional. | |
| 38 | − | |
| 39 | −It means the browser UI, CLI, and local MCP entrypoint all talk to the same product core instead of each carrying their own business logic. That keeps behavior consistent across entrypoints and avoids reimplementing source, credential, and execution rules in multiple places. | |
| 40 | − | |
| 41 | −## How it talks to the rest of the system | |
| 42 | − | |
| 43 | −At a high level, the CLI: | |
| 44 | − | |
| 45 | −1. starts a foreground local session or ensures the detached daemon is available | |
| 46 | −2. calls the local HTTP API exposed by `packages/platform/server` | |
| 47 | −3. hands off real work to `packages/platform/sdk` and `packages/platform/api` | |
| 48 | − | |
| 49 | −That makes the CLI a stable shell over a shared local runtime rather than a second product core. | |
| 50 | − | |
| 51 | −## Why this is flexible | |
| 52 | − | |
| 53 | −Because the CLI is thin, new product capabilities usually do not require redesigning the CLI architecture. | |
| 54 | − | |
| 55 | −Most new work lands in: | |
| 56 | − | |
| 57 | −- control-plane runtime logic | |
| 58 | −- source adapters | |
| 59 | −- codemode packages | |
| 60 | −- server/API surfaces | |
| 61 | − | |
| 62 | −The CLI mostly needs new commands or command wiring on top of those primitives. | |
| @@ -1,69 +0,0 @@ | ||
| 1 | −--- | |
| 2 | −title: "Code Mode" | |
| 3 | −description: "The shared tool abstractions and adapter packages that connect sources to execution." | |
| 4 | −--- | |
| 5 | − | |
| 6 | −The codemode packages are the bridge between imported integrations and executable tools. | |
| 7 | − | |
| 8 | −They provide the shared tool abstractions that both the control plane and runtime can depend on. | |
| 9 | − | |
| 10 | −## Core role | |
| 11 | − | |
| 12 | −The codemode layer is where executor turns "some external thing" into a discoverable and invokable tool surface. | |
| 13 | − | |
| 14 | −That is why it exists as a separate layer instead of being buried directly inside each product surface. | |
| 15 | − | |
| 16 | −## Main packages | |
| 17 | − | |
| 18 | −### `packages/kernel/core` | |
| 19 | − | |
| 20 | −This package holds the common tool model: | |
| 21 | − | |
| 22 | −- tool definitions | |
| 23 | −- tool catalogs | |
| 24 | −- tool invokers | |
| 25 | −- discovery helpers | |
| 26 | −- schema-aware tool contracts | |
| 27 | −- shared system-tool concepts | |
| 28 | − | |
| 29 | −This is the main abstraction boundary that other layers build on. | |
| 30 | − | |
| 31 | −### `packages/sources/openapi` | |
| 32 | − | |
| 33 | −This package handles OpenAPI-specific extraction and tool generation. | |
| 34 | − | |
| 35 | −Its job is to understand OpenAPI and compile it into the shared codemode tool model, not to own workspace persistence or auth lifecycle. | |
| 36 | − | |
| 37 | −### `packages/sources/mcp` | |
| 38 | − | |
| 39 | −This package handles MCP-specific tool discovery and invocation behavior and adapts remote MCP tools into the same shared tool model. | |
| 40 | − | |
| 41 | −## Why this layer exists | |
| 42 | − | |
| 43 | −Without the codemode layer, the control plane would have to understand every source format and execution detail directly. | |
| 44 | − | |
| 45 | −Instead, executor keeps the core model broad and pushes format-specific transformation logic into focused packages. | |
| 46 | − | |
| 47 | −That is what makes the system extensible: | |
| 48 | − | |
| 49 | −- new adapters can target the same internal tool model | |
| 50 | −- the runtime can execute against one logical tool surface | |
| 51 | −- the same abstractions can be reused outside the control plane, including SDK-style consumers | |
| 52 | − | |
| 53 | −## How it fits with the control plane | |
| 54 | − | |
| 55 | −The control plane owns: | |
| 56 | − | |
| 57 | −- bindings | |
| 58 | −- credentials | |
| 59 | −- revisions and materialization | |
| 60 | −- tool indexing | |
| 61 | −- policy and invocation lifecycle | |
| 62 | − | |
| 63 | −The codemode layer owns: | |
| 64 | − | |
| 65 | −- how external formats become tools | |
| 66 | −- how those tools are described | |
| 67 | −- how format-specific invocation helpers are implemented | |
| 68 | − | |
| 69 | −That boundary is what keeps the architecture adapter-based instead of turning the control plane into one large protocol-specific switch statement. | |
| @@ -1,129 +0,0 @@ | ||
| 1 | −--- | |
| 2 | −title: "Core Model" | |
| 3 | −description: "The main data and runtime concepts in executor today." | |
| 4 | −--- | |
| 5 | − | |
| 6 | −The most important thing to understand about executor is that it does **not** model the system as one flat `source` row. | |
| 7 | − | |
| 8 | −The current model has a few separate concepts that each exist for a different reason. | |
| 9 | − | |
| 10 | −## The primary concepts | |
| 11 | − | |
| 12 | −### Source binding | |
| 13 | − | |
| 14 | −A source is the workspace-local installation record. | |
| 15 | − | |
| 16 | −It answers: | |
| 17 | − | |
| 18 | −- what is this integration called in this workspace? | |
| 19 | −- is it enabled? | |
| 20 | −- what namespace should its tools use? | |
| 21 | −- what install-local config does it have? | |
| 22 | − | |
| 23 | −This is why a source carries local concerns like name, namespace, enabled state, binding config, and status. | |
| 24 | − | |
| 25 | −### Recipe | |
| 26 | − | |
| 27 | −A recipe is the shareable integration identity. | |
| 28 | − | |
| 29 | −It answers: | |
| 30 | − | |
| 31 | −- what integration definition is this? | |
| 32 | −- what adapter produced it? | |
| 33 | −- what broad catalog kind does it belong to? | |
| 34 | − | |
| 35 | −A recipe is meant to converge across workspaces when the underlying integration definition is the same. | |
| 36 | − | |
| 37 | −### Revision / materialization | |
| 38 | − | |
| 39 | −A recipe revision is the compiled result of an import. | |
| 40 | − | |
| 41 | −It answers: | |
| 42 | − | |
| 43 | −- what source config produced this compiled shape? | |
| 44 | −- what manifest, documents, schema bundles, and operations belong to it? | |
| 45 | − | |
| 46 | −This is the durable execution artifact layer. | |
| 47 | − | |
| 48 | −### Operation IR | |
| 49 | − | |
| 50 | −Operations are the canonical callable tool index. | |
| 51 | − | |
| 52 | −They answer: | |
| 53 | − | |
| 54 | −- what tools exist? | |
| 55 | −- how should they be searched and described? | |
| 56 | −- how does invocation find the right persisted operation quickly? | |
| 57 | − | |
| 58 | −The operation layer exists because raw upstream documents are not a good direct query surface for search, policy, and invocation lookup. | |
| 59 | − | |
| 60 | −### Credential | |
| 61 | − | |
| 62 | −Credentials are private auth bindings. | |
| 63 | − | |
| 64 | −They answer: | |
| 65 | − | |
| 66 | −- who can authenticate for this binding? | |
| 67 | −- for which slot? | |
| 68 | −- using what auth kind and secret refs? | |
| 69 | − | |
| 70 | −Credentials are actor-scoped by default because two users in the same workspace may connect different identities to the same source. | |
| 71 | − | |
| 72 | −## Why the split matters | |
| 73 | − | |
| 74 | −This split fixes a few old problems: | |
| 75 | − | |
| 76 | −- shareable definition is no longer confused with workspace-local installation | |
| 77 | −- auth is no longer implicitly one-token-per-source | |
| 78 | −- materialized tool content is no longer stored directly under one workspace source | |
| 79 | −- provider-specific import details do not need to become universal top-level columns | |
| 80 | − | |
| 81 | −## Binding config | |
| 82 | − | |
| 83 | −Source-specific connection config now lives in adapter-owned binding payloads: | |
| 84 | − | |
| 85 | −- `bindingVersion` | |
| 86 | −- `binding` | |
| 87 | − | |
| 88 | −That means the core model does not need built-in fields forever for every provider-specific connection knob. | |
| 89 | − | |
| 90 | −Examples: | |
| 91 | − | |
| 92 | −- OpenAPI binding can carry `specUrl` and `defaultHeaders` | |
| 93 | −- MCP binding can carry `transport`, `queryParams`, and `headers` | |
| 94 | −- internal bindings can be empty | |
| 95 | − | |
| 96 | −The adapter owns serialization, decoding, defaults, and validation for that payload. | |
| 97 | − | |
| 98 | −## Catalog kinds vs adapters | |
| 99 | − | |
| 100 | −Executor separates broad catalog kinds from adapter keys. | |
| 101 | − | |
| 102 | −Catalog kinds: | |
| 103 | − | |
| 104 | −- `imported` | |
| 105 | −- `internal` | |
| 106 | − | |
| 107 | −Adapters are more specific: | |
| 108 | − | |
| 109 | −- `openapi` | |
| 110 | −- `graphql` | |
| 111 | −- `mcp` | |
| 112 | −- `google_discovery` | |
| 113 | −- future `postman_collection` | |
| 114 | −- future `snippet_bundle` | |
| 115 | − | |
| 116 | −The important distinction is that execution now belongs to adapters. The shared catalog stays broad and stable, while connect/auth/invoke behavior is described through adapter strategies instead of one top-level runtime family enum. | |
| 117 | − | |
| 118 | −## Why this is the right shape | |
| 119 | − | |
| 120 | −The current model is designed so that new support usually means: | |
| 121 | − | |
| 122 | −- add or evolve an adapter | |
| 123 | −- compile into the same recipe/revision/operation layers | |
| 124 | − | |
| 125 | −and not: | |
| 126 | − | |
| 127 | −- redesign how source persistence fundamentally works | |
| 128 | − | |
| 129 | −That is the main goal of the current architecture. | |
| @@ -1,113 +0,0 @@ | ||
| 1 | −--- | |
| 2 | −title: "Credentials And Auth" | |
| 3 | −description: "How executor models auth, interactive flows, and actor-scoped credential ownership." | |
| 4 | −--- | |
| 5 | − | |
| 6 | −Auth in executor is not treated as a side detail on the source row. | |
| 7 | − | |
| 8 | −It is modeled as its own layer because auth has different ownership and lifecycle semantics from source bindings and recipe materialization. | |
| 9 | − | |
| 10 | −## Auth ownership | |
| 11 | − | |
| 12 | −Credentials are scoped to: | |
| 13 | − | |
| 14 | −- workspace | |
| 15 | −- source binding | |
| 16 | −- actor | |
| 17 | −- slot | |
| 18 | − | |
| 19 | −This is important because different users in the same workspace may connect different identities to the same source. | |
| 20 | − | |
| 21 | −## Auth slots | |
| 22 | − | |
| 23 | −Executor distinguishes where auth is used. | |
| 24 | − | |
| 25 | −Important current slots: | |
| 26 | − | |
| 27 | −- `runtime` | |
| 28 | −- `import` | |
| 29 | − | |
| 30 | −Why this matters: | |
| 31 | − | |
| 32 | −- some integrations use the same auth for both | |
| 33 | −- some need auth only to fetch or generate the materialization | |
| 34 | −- some may eventually need setup/bootstrap-specific auth | |
| 35 | − | |
| 36 | −This is why source auth is more than a single token field. | |
| 37 | − | |
| 38 | −## Static credentials | |
| 39 | − | |
| 40 | −The common case today is still static auth backed by secret references. | |
| 41 | − | |
| 42 | −Examples: | |
| 43 | − | |
| 44 | −- bearer token | |
| 45 | −- OAuth access token | |
| 46 | −- OAuth refresh token | |
| 47 | − | |
| 48 | −The control plane stores secret refs, not raw secret values inline on the source or credential rows. | |
| 49 | − | |
| 50 | −## Interactive auth sessions | |
| 51 | − | |
| 52 | −Interactive auth, such as OAuth flows, is modeled separately from stored credentials. | |
| 53 | − | |
| 54 | −That separation exists because: | |
| 55 | − | |
| 56 | −- a pending auth flow has state and lifecycle of its own | |
| 57 | −- it may be tied to an interaction or execution | |
| 58 | −- it is not the same thing as a resolved credential | |
| 59 | − | |
| 60 | −This is why executor has auth sessions and credentials as separate concepts. | |
| 61 | − | |
| 62 | −## Shareable config vs private state | |
| 63 | − | |
| 64 | −Project config under `.executor/executor.jsonc` is intended to stay portable and committable. | |
| 65 | − | |
| 66 | −Private auth state does not belong in the workspace tree: | |
| 67 | − | |
| 68 | −- actor-scoped credentials | |
| 69 | −- OAuth sessions | |
| 70 | −- refresh leases | |
| 71 | −- secret material | |
| 72 | − | |
| 73 | −That state lives in the local control-plane store under the user's home-scoped executor state instead of the project `.executor/state` directory. | |
| 74 | − | |
| 75 | −## Why actor scope matters | |
| 76 | − | |
| 77 | −The old one-token-per-source assumption does not hold once: | |
| 78 | − | |
| 79 | −- multiple users share a workspace | |
| 80 | −- each user connects their own Google account, GitHub account, or other external identity | |
| 81 | − | |
| 82 | −Actor-scoped ownership is the durable fix for that. | |
| 83 | − | |
| 84 | −## The next auth primitive | |
| 85 | − | |
| 86 | −One important extension area is dynamic credentials. | |
| 87 | − | |
| 88 | −Static secret refs are not always enough for integrations that: | |
| 89 | − | |
| 90 | −- derive short-lived auth by calling login or refresh endpoints | |
| 91 | −- need app-specific runtime headers | |
| 92 | −- require token renewal logic | |
| 93 | − | |
| 94 | −The likely next auth-layer primitive is an auth provider or auth material resolver: | |
| 95 | − | |
| 96 | −- configured with secret refs and stable inputs | |
| 97 | −- produces runtime auth material on demand | |
| 98 | −- caches it with expiry metadata | |
| 99 | − | |
| 100 | −That is broader than sources and should be reusable by future source adapters, tool providers, and code bundles. | |
| 101 | − | |
| 102 | −## Why auth is separated from recipes | |
| 103 | − | |
| 104 | −Recipes are shareable integration definitions. | |
| 105 | −Credentials are private installation-specific state. | |
| 106 | − | |
| 107 | −That separation is important both for correctness and for shareability: | |
| 108 | − | |
| 109 | −- a recipe can be portable | |
| 110 | −- a source binding can be local | |
| 111 | −- credentials stay private | |
| 112 | − | |
| 113 | −This is the main reason auth is not modeled as part of the shareable recipe layer. | |
| @@ -1,83 +0,0 @@ | ||
| 1 | −--- | |
| 2 | −title: "Extending Executor" | |
| 3 | −description: "How to add new source support without breaking the current architecture." | |
| 4 | −--- | |
| 5 | − | |
| 6 | −The best way to extend executor is to respect the current boundaries instead of stretching the core model. | |
| 7 | − | |
| 8 | −## First question: is it really a source? | |
| 9 | − | |
| 10 | −Not every new integration should be modeled as a source. | |
| 11 | − | |
| 12 | −Use the source model when the thing is: | |
| 13 | − | |
| 14 | −- a persistent external system | |
| 15 | −- with install-local configuration | |
| 16 | −- with auth state | |
| 17 | −- that materializes into callable tools | |
| 18 | − | |
| 19 | −If the new thing is really: | |
| 20 | − | |
| 21 | −- a local tool library | |
| 22 | −- a package bundle | |
| 23 | −- a browser automation runtime | |
| 24 | − | |
| 25 | −then it may need a different top-level primitive instead of being shoved into the source model. | |
| 26 | − | |
| 27 | −## If it is a source, add an adapter | |
| 28 | − | |
| 29 | −The happy path for a new integration is: | |
| 30 | − | |
| 31 | −1. define the adapter key, catalog kind, and strategies | |
| 32 | −2. define the connect and binding config shape | |
| 33 | −3. materialize into documents, schema bundles, and operations | |
| 34 | −4. implement invocation | |
| 35 | −5. add tests against the canonical model | |
| 36 | − | |
| 37 | −The adapter should absorb provider-specific logic so the core does not have to. | |
| 38 | − | |
| 39 | −## What a new adapter should not do | |
| 40 | − | |
| 41 | −Avoid these patterns: | |
| 42 | − | |
| 43 | −- adding new provider-specific columns to generic tables because one adapter wants them | |
| 44 | −- storing expanded presentation output as canonical truth | |
| 45 | −- bypassing the operation layer and invoking directly from raw documents | |
| 46 | −- treating auth as provider-specific ad hoc fields on the source row | |
| 47 | − | |
| 48 | −Those are the patterns that lead back to churn. | |
| 49 | − | |
| 50 | −## The standard for a good extension | |
| 51 | − | |
| 52 | −A good new adapter should make the core system feel boring. | |
| 53 | − | |
| 54 | −That means: | |
| 55 | − | |
| 56 | −- persistence still looks like recipe/revision/operations | |
| 57 | −- auth still flows through the same credential/session primitives | |
| 58 | −- discovery and inspection still work through the same catalog ideas | |
| 59 | −- execution still delegates through the same tool and adapter boundaries | |
| 60 | − | |
| 61 | −If a new adapter needs deep new behavior in all of those places, that is usually a sign it may be a new primitive, not just a new adapter. | |
| 62 | − | |
| 63 | −## What feels like the next natural extensions | |
| 64 | − | |
| 65 | −The current architecture is well-suited to future additions like: | |
| 66 | − | |
| 67 | −- Google Discovery imports | |
| 68 | −- Postman collection imports | |
| 69 | −- snippet bundles | |
| 70 | −- richer auth material providers | |
| 71 | − | |
| 72 | −Those should mostly be adapter and auth-layer work, not another persistence redesign. | |
| 73 | − | |
| 74 | −## Why this is the best setup | |
| 75 | − | |
| 76 | −The current setup is not trying to be abstract for its own sake. | |
| 77 | −It is trying to keep the codebase in a state where: | |
| 78 | − | |
| 79 | −- core concepts stay stable | |
| 80 | −- new support lands as extensions | |
| 81 | −- source support does not require philosophy rewrites every few weeks | |
| 82 | − | |
| 83 | −That is the real reason the system is shaped around adapters, recipes, revisions, operations, and actor-scoped credentials. | |
| @@ -1,132 +0,0 @@ | ||
| 1 | −--- | |
| 2 | −title: "Import Fidelity" | |
| 3 | −description: "What executor preserves, normalizes, and currently loses when importing external formats." | |
| 4 | −--- | |
| 5 | − | |
| 6 | −Executor should normalize syntax, not meaning. | |
| 7 | − | |
| 8 | −That means the import pipeline can simplify surface differences between OpenAPI, GraphQL, Google Discovery, and MCP, but it should not silently erase semantics that change execution, trust, or user understanding. | |
| 9 | − | |
| 10 | −The practical shape is: | |
| 11 | − | |
| 12 | −- keep the raw source document or manifest | |
| 13 | −- keep adapter-native metadata that does not fit the common model cleanly | |
| 14 | −- promote execution-relevant semantics into first-class IR | |
| 15 | − | |
| 16 | −## The fidelity buckets | |
| 17 | − | |
| 18 | −This audit uses four buckets: | |
| 19 | − | |
| 20 | −- `Raw source`: the original imported document or manifest | |
| 21 | −- `Adapter/native`: typed provider data or native blobs attached during import | |
| 22 | −- `IR`: first-class catalog symbols and capability metadata | |
| 23 | −- `UI today`: what current inspection surfaces directly | |
| 24 | − | |
| 25 | −Current inspection is still narrow. It mostly shows projected schemas plus `tool.executable.native[0]`, not all preserved native blobs or capability-native metadata. | |
| 26 | − | |
| 27 | −## OpenAPI | |
| 28 | − | |
| 29 | −| Source detail | Raw source | Adapter/native | IR | UI today | Notes | | |
| 30 | −| --- | --- | --- | --- | --- | --- | | |
| 31 | −| Operation auth requirements and security schemes | Yes | No | No | No | Operation `security` is not promoted; imported capabilities currently use `auth: none`. | | |
| 32 | −| Exact response variants, status codes, and headers | Yes | Partial | No | Partial | Import chooses one preferred response, then projects one synthetic `2XX` response set. | | |
| 33 | −| Parameter serialization (`style`, `explode`, `allowReserved`, content-based params) | Yes | Mostly no | No | No | The IR can model these, but the OpenAPI importer currently only takes parameter `schema`. | | |
| 34 | −| Multiple request and response media types | Yes | Partial | Partial | Partial | Import picks a preferred content entry for typing; response docs keep content types only for the chosen response. | | |
| 35 | −| `servers` defaults | Yes | No | No | No | The IR can represent scope server defaults, but OpenAPI import does not populate them. | | |
| 36 | −| Rich schema semantics beyond the importer subset | Yes | Partial | Partial | Partial | Unsupported JSON Schema shapes fall back to `unknown` plus native preservation. | | |
| 37 | − | |
| 38 | −## GraphQL | |
| 39 | − | |
| 40 | −| Source detail | Raw source | Adapter/native | IR | UI today | Notes | | |
| 41 | −| --- | --- | --- | --- | --- | --- | | |
| 42 | −| Subscription root fields as tools | Yes | Partial | No | No | The manifest records `subscriptionTypeName`, but tool generation only materializes query and mutation fields. | | |
| 43 | −| Full field result shapes for generated field tools | Yes | Partial | Partial | Partial | Field-tool outputs are heuristic projections, not full result types. | | |
| 44 | −| Union and deep nested selections | Yes | Partial | Partial | Partial | Unions degrade to `__typename`; nested selection stops after depth 2 in generated field tools. | | |
| 45 | −| Arbitrary caller-selected field projections | Yes | Partial | No | No | Generated field tools are compiled with fixed selections; the raw `request` tool is the escape hatch. | | |
| 46 | −| Deprecation reasons | Yes | Partial | Partial | No | GraphQL schemas emit `deprecated: true` plus `x-deprecationReason`, but the generic schema importer only keeps `deprecated` as first-class shape metadata. | | |
| 47 | − | |
| 48 | −GraphQL is a special case: the main loss is not the raw source being discarded, but the generated field-tool projection being intentionally narrower than the schema. That tradeoff is reasonable for discoverability, but only if the system continues to preserve and expose the raw request path. | |
| 49 | − | |
| 50 | −## Google Discovery | |
| 51 | − | |
| 52 | −| Source detail | Raw source | Adapter/native | IR | UI today | Notes | | |
| 53 | −| --- | --- | --- | --- | --- | --- | | |
| 54 | −| OAuth scope descriptions | Yes | Partial | No | No | Document-level scope descriptions are extracted, but the IR security scheme rewrites scopes to `{ scope: scope }`. | | |
| 55 | −| Rich media upload metadata | Yes | Partial | Partial | Partial | Upload support is collapsed to booleans such as `supportsMediaUpload` and `supportsMediaDownload`. | | |
| 56 | −| Service-level metadata (`title`, `description`, `batchPath`, `documentationLink`) | Yes | Partial | No | No | The extracted manifest has these fields, but per-operation provider data does not carry them into the catalog. | | |
| 57 | −| Parameter descriptions, repeated-ness, enums, defaults | Yes | Yes | Mostly yes | Mostly yes | This area is comparatively good. | | |
| 58 | −| Request and response schemas | Yes | Yes | Yes | Yes | Core schema structure is preserved reasonably well. | | |
| 59 | − | |
| 60 | −Google Discovery is less lossy than OpenAPI on request and response typing, but it still loses useful service metadata and the human meaning attached to OAuth scopes. | |
| 61 | − | |
| 62 | −## MCP | |
| 63 | − | |
| 64 | −| Source detail | Raw source | Adapter/native | IR | UI today | Notes | | |
| 65 | −| --- | --- | --- | --- | --- | --- | | |
| 66 | −| Non-tool MCP resources such as prompts or resources | No | No | No | No | The adapter only discovers `listTools()`. Anything outside the tool list never enters import. | | |
| 67 | −| Extra tool metadata beyond `name`, `description`, `inputSchema` or `parameters`, and `outputSchema` | Partial | Partial | No | No | The manifest schema keeps only a narrow subset of listed tool fields. | | |
| 68 | −| Tool-level safety or effect hints, if present | Partial | No | No | No | Imported MCP operations are currently hardcoded to `effect: action`. | | |
| 69 | −| Tool input and output schemas | Yes | Yes | Yes | Yes | This is the strongest part of the current MCP import. | | |
| 70 | −| Server-level metadata outside the tool list | No | No | No | No | The stored MCP "document" is a synthesized tool manifest, not a richer upstream server description. | | |
| 71 | − | |
| 72 | −MCP is the most structurally lossy adapter today because the discovery step itself only imports tools and already narrows their metadata aggressively. | |
| 73 | − | |
| 74 | −## Cross-cutting UI caveat | |
| 75 | − | |
| 76 | −Even when data is preserved natively, the current inspection UI often does not show it directly. | |
| 77 | − | |
| 78 | −Today inspection primarily exposes: | |
| 79 | − | |
| 80 | −- projected call and result schemas | |
| 81 | −- capability summary text | |
| 82 | −- the first executable native blob | |
| 83 | − | |
| 84 | −That means "preserved in native data" and "visible to a user" are still different states. | |
| 85 | − | |
| 86 | −## What should be first-class in IR | |
| 87 | − | |
| 88 | −These semantics are worth promoting into the common model because they affect execution or trust: | |
| 89 | − | |
| 90 | −- auth and security requirements described by the source format | |
| 91 | −- response and status variants | |
| 92 | −- HTTP parameter serialization | |
| 93 | −- upload, download, and streaming traits | |
| 94 | −- GraphQL selection and subscription semantics | |
| 95 | −- source-provided safety or idempotency hints | |
| 96 | − | |
| 97 | −By contrast, many provider-specific labels, service metadata fields, and raw source extras are fine to keep in adapter-native metadata as long as they remain inspectable. | |
| 98 | − | |
| 99 | −## Recommended priority order | |
| 100 | − | |
| 101 | −### P0 | |
| 102 | − | |
| 103 | −- OpenAPI auth and security requirements | |
| 104 | −- OpenAPI response variants and status distinctions | |
| 105 | −- Google Discovery scope descriptions | |
| 106 | −- MCP discovery breadth: decide whether executor intends to import only tools or broader MCP surface area | |
| 107 | − | |
| 108 | −### P1 | |
| 109 | − | |
| 110 | −- OpenAPI parameter serialization | |
| 111 | −- GraphQL subscription modeling | |
| 112 | −- GraphQL field-tool projection metadata so the fixed-selection behavior is explicit | |
| 113 | −- Google Discovery service metadata surfacing | |
| 114 | −- MCP tool metadata preservation beyond the current narrow manifest | |
| 115 | − | |
| 116 | −### P2 | |
| 117 | − | |
| 118 | −- Better UI fallback to native/provider metadata | |
| 119 | −- Richer diagnostics for intentionally lossy normalization | |
| 120 | −- A fixture-backed audit matrix test per adapter | |
| 121 | − | |
| 122 | −## The IR boundary | |
| 123 | − | |
| 124 | −The right boundary is: | |
| 125 | − | |
| 126 | −- `raw source` remains the source of truth | |
| 127 | −- `adapter-native metadata` keeps source-specific details close to their format | |
| 128 | −- `IR` stays unified, but it must be lossless for downstream semantics | |
| 129 | − | |
| 130 | −If executor keeps that line clear, a standardized representation is a strength. | |
| 131 | − | |
| 132 | −If executor lets semantically important details live only in hidden native blobs, the standardized representation becomes misleading. | |
| @@ -1,51 +0,0 @@ | ||
| 1 | −--- | |
| 2 | −title: "MCP" | |
| 3 | −description: "How executor uses MCP both as an adapter and as an external interface." | |
| 4 | −--- | |
| 5 | − | |
| 6 | −MCP shows up in executor in two different ways. | |
| 7 | − | |
| 8 | −## 1. Executor can connect to MCP servers | |
| 9 | − | |
| 10 | −This is the source side of MCP. | |
| 11 | − | |
| 12 | −When a user connects an MCP endpoint, executor treats it as an adapter with MCP-specific connection, discovery, and invocation behavior. The control plane persists the binding and credential state, while MCP-specific execution behavior lives behind the adapter and codemode layers. | |
| 13 | − | |
| 14 | −This is why MCP is modeled as its own adapter instead of being flattened into a generic HTTP execution model. | |
| 15 | − | |
| 16 | −## 2. Executor can expose itself as an MCP server | |
| 17 | − | |
| 18 | −This is the outward-facing side of MCP. | |
| 19 | − | |
| 20 | −`packages/hosts/mcp` and the `/mcp` route in `packages/platform/server` let external MCP clients talk to the local executor runtime. | |
| 21 | − | |
| 22 | −That means external MCP hosts can: | |
| 23 | − | |
| 24 | −- discover executor tools | |
| 25 | −- trigger executions | |
| 26 | −- resume paused interactions | |
| 27 | −- work against the same local workspace state as the CLI and browser UI | |
| 28 | − | |
| 29 | −## Why the distinction matters | |
| 30 | − | |
| 31 | −These two roles are related, but they are not the same thing: | |
| 32 | − | |
| 33 | −- inbound MCP means executor is acting as a client to a remote MCP server | |
| 34 | −- outbound MCP means executor is acting as a server for external MCP clients | |
| 35 | − | |
| 36 | −Keeping that distinction clear avoids collapsing very different responsibilities into one vague "MCP integration" concept. | |
| 37 | − | |
| 38 | −## Where the logic lives | |
| 39 | − | |
| 40 | −Broadly: | |
| 41 | − | |
| 42 | −- control plane owns workspace state, bindings, credentials, and execution lifecycle | |
| 43 | −- `packages/sources/mcp` owns MCP tool loading and invocation helpers | |
| 44 | −- `packages/hosts/mcp` owns the local MCP server surface | |
| 45 | −- `packages/platform/server` mounts the `/mcp` handler | |
| 46 | − | |
| 47 | −## Why MCP stays flexible in this model | |
| 48 | − | |
| 49 | −Because MCP keeps its own adapter/runtime behavior, executor can support protocol-specific features such as sessions, elicitation, and resume without bending the generic imported-tool model to fit them. | |
| 50 | − | |
| 51 | −That is the main reason MCP is a first-class category in the architecture. | |
| @@ -1,108 +0,0 @@ | ||
| 1 | −--- | |
| 2 | −title: "Control Plane Overview" | |
| 3 | −description: "How the control plane is structured today and how the major pieces fit together." | |
| 4 | −--- | |
| 5 | − | |
| 6 | −Executor is built around one central idea: | |
| 7 | − | |
| 8 | −- turn connected systems into a durable, discoverable tool catalog | |
| 9 | −- run TypeScript against that catalog inside a controlled runtime | |
| 10 | −- keep connection state, auth, execution, and inspection in one local control plane | |
| 11 | − | |
| 12 | −## One-line architecture | |
| 13 | − | |
| 14 | −Executor is a local control plane that: | |
| 15 | − | |
| 16 | −1. stores connected integrations as sources | |
| 17 | −2. materializes those sources into callable tools | |
| 18 | −3. exposes those tools to the runtime, the UI, and MCP clients | |
| 19 | −4. resolves auth and policy at invocation time | |
| 20 | − | |
| 21 | −## Major packages | |
| 22 | − | |
| 23 | −### `apps/executor` | |
| 24 | − | |
| 25 | −The CLI and daemon manager. | |
| 26 | − | |
| 27 | −Responsibilities: | |
| 28 | − | |
| 29 | −- start and stop the local daemon | |
| 30 | −- submit executions | |
| 31 | −- resume paused interactions | |
| 32 | −- seed and inspect sources in development flows | |
| 33 | − | |
| 34 | −### `packages/platform/server` | |
| 35 | − | |
| 36 | −The local server shell around the control plane. | |
| 37 | − | |
| 38 | −Responsibilities: | |
| 39 | − | |
| 40 | −- mount the HTTP API at `/v1` | |
| 41 | −- mount the local MCP server at `/mcp` | |
| 42 | −- serve the web UI | |
| 43 | − | |
| 44 | −### `packages/platform/sdk` | |
| 45 | − | |
| 46 | −The product core. | |
| 47 | − | |
| 48 | −Responsibilities: | |
| 49 | − | |
| 50 | −- source connection and lifecycle | |
| 51 | −- auth and credential state | |
| 52 | −- persistence | |
| 53 | −- tool discovery and inspection | |
| 54 | −- execution management | |
| 55 | −- policy-aware invocation | |
| 56 | − | |
| 57 | −### Runtime and codemode packages | |
| 58 | − | |
| 59 | −These packages provide the execution and tool abstractions: | |
| 60 | − | |
| 61 | −- `packages/kernel/runtime-ses` | |
| 62 | −- `packages/kernel/core` | |
| 63 | −- `packages/sources/openapi` | |
| 64 | −- `packages/sources/mcp` | |
| 65 | −- `packages/clients/react` | |
| 66 | − | |
| 67 | −## The mental model | |
| 68 | − | |
| 69 | −The current architecture is easiest to understand if you think of it as an adapter-based compiler pipeline: | |
| 70 | − | |
| 71 | −- adapters ingest external formats and protocols | |
| 72 | −- the control plane stores a canonical internal representation | |
| 73 | −- the runtime executes against that representation | |
| 74 | − | |
| 75 | −That is why executor has strong internal concepts like: | |
| 76 | − | |
| 77 | −- source bindings | |
| 78 | −- recipes and revisions | |
| 79 | −- materialized operations | |
| 80 | −- schema bundles | |
| 81 | −- actor-scoped credentials | |
| 82 | − | |
| 83 | −Those are not UI concepts. They are the durable internal model that lets executor support multiple integration styles without rewriting the core every time. | |
| 84 | − | |
| 85 | −## What is intentionally centralized | |
| 86 | − | |
| 87 | −Executor intentionally centralizes a few things that many products split across multiple services: | |
| 88 | − | |
| 89 | −- local identity and workspace bootstrap | |
| 90 | −- source connection state | |
| 91 | −- secret material references | |
| 92 | −- tool inspection data | |
| 93 | −- execution history | |
| 94 | −- interaction/resume state | |
| 95 | − | |
| 96 | −This is why the UI, CLI, and local MCP entrypoint can all behave like one system instead of separate tools bolted together. | |
| 97 | − | |
| 98 | −## Why the architecture is shaped this way | |
| 99 | − | |
| 100 | −The current implementation optimizes for: | |
| 101 | − | |
| 102 | −- local-first operation | |
| 103 | −- strong schema-aware tool usage | |
| 104 | −- reusable connected integrations | |
| 105 | −- minimal provider-specific logic in the control plane core | |
| 106 | −- extensibility through adapters instead of one-off execution paths | |
| 107 | − | |
| 108 | −The rest of the developer docs explain how those goals show up in the current model. | |
| @@ -1,86 +0,0 @@ | ||
| 1 | −--- | |
| 2 | −title: "Persistence And Migrations" | |
| 3 | −description: "How executor stores source state today and how migration logic is intended to work." | |
| 4 | −--- | |
| 5 | − | |
| 6 | −Executor persistence is designed around durable runtime state, not just CRUD storage. | |
| 7 | − | |
| 8 | −That means the database stores: | |
| 9 | − | |
| 10 | −- installation and workspace identity | |
| 11 | −- source bindings | |
| 12 | −- recipes and revisions | |
| 13 | −- raw documents | |
| 14 | −- schema bundles | |
| 15 | −- operations | |
| 16 | −- credentials and auth sessions | |
| 17 | −- executions and interactions | |
| 18 | − | |
| 19 | −## Why the storage is layered | |
| 20 | − | |
| 21 | −The storage model is layered because these concerns have different reuse and ownership semantics. | |
| 22 | − | |
| 23 | −Examples: | |
| 24 | − | |
| 25 | −- raw documents are provenance | |
| 26 | −- revisions are compiled materializations | |
| 27 | −- operations are the hot-path query surface | |
| 28 | −- source bindings are workspace-local | |
| 29 | −- credentials are private and actor-scoped | |
| 30 | − | |
| 31 | −Trying to collapse those into one table or one document shape causes churn quickly. | |
| 32 | − | |
| 33 | −## What is normalized vs serialized | |
| 34 | − | |
| 35 | −Executor intentionally uses both relational columns and serialized JSON. | |
| 36 | − | |
| 37 | −Normalized: | |
| 38 | − | |
| 39 | −- ownership | |
| 40 | −- ids and references | |
| 41 | −- search and lookup fields | |
| 42 | −- status and visibility | |
| 43 | −- auth/session ownership | |
| 44 | − | |
| 45 | −Serialized: | |
| 46 | − | |
| 47 | −- adapter-owned binding config | |
| 48 | −- source config blobs | |
| 49 | −- provider metadata | |
| 50 | −- some session payloads | |
| 51 | − | |
| 52 | −This is deliberate. | |
| 53 | −The rule is to normalize what the core system queries and enforces, and serialize what belongs to adapter-specific contracts. | |
| 54 | − | |
| 55 | −## Migrations philosophy | |
| 56 | − | |
| 57 | −The intended migration model is: | |
| 58 | − | |
| 59 | −- SQL migrations move structural schema into the new shape | |
| 60 | −- one-shot code migrations repair or rebuild data that SQL cannot derive sanely | |
| 61 | −- steady-state runtime paths should not carry compatibility branches forever | |
| 62 | − | |
| 63 | −That last point matters. | |
| 64 | −Compatibility logic belongs in upgrade code, not in normal reads and writes. | |
| 65 | − | |
| 66 | −## What is still acceptable in migration code | |
| 67 | − | |
| 68 | −One-shot rebuilds are still acceptable when: | |
| 69 | − | |
| 70 | −- the old data shape is structurally valid but not yet in the best compiled form | |
| 71 | −- the rebuild can be derived from already-stored documents | |
| 72 | −- the logic is ledgered and run once | |
| 73 | − | |
| 74 | −What is not acceptable: | |
| 75 | − | |
| 76 | −- provider-specific legacy fallbacks in steady-state runtime logic | |
| 77 | −- carrying old columns indefinitely because one path still reads them | |
| 78 | − | |
| 79 | −## Why this supports extensibility | |
| 80 | − | |
| 81 | −This persistence shape is meant to let new integrations land in one of two ways: | |
| 82 | − | |
| 83 | −- add a new adapter that compiles into the same source model | |
| 84 | −- add a genuinely new top-level primitive if the thing is not really a source | |
| 85 | − | |
| 86 | −That is how executor avoids turning every new integration into another schema redesign. | |
| @@ -1,91 +0,0 @@ | ||
| 1 | −--- | |
| 2 | −title: "Tool Catalog And Execution" | |
| 3 | −description: "How source materialization becomes callable tools and how invocation works." | |
| 4 | −--- | |
| 5 | − | |
| 6 | −Executor turns connected integrations into a workspace tool catalog. | |
| 7 | − | |
| 8 | −That catalog is the thing the runtime actually executes against. | |
| 9 | − | |
| 10 | −The execution sandbox is selected from `.executor/executor.jsonc`: | |
| 11 | − | |
| 12 | −```jsonc | |
| 13 | −{ | |
| 14 | − "runtime": "quickjs" | |
| 15 | −} | |
| 16 | −``` | |
| 17 | − | |
| 18 | −Supported values are `quickjs` (default), `ses`, and `deno`. | |
| 19 | − | |
| 20 | −## How tools are produced | |
| 21 | − | |
| 22 | −At a high level: | |
| 23 | − | |
| 24 | −1. an adapter materializes a source | |
| 25 | −2. the materialization writes documents, schema bundles, and operations | |
| 26 | −3. the control plane loads those persisted operations into a workspace tool index | |
| 27 | −4. the runtime invokes one tool at a time through the control plane | |
| 28 | − | |
| 29 | −This means executor does not rebuild every integration from scratch on every execution. | |
| 30 | − | |
| 31 | −## Why operations exist as a separate layer | |
| 32 | − | |
| 33 | −The operation layer exists to support: | |
| 34 | − | |
| 35 | −- search | |
| 36 | −- inspection | |
| 37 | −- policy | |
| 38 | −- fast tool lookup | |
| 39 | −- tool description without loading huge raw documents every time | |
| 40 | − | |
| 41 | −Raw upstream documents are still kept, but they are not the best hot-path query surface. | |
| 42 | − | |
| 43 | −## Schema transport | |
| 44 | − | |
| 45 | −Executor uses compact operation-root schemas plus shared schema bundles. | |
| 46 | − | |
| 47 | −This is better than storing expanded per-tool schema blobs because it avoids: | |
| 48 | − | |
| 49 | −- massive duplicate schema payloads | |
| 50 | −- slow formatting and rendering | |
| 51 | −- treating presentation output as canonical storage | |
| 52 | − | |
| 53 | −The result is: | |
| 54 | − | |
| 55 | −- smaller payloads | |
| 56 | −- more reusable schema graphs | |
| 57 | −- cleaner tool detail responses | |
| 58 | − | |
| 59 | −## Tool catalog shape | |
| 60 | − | |
| 61 | −The tool catalog is built from: | |
| 62 | − | |
| 63 | −- persisted source tools | |
| 64 | −- built-in executor tools | |
| 65 | − | |
| 66 | −That lets executor present one unified discovery and invocation surface even though the underlying tools come from different places. | |
| 67 | − | |
| 68 | −## Invocation flow | |
| 69 | − | |
| 70 | −At a high level, invocation works like this: | |
| 71 | − | |
| 72 | −1. resolve the persisted operation by tool path | |
| 73 | −2. run policy and approval checks | |
| 74 | −3. resolve auth material for the correct slot | |
| 75 | −4. load the manifest and schema bundle needed for the call | |
| 76 | −5. delegate actual invocation to the adapter | |
| 77 | − | |
| 78 | −This is the key point: | |
| 79 | − | |
| 80 | −- the control plane orchestrates | |
| 81 | −- the adapter performs the protocol-specific call | |
| 82 | − | |
| 83 | −## Why this is flexible | |
| 84 | − | |
| 85 | −This architecture is flexible because it keeps a stable middle: | |
| 86 | − | |
| 87 | −- the runtime only needs tools and invokers | |
| 88 | −- the control plane only needs the canonical operation and auth model | |
| 89 | −- adapters can vary widely at the source boundary without changing the runtime contract | |
| 90 | − | |
| 91 | −That is what makes it possible to add more source types without constantly rewriting execution logic. | |
| @@ -11,50 +11,15 @@ · 10 unmodified lines | ||
| 11 | 11 | "navigation": { |
| 12 | 12 | "tabs": [ |
| 13 | 13 | { |
| 14 | − "tab": "Product", | |
| 14 | + "tab": "Docs", | |
| 15 | 15 | "groups": [ |
| 16 | 16 | { |
| 17 | − "group": "Introduction", | |
| 17 | + "group": "Status", | |
| 18 | 18 | "pages": [ |
| 19 | 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 | }, |
| @@ -5,16 +5,6 @@ description: "A local-first execution environment for AI agents." · 4 unmodified lines | ||
| 5 | 5 | |
| 6 | 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 | |
| 8 | −This docs site is split into two documentation surfaces: | |
| 8 | +The docs set has been intentionally removed for now while the plugin architecture is still shifting. | |
| 9 | 9 | |
| 10 | −- product-facing docs for how to install and use executor | |
| 11 | −- developer-facing docs for how executor is built and how to extend it | |
| 12 | − | |
| 13 | −The product-facing section will come later. The current docs start with the developer-facing architecture and implementation model. | |
| 14 | − | |
| 15 | −If you are trying to understand the current system shape, start with: | |
| 16 | − | |
| 17 | −- [Control Plane](/developer/overview) | |
| 18 | −- [CLI](/developer/cli) | |
| 19 | −- [MCP](/developer/mcp) | |
| 20 | −- [Code Mode](/developer/codemode) | |
| 10 | +Use the repository source directly if you need current implementation details. | |
| @@ -38,8 +38,11 @@ · 37 unmodified lines | ||
| 38 | 38 | "@effect/platform-node": "catalog:", |
| 39 | 39 | "@executor/codemode-core": "workspace:*", |
| 40 | 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 | 45 | "@executor/platform-api": "workspace:*", |
| 42 | − "@executor/platform-internal": "workspace:*", | |
| 43 | 46 | "@executor/platform-sdk": "workspace:*", |
| 44 | 47 | "@executor/platform-sdk-file": "workspace:*", |
| 45 | 48 | "@executor/server": "workspace:*", |
| @@ -1,231 +0,0 @@ | ||
| 1 | −import type { ExecutorApiEffectClient as ExecutorApiClient } from "@executor/platform-api/effect"; | |
| 2 | −import { ScopeIdSchema } from "@executor/platform-sdk/schema"; | |
| 3 | −import * as Effect from "effect/Effect"; | |
| 4 | − | |
| 5 | −const readBindingString = (binding: Record<string, unknown>, key: string): string | null => | |
| 6 | − typeof binding[key] === "string" ? String(binding[key]) : null; | |
| 7 | − | |
| 8 | −type SeedDemoMcpSourceInput = { | |
| 9 | − client: ExecutorApiClient; | |
| 10 | − workspaceId: string; | |
| 11 | − endpoint: string; | |
| 12 | − name: string; | |
| 13 | − namespace: string; | |
| 14 | −}; | |
| 15 | − | |
| 16 | −type SeedDemoMcpSourceResult = | |
| 17 | − | { | |
| 18 | − action: "noop"; | |
| 19 | − sourceId: string; | |
| 20 | − workspaceId: string; | |
| 21 | − endpoint: string; | |
| 22 | − } | |
| 23 | − | { | |
| 24 | − action: "updated" | "created"; | |
| 25 | − sourceId: string; | |
| 26 | − workspaceId: string; | |
| 27 | − endpoint: string; | |
| 28 | − }; | |
| 29 | − | |
| 30 | −type SeedGithubOpenApiSourceInput = { | |
| 31 | − client: ExecutorApiClient; | |
| 32 | − workspaceId: string; | |
| 33 | − endpoint: string; | |
| 34 | − specUrl: string; | |
| 35 | − name: string; | |
| 36 | − namespace: string; | |
| 37 | − credentialEnvVar?: string; | |
| 38 | −}; | |
| 39 | − | |
| 40 | −export const seedDemoMcpSourceInWorkspace = ( | |
| 41 | − input: SeedDemoMcpSourceInput, | |
| 42 | −): Effect.Effect<SeedDemoMcpSourceResult, unknown, never> => | |
| 43 | − Effect.gen(function* () { | |
| 44 | − const workspaceId = ScopeIdSchema.make(input.workspaceId); | |
| 45 | − | |
| 46 | − const existing = yield* input.client.sources.list({ | |
| 47 | − path: { | |
| 48 | − workspaceId, | |
| 49 | − }, | |
| 50 | − }); | |
| 51 | − | |
| 52 | − const existingByName = existing.find( | |
| 53 | − (source) => source.kind === "mcp" && source.name === input.name, | |
| 54 | − ); | |
| 55 | − | |
| 56 | − const expected = { | |
| 57 | − endpoint: input.endpoint, | |
| 58 | − namespace: input.namespace, | |
| 59 | − transport: "streamable-http" as const, | |
| 60 | − }; | |
| 61 | − | |
| 62 | − if ( | |
| 63 | − existingByName !== undefined | |
| 64 | − && existingByName.endpoint === expected.endpoint | |
| 65 | − && existingByName.namespace === expected.namespace | |
| 66 | − && readBindingString(existingByName.binding, "transport") === expected.transport | |
| 67 | − && existingByName.auth.kind === "none" | |
| 68 | − ) { | |
| 69 | − return { | |
| 70 | − action: "noop", | |
| 71 | − sourceId: existingByName.id, | |
| 72 | − workspaceId: input.workspaceId, | |
| 73 | − endpoint: existingByName.endpoint, | |
| 74 | − }; | |
| 75 | − } | |
| 76 | − | |
| 77 | − if (existingByName !== undefined) { | |
| 78 | − const updated = yield* input.client.sources.update({ | |
| 79 | − path: { | |
| 80 | − workspaceId, | |
| 81 | − sourceId: existingByName.id, | |
| 82 | − }, | |
| 83 | − payload: { | |
| 84 | − endpoint: input.endpoint, | |
| 85 | − status: "connected", | |
| 86 | − enabled: true, | |
| 87 | − namespace: input.namespace, | |
| 88 | − binding: { | |
| 89 | − transport: "streamable-http", | |
| 90 | − queryParams: null, | |
| 91 | − headers: null, | |
| 92 | − }, | |
| 93 | − auth: { | |
| 94 | − kind: "none", | |
| 95 | − }, | |
| 96 | − }, | |
| 97 | − }); | |
| 98 | − | |
| 99 | − return { | |
| 100 | − action: "updated", | |
| 101 | − sourceId: updated.id, | |
| 102 | − workspaceId: input.workspaceId, | |
| 103 | − endpoint: updated.endpoint, | |
| 104 | − }; | |
| 105 | − } | |
| 106 | − | |
| 107 | − const created = yield* input.client.sources.create({ | |
| 108 | − path: { | |
| 109 | − workspaceId, | |
| 110 | − }, | |
| 111 | − payload: { | |
| 112 | − name: input.name, | |
| 113 | − kind: "mcp", | |
| 114 | − endpoint: input.endpoint, | |
| 115 | − status: "connected", | |
| 116 | − enabled: true, | |
| 117 | − namespace: input.namespace, | |
| 118 | − binding: { | |
| 119 | − transport: "streamable-http", | |
| 120 | − queryParams: null, | |
| 121 | − headers: null, | |
| 122 | − }, | |
| 123 | − auth: { | |
| 124 | − kind: "none", | |
| 125 | − }, | |
| 126 | − }, | |
| 127 | − }); | |
| 128 | − | |
| 129 | − return { | |
| 130 | − action: "created", | |
| 131 | − sourceId: created.id, | |
| 132 | − workspaceId: input.workspaceId, | |
| 133 | − endpoint: created.endpoint, | |
| 134 | − }; | |
| 135 | − }); | |
| 136 | − | |
| 137 | −export const seedGithubOpenApiSourceInWorkspace = ( | |
| 138 | − input: SeedGithubOpenApiSourceInput, | |
| 139 | −): Effect.Effect<SeedDemoMcpSourceResult, unknown, never> => | |
| 140 | − Effect.gen(function* () { | |
| 141 | − const workspaceId = ScopeIdSchema.make(input.workspaceId); | |
| 142 | − | |
| 143 | − const existing = yield* input.client.sources.list({ | |
| 144 | − path: { | |
| 145 | − workspaceId, | |
| 146 | − }, | |
| 147 | − }); | |
| 148 | − | |
| 149 | − const existingByName = existing.find( | |
| 150 | − (source) => source.kind === "openapi" && source.name === input.name, | |
| 151 | − ); | |
| 152 | − | |
| 153 | − const auth = { | |
| 154 | − kind: "bearer" as const, | |
| 155 | − headerName: "Authorization", | |
| 156 | − prefix: "Bearer ", | |
| 157 | − token: { | |
| 158 | − providerId: "env", | |
| 159 | − handle: input.credentialEnvVar ?? "GITHUB_TOKEN", | |
| 160 | − }, | |
| 161 | − }; | |
| 162 | − | |
| 163 | − if ( | |
| 164 | − existingByName !== undefined | |
| 165 | − && existingByName.endpoint === input.endpoint | |
| 166 | − && existingByName.namespace === input.namespace | |
| 167 | − && readBindingString(existingByName.binding, "specUrl") === input.specUrl | |
| 168 | − && JSON.stringify(existingByName.binding.defaultHeaders ?? null) === JSON.stringify(null) | |
| 169 | − && JSON.stringify(existingByName.auth) === JSON.stringify(auth) | |
| 170 | − ) { | |
| 171 | − return { | |
| 172 | − action: "noop", | |
| 173 | − sourceId: existingByName.id, | |
| 174 | − workspaceId: input.workspaceId, | |
| 175 | − endpoint: existingByName.endpoint, | |
| 176 | − }; | |
| 177 | − } | |
| 178 | − | |
| 179 | − if (existingByName !== undefined) { | |
| 180 | − const updated = yield* input.client.sources.update({ | |
| 181 | − path: { | |
| 182 | − workspaceId, | |
| 183 | − sourceId: existingByName.id, | |
| 184 | − }, | |
| 185 | − payload: { | |
| 186 | − endpoint: input.endpoint, | |
| 187 | − status: "connected", | |
| 188 | − enabled: true, | |
| 189 | − namespace: input.namespace, | |
| 190 | − binding: { | |
| 191 | − specUrl: input.specUrl, | |
| 192 | − defaultHeaders: null, | |
| 193 | − }, | |
| 194 | − auth, | |
| 195 | − }, | |
| 196 | − }); | |
| 197 | − | |
| 198 | − return { | |
| 199 | − action: "updated", | |
| 200 | − sourceId: updated.id, | |
| 201 | − workspaceId: input.workspaceId, | |
| 202 | − endpoint: updated.endpoint, | |
| 203 | − }; | |
| 204 | − } | |
| 205 | − | |
| 206 | − const created = yield* input.client.sources.create({ | |
| 207 | − path: { | |
| 208 | − workspaceId, | |
| 209 | − }, | |
| 210 | − payload: { | |
| 211 | − name: input.name, | |
| 212 | − kind: "openapi", | |
| 213 | − endpoint: input.endpoint, | |
| 214 | − status: "connected", | |
| 215 | − enabled: true, | |
| 216 | − namespace: input.namespace, | |
| 217 | − binding: { | |
| 218 | − specUrl: input.specUrl, | |
| 219 | − defaultHeaders: null, | |
| 220 | − }, | |
| 221 | − auth, | |
| 222 | − }, | |
| 223 | − }); | |
| 224 | − | |
| 225 | − return { | |
| 226 | − action: "created", | |
| 227 | − sourceId: created.id, | |
| 228 | − workspaceId: input.workspaceId, | |
| 229 | − endpoint: created.endpoint, | |
| 230 | − }; | |
| 231 | − }); | |
| @@ -14,9 +14,20 @@ import { · 13 unmodified lines | ||
| 14 | 14 | type ExecutorApiEffectClient as ExecutorApiClient, |
| 15 | 15 | } from "@executor/platform-api/effect"; |
| 16 | 16 | import { runExecutorMcpStdioServer } from "@executor/executor-mcp"; |
| 17 | −import { createWorkspaceExecutorAdminToolMap } from "@executor/platform-internal"; | |
| 18 | 17 | import { |
| 19 | − EXECUTOR_SOURCES_ADD_HELP_LINES, | |
| 18 | + googleDiscoverySdkPlugin, | |
| 19 | +} from "@executor/plugin-google-discovery-sdk"; | |
| 20 | +import { | |
| 21 | + graphqlSdkPlugin, | |
| 22 | +} from "@executor/plugin-graphql-sdk"; | |
| 23 | +import { | |
| 24 | + mcpSdkPlugin, | |
| 25 | +} from "@executor/plugin-mcp-sdk"; | |
| 26 | +import { | |
| 27 | + openApiSdkPlugin, | |
| 28 | +} from "@executor/plugin-openapi-sdk"; | |
| 29 | +import { | |
| 30 | + getExecutorSourcesAddHelpLines, | |
| 20 | 31 | RuntimeExecutionResolverService, |
| 21 | 32 | } from "@executor/platform-sdk/runtime"; |
| 22 | 33 | import { |
| @@ -37,10 +48,15 @@ import * as Option from "effect/Option"; · 14 unmodified lines | ||
| 37 | 48 | import * as Cause from "effect/Cause"; |
| 38 | 49 | |
| 39 | 50 | import { |
| 51 | + createFileGoogleDiscoveryOAuthSessionStorage, | |
| 52 | + createFileGoogleDiscoverySourceStorage, | |
| 53 | + createFileGraphqlSourceStorage, | |
| 54 | + createFileMcpOAuthSessionStorage, | |
| 55 | + createFileMcpSourceStorage, | |
| 56 | + createFileOpenApiSourceStorage, | |
| 40 | 57 | createLocalExecutorServer, |
| 41 | 58 | DEFAULT_SERVER_BASE_URL, |
| 42 | 59 | DEFAULT_SERVER_HOST, |
| 43 | − DEFAULT_LOCAL_DATA_DIR, | |
| 44 | 60 | DEFAULT_SERVER_LOG_FILE, |
| 45 | 61 | DEFAULT_SERVER_PID_FILE, |
| 46 | 62 | DEFAULT_SERVER_PORT, |
| @@ -49,10 +65,7 @@ import { · 2 unmodified lines | ||
| 49 | 65 | runLocalExecutorServer, |
| 50 | 66 | } from "@executor/server"; |
| 51 | 67 | import { |
| 52 | − seedDemoMcpSourceInWorkspace, | |
| 53 | − seedGithubOpenApiSourceInWorkspace, | |
| 54 | −} from "./dev"; | |
| 55 | −import { | |
| 68 | + resolveCliLocalDataDir, | |
| 56 | 69 | resolveRuntimeWebAssetsDir, |
| 57 | 70 | resolveSelfCommand, |
| 58 | 71 | } from "./runtime-paths"; |
| @@ -74,7 +87,6 @@ import { · 15 unmodified lines | ||
| 74 | 87 | |
| 75 | 88 | const toError = (cause: unknown): Error => |
| 76 | 89 | cause instanceof Error ? cause : new Error(String(cause)); |
| 77 | − | |
| 78 | 90 | const sleep = (ms: number) => |
| 79 | 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 | ||
| 166 | 178 | const decodeExecutionId = Schema.decodeUnknown(ExecutionIdSchema); |
| 167 | 179 | const require = createRequire(import.meta.url); |
| 168 | 180 | const CLI_NAME = "executor"; |
| 181 | +const CLI_LOCAL_DATA_DIR = resolveCliLocalDataDir(); | |
| 169 | 182 | const CLI_VERSION = (() => { |
| 170 | 183 | const candidatePaths = [ |
| 171 | 184 | "../package.json", |
| @@ -217,13 +230,13 @@ const buildWorkflowText = (namespaces: readonly string[] = []): string => · 45 unmodified lines | ||
| 217 | 230 | '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', |
| 218 | 231 | "2) const details = await tools.describe.tool({ path, includeSchemas: true });", |
| 219 | 232 | "3) Call selected tools.<path>(input).", |
| 220 | − '4) To connect a source, call tools.executor.sources.add(...) for MCP, OpenAPI, or GraphQL APIs.', | |
| 221 | − ...EXECUTOR_SOURCES_ADD_HELP_LINES, | |
| 233 | + "4) Use source plugins to inspect or add API sources.", | |
| 234 | + ...getExecutorSourcesAddHelpLines(), | |
| 222 | 235 | "5) If execution pauses for interaction, resume it with `executor resume --execution-id ...`.", |
| 223 | 236 | "Do not use fetch; use tools.* only.", |
| 224 | 237 | ].join("\n"); |
| 225 | 238 | |
| 226 | −const DEFAULT_RUN_WORKFLOW = buildWorkflowText(); | |
| 239 | +const getDefaultRunWorkflow = () => buildWorkflowText(); | |
| 227 | 240 | |
| 228 | 241 | const indentBlock = (value: string, prefix: string = " "): string => |
| 229 | 242 | value |
| @@ -268,7 +281,7 @@ const buildRunWorkflowText = ( · 38 unmodified lines | ||
| 268 | 281 | catalog?: ToolCatalog, |
| 269 | 282 | ): Effect.Effect<string, Error, never> => { |
| 270 | 283 | if (!catalog) { |
| 271 | − return Effect.succeed(DEFAULT_RUN_WORKFLOW); | |
| 284 | + return Effect.succeed(getDefaultRunWorkflow()); | |
| 272 | 285 | } |
| 273 | 286 | |
| 274 | 287 | return catalog.listNamespaces({ limit: 200 }).pipe( |
| @@ -287,9 +300,36 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 12 unmodified lines | ||
| 287 | 300 | Effect.acquireUseRelease( |
| 288 | 301 | createExecutorEffect({ |
| 289 | 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 | 333 | }).pipe(Effect.mapError(toError)), |
| 294 | 334 | (executor) => |
| 295 | 335 | Effect.gen(function* () { |
| @@ -312,7 +352,7 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 16 unmodified lines | ||
| 312 | 352 | Effect.catchAllCause((cause) => |
| 313 | 353 | Effect.succeed( |
| 314 | 354 | [ |
| 315 | − DEFAULT_RUN_WORKFLOW, | |
| 355 | + getDefaultRunWorkflow(), | |
| 316 | 356 | "", |
| 317 | 357 | formatCatalogUnavailableMessage(cause), |
| 318 | 358 | ].join("\n"), |
| @@ -389,8 +429,6 @@ const printCallHelp = (workflow: string) => · 70 unmodified lines | ||
| 389 | 429 | "", |
| 390 | 430 | ' executor call \'const matches = await tools.discover({ query: "github issues", limit: 5 }); return matches;\'', |
| 391 | 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 | 432 | " cat script.ts | executor call --stdin", |
| 395 | 433 | " executor call --file script.ts", |
| 396 | 434 | " executor call --no-open --file script.ts", |
| @@ -418,7 +456,6 @@ const getLocalAuthedClient = (baseUrl: string = DEFAULT_SERVER_BASE_URL) => · 21 unmodified lines | ||
| 418 | 456 | const installation = yield* bootstrapClient.local.installation({}); |
| 419 | 457 | const client = yield* createExecutorApiClient({ |
| 420 | 458 | baseUrl, |
| 421 | − accountId: installation.actorScopeId, | |
| 422 | 459 | }); |
| 423 | 460 | |
| 424 | 461 | return { |
| @@ -440,7 +477,7 @@ const getDefaultServerOptions = (port: number = DEFAULT_SERVER_PORT) => { · 15 unmodified lines | ||
| 440 | 477 | return { |
| 441 | 478 | host: DEFAULT_SERVER_HOST, |
| 442 | 479 | port, |
| 443 | − localDataDir: DEFAULT_LOCAL_DATA_DIR, | |
| 480 | + localDataDir: CLI_LOCAL_DATA_DIR, | |
| 444 | 481 | pidFile: DEFAULT_SERVER_PID_FILE, |
| 445 | 482 | ui: assetsDir ? { assetsDir } : undefined, |
| 446 | 483 | }; |
| @@ -602,7 +639,7 @@ const getServerStatus = ( · 155 unmodified lines | ||
| 602 | 639 | pid, |
| 603 | 640 | pidRunning, |
| 604 | 641 | logFile, |
| 605 | − localDataDir: DEFAULT_LOCAL_DATA_DIR, | |
| 642 | + localDataDir: CLI_LOCAL_DATA_DIR, | |
| 606 | 643 | webAssetsDir: resolveRuntimeWebAssetsDir(), |
| 607 | 644 | installation, |
| 608 | 645 | } satisfies LocalServerStatus; |
| @@ -1036,54 +1073,6 @@ const printExecution = (envelope: ExecutionEnvelope) => · 427 unmodified lines | ||
| 1036 | 1073 | })); |
| 1037 | 1074 | }); |
| 1038 | 1075 | |
| 1039 | −const seedDemoMcpSource = (input: { | |
| 1040 | − baseUrl: string; | |
| 1041 | − endpoint: string; | |
| 1042 | − name: string; | |
| 1043 | − namespace: string; | |
| 1044 | −}) => | |
| 1045 | − Effect.gen(function* () { | |
| 1046 | − yield* ensureServer(input.baseUrl); | |
| 1047 | − const { installation, client } = yield* getLocalAuthedClient(input.baseUrl); | |
| 1048 | − const result = yield* seedDemoMcpSourceInWorkspace({ | |
| 1049 | − client, | |
| 1050 | − workspaceId: installation.scopeId, | |
| 1051 | − endpoint: input.endpoint, | |
| 1052 | − name: input.name, | |
| 1053 | − namespace: input.namespace, | |
| 1054 | − }); | |
| 1055 | − | |
| 1056 | − yield* Effect.sync(() => { | |
| 1057 | − console.log(JSON.stringify(result)); | |
| 1058 | − }); | |
| 1059 | − }); | |
| 1060 | − | |
| 1061 | −const seedGithubOpenApiSource = (input: { | |
| 1062 | − baseUrl: string; | |
| 1063 | − endpoint: string; | |
| 1064 | − specUrl: string; | |
| 1065 | − name: string; | |
| 1066 | − namespace: string; | |
| 1067 | − credentialEnvVar?: string; | |
| 1068 | −}) => | |
| 1069 | − Effect.gen(function* () { | |
| 1070 | − yield* ensureServer(input.baseUrl); | |
| 1071 | − const { installation, client } = yield* getLocalAuthedClient(input.baseUrl); | |
| 1072 | − const result = yield* seedGithubOpenApiSourceInWorkspace({ | |
| 1073 | − client, | |
| 1074 | − workspaceId: installation.scopeId, | |
| 1075 | − endpoint: input.endpoint, | |
| 1076 | − specUrl: input.specUrl, | |
| 1077 | − name: input.name, | |
| 1078 | − namespace: input.namespace, | |
| 1079 | − credentialEnvVar: input.credentialEnvVar, | |
| 1080 | − }); | |
| 1081 | − | |
| 1082 | − yield* Effect.sync(() => { | |
| 1083 | − console.log(JSON.stringify(result)); | |
| 1084 | − }); | |
| 1085 | − }); | |
| 1086 | − | |
| 1087 | 1076 | const driveExecution = (input: { |
| 1088 | 1077 | client: ExecutorApiClient; |
| 1089 | 1078 | workspaceId: ExecutionEnvelope["execution"]["scopeId"]; |
| @@ -1359,64 +1348,7 @@ const resumeCommand = Command.make( · 269 unmodified lines | ||
| 1359 | 1348 | }), |
| 1360 | 1349 | ).pipe(Command.withDescription("Resume a paused execution")); |
| 1361 | 1350 | |
| 1362 | −const devSeedMcpDemoCommand = Command.make( | |
| 1363 | − "seed-mcp-demo", | |
| 1364 | − { | |
| 1365 | − baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_SERVER_BASE_URL)), | |
| 1366 | − endpoint: Options.text("endpoint").pipe( | |
| 1367 | − Options.withDefault("http://127.0.0.1:58506/mcp"), | |
| 1368 | − ), | |
| 1369 | − name: Options.text("name").pipe(Options.withDefault("Demo")), | |
| 1370 | − namespace: Options.text("namespace").pipe(Options.withDefault("demo")), | |
| 1371 | − }, | |
| 1372 | − ({ baseUrl, endpoint, name, namespace }) => | |
| 1373 | − seedDemoMcpSource({ | |
| 1374 | − baseUrl, | |
| 1375 | − endpoint, | |
| 1376 | − name, | |
| 1377 | − namespace, | |
| 1378 | − }), | |
| 1379 | −).pipe( | |
| 1380 | − Command.withDescription( | |
| 1381 | − "Seed the localhost MCP elicitation demo source into the default workspace", | |
| 1382 | − ), | |
| 1383 | −); | |
| 1384 | − | |
| 1385 | −const devSeedGithubCommand = Command.make( | |
| 1386 | − "seed-github", | |
| 1387 | − { | |
| 1388 | − baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_SERVER_BASE_URL)), | |
| 1389 | − endpoint: Options.text("endpoint").pipe( | |
| 1390 | − Options.withDefault("https://api.github.com"), | |
| 1391 | − ), | |
| 1392 | − specUrl: Options.text("spec-url").pipe( | |
| 1393 | − Options.withDefault( | |
| 1394 | − "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json", | |
| 1395 | − ), | |
| 1396 | − ), | |
| 1397 | − name: Options.text("name").pipe(Options.withDefault("GitHub")), | |
| 1398 | − namespace: Options.text("namespace").pipe(Options.withDefault("github")), | |
| 1399 | − credentialEnvVar: Options.text("credential-env-var").pipe( | |
| 1400 | − Options.withDefault("GITHUB_TOKEN"), | |
| 1401 | − ), | |
| 1402 | − }, | |
| 1403 | − ({ baseUrl, endpoint, specUrl, name, namespace, credentialEnvVar }) => | |
| 1404 | − seedGithubOpenApiSource({ | |
| 1405 | − baseUrl, | |
| 1406 | − endpoint, | |
| 1407 | − specUrl, | |
| 1408 | − name, | |
| 1409 | − namespace, | |
| 1410 | − credentialEnvVar, | |
| 1411 | − }), | |
| 1412 | −).pipe( | |
| 1413 | − Command.withDescription( | |
| 1414 | − "Seed a GitHub OpenAPI source into the default workspace", | |
| 1415 | − ), | |
| 1416 | −); | |
| 1417 | − | |
| 1418 | 1351 | const devCommand = Command.make("dev").pipe( |
| 1419 | − Command.withSubcommands([devSeedMcpDemoCommand, devSeedGithubCommand] as const), | |
| 1420 | 1352 | Command.withDescription("Development helpers"), |
| 1421 | 1353 | ); |
| 1422 | 1354 | |
| @@ -1,113 +0,0 @@ | ||
| 1 | −import { describe, expect, it } from "@effect/vitest"; | |
| 2 | −import type { ExecutionInteraction } from "@executor/platform-sdk/schema"; | |
| 3 | − | |
| 4 | −import { | |
| 5 | − buildPausedExecutionOutput, | |
| 6 | − parseInteractionPayload, | |
| 7 | −} from "./pending-interaction-output"; | |
| 8 | − | |
| 9 | −const makeInteraction = (patch: Partial<ExecutionInteraction> = {}): ExecutionInteraction => ({ | |
| 10 | − id: "exec_123:tool_execution_gate:call_1" as never, | |
| 11 | − executionId: "exec_123" as never, | |
| 12 | − status: "pending", | |
| 13 | − kind: "form", | |
| 14 | − purpose: "tool_execution_gate", | |
| 15 | − payloadJson: JSON.stringify({ | |
| 16 | − elicitation: { | |
| 17 | − mode: "form", | |
| 18 | − message: "Allow DELETE /v2/domains/{domain}/records/{recordId}?", | |
| 19 | − requestedSchema: { | |
| 20 | − type: "object", | |
| 21 | − properties: { | |
| 22 | − approve: { | |
| 23 | − type: "boolean", | |
| 24 | − description: "Whether to approve the tool call", | |
| 25 | − }, | |
| 26 | − }, | |
| 27 | − required: ["approve"], | |
| 28 | − additionalProperties: false, | |
| 29 | − }, | |
| 30 | − }, | |
| 31 | − }), | |
| 32 | − responseJson: null, | |
| 33 | − responsePrivateJson: null, | |
| 34 | − createdAt: 1, | |
| 35 | − updatedAt: 1, | |
| 36 | − ...patch, | |
| 37 | −}); | |
| 38 | − | |
| 39 | −describe("pending-interaction-output", () => { | |
| 40 | − it("parses requested schema from interaction payload", () => { | |
| 41 | − const parsed = parseInteractionPayload(makeInteraction()); | |
| 42 | − | |
| 43 | − expect(parsed).not.toBeNull(); | |
| 44 | − expect(parsed?.mode).toBe("form"); | |
| 45 | − expect(parsed?.requestedSchema).toEqual({ | |
| 46 | − type: "object", | |
| 47 | − properties: { | |
| 48 | − approve: { | |
| 49 | − type: "boolean", | |
| 50 | − description: "Whether to approve the tool call", | |
| 51 | − }, | |
| 52 | − }, | |
| 53 | − required: ["approve"], | |
| 54 | − additionalProperties: false, | |
| 55 | − }); | |
| 56 | − }); | |
| 57 | − | |
| 58 | − it("builds a structured paused execution instruction with full resume command", () => { | |
| 59 | − const output = buildPausedExecutionOutput({ | |
| 60 | − executionId: "exec_123", | |
| 61 | − interaction: makeInteraction(), | |
| 62 | − baseUrl: "http://127.0.0.1:8788", | |
| 63 | − shouldOpenUrls: false, | |
| 64 | − }); | |
| 65 | − | |
| 66 | − expect(output.resumeCommand).toBe( | |
| 67 | − "executor resume --execution-id exec_123 --base-url http://127.0.0.1:8788 --no-open", | |
| 68 | − ); | |
| 69 | − expect(output.interaction.requestedSchema).toEqual({ | |
| 70 | − type: "object", | |
| 71 | − properties: { | |
| 72 | − approve: { | |
| 73 | − type: "boolean", | |
| 74 | − description: "Whether to approve the tool call", | |
| 75 | − }, | |
| 76 | − }, | |
| 77 | − required: ["approve"], | |
| 78 | − additionalProperties: false, | |
| 79 | − }); | |
| 80 | − expect(output.instruction).toContain("requires approval"); | |
| 81 | − expect(output.instruction).toContain("Allow DELETE /v2/domains/{domain}/records/{recordId}?"); | |
| 82 | − expect(output.instruction).toContain(output.resumeCommand); | |
| 83 | − expect(output.instruction).toContain("interaction.requestedSchema"); | |
| 84 | − }); | |
| 85 | − | |
| 86 | − it("describes URL interactions with the next command to run", () => { | |
| 87 | − const output = buildPausedExecutionOutput({ | |
| 88 | − executionId: "exec_456", | |
| 89 | − interaction: makeInteraction({ | |
| 90 | − id: "exec_456:source_connect_oauth2:call_1" as never, | |
| 91 | − executionId: "exec_456" as never, | |
| 92 | − kind: "url", | |
| 93 | − purpose: "source_connect_oauth2", | |
| 94 | − payloadJson: JSON.stringify({ | |
| 95 | − elicitation: { | |
| 96 | − mode: "url", | |
| 97 | − message: "Finish connecting Vercel", | |
| 98 | − url: "https://vercel.com/oauth/start", | |
| 99 | − }, | |
| 100 | − }), | |
| 101 | − }), | |
| 102 | − baseUrl: "http://127.0.0.1:8788", | |
| 103 | − shouldOpenUrls: true, | |
| 104 | − }); | |
| 105 | − | |
| 106 | − expect(output.interaction.mode).toBe("url"); | |
| 107 | − expect(output.interaction.url).toBe("https://vercel.com/oauth/start"); | |
| 108 | − expect(output.instruction).toContain("https://vercel.com/oauth/start"); | |
| 109 | − expect(output.instruction).toContain( | |
| 110 | − "executor resume --execution-id exec_456 --base-url http://127.0.0.1:8788", | |
| 111 | − ); | |
| 112 | − }); | |
| 113 | −}); | |
| @@ -55,10 +55,6 @@ const describePauseReason = (interaction: ExecutionInteraction): string => { · 54 unmodified lines | ||
| 55 | 55 | switch (interaction.purpose) { |
| 56 | 56 | case "tool_execution_gate": |
| 57 | 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 | 58 | default: |
| 63 | 59 | return "executor needs additional input before it can continue"; |
| 64 | 60 | } |
| @@ -0,0 +1,47 @@ | ||
| 1 | +import { join } from "node:path"; | |
| 2 | +import { describe, expect, it } from "@effect/vitest"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + DEFAULT_EXECUTOR_DATA_DIR, | |
| 6 | + DEFAULT_LOCAL_DATA_DIR, | |
| 7 | +} from "@executor/server"; | |
| 8 | + | |
| 9 | +import { | |
| 10 | + resolveCliLocalDataDir, | |
| 11 | +} from "./runtime-paths"; | |
| 12 | + | |
| 13 | +describe("runtime-paths", () => { | |
| 14 | + it("uses the web dev local data dir for source-launched CLI runs", () => { | |
| 15 | + const originalArgv1 = process.argv[1]; | |
| 16 | + | |
| 17 | + try { | |
| 18 | + process.argv[1] = "/tmp/executor.ts"; | |
| 19 | + | |
| 20 | + expect(resolveCliLocalDataDir()).toBe( | |
| 21 | + join(DEFAULT_EXECUTOR_DATA_DIR, "control-plane-web-dev"), | |
| 22 | + ); | |
| 23 | + } finally { | |
| 24 | + if (originalArgv1 === undefined) { | |
| 25 | + delete process.argv[1]; | |
| 26 | + } else { | |
| 27 | + process.argv[1] = originalArgv1; | |
| 28 | + } | |
| 29 | + } | |
| 30 | + }); | |
| 31 | + | |
| 32 | + it("keeps the default local data dir for bundled launches", () => { | |
| 33 | + const originalArgv1 = process.argv[1]; | |
| 34 | + | |
| 35 | + try { | |
| 36 | + process.argv[1] = "/tmp/executor"; | |
| 37 | + | |
| 38 | + expect(resolveCliLocalDataDir()).toBe(DEFAULT_LOCAL_DATA_DIR); | |
| 39 | + } finally { | |
| 40 | + if (originalArgv1 === undefined) { | |
| 41 | + delete process.argv[1]; | |
| 42 | + } else { | |
| 43 | + process.argv[1] = originalArgv1; | |
| 44 | + } | |
| 45 | + } | |
| 46 | + }); | |
| 47 | +}); | |
| @@ -1,8 +1,11 @@ | ||
| 1 | 1 | import { existsSync } from "node:fs"; |
| 2 | −import { dirname, extname, resolve } from "node:path"; | |
| 2 | +import { dirname, extname, join, resolve } from "node:path"; | |
| 3 | 3 | import { fileURLToPath } from "node:url"; |
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | + DEFAULT_EXECUTOR_DATA_DIR, | |
| 7 | + DEFAULT_LOCAL_DATA_DIR, | |
| 8 | + EXECUTOR_LOCAL_DATA_DIR_ENV, | |
| 6 | 9 | EXECUTOR_WEB_ASSETS_DIR_ENV, |
| 7 | 10 | } from "@executor/server"; |
| 8 | 11 | |
| @@ -36,6 +39,8 @@ const getSourceEntrypoint = (): string | null => { · 27 unmodified lines | ||
| 36 | 39 | : null; |
| 37 | 40 | }; |
| 38 | 41 | |
| 42 | +const isSourceCliLaunch = (): boolean => getSourceEntrypoint() !== null; | |
| 43 | + | |
| 39 | 44 | const resolveBundledNodeLauncher = (): string | null => { |
| 40 | 45 | const candidate = resolve(sourceDir, "executor.js"); |
| 41 | 46 | return existsSync(candidate) ? candidate : null; |
| @@ -76,6 +81,16 @@ export const resolveSelfCommand = (args: readonly string[]): readonly string[] = · 34 unmodified lines | ||
| 76 | 81 | : [process.execPath, sourceEntrypoint, ...args]; |
| 77 | 82 | }; |
| 78 | 83 | |
| 84 | +export const resolveCliLocalDataDir = (): string => { | |
| 85 | + if (trim(process.env[EXECUTOR_LOCAL_DATA_DIR_ENV])) { | |
| 86 | + return DEFAULT_LOCAL_DATA_DIR; | |
| 87 | + } | |
| 88 | + | |
| 89 | + return isSourceCliLaunch() | |
| 90 | + ? join(DEFAULT_EXECUTOR_DATA_DIR, "control-plane-web-dev") | |
| 91 | + : DEFAULT_LOCAL_DATA_DIR; | |
| 92 | +}; | |
| 93 | + | |
| 79 | 94 | export const resolveRuntimeWebAssetsDir = (): string | null => { |
| 80 | 95 | const explicit = resolveIfExists(process.env[EXECUTOR_WEB_ASSETS_DIR_ENV]); |
| 81 | 96 | if (explicit) { |
| @@ -187,12 +187,6 @@ export const buildDistributionPackage = async ( · 186 unmodified lines | ||
| 187 | 187 | |
| 188 | 188 | await cp(webDistDir, webDir, { recursive: true }); |
| 189 | 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 | 190 | await cp( |
| 197 | 191 | join(repoRoot, "packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs"), |
| 198 | 192 | join(binDir, "deno-subprocess-worker.mjs"), |
| @@ -1,262 +0,0 @@ | ||
| 1 | −import { FileSystem } from "@effect/platform"; | |
| 2 | −import { NodeFileSystem } from "@effect/platform-node"; | |
| 3 | −import { describe, expect, it } from "@effect/vitest"; | |
| 4 | −import * as Effect from "effect/Effect"; | |
| 5 | − | |
| 6 | −import { | |
| 7 | − createExecutorApiEffectClient as createExecutorApiClient, | |
| 8 | −} from "@executor/platform-api/effect"; | |
| 9 | −import { type SourceDiscoveryKind } from "@executor/platform-sdk/schema"; | |
| 10 | −import { createLocalExecutorServer } from "@executor/server"; | |
| 11 | − | |
| 12 | −const REAL_VERCEL_SPEC_URL = "https://openapi.vercel.sh"; | |
| 13 | −const REAL_VERCEL_API_ENDPOINT = "https://api.vercel.com"; | |
| 14 | −const REAL_LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql"; | |
| 15 | −const REAL_DEEPWIKI_MCP_ENDPOINT = "https://mcp.deepwiki.com/mcp"; | |
| 16 | − | |
| 17 | −const makeTempWorkspaceRoot = () => | |
| 18 | − FileSystem.FileSystem.pipe( | |
| 19 | − Effect.flatMap((fs) => fs.makeTempDirectory({ prefix: "executor-real-ingestion-" })), | |
| 20 | − Effect.provide(NodeFileSystem.layer), | |
| 21 | − ); | |
| 22 | − | |
| 23 | −const createIsolatedLocalExecutorServer = () => | |
| 24 | − Effect.gen(function* () { | |
| 25 | − const workspaceRoot = yield* makeTempWorkspaceRoot(); | |
| 26 | − return yield* createLocalExecutorServer({ | |
| 27 | − port: 0, | |
| 28 | − localDataDir: ":memory:", | |
| 29 | − workspaceRoot, | |
| 30 | − }); | |
| 31 | − }); | |
| 32 | − | |
| 33 | −const createApiClientHarness = () => | |
| 34 | − Effect.gen(function* () { | |
| 35 | − const server = yield* createIsolatedLocalExecutorServer(); | |
| 36 | − const bootstrapClient = yield* createExecutorApiClient({ | |
| 37 | − baseUrl: server.baseUrl, | |
| 38 | − }); | |
| 39 | − const installation = yield* bootstrapClient.local.installation({}); | |
| 40 | − const client = yield* createExecutorApiClient({ | |
| 41 | − baseUrl: server.baseUrl, | |
| 42 | − accountId: installation.actorScopeId, | |
| 43 | − }); | |
| 44 | − | |
| 45 | − return { | |
| 46 | − installation, | |
| 47 | − client, | |
| 48 | − }; | |
| 49 | − }); | |
| 50 | − | |
| 51 | −const canonicalUrl = (value: string): string => | |
| 52 | − new URL(value).toString(); | |
| 53 | − | |
| 54 | −const expectDiscoveredKind = <T extends { detectedKind: SourceDiscoveryKind }>( | |
| 55 | − result: T, | |
| 56 | − expectedKind: SourceDiscoveryKind, | |
| 57 | −) => { | |
| 58 | − expect(result.detectedKind).toBe(expectedKind); | |
| 59 | −}; | |
| 60 | − | |
| 61 | −describe("local-executor-server real ingestion", () => { | |
| 62 | − it.live("discovers and ingests the real Vercel OpenAPI source through the API client", () => | |
| 63 | − Effect.scoped(Effect.gen(function* () { | |
| 64 | − const { installation, client } = yield* createApiClientHarness(); | |
| 65 | − | |
| 66 | − const discovered = yield* client.sources.discover({ | |
| 67 | − payload: { | |
| 68 | − url: REAL_VERCEL_SPEC_URL, | |
| 69 | − }, | |
| 70 | − }); | |
| 71 | − | |
| 72 | − expectDiscoveredKind(discovered, "openapi"); | |
| 73 | − expect(canonicalUrl(discovered.endpoint)).toBe(canonicalUrl(REAL_VERCEL_API_ENDPOINT)); | |
| 74 | − expect(canonicalUrl(discovered.specUrl ?? "")).toBe(canonicalUrl(REAL_VERCEL_SPEC_URL)); | |
| 75 | − | |
| 76 | − const connected = yield* client.sources.connect({ | |
| 77 | − path: { | |
| 78 | − workspaceId: installation.scopeId, | |
| 79 | − }, | |
| 80 | − payload: { | |
| 81 | − kind: "openapi", | |
| 82 | − name: "Vercel", | |
| 83 | − namespace: "vercel", | |
| 84 | − endpoint: REAL_VERCEL_API_ENDPOINT, | |
| 85 | − specUrl: REAL_VERCEL_SPEC_URL, | |
| 86 | − auth: { | |
| 87 | − kind: "none", | |
| 88 | − }, | |
| 89 | − }, | |
| 90 | − }); | |
| 91 | − | |
| 92 | − expect(connected.kind).toBe("connected"); | |
| 93 | − if (connected.kind !== "connected") { | |
| 94 | − throw new Error(`Expected connected result, received ${connected.kind}`); | |
| 95 | − } | |
| 96 | − | |
| 97 | − const inspection = yield* client.sources.inspection({ | |
| 98 | − path: { | |
| 99 | − workspaceId: installation.scopeId, | |
| 100 | − sourceId: connected.source.id, | |
| 101 | − }, | |
| 102 | − }); | |
| 103 | − | |
| 104 | − expect(inspection.namespace).toBe("vercel"); | |
| 105 | − expect(inspection.toolCount).toBeGreaterThan(250); | |
| 106 | − | |
| 107 | − const discoveredTools = yield* client.sources.inspectionDiscover({ | |
| 108 | − path: { | |
| 109 | − workspaceId: installation.scopeId, | |
| 110 | − sourceId: connected.source.id, | |
| 111 | − }, | |
| 112 | − payload: { | |
| 113 | − query: "list user events", | |
| 114 | − limit: 5, | |
| 115 | − }, | |
| 116 | − }); | |
| 117 | − | |
| 118 | − expect(discoveredTools.bestPath).toBeTruthy(); | |
| 119 | − expect(discoveredTools.total).toBeGreaterThan(0); | |
| 120 | − })), | |
| 121 | − 15_000, | |
| 122 | − ); | |
| 123 | − | |
| 124 | − it.live("discovers and ingests the real Linear GraphQL source through the API client", () => | |
| 125 | − Effect.scoped(Effect.gen(function* () { | |
| 126 | − const { installation, client } = yield* createApiClientHarness(); | |
| 127 | − | |
| 128 | − const discovered = yield* client.sources.discover({ | |
| 129 | − payload: { | |
| 130 | − url: REAL_LINEAR_GRAPHQL_ENDPOINT, | |
| 131 | − }, | |
| 132 | − }); | |
| 133 | − | |
| 134 | − expectDiscoveredKind(discovered, "graphql"); | |
| 135 | − expect(canonicalUrl(discovered.endpoint)).toBe(canonicalUrl(REAL_LINEAR_GRAPHQL_ENDPOINT)); | |
| 136 | − expect(discovered.specUrl).toBeNull(); | |
| 137 | − | |
| 138 | − const connected = yield* client.sources.connect({ | |
| 139 | − path: { | |
| 140 | − workspaceId: installation.scopeId, | |
| 141 | − }, | |
| 142 | − payload: { | |
| 143 | − kind: "graphql", | |
| 144 | − name: "Linear", | |
| 145 | − namespace: "linear", | |
| 146 | − endpoint: REAL_LINEAR_GRAPHQL_ENDPOINT, | |
| 147 | − auth: { | |
| 148 | − kind: "none", | |
| 149 | − }, | |
| 150 | − }, | |
| 151 | − }); | |
| 152 | − | |
| 153 | − expect(connected.kind).toBe("connected"); | |
| 154 | − if (connected.kind !== "connected") { | |
| 155 | − throw new Error(`Expected connected result, received ${connected.kind}`); | |
| 156 | − } | |
| 157 | − expect(connected.source.status).toBe("connected"); | |
| 158 | − expect(connected.source.namespace).toBe("linear"); | |
| 159 | − | |
| 160 | − const inspection = yield* client.sources.inspection({ | |
| 161 | − path: { | |
| 162 | − workspaceId: installation.scopeId, | |
| 163 | − sourceId: connected.source.id, | |
| 164 | − }, | |
| 165 | − }); | |
| 166 | − | |
| 167 | − expect(inspection.source.kind).toBe("graphql"); | |
| 168 | − expect(inspection.toolCount).toBeGreaterThan(100); | |
| 169 | − expect( | |
| 170 | − inspection.tools.every((tool) => tool.inputTypePreview === undefined), | |
| 171 | − ).toBe(true); | |
| 172 | − expect( | |
| 173 | − inspection.tools.every((tool) => tool.outputTypePreview === undefined), | |
| 174 | − ).toBe(true); | |
| 175 | − | |
| 176 | − const toolDetail = yield* client.sources.inspectionTool({ | |
| 177 | − path: { | |
| 178 | − workspaceId: installation.scopeId, | |
| 179 | − sourceId: connected.source.id, | |
| 180 | − toolPath: "linear.administrableTeams", | |
| 181 | − }, | |
| 182 | − }); | |
| 183 | − | |
| 184 | − expect(toolDetail.summary.path).toBe("linear.administrableTeams"); | |
| 185 | − expect(toolDetail.summary.inputTypePreview).toBeUndefined(); | |
| 186 | − expect(toolDetail.summary.outputTypePreview).toBeUndefined(); | |
| 187 | − expect(toolDetail.contract.input.typeDeclaration).toContain( | |
| 188 | − "type LinearAdministrableTeamsCall = {", | |
| 189 | − ); | |
| 190 | − expect(toolDetail.contract.input.schemaJson).toContain("\"$defs\""); | |
| 191 | − | |
| 192 | − const storedSource = yield* client.sources.get({ | |
| 193 | − path: { | |
| 194 | − workspaceId: installation.scopeId, | |
| 195 | − sourceId: connected.source.id, | |
| 196 | − }, | |
| 197 | − }); | |
| 198 | − | |
| 199 | − expect(storedSource.kind).toBe("graphql"); | |
| 200 | − expect(storedSource.status).toBe("connected"); | |
| 201 | − expect(storedSource.endpoint).toBe(REAL_LINEAR_GRAPHQL_ENDPOINT); | |
| 202 | − })), | |
| 203 | − 15_000, | |
| 204 | − ); | |
| 205 | − | |
| 206 | − it.live("discovers and ingests a real public MCP source through the API client", () => | |
| 207 | − Effect.scoped(Effect.gen(function* () { | |
| 208 | − const { installation, client } = yield* createApiClientHarness(); | |
| 209 | − | |
| 210 | − const discovered = yield* client.sources.discover({ | |
| 211 | − payload: { | |
| 212 | − url: REAL_DEEPWIKI_MCP_ENDPOINT, | |
| 213 | − }, | |
| 214 | − }); | |
| 215 | − | |
| 216 | − expectDiscoveredKind(discovered, "mcp"); | |
| 217 | − expect(canonicalUrl(discovered.endpoint)).toBe(canonicalUrl(REAL_DEEPWIKI_MCP_ENDPOINT)); | |
| 218 | − | |
| 219 | − const connected = yield* client.sources.connect({ | |
| 220 | − path: { | |
| 221 | − workspaceId: installation.scopeId, | |
| 222 | − }, | |
| 223 | − payload: { | |
| 224 | − kind: "mcp", | |
| 225 | − name: "DeepWiki", | |
| 226 | − namespace: "deepwiki", | |
| 227 | − endpoint: REAL_DEEPWIKI_MCP_ENDPOINT, | |
| 228 | − }, | |
| 229 | − }); | |
| 230 | − | |
| 231 | − expect(connected.kind).toBe("connected"); | |
| 232 | − if (connected.kind !== "connected") { | |
| 233 | − throw new Error(`Expected connected result, received ${connected.kind}`); | |
| 234 | − } | |
| 235 | − | |
| 236 | − const inspection = yield* client.sources.inspection({ | |
| 237 | − path: { | |
| 238 | − workspaceId: installation.scopeId, | |
| 239 | − sourceId: connected.source.id, | |
| 240 | − }, | |
| 241 | − }); | |
| 242 | − | |
| 243 | − expect(inspection.namespace).toBe("deepwiki"); | |
| 244 | − expect(inspection.toolCount).toBeGreaterThan(0); | |
| 245 | − | |
| 246 | − const discoveredTools = yield* client.sources.inspectionDiscover({ | |
| 247 | − path: { | |
| 248 | − workspaceId: installation.scopeId, | |
| 249 | − sourceId: connected.source.id, | |
| 250 | − }, | |
| 251 | − payload: { | |
| 252 | − query: "repository", | |
| 253 | − limit: 5, | |
| 254 | − }, | |
| 255 | − }); | |
| 256 | − | |
| 257 | − expect(discoveredTools.bestPath).toBeTruthy(); | |
| 258 | − expect(discoveredTools.total).toBeGreaterThan(0); | |
| 259 | − })), | |
| 260 | − 15_000, | |
| 261 | − ); | |
| 262 | −}); | |
| @@ -9,6 +9,11 @@ · 8 unmodified lines | ||
| 9 | 9 | "start": "NODE_ENV=production bun src/server.ts" |
| 10 | 10 | }, |
| 11 | 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 | 17 | "@executor/react": "workspace:*", |
| 13 | 18 | "@executor/server": "workspace:*", |
| 14 | 19 | "@shikijs/langs": "^4.0.1", |
| @@ -4,6 +4,8 @@ import { cn } from "../lib/utils"; · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | type LocalMcpInstallMode = "stdio" | "http"; |
| 6 | 6 | |
| 7 | +const isDevBuild = import.meta.env.DEV; | |
| 8 | + | |
| 7 | 9 | export function LocalMcpInstallCard(props: { |
| 8 | 10 | title?: string; |
| 9 | 11 | description?: string; |
| @@ -17,7 +19,9 @@ export function LocalMcpInstallCard(props: { · 7 unmodified lines | ||
| 17 | 19 | }, []); |
| 18 | 20 | |
| 19 | 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 | 25 | : origin |
| 22 | 26 | ? `npx add-mcp "${origin}/mcp" --transport http --name "executor"` |
| 23 | 27 | : 'npx add-mcp "<this-server>/mcp" --transport http --name "executor"'; |
| @@ -57,9 +61,22 @@ export function LocalMcpInstallCard(props: { · 33 unmodified lines | ||
| 57 | 61 | </div> |
| 58 | 62 | <CodeBlock code={command} lang="bash" className="rounded-xl border border-border bg-background/70" /> |
| 59 | 63 | {mode === "stdio" ? ( |
| 60 | − <p className="mt-3 text-[12px] text-muted-foreground"> | |
| 61 | − Requires the `executor` CLI to be available on your PATH. Uses a distinct MCP name to avoid colliding with an existing remote `executor` entry. | |
| 62 | − </p> | |
| 64 | + <div className="mt-3 space-y-1 text-[12px] text-muted-foreground"> | |
| 65 | + {!isDevBuild ? ( | |
| 66 | + <p> | |
| 67 | + Requires the `executor` CLI on your PATH. Uses a distinct MCP name to avoid colliding with an existing remote `executor` entry. | |
| 68 | + </p> | |
| 69 | + ) : ( | |
| 70 | + <> | |
| 71 | + <p> | |
| 72 | + Uses the repo-local dev CLI: <code>bun run executor mcp --stdio</code>. | |
| 73 | + </p> | |
| 74 | + <p> | |
| 75 | + Run the `add-mcp` command from the repository root, or set your MCP client working directory to this repo before using the saved entry. | |
| 76 | + </p> | |
| 77 | + </> | |
| 78 | + )} | |
| 79 | + </div> | |
| 63 | 80 | ) : null} |
| 64 | 81 | </section> |
| 65 | 82 | ); |
| @@ -1,10 +1,15 @@ | ||
| 1 | −import { Link, Outlet, useMatchRoute } from "@tanstack/react-router"; | |
| 1 | +import { Link, Outlet, useLocation, useMatchRoute } from "@tanstack/react-router"; | |
| 2 | 2 | import { useCallback, useEffect, useState } from "react"; |
| 3 | 3 | import { useSources, type Source } from "@executor/react"; |
| 4 | +import { sourcePluginsIndexPath } from "@executor/react/plugins"; | |
| 4 | 5 | import { cn } from "../lib/utils"; |
| 5 | 6 | import { IconPlus, IconCopy, IconCheck } from "./icons"; |
| 6 | 7 | import { LoadableBlock } from "./loadable"; |
| 7 | 8 | import { SourceFavicon } from "./source-favicon"; |
| 9 | +import { | |
| 10 | + getSourceFrontendPaths, | |
| 11 | + registeredFrontendPluginNavRoutes, | |
| 12 | +} from "../plugins"; | |
| 8 | 13 | |
| 9 | 14 | // ── Status dot color ───────────────────────────────────────────────────── |
| 10 | 15 | |
| @@ -217,10 +222,18 @@ function UpdateCard(props: { latestVersion: string; channel: UpdateChannel }) { · 206 unmodified lines | ||
| 217 | 222 | // ── AppShell ───────────────────────────────────────────────────────────── |
| 218 | 223 | export function AppShell() { |
| 219 | 224 | const sources = useSources(); |
| 225 | + const location = useLocation(); | |
| 220 | 226 | const matchRoute = useMatchRoute(); |
| 221 | 227 | const isHome = matchRoute({ to: "/" }); |
| 222 | 228 | const isSecrets = matchRoute({ to: "/secrets" }); |
| 223 | 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 | 237 | return ( |
| 225 | 238 | <div className="flex h-screen overflow-hidden"> |
| 226 | 239 | {/* Sidebar */} |
| @@ -241,13 +254,21 @@ export function AppShell() { · 14 unmodified lines | ||
| 241 | 254 | <nav className="flex flex-1 flex-col p-2 overflow-y-auto"> |
| 242 | 255 | <NavItem to="/" label="Dashboard" active={!!isHome} /> |
| 243 | 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 | 266 | {/* Sources */} |
| 246 | 267 | <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50"> |
| 247 | 268 | <div className="flex items-center justify-between gap-2"> |
| 248 | 269 | <span>Sources</span> |
| 249 | 270 | <Link |
| 250 | − to="/sources/add" | |
| 271 | + to={sourcePluginsIndexPath} | |
| 251 | 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 | 274 | <IconPlus className="size-3" /> |
| @@ -255,16 +276,39 @@ export function AppShell() { · 1 unmodified line | ||
| 255 | 276 | </Link> |
| 256 | 277 | </div> |
| 257 | 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 | 294 | <LoadableBlock loadable={sources} loading="Loading..."> |
| 259 | 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 | 301 | <div className="px-2.5 py-2 text-[11px] leading-relaxed text-muted-foreground/40"> |
| 262 | 302 | No sources yet |
| 263 | 303 | </div> |
| 264 | 304 | ) : ( |
| 265 | 305 | <div className="flex flex-col gap-px"> |
| 266 | 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 | 313 | </div> |
| 270 | 314 | ) |
| @@ -294,7 +338,7 @@ export function AppShell() { · 23 unmodified lines | ||
| 294 | 338 | </aside> |
| 295 | 339 | |
| 296 | 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 | 342 | <Outlet /> |
| 299 | 343 | </main> |
| 300 | 344 | </div> |
| @@ -304,20 +348,34 @@ export function AppShell() { · 3 unmodified lines | ||
| 304 | 348 | // ── SourceItem ─────────────────────────────────────────────────────────── |
| 305 | 349 | |
| 306 | 350 | function SourceItem(props: { |
| 351 | + pathname: string; | |
| 307 | 352 | source: Source; |
| 308 | − matchRoute: ReturnType<typeof useMatchRoute>; | |
| 309 | 353 | }) { |
| 310 | − const { source, matchRoute } = props; | |
| 311 | − const active = matchRoute({ | |
| 312 | − to: "/sources/$sourceId", | |
| 313 | − params: { sourceId: source.id }, | |
| 314 | − fuzzy: true, | |
| 315 | − }); | |
| 354 | + const paths = getSourceFrontendPaths(props.source.kind); | |
| 355 | + | |
| 356 | + if (!paths) { | |
| 357 | + return ( | |
| 358 | + <div className="group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] text-sidebar-foreground/60"> | |
| 359 | + <div className="flex size-3 shrink-0 items-center justify-center text-muted-foreground/50"> | |
| 360 | + <SourceFavicon source={props.source} className="size-3" /> | |
| 361 | + </div> | |
| 362 | + <span className="flex-1 truncate">{props.source.name}</span> | |
| 363 | + <span | |
| 364 | + className={cn("size-1.5 shrink-0 rounded-full", statusColor[props.source.status] ?? "bg-muted-foreground/30")} | |
| 365 | + title={props.source.status} | |
| 366 | + /> | |
| 367 | + </div> | |
| 368 | + ); | |
| 369 | + } | |
| 370 | + | |
| 371 | + const detailPath = paths.detail(props.source.id); | |
| 372 | + const active = | |
| 373 | + props.pathname === detailPath | |
| 374 | + || props.pathname.startsWith(`${detailPath}/`); | |
| 316 | 375 | |
| 317 | 376 | return ( |
| 318 | 377 | <Link |
| 319 | − to="/sources/$sourceId" | |
| 320 | − params={{ sourceId: source.id }} | |
| 378 | + to={detailPath} | |
| 321 | 379 | search={{ tab: "model" }} |
| 322 | 380 | className={cn( |
| 323 | 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 | 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 | 389 | </div> |
| 332 | − <span className="flex-1 truncate">{source.name}</span> | |
| 390 | + <span className="flex-1 truncate">{props.source.name}</span> | |
| 333 | 391 | <span |
| 334 | − className={cn("size-1.5 shrink-0 rounded-full", statusColor[source.status] ?? "bg-muted-foreground/30")} | |
| 335 | − title={source.status} | |
| 392 | + className={cn("size-1.5 shrink-0 rounded-full", statusColor[props.source.status] ?? "bg-muted-foreground/30")} | |
| 393 | + title={props.source.status} | |
| 336 | 394 | /> |
| 337 | 395 | </Link> |
| 338 | 396 | ); |
| @@ -1,34 +1,25 @@ | ||
| 1 | −import { useMemo, useState } from "react"; | |
| 1 | +import { useState } from "react"; | |
| 2 | 2 | import type { Source } from "@executor/react"; |
| 3 | −import { getGoogleProductIconUrl, getSourceFaviconUrl } from "../lib/source-favicon"; | |
| 3 | +import { getSourceFrontendIconUrl } from "../plugins"; | |
| 4 | 4 | import { cn } from "../lib/utils"; |
| 5 | 5 | |
| 6 | 6 | type SourceKind = Source["kind"] | string; |
| 7 | 7 | |
| 8 | 8 | export function SourceFavicon({ |
| 9 | − endpoint, | |
| 10 | − kind, | |
| 9 | + source, | |
| 11 | 10 | className, |
| 12 | 11 | size = 16, |
| 13 | 12 | }: { |
| 14 | − endpoint?: string | null; | |
| 15 | − kind: SourceKind; | |
| 13 | + source: Source; | |
| 16 | 14 | className?: string; |
| 17 | 15 | size?: number; |
| 18 | 16 | }) { |
| 19 | − const faviconUrl = useMemo(() => { | |
| 20 | − // For google_discovery, prefer the real product icon | |
| 21 | − if (kind === "google_discovery") { | |
| 22 | − return getGoogleProductIconUrl(endpoint) ?? getSourceFaviconUrl(endpoint); | |
| 23 | − } | |
| 24 | − return getSourceFaviconUrl(endpoint); | |
| 25 | − }, [endpoint, kind]); | |
| 26 | − | |
| 17 | + const faviconUrl = getSourceFrontendIconUrl(source); | |
| 27 | 18 | const [failedUrl, setFailedUrl] = useState<string | null>(null); |
| 28 | 19 | const isFailed = Boolean(faviconUrl && failedUrl === faviconUrl); |
| 29 | 20 | |
| 30 | 21 | if (!faviconUrl || isFailed) { |
| 31 | − return <DefaultSourceIcon kind={kind} className={className} />; | |
| 22 | + return <DefaultSourceIcon kind={source.kind} className={className} />; | |
| 32 | 23 | } |
| 33 | 24 | |
| 34 | 25 | return ( |
| @@ -47,7 +38,7 @@ export function SourceFavicon({ · 12 unmodified lines | ||
| 47 | 38 | } |
| 48 | 39 | |
| 49 | 40 | export function DefaultSourceIcon({ |
| 50 | − kind, | |
| 41 | + kind: _kind, | |
| 51 | 42 | className, |
| 52 | 43 | }: { |
| 53 | 44 | kind: SourceKind; |
| @@ -55,49 +46,10 @@ export function DefaultSourceIcon({ · 1 unmodified line | ||
| 55 | 46 | }) { |
| 56 | 47 | const base = cn("shrink-0", className); |
| 57 | 48 | |
| 58 | − switch (kind) { | |
| 59 | − case "mcp": | |
| 60 | − return ( | |
| 61 | − <svg viewBox="0 0 16 16" fill="none" className={base}> | |
| 62 | − <rect x="2" y="3" width="12" height="10" rx="1.5" stroke="currentColor" strokeWidth="1.2" /> | |
| 63 | − <path d="M5 7h1M5 9h4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /> | |
| 64 | − </svg> | |
| 65 | − ); | |
| 66 | − case "graphql": | |
| 67 | − return ( | |
| 68 | − <svg viewBox="0 0 16 16" fill="none" className={base}> | |
| 69 | − <path d="M8 2L14 5.5V10.5L8 14L2 10.5V5.5L8 2Z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" /> | |
| 70 | − <circle cx="8" cy="8" r="1.5" fill="currentColor" opacity="0.5" /> | |
| 71 | − </svg> | |
| 72 | − ); | |
| 73 | − case "openapi": | |
| 74 | − return ( | |
| 75 | − <svg viewBox="0 0 16 16" fill="none" className={base}> | |
| 76 | − <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" /> | |
| 77 | − <path d="M5 6h6M5 8h4M5 10h5" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" /> | |
| 78 | − </svg> | |
| 79 | − ); | |
| 80 | − case "google_discovery": | |
| 81 | − return ( | |
| 82 | − <svg viewBox="0 0 16 16" className={base}> | |
| 83 | − <path d="M8 3.2a4.8 4.8 0 00-3.39 8.2l1.02-1.02A3.36 3.36 0 018 4.64a3.33 3.33 0 012.34.96l1.03-1.03A4.78 4.78 0 008 3.2z" fill="#EA4335" /> | |
| 84 | − <path d="M12.8 8c0-.37-.04-.72-.1-1.06H8v2.12h2.7a2.4 2.4 0 01-1 1.52l1.02 1.02A4.8 4.8 0 0012.8 8z" fill="#4285F4" /> | |
| 85 | − <path d="M5.63 9.38A3.36 3.36 0 014.64 8c0-.5.11-.97.3-1.4L3.92 5.58A4.78 4.78 0 003.2 8c0 .88.26 1.7.72 2.4l1.02-1.02z" fill="#FBBC05" /> | |
| 86 | − <path d="M8 12.8c1.2 0 2.27-.4 3.1-1.1l-1.02-1.02c-.54.38-1.24.6-2.08.6a3.36 3.36 0 01-3.07-2.28l-1.02 1.02A4.8 4.8 0 008 12.8z" fill="#34A853" /> | |
| 87 | − </svg> | |
| 88 | − ); | |
| 89 | − case "internal": | |
| 90 | − return ( | |
| 91 | − <svg viewBox="0 0 16 16" fill="none" className={base}> | |
| 92 | − <path d="M8 2v12M4 6l4-4 4 4M4 10l4 4 4-4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" /> | |
| 93 | − </svg> | |
| 94 | − ); | |
| 95 | − default: | |
| 96 | − return ( | |
| 97 | − <svg viewBox="0 0 16 16" fill="none" className={base}> | |
| 98 | − <rect x="2.5" y="2.5" width="11" height="11" rx="2" stroke="currentColor" strokeWidth="1.2" /> | |
| 99 | − <path d="M5 6h6M5 8h4M5 10h3" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" /> | |
| 100 | − </svg> | |
| 101 | − ); | |
| 102 | − } | |
| 49 | + return ( | |
| 50 | + <svg viewBox="0 0 16 16" fill="none" className={base}> | |
| 51 | + <rect x="2.5" y="2.5" width="11" height="11" rx="2" stroke="currentColor" strokeWidth="1.2" /> | |
| 52 | + <path d="M5 6h6M5 8h4M5 10h3" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" /> | |
| 53 | + </svg> | |
| 54 | + ); | |
| 103 | 55 | } |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { Link } from "@tanstack/react-router"; |
| 2 | +import { sourcePluginsIndexPath } from "@executor/react/plugins"; | |
| 2 | 3 | |
| 3 | 4 | import { IconEmpty } from "./icons"; |
| 4 | 5 | import { cn } from "../lib/utils"; |
| @@ -23,7 +24,7 @@ export function SourceNotFoundState() { · 18 unmodified lines | ||
| 23 | 24 | Back to dashboard |
| 24 | 25 | </Link> |
| 25 | 26 | <Link |
| 26 | − to="/sources/add" | |
| 27 | + to={sourcePluginsIndexPath} | |
| 27 | 28 | className={cn( |
| 28 | 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 | 30 | "hover:bg-primary/90", |
| @@ -0,0 +1,27 @@ | ||
| 1 | +import type { ReactNode } from "react"; | |
| 2 | + | |
| 3 | +type SourcePluginsResetStateProps = { | |
| 4 | + title: string; | |
| 5 | + message: string; | |
| 6 | + action?: ReactNode; | |
| 7 | +}; | |
| 8 | + | |
| 9 | +export function SourcePluginsResetState( | |
| 10 | + input: SourcePluginsResetStateProps, | |
| 11 | +) { | |
| 12 | + return ( | |
| 13 | + <div className="min-h-0 flex-1 overflow-y-auto max-w-[300px]"> | |
| 14 | + <div className="mx-auto max-w-3xl px-6 py-10 lg:px-10 lg:py-14"> | |
| 15 | + <div className="rounded-2xl border border-border bg-card p-8"> | |
| 16 | + <h2 className="font-display text-xl tracking-tight text-foreground"> | |
| 17 | + {input.title} | |
| 18 | + </h2> | |
| 19 | + <p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground"> | |
| 20 | + {input.message} | |
| 21 | + </p> | |
| 22 | + {input.action ? <div className="mt-5">{input.action}</div> : null} | |
| 23 | + </div> | |
| 24 | + </div> | |
| 25 | + </div> | |
| 26 | + ); | |
| 27 | +} | |
| @@ -1,123 +0,0 @@ | ||
| 1 | −import { parse as parseDomain } from "tldts"; | |
| 2 | − | |
| 3 | −const RAW_HOSTS = new Set([ | |
| 4 | − "raw.githubusercontent.com", | |
| 5 | − "cdn.jsdelivr.net", | |
| 6 | − "unpkg.com", | |
| 7 | − "raw.github.com", | |
| 8 | −]); | |
| 9 | − | |
| 10 | −// --------------------------------------------------------------------------- | |
| 11 | −// Google product icon URLs (official CDN assets) | |
| 12 | −// --------------------------------------------------------------------------- | |
| 13 | − | |
| 14 | −const GOOGLE_SERVICE_ICONS: Record<string, string> = { | |
| 15 | − calendar: "https://ssl.gstatic.com/calendar/images/dynamiclogo_2020q4/calendar_31_2x.png", | |
| 16 | − drive: "https://ssl.gstatic.com/images/branding/product/2x/drive_2020q4_48dp.png", | |
| 17 | − gmail: "https://ssl.gstatic.com/ui/v1/icons/mail/rfr/gmail.ico", | |
| 18 | − docs: "https://ssl.gstatic.com/docs/documents/images/kix-favicon7.ico", | |
| 19 | − sheets: "https://ssl.gstatic.com/docs/spreadsheets/favicon3.ico", | |
| 20 | − slides: "https://ssl.gstatic.com/docs/presentations/images/favicon5.ico", | |
| 21 | − forms: "https://ssl.gstatic.com/docs/forms/device_home/android_192.png", | |
| 22 | − searchconsole: "https://ssl.gstatic.com/search-console/scfe/search_console-64.png", | |
| 23 | − people: "https://ssl.gstatic.com/images/branding/product/2x/contacts_2022_48dp.png", | |
| 24 | − tasks: "https://ssl.gstatic.com/tasks/images/favicon.ico", | |
| 25 | − chat: "https://ssl.gstatic.com/chat/favicon/favicon_v2.ico", | |
| 26 | − keep: "https://ssl.gstatic.com/keep/icon_2020q4v2_128.png", | |
| 27 | − classroom: "https://ssl.gstatic.com/classroom/favicon.png", | |
| 28 | − admin: "https://ssl.gstatic.com/images/branding/product/2x/admin_2020q4_48dp.png", | |
| 29 | − script: "https://ssl.gstatic.com/script/images/favicon.ico", | |
| 30 | − bigquery: "https://ssl.gstatic.com/bqui1/favicon.ico", | |
| 31 | − cloudresourcemanager: "https://www.gstatic.com/devrel-devsite/prod/v0e0f589edd85502a40d78d7d0825db8ea5ef3b99b1571571945f0f3f764ff61b/cloud/images/favicons/onecloud/favicon.ico", | |
| 32 | − youtube: "https://www.youtube.com/s/desktop/a94e1818/img/favicon_32x32.png", | |
| 33 | −}; | |
| 34 | − | |
| 35 | −/** | |
| 36 | − * For google_discovery endpoints, extract the service name and return | |
| 37 | − * the official product icon URL if we have one. | |
| 38 | − */ | |
| 39 | −const getGoogleServiceIconUrl = (endpoint: string | null | undefined): string | null => { | |
| 40 | − if (!endpoint) return null; | |
| 41 | − try { | |
| 42 | − const url = new URL(endpoint); | |
| 43 | − const host = url.hostname; | |
| 44 | − | |
| 45 | − // Direct service subdomain: "calendar-json.googleapis.com", "gmail.googleapis.com" | |
| 46 | − if (host.endsWith(".googleapis.com")) { | |
| 47 | − const sub = host.replace(/\.googleapis\.com$/, "").replace(/-json$/, ""); | |
| 48 | − if (GOOGLE_SERVICE_ICONS[sub]) return GOOGLE_SERVICE_ICONS[sub]; | |
| 49 | − } | |
| 50 | − | |
| 51 | − // Discovery URL pattern: /discovery/v1/apis/{service}/{version}/rest | |
| 52 | − const discoveryMatch = url.pathname.match(/\/apis\/([^/]+)\//); | |
| 53 | − if (discoveryMatch?.[1] && GOOGLE_SERVICE_ICONS[discoveryMatch[1]]) { | |
| 54 | − return GOOGLE_SERVICE_ICONS[discoveryMatch[1]]; | |
| 55 | − } | |
| 56 | − | |
| 57 | − // $discovery/rest pattern on service subdomains is already handled above | |
| 58 | − } catch { | |
| 59 | − // fall through | |
| 60 | − } | |
| 61 | − return null; | |
| 62 | −}; | |
| 63 | − | |
| 64 | −// --------------------------------------------------------------------------- | |
| 65 | −// General favicon resolution | |
| 66 | −// --------------------------------------------------------------------------- | |
| 67 | − | |
| 68 | −const parseUrl = (value: string | null | undefined): URL | null => { | |
| 69 | − if (!value) { | |
| 70 | − return null; | |
| 71 | − } | |
| 72 | − | |
| 73 | − try { | |
| 74 | − return new URL(value); | |
| 75 | − } catch { | |
| 76 | − return null; | |
| 77 | − } | |
| 78 | −}; | |
| 79 | − | |
| 80 | −const normalizeSeedUrl = (value: string | null | undefined): string | null => { | |
| 81 | − const parsed = parseUrl(value); | |
| 82 | − if (!parsed) { | |
| 83 | − return null; | |
| 84 | − } | |
| 85 | − | |
| 86 | − if (!RAW_HOSTS.has(parsed.hostname)) { | |
| 87 | − return parsed.origin; | |
| 88 | − } | |
| 89 | − | |
| 90 | − const segments = parsed.pathname | |
| 91 | − .split("/") | |
| 92 | − .map((segment) => segment.trim()) | |
| 93 | − .filter((segment) => segment.length > 0); | |
| 94 | − | |
| 95 | − for (const segment of segments) { | |
| 96 | − const withoutExtension = segment.replace(/\.(ya?ml|json)$/i, ""); | |
| 97 | − if (/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(withoutExtension)) { | |
| 98 | − return `https://${withoutExtension}`; | |
| 99 | − } | |
| 100 | − } | |
| 101 | − | |
| 102 | − return parsed.origin; | |
| 103 | −}; | |
| 104 | − | |
| 105 | −export const getGoogleProductIconUrl = getGoogleServiceIconUrl; | |
| 106 | − | |
| 107 | −export const getSourceFaviconUrl = ( | |
| 108 | − value: string | null | undefined, | |
| 109 | −): string | null => { | |
| 110 | − const seedUrl = normalizeSeedUrl(value); | |
| 111 | − if (!seedUrl) { | |
| 112 | − return null; | |
| 113 | − } | |
| 114 | − | |
| 115 | − try { | |
| 116 | − const hostname = new URL(seedUrl).hostname; | |
| 117 | − const parsed = parseDomain(hostname); | |
| 118 | − const domain = parsed.domain ?? hostname; | |
| 119 | − return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=32`; | |
| 120 | − } catch { | |
| 121 | − return null; | |
| 122 | − } | |
| 123 | −}; | |
| @@ -1,4 +1,3 @@ | ||
| 1 | −import * as React from "react"; | |
| 2 | 1 | import { |
| 3 | 2 | RouterProvider, |
| 4 | 3 | createRootRoute, |
| @@ -7,31 +6,25 @@ import { · 2 unmodified lines | ||
| 7 | 6 | useNavigate, |
| 8 | 7 | } from "@tanstack/react-router"; |
| 9 | 8 | import { ExecutorReactProvider } from "@executor/react"; |
| 9 | +import { | |
| 10 | + createExecutorPluginPaths, | |
| 11 | + sourcePluginsIndexPath, | |
| 12 | + type ExecutorPluginNavigation, | |
| 13 | + type FrontendPluginRouteSearch, | |
| 14 | +} from "@executor/react/plugins"; | |
| 10 | 15 | |
| 11 | 16 | import "./globals.css"; |
| 12 | 17 | |
| 13 | 18 | import { AppShell } from "./components/shell"; |
| 19 | +import { | |
| 20 | + registeredFrontendPluginRoutes, | |
| 21 | +} from "./plugins"; | |
| 22 | +import { | |
| 23 | + ExecutorPluginRoutePage, | |
| 24 | + SourcePluginsIndexPage, | |
| 25 | +} from "./plugins/pages"; | |
| 14 | 26 | import { HomePage } from "./views/home"; |
| 15 | −import { EditSourcePage, NewSourcePage } from "./views/source-editor"; | |
| 16 | −import { SourceDetailPage } from "./views/source-detail"; | |
| 17 | 27 | import { SecretsPage } from "./views/secrets"; |
| 18 | −import { AddSourcePage } from "./views/add-source"; | |
| 19 | − | |
| 20 | −// --------------------------------------------------------------------------- | |
| 21 | −// Route search schema | |
| 22 | −// --------------------------------------------------------------------------- | |
| 23 | − | |
| 24 | −type SourceRouteSearch = { | |
| 25 | − tab: "model" | "discover"; | |
| 26 | − tool?: string; | |
| 27 | − query?: string; | |
| 28 | −}; | |
| 29 | − | |
| 30 | −const sourceTabs = ["model", "discover"] as const; | |
| 31 | − | |
| 32 | −// --------------------------------------------------------------------------- | |
| 33 | −// Routes | |
| 34 | −// --------------------------------------------------------------------------- | |
| 35 | 28 | |
| 36 | 29 | const rootRoute = createRootRoute({ |
| 37 | 30 | component: AppShell, |
| @@ -43,36 +36,10 @@ const homeRoute = createRoute({ · 5 unmodified lines | ||
| 43 | 36 | component: HomePage, |
| 44 | 37 | }); |
| 45 | 38 | |
| 46 | −const newSourceRoute = createRoute({ | |
| 47 | − getParentRoute: () => rootRoute, | |
| 48 | − path: "/sources/new", | |
| 49 | − component: NewSourcePage, | |
| 50 | −}); | |
| 51 | − | |
| 52 | −const addSourceRoute = createRoute({ | |
| 39 | +const sourcePluginsIndexRoute = createRoute({ | |
| 53 | 40 | getParentRoute: () => rootRoute, |
| 54 | − path: "/sources/add", | |
| 55 | − component: AddSourcePage, | |
| 56 | −}); | |
| 57 | − | |
| 58 | −const sourceRoute = createRoute({ | |
| 59 | − getParentRoute: () => rootRoute, | |
| 60 | − path: "/sources/$sourceId", | |
| 61 | − validateSearch: (search: Record<string, unknown>): SourceRouteSearch => ({ | |
| 62 | − tab: | |
| 63 | − typeof search.tab === "string" && sourceTabs.includes(search.tab as SourceRouteSearch["tab"]) | |
| 64 | − ? (search.tab as SourceRouteSearch["tab"]) | |
| 65 | − : "model", | |
| 66 | − tool: typeof search.tool === "string" && search.tool.length > 0 ? search.tool : undefined, | |
| 67 | − query: typeof search.query === "string" ? search.query : undefined, | |
| 68 | − }), | |
| 69 | − component: SourceDetailPageWrapper, | |
| 70 | −}); | |
| 71 | − | |
| 72 | −const editSourceRoute = createRoute({ | |
| 73 | − getParentRoute: () => rootRoute, | |
| 74 | − path: "/sources/$sourceId/edit", | |
| 75 | − component: EditSourcePageWrapper, | |
| 41 | + path: sourcePluginsIndexPath, | |
| 42 | + component: SourcePluginsIndexPage, | |
| 76 | 43 | }); |
| 77 | 44 | |
| 78 | 45 | const secretsRoute = createRoute({ |
| @@ -81,35 +48,73 @@ const secretsRoute = createRoute({ · 2 unmodified lines | ||
| 81 | 48 | component: SecretsPage, |
| 82 | 49 | }); |
| 83 | 50 | |
| 51 | +const createExecutorPluginNavigation = ( | |
| 52 | + pluginKey: string, | |
| 53 | + input: { | |
| 54 | + navigateTo: ( | |
| 55 | + to: string, | |
| 56 | + search?: FrontendPluginRouteSearch, | |
| 57 | + ) => void | Promise<void>; | |
| 58 | + updateSearch?: ( | |
| 59 | + search: FrontendPluginRouteSearch, | |
| 60 | + ) => void | Promise<void>; | |
| 61 | + }, | |
| 62 | +): ExecutorPluginNavigation => { | |
| 63 | + const paths = createExecutorPluginPaths(pluginKey); | |
| 64 | + | |
| 65 | + return { | |
| 66 | + paths, | |
| 67 | + home: () => input.navigateTo("/"), | |
| 68 | + route: (path = "", search) => input.navigateTo(paths.route(path), search), | |
| 69 | + updateSearch: (search) => input.updateSearch?.(search), | |
| 70 | + }; | |
| 71 | +}; | |
| 84 | 72 | |
| 85 | −function SourceDetailPageWrapper() { | |
| 86 | − const { sourceId } = sourceRoute.useParams(); | |
| 87 | − const search = sourceRoute.useSearch(); | |
| 88 | − const navigate = useNavigate({ from: sourceRoute.fullPath }); | |
| 89 | − | |
| 90 | − return ( | |
| 91 | − <SourceDetailPage | |
| 92 | − sourceId={sourceId} | |
| 93 | − search={search} | |
| 94 | − navigate={navigate as any} | |
| 95 | − /> | |
| 96 | − ); | |
| 97 | −} | |
| 98 | − | |
| 99 | −function EditSourcePageWrapper() { | |
| 100 | − const { sourceId } = editSourceRoute.useParams(); | |
| 101 | − return <EditSourcePage sourceId={sourceId} />; | |
| 102 | −} | |
| 103 | − | |
| 104 | −// --------------------------------------------------------------------------- | |
| 105 | −// Router | |
| 106 | −// --------------------------------------------------------------------------- | |
| 73 | +const frontendPluginRoutes = registeredFrontendPluginRoutes.map(({ plugin, route }) => { | |
| 74 | + const paths = createExecutorPluginPaths(plugin.key); | |
| 75 | + | |
| 76 | + const PluginRouteComponent = () => { | |
| 77 | + const params = pluginRoute.useParams() as Record<string, string | undefined>; | |
| 78 | + const search = pluginRoute.useSearch(); | |
| 79 | + const navigate = useNavigate(); | |
| 80 | + const navigateFromRoute = useNavigate({ from: pluginRoute.fullPath }); | |
| 81 | + const navigation = createExecutorPluginNavigation(plugin.key, { | |
| 82 | + navigateTo: (to, nextSearch) => | |
| 83 | + nextSearch === undefined ? navigate({ to }) : navigate({ to, search: nextSearch }), | |
| 84 | + updateSearch: (nextSearch) => navigateFromRoute({ search: nextSearch }), | |
| 85 | + }); | |
| 86 | + | |
| 87 | + return ( | |
| 88 | + <ExecutorPluginRoutePage | |
| 89 | + pluginKey={plugin.key} | |
| 90 | + routeKey={route.key} | |
| 91 | + params={params} | |
| 92 | + search={search} | |
| 93 | + navigation={navigation} | |
| 94 | + /> | |
| 95 | + ); | |
| 96 | + }; | |
| 97 | + | |
| 98 | + const pluginRoute = createRoute({ | |
| 99 | + getParentRoute: () => rootRoute, | |
| 100 | + path: paths.routePattern(route.path ?? ""), | |
| 101 | + component: PluginRouteComponent, | |
| 102 | + }); | |
| 103 | + | |
| 104 | + return pluginRoute; | |
| 105 | +}); | |
| 107 | 106 | |
| 108 | −const routeTree = rootRoute.addChildren([homeRoute, newSourceRoute, addSourceRoute, sourceRoute, editSourceRoute, secretsRoute]); | |
| 107 | +const routeTree = rootRoute.addChildren([ | |
| 108 | + homeRoute, | |
| 109 | + secretsRoute, | |
| 110 | + sourcePluginsIndexRoute, | |
| 111 | + ...frontendPluginRoutes, | |
| 112 | +]); | |
| 109 | 113 | |
| 110 | 114 | const router = createRouter({ |
| 111 | 115 | routeTree, |
| 112 | 116 | defaultPreload: "intent", |
| 117 | + defaultPreloadStaleTime: 0, | |
| 113 | 118 | }); |
| 114 | 119 | |
| 115 | 120 | declare module "@tanstack/react-router" { |
| @@ -118,16 +123,8 @@ declare module "@tanstack/react-router" { · 2 unmodified lines | ||
| 118 | 123 | } |
| 119 | 124 | } |
| 120 | 125 | |
| 121 | −// --------------------------------------------------------------------------- | |
| 122 | −// App | |
| 123 | −// --------------------------------------------------------------------------- | |
| 124 | − | |
| 125 | −export function App() { | |
| 126 | − return ( | |
| 127 | − <React.StrictMode> | |
| 128 | − <ExecutorReactProvider> | |
| 129 | − <RouterProvider router={router} /> | |
| 130 | − </ExecutorReactProvider> | |
| 131 | − </React.StrictMode> | |
| 132 | − ); | |
| 133 | −} | |
| 126 | +export const App = () => ( | |
| 127 | + <ExecutorReactProvider> | |
| 128 | + <RouterProvider router={router} /> | |
| 129 | + </ExecutorReactProvider> | |
| 130 | +); | |
| @@ -0,0 +1,83 @@ | ||
| 1 | +import type { | |
| 2 | + Source, | |
| 3 | +} from "@executor/react"; | |
| 4 | +import { | |
| 5 | + ExecutionHistoryReactPlugin, | |
| 6 | +} from "@executor/plugin-execution-history-react"; | |
| 7 | +import { | |
| 8 | + GoogleDiscoveryReactPlugin, | |
| 9 | + getGoogleDiscoveryIconUrl, | |
| 10 | +} from "@executor/plugin-google-discovery-react"; | |
| 11 | +import { | |
| 12 | + GraphqlReactPlugin, | |
| 13 | +} from "@executor/plugin-graphql-react"; | |
| 14 | +import { | |
| 15 | + McpReactPlugin, | |
| 16 | +} from "@executor/plugin-mcp-react"; | |
| 17 | +import { | |
| 18 | + OpenApiReactPlugin, | |
| 19 | +} from "@executor/plugin-openapi-react"; | |
| 20 | +import { | |
| 21 | + createExecutorPluginPaths, | |
| 22 | + createSourcePluginPaths, | |
| 23 | + registerExecutorFrontendPlugins, | |
| 24 | + type ExecutorFrontendPlugin, | |
| 25 | +} from "@executor/react/plugins"; | |
| 26 | + | |
| 27 | +const frontendPlugins = [ | |
| 28 | + ExecutionHistoryReactPlugin, | |
| 29 | + McpReactPlugin, | |
| 30 | + GraphqlReactPlugin, | |
| 31 | + GoogleDiscoveryReactPlugin, | |
| 32 | + OpenApiReactPlugin, | |
| 33 | +] as const satisfies readonly ExecutorFrontendPlugin[]; | |
| 34 | + | |
| 35 | +const frontendPluginRegistry = registerExecutorFrontendPlugins(frontendPlugins); | |
| 36 | + | |
| 37 | +const hasRouteKey = ( | |
| 38 | + plugin: ExecutorFrontendPlugin, | |
| 39 | + routeKey: string, | |
| 40 | +): boolean => | |
| 41 | + (plugin.routes ?? []).some((route) => route.key === routeKey); | |
| 42 | + | |
| 43 | +const isSourceFrontendPlugin = ( | |
| 44 | + plugin: ExecutorFrontendPlugin, | |
| 45 | +): boolean => | |
| 46 | + hasRouteKey(plugin, "add") | |
| 47 | + && hasRouteKey(plugin, "detail"); | |
| 48 | + | |
| 49 | +export const registeredFrontendPlugins = frontendPluginRegistry.plugins; | |
| 50 | +export const registeredFrontendPluginRoutes = frontendPluginRegistry.routes; | |
| 51 | +export const registeredFrontendPluginNavRoutes = | |
| 52 | + registeredFrontendPluginRoutes | |
| 53 | + .filter(({ route }) => route.nav !== undefined) | |
| 54 | + .map(({ plugin, route }) => ({ | |
| 55 | + plugin, | |
| 56 | + route, | |
| 57 | + to: createExecutorPluginPaths(plugin.key).route(route.path ?? ""), | |
| 58 | + })); | |
| 59 | +export const registeredSourceFrontendPlugins = | |
| 60 | + registeredFrontendPlugins.filter(isSourceFrontendPlugin); | |
| 61 | + | |
| 62 | +export const getFrontendPlugin = (key: string) => | |
| 63 | + frontendPluginRegistry.getPlugin(key); | |
| 64 | + | |
| 65 | +export const getFrontendPluginRoute = ( | |
| 66 | + pluginKey: string, | |
| 67 | + routeKey: string, | |
| 68 | +) => frontendPluginRegistry.getRoute(pluginKey, routeKey); | |
| 69 | + | |
| 70 | +export const getSourceFrontendPlugin = (kind: string) => { | |
| 71 | + const plugin = getFrontendPlugin(kind); | |
| 72 | + return plugin && isSourceFrontendPlugin(plugin) ? plugin : null; | |
| 73 | +}; | |
| 74 | + | |
| 75 | +export const getSourceFrontendPaths = (kind: string) => { | |
| 76 | + const plugin = getSourceFrontendPlugin(kind); | |
| 77 | + return plugin ? createSourcePluginPaths(plugin.key) : null; | |
| 78 | +}; | |
| 79 | + | |
| 80 | +export const getSourceFrontendIconUrl = (source: Source) => | |
| 81 | + source.kind === "google-discovery" | |
| 82 | + ? getGoogleDiscoveryIconUrl(source) | |
| 83 | + : null; | |
| @@ -0,0 +1,259 @@ | ||
| 1 | +import { useState, type FormEvent } from "react"; | |
| 2 | +import { Link, useNavigate } from "@tanstack/react-router"; | |
| 3 | +import { | |
| 4 | + ExecutorPluginRouteProvider, | |
| 5 | + createSourcePluginPaths, | |
| 6 | + type ExecutorPluginNavigation, | |
| 7 | + type FrontendPluginRouteDefinition, | |
| 8 | + type FrontendPluginRouteParams, | |
| 9 | + type FrontendPluginRouteSearch, | |
| 10 | +} from "@executor/react/plugins"; | |
| 11 | + | |
| 12 | +import { DefaultSourceIcon } from "../components/source-favicon"; | |
| 13 | +import { LocalMcpInstallCard } from "../components/local-mcp-install-card"; | |
| 14 | +import { SourcePluginsResetState } from "../components/source-plugins-reset-state"; | |
| 15 | +import { | |
| 16 | + getFrontendPlugin, | |
| 17 | + getFrontendPluginRoute, | |
| 18 | + registeredSourceFrontendPlugins, | |
| 19 | +} from "./index"; | |
| 20 | +import { | |
| 21 | + buildSourcePresetSearch, | |
| 22 | + resolveQuickSourceInput, | |
| 23 | + sourcePresets, | |
| 24 | +} from "./source-presets"; | |
| 25 | + | |
| 26 | +const FrontendPluginUnavailableState = () => ( | |
| 27 | + <SourcePluginsResetState | |
| 28 | + title="Plugins unavailable" | |
| 29 | + message="No plugins are available in this build." | |
| 30 | + /> | |
| 31 | +); | |
| 32 | + | |
| 33 | +const SourcePluginUnavailableState = () => ( | |
| 34 | + <SourcePluginsResetState | |
| 35 | + title="No source plugins available" | |
| 36 | + message="No source plugins are available in this build." | |
| 37 | + /> | |
| 38 | +); | |
| 39 | + | |
| 40 | +const SourceQuickEntry = () => { | |
| 41 | + const navigate = useNavigate(); | |
| 42 | + const [value, setValue] = useState(""); | |
| 43 | + const [error, setError] = useState<string | null>(null); | |
| 44 | + | |
| 45 | + const handleSubmit = (event: FormEvent<HTMLFormElement>) => { | |
| 46 | + event.preventDefault(); | |
| 47 | + | |
| 48 | + const resolved = resolveQuickSourceInput(value); | |
| 49 | + if (resolved === null) { | |
| 50 | + setError( | |
| 51 | + "Could not recognize that input. Try a GraphQL endpoint, MCP URL, Google Discovery URL, OpenAPI spec URL, or a local MCP command.", | |
| 52 | + ); | |
| 53 | + return; | |
| 54 | + } | |
| 55 | + | |
| 56 | + setError(null); | |
| 57 | + void navigate({ | |
| 58 | + to: createSourcePluginPaths(resolved.pluginKey).add, | |
| 59 | + search: resolved.search, | |
| 60 | + }); | |
| 61 | + }; | |
| 62 | + | |
| 63 | + return ( | |
| 64 | + <div className="rounded-2xl border border-border bg-card p-6"> | |
| 65 | + <h2 className="font-display text-lg font-semibold tracking-tight text-foreground"> | |
| 66 | + Quick add | |
| 67 | + </h2> | |
| 68 | + <p className="mt-1 text-sm leading-6 text-muted-foreground"> | |
| 69 | + Paste a URL or command and we'll detect the right plugin automatically. | |
| 70 | + </p> | |
| 71 | + | |
| 72 | + <form onSubmit={handleSubmit} className="mt-4"> | |
| 73 | + <div className="flex flex-col gap-3 sm:flex-row"> | |
| 74 | + <input | |
| 75 | + value={value} | |
| 76 | + onChange={(event) => setValue(event.target.value)} | |
| 77 | + placeholder="https://api.github.com/graphql or npx -y chrome-devtools-mcp@latest" | |
| 78 | + className="h-10 flex-1 rounded-lg border border-input bg-background px-3.5 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground/45 focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 79 | + /> | |
| 80 | + <button | |
| 81 | + type="submit" | |
| 82 | + className="inline-flex h-10 items-center justify-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90" | |
| 83 | + > | |
| 84 | + Add source | |
| 85 | + </button> | |
| 86 | + </div> | |
| 87 | + | |
| 88 | + {error && ( | |
| 89 | + <div className="mt-3 rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-sm text-destructive"> | |
| 90 | + {error} | |
| 91 | + </div> | |
| 92 | + )} | |
| 93 | + </form> | |
| 94 | + </div> | |
| 95 | + ); | |
| 96 | +}; | |
| 97 | + | |
| 98 | +export function ExecutorPluginRoutePage(input: { | |
| 99 | + pluginKey: string; | |
| 100 | + routeKey: FrontendPluginRouteDefinition["key"]; | |
| 101 | + params: FrontendPluginRouteParams; | |
| 102 | + search: FrontendPluginRouteSearch; | |
| 103 | + navigation: ExecutorPluginNavigation; | |
| 104 | +}) { | |
| 105 | + const plugin = getFrontendPlugin(input.pluginKey); | |
| 106 | + if (plugin === null) { | |
| 107 | + return <FrontendPluginUnavailableState />; | |
| 108 | + } | |
| 109 | + | |
| 110 | + const routeEntry = getFrontendPluginRoute(input.pluginKey, input.routeKey); | |
| 111 | + if (routeEntry === null) { | |
| 112 | + return ( | |
| 113 | + <SourcePluginsResetState | |
| 114 | + title="Route unavailable" | |
| 115 | + message={`The route "${input.routeKey}" is not available for this plugin.`} | |
| 116 | + /> | |
| 117 | + ); | |
| 118 | + } | |
| 119 | + | |
| 120 | + const PluginPage = routeEntry.route.component; | |
| 121 | + | |
| 122 | + return ( | |
| 123 | + <ExecutorPluginRouteProvider | |
| 124 | + value={{ | |
| 125 | + plugin, | |
| 126 | + route: routeEntry.route, | |
| 127 | + params: input.params, | |
| 128 | + search: input.search, | |
| 129 | + navigation: input.navigation, | |
| 130 | + }} | |
| 131 | + > | |
| 132 | + <div className="flex h-full min-h-0 flex-col overflow-hidden"> | |
| 133 | + <PluginPage /> | |
| 134 | + </div> | |
| 135 | + </ExecutorPluginRouteProvider> | |
| 136 | + ); | |
| 137 | +} | |
| 138 | + | |
| 139 | +export function SourcePluginsIndexPage() { | |
| 140 | + if (registeredSourceFrontendPlugins.length === 0) { | |
| 141 | + return <SourcePluginUnavailableState />; | |
| 142 | + } | |
| 143 | + | |
| 144 | + const hasMcpPlugin = registeredSourceFrontendPlugins.some( | |
| 145 | + (plugin) => plugin.key === "mcp", | |
| 146 | + ); | |
| 147 | + | |
| 148 | + const presetsByPlugin = registeredSourceFrontendPlugins | |
| 149 | + .map((plugin) => ({ | |
| 150 | + plugin, | |
| 151 | + presets: sourcePresets.filter((preset) => preset.pluginKey === plugin.key), | |
| 152 | + })) | |
| 153 | + .filter((entry) => entry.presets.length > 0); | |
| 154 | + | |
| 155 | + return ( | |
| 156 | + <div className="min-h-0 flex-1 overflow-y-auto"> | |
| 157 | + <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14"> | |
| 158 | + <div className="mb-8"> | |
| 159 | + <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl"> | |
| 160 | + Add source | |
| 161 | + </h1> | |
| 162 | + <p className="mt-1.5 text-[14px] text-muted-foreground"> | |
| 163 | + Connect a new tool provider to this workspace. | |
| 164 | + </p> | |
| 165 | + </div> | |
| 166 | + | |
| 167 | + <SourceQuickEntry /> | |
| 168 | + | |
| 169 | + {hasMcpPlugin && ( | |
| 170 | + <LocalMcpInstallCard | |
| 171 | + className="mt-8" | |
| 172 | + title="Install this executor as MCP" | |
| 173 | + description="Prefer a one-command setup? Install this local executor server into your MCP client, or add an external MCP source below." | |
| 174 | + /> | |
| 175 | + )} | |
| 176 | + | |
| 177 | + {presetsByPlugin.length > 0 && ( | |
| 178 | + <div className="mt-10"> | |
| 179 | + <h2 className="text-lg font-semibold text-foreground">Popular sources</h2> | |
| 180 | + <p className="mt-1 text-sm leading-6 text-muted-foreground"> | |
| 181 | + One-click presets for common APIs and tools. | |
| 182 | + </p> | |
| 183 | + | |
| 184 | + <div className="mt-5 space-y-8"> | |
| 185 | + {presetsByPlugin.map(({ plugin, presets }) => { | |
| 186 | + const paths = createSourcePluginPaths(plugin.key); | |
| 187 | + | |
| 188 | + return ( | |
| 189 | + <section key={plugin.key}> | |
| 190 | + <h3 className="mb-3 text-sm font-semibold text-foreground"> | |
| 191 | + {plugin.displayName ?? plugin.key} | |
| 192 | + </h3> | |
| 193 | + | |
| 194 | + <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 195 | + {presets.map((preset) => ( | |
| 196 | + <Link | |
| 197 | + key={preset.id} | |
| 198 | + to={paths.add} | |
| 199 | + search={buildSourcePresetSearch(preset)} | |
| 200 | + className="rounded-xl border border-border bg-card px-4 py-3 text-left transition-colors hover:border-primary/25 hover:bg-card/90" | |
| 201 | + > | |
| 202 | + <div className="mb-2 flex items-center gap-2.5"> | |
| 203 | + <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground"> | |
| 204 | + <DefaultSourceIcon kind={preset.kind} className="size-4" /> | |
| 205 | + </div> | |
| 206 | + <div className="min-w-0"> | |
| 207 | + <div className="truncate text-sm font-semibold text-foreground"> | |
| 208 | + {preset.name} | |
| 209 | + </div> | |
| 210 | + <div className="mt-0.5 text-[11px] text-muted-foreground"> | |
| 211 | + {preset.kind} | |
| 212 | + </div> | |
| 213 | + </div> | |
| 214 | + </div> | |
| 215 | + | |
| 216 | + <p className="text-sm leading-6 text-muted-foreground"> | |
| 217 | + {preset.summary} | |
| 218 | + </p> | |
| 219 | + </Link> | |
| 220 | + ))} | |
| 221 | + </div> | |
| 222 | + </section> | |
| 223 | + ); | |
| 224 | + })} | |
| 225 | + </div> | |
| 226 | + </div> | |
| 227 | + )} | |
| 228 | + | |
| 229 | + <div className="mt-10"> | |
| 230 | + <h2 className="text-lg font-semibold text-foreground">All source plugins</h2> | |
| 231 | + <p className="mt-1 text-sm leading-6 text-muted-foreground"> | |
| 232 | + Start from a clean configuration page for any installed source plugin. | |
| 233 | + </p> | |
| 234 | + | |
| 235 | + <div className="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 236 | + {registeredSourceFrontendPlugins.map((plugin) => { | |
| 237 | + const paths = createSourcePluginPaths(plugin.key); | |
| 238 | + | |
| 239 | + return ( | |
| 240 | + <Link | |
| 241 | + key={plugin.key} | |
| 242 | + to={paths.add} | |
| 243 | + className="rounded-xl border border-border bg-card px-4 py-3 text-left transition-colors hover:border-primary/25 hover:bg-card/90" | |
| 244 | + > | |
| 245 | + <div className="mb-2 text-sm font-semibold text-foreground"> | |
| 246 | + {plugin.displayName ?? plugin.key} | |
| 247 | + </div> | |
| 248 | + <p className="text-sm leading-6 text-muted-foreground"> | |
| 249 | + {plugin.description ?? "Configure a new source for this plugin."} | |
| 250 | + </p> | |
| 251 | + </Link> | |
| 252 | + ); | |
| 253 | + })} | |
| 254 | + </div> | |
| 255 | + </div> | |
| 256 | + </div> | |
| 257 | + </div> | |
| 258 | + ); | |
| 259 | +} | |
| @@ -0,0 +1,657 @@ | ||
| 1 | +const GOOGLE_DISCOVERY_PLUGIN_KEY = "google-discovery" as const; | |
| 2 | +const GOOGLE_DISCOVERY_SOURCE_KIND = "google_discovery" as const; | |
| 3 | + | |
| 4 | +export type SourcePreset = | |
| 5 | + | { | |
| 6 | + id: string; | |
| 7 | + pluginKey: "mcp"; | |
| 8 | + kind: "mcp"; | |
| 9 | + name: string; | |
| 10 | + summary: string; | |
| 11 | + previewUrl: string; | |
| 12 | + endpoint?: string; | |
| 13 | + transport?: "auto" | "streamable-http" | "sse" | "stdio"; | |
| 14 | + command?: string; | |
| 15 | + args?: string[]; | |
| 16 | + env?: Record<string, string>; | |
| 17 | + cwd?: string; | |
| 18 | + } | |
| 19 | + | { | |
| 20 | + id: string; | |
| 21 | + pluginKey: "openapi"; | |
| 22 | + kind: "openapi"; | |
| 23 | + name: string; | |
| 24 | + summary: string; | |
| 25 | + previewUrl: string; | |
| 26 | + baseUrl: string; | |
| 27 | + specUrl: string; | |
| 28 | + } | |
| 29 | + | { | |
| 30 | + id: string; | |
| 31 | + pluginKey: "graphql"; | |
| 32 | + kind: "graphql"; | |
| 33 | + name: string; | |
| 34 | + summary: string; | |
| 35 | + previewUrl: string; | |
| 36 | + endpoint: string; | |
| 37 | + } | |
| 38 | + | { | |
| 39 | + id: string; | |
| 40 | + pluginKey: typeof GOOGLE_DISCOVERY_PLUGIN_KEY; | |
| 41 | + kind: typeof GOOGLE_DISCOVERY_SOURCE_KIND; | |
| 42 | + name: string; | |
| 43 | + summary: string; | |
| 44 | + previewUrl: string; | |
| 45 | + service: string; | |
| 46 | + version: string; | |
| 47 | + discoveryUrl: string; | |
| 48 | + }; | |
| 49 | + | |
| 50 | +const googleDiscoveryPreset = (input: { | |
| 51 | + id: string; | |
| 52 | + name: string; | |
| 53 | + summary: string; | |
| 54 | + service: string; | |
| 55 | + version: string; | |
| 56 | + discoveryUrl: string; | |
| 57 | +}): SourcePreset => ({ | |
| 58 | + id: input.id, | |
| 59 | + pluginKey: GOOGLE_DISCOVERY_PLUGIN_KEY, | |
| 60 | + kind: GOOGLE_DISCOVERY_SOURCE_KIND, | |
| 61 | + name: input.name, | |
| 62 | + summary: input.summary, | |
| 63 | + previewUrl: input.discoveryUrl, | |
| 64 | + service: input.service, | |
| 65 | + version: input.version, | |
| 66 | + discoveryUrl: input.discoveryUrl, | |
| 67 | +}); | |
| 68 | + | |
| 69 | +export const sourcePresets: ReadonlyArray<SourcePreset> = [ | |
| 70 | + { | |
| 71 | + id: "deepwiki-mcp", | |
| 72 | + pluginKey: "mcp", | |
| 73 | + kind: "mcp", | |
| 74 | + name: "DeepWiki MCP", | |
| 75 | + summary: "Repository docs and knowledge graphs via MCP.", | |
| 76 | + previewUrl: "https://mcp.deepwiki.com/mcp", | |
| 77 | + endpoint: "https://mcp.deepwiki.com/mcp", | |
| 78 | + transport: "auto", | |
| 79 | + }, | |
| 80 | + { | |
| 81 | + id: "axiom-mcp", | |
| 82 | + pluginKey: "mcp", | |
| 83 | + kind: "mcp", | |
| 84 | + name: "Axiom MCP", | |
| 85 | + summary: "Query, stream, and analyze logs, traces, and event data.", | |
| 86 | + previewUrl: "https://mcp.axiom.co/mcp", | |
| 87 | + endpoint: "https://mcp.axiom.co/mcp", | |
| 88 | + transport: "auto", | |
| 89 | + }, | |
| 90 | + { | |
| 91 | + id: "neon-mcp", | |
| 92 | + pluginKey: "mcp", | |
| 93 | + kind: "mcp", | |
| 94 | + name: "Neon MCP", | |
| 95 | + summary: "Manage Postgres databases, branches, and queries via MCP.", | |
| 96 | + previewUrl: "https://mcp.neon.tech/mcp", | |
| 97 | + endpoint: "https://mcp.neon.tech/mcp", | |
| 98 | + transport: "auto", | |
| 99 | + }, | |
| 100 | + { | |
| 101 | + id: "chrome-devtools-mcp", | |
| 102 | + pluginKey: "mcp", | |
| 103 | + kind: "mcp", | |
| 104 | + name: "Chrome DevTools MCP", | |
| 105 | + summary: "Debug a live Chrome browser session over a local MCP stdio transport.", | |
| 106 | + previewUrl: "npx -y chrome-devtools-mcp@latest", | |
| 107 | + transport: "stdio", | |
| 108 | + command: "npx", | |
| 109 | + args: ["-y", "chrome-devtools-mcp@latest"], | |
| 110 | + }, | |
| 111 | + { | |
| 112 | + id: "neon-api", | |
| 113 | + pluginKey: "openapi", | |
| 114 | + kind: "openapi", | |
| 115 | + name: "Neon API", | |
| 116 | + summary: "Projects, branches, endpoints, databases, and API keys.", | |
| 117 | + previewUrl: "https://console.neon.tech/api/v2", | |
| 118 | + baseUrl: "https://console.neon.tech/api/v2", | |
| 119 | + specUrl: "https://neon.com/api_spec/release/v2.json", | |
| 120 | + }, | |
| 121 | + { | |
| 122 | + id: "github-rest", | |
| 123 | + pluginKey: "openapi", | |
| 124 | + kind: "openapi", | |
| 125 | + name: "GitHub REST API", | |
| 126 | + summary: "Repos, issues, pull requests, actions, and org settings.", | |
| 127 | + previewUrl: "https://api.github.com", | |
| 128 | + baseUrl: "https://api.github.com", | |
| 129 | + specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml", | |
| 130 | + }, | |
| 131 | + { | |
| 132 | + id: "github-graphql", | |
| 133 | + pluginKey: "graphql", | |
| 134 | + kind: "graphql", | |
| 135 | + name: "GitHub GraphQL", | |
| 136 | + summary: "Issues, pull requests, discussions, and repository objects via GraphQL.", | |
| 137 | + previewUrl: "https://api.github.com/graphql", | |
| 138 | + endpoint: "https://api.github.com/graphql", | |
| 139 | + }, | |
| 140 | + { | |
| 141 | + id: "gitlab-graphql", | |
| 142 | + pluginKey: "graphql", | |
| 143 | + kind: "graphql", | |
| 144 | + name: "GitLab GraphQL", | |
| 145 | + summary: "Projects, merge requests, issues, CI pipelines, and users.", | |
| 146 | + previewUrl: "https://gitlab.com/api/graphql", | |
| 147 | + endpoint: "https://gitlab.com/api/graphql", | |
| 148 | + }, | |
| 149 | + { | |
| 150 | + id: "openai-api", | |
| 151 | + pluginKey: "openapi", | |
| 152 | + kind: "openapi", | |
| 153 | + name: "OpenAI API", | |
| 154 | + summary: "Models, files, responses, and fine-tuning.", | |
| 155 | + previewUrl: "https://api.openai.com/v1", | |
| 156 | + baseUrl: "https://api.openai.com/v1", | |
| 157 | + specUrl: "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml", | |
| 158 | + }, | |
| 159 | + { | |
| 160 | + id: "vercel-api", | |
| 161 | + pluginKey: "openapi", | |
| 162 | + kind: "openapi", | |
| 163 | + name: "Vercel API", | |
| 164 | + summary: "Deployments, projects, domains, and environments.", | |
| 165 | + previewUrl: "https://api.vercel.com", | |
| 166 | + baseUrl: "https://api.vercel.com", | |
| 167 | + specUrl: "https://openapi.vercel.sh", | |
| 168 | + }, | |
| 169 | + { | |
| 170 | + id: "stripe-api", | |
| 171 | + pluginKey: "openapi", | |
| 172 | + kind: "openapi", | |
| 173 | + name: "Stripe API", | |
| 174 | + summary: "Payments, billing, subscriptions, and invoices.", | |
| 175 | + previewUrl: "https://api.stripe.com", | |
| 176 | + baseUrl: "https://api.stripe.com", | |
| 177 | + specUrl: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", | |
| 178 | + }, | |
| 179 | + { | |
| 180 | + id: "linear-graphql", | |
| 181 | + pluginKey: "graphql", | |
| 182 | + kind: "graphql", | |
| 183 | + name: "Linear GraphQL", | |
| 184 | + summary: "Issues, teams, cycles, and projects.", | |
| 185 | + previewUrl: "https://api.linear.app/graphql", | |
| 186 | + endpoint: "https://api.linear.app/graphql", | |
| 187 | + }, | |
| 188 | + { | |
| 189 | + id: "monday-graphql", | |
| 190 | + pluginKey: "graphql", | |
| 191 | + kind: "graphql", | |
| 192 | + name: "Monday GraphQL", | |
| 193 | + summary: "Boards, items, updates, users, and workspace metadata.", | |
| 194 | + previewUrl: "https://api.monday.com/v2", | |
| 195 | + endpoint: "https://api.monday.com/v2", | |
| 196 | + }, | |
| 197 | + { | |
| 198 | + id: "anilist-graphql", | |
| 199 | + pluginKey: "graphql", | |
| 200 | + kind: "graphql", | |
| 201 | + name: "AniList GraphQL", | |
| 202 | + summary: "Anime, manga, characters, media lists, and recommendations.", | |
| 203 | + previewUrl: "https://graphql.anilist.co", | |
| 204 | + endpoint: "https://graphql.anilist.co", | |
| 205 | + }, | |
| 206 | + googleDiscoveryPreset({ | |
| 207 | + id: "google-calendar", | |
| 208 | + name: "Google Calendar", | |
| 209 | + summary: "Calendars, events, ACLs, and scheduling workflows.", | |
| 210 | + service: "calendar", | |
| 211 | + version: "v3", | |
| 212 | + discoveryUrl: "https://calendar-json.googleapis.com/$discovery/rest?version=v3", | |
| 213 | + }), | |
| 214 | + googleDiscoveryPreset({ | |
| 215 | + id: "google-drive", | |
| 216 | + name: "Google Drive", | |
| 217 | + summary: "Files, folders, permissions, comments, and shared drives.", | |
| 218 | + service: "drive", | |
| 219 | + version: "v3", | |
| 220 | + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest", | |
| 221 | + }), | |
| 222 | + googleDiscoveryPreset({ | |
| 223 | + id: "google-gmail", | |
| 224 | + name: "Gmail", | |
| 225 | + summary: "Messages, threads, labels, drafts, and mailbox automation.", | |
| 226 | + service: "gmail", | |
| 227 | + version: "v1", | |
| 228 | + discoveryUrl: "https://gmail.googleapis.com/$discovery/rest?version=v1", | |
| 229 | + }), | |
| 230 | + googleDiscoveryPreset({ | |
| 231 | + id: "google-docs", | |
| 232 | + name: "Google Docs", | |
| 233 | + summary: "Documents, structural edits, text ranges, and formatting.", | |
| 234 | + service: "docs", | |
| 235 | + version: "v1", | |
| 236 | + discoveryUrl: "https://docs.googleapis.com/$discovery/rest?version=v1", | |
| 237 | + }), | |
| 238 | + googleDiscoveryPreset({ | |
| 239 | + id: "google-sheets", | |
| 240 | + name: "Google Sheets", | |
| 241 | + summary: "Spreadsheets, values, ranges, formatting, and batch updates.", | |
| 242 | + service: "sheets", | |
| 243 | + version: "v4", | |
| 244 | + discoveryUrl: "https://sheets.googleapis.com/$discovery/rest?version=v4", | |
| 245 | + }), | |
| 246 | + googleDiscoveryPreset({ | |
| 247 | + id: "google-slides", | |
| 248 | + name: "Google Slides", | |
| 249 | + summary: "Presentations, slides, page elements, and deck updates.", | |
| 250 | + service: "slides", | |
| 251 | + version: "v1", | |
| 252 | + discoveryUrl: "https://slides.googleapis.com/$discovery/rest?version=v1", | |
| 253 | + }), | |
| 254 | + googleDiscoveryPreset({ | |
| 255 | + id: "google-forms", | |
| 256 | + name: "Google Forms", | |
| 257 | + summary: "Forms, questions, responses, quizzes, and form metadata.", | |
| 258 | + service: "forms", | |
| 259 | + version: "v1", | |
| 260 | + discoveryUrl: "https://forms.googleapis.com/$discovery/rest?version=v1", | |
| 261 | + }), | |
| 262 | + googleDiscoveryPreset({ | |
| 263 | + id: "google-search-console", | |
| 264 | + name: "Google Search Console", | |
| 265 | + summary: "Sites, sitemaps, URL inspection, and search and Discover performance.", | |
| 266 | + service: "searchconsole", | |
| 267 | + version: "v1", | |
| 268 | + discoveryUrl: "https://searchconsole.googleapis.com/$discovery/rest?version=v1", | |
| 269 | + }), | |
| 270 | + googleDiscoveryPreset({ | |
| 271 | + id: "google-people", | |
| 272 | + name: "Google People", | |
| 273 | + summary: "Contacts, profiles, directory people, and contact groups.", | |
| 274 | + service: "people", | |
| 275 | + version: "v1", | |
| 276 | + discoveryUrl: "https://people.googleapis.com/$discovery/rest?version=v1", | |
| 277 | + }), | |
| 278 | + googleDiscoveryPreset({ | |
| 279 | + id: "google-tasks", | |
| 280 | + name: "Google Tasks", | |
| 281 | + summary: "Task lists, task items, notes, and due dates.", | |
| 282 | + service: "tasks", | |
| 283 | + version: "v1", | |
| 284 | + discoveryUrl: "https://tasks.googleapis.com/$discovery/rest?version=v1", | |
| 285 | + }), | |
| 286 | + googleDiscoveryPreset({ | |
| 287 | + id: "google-chat", | |
| 288 | + name: "Google Chat", | |
| 289 | + summary: "Spaces, messages, members, reactions, and chat workflows.", | |
| 290 | + service: "chat", | |
| 291 | + version: "v1", | |
| 292 | + discoveryUrl: "https://chat.googleapis.com/$discovery/rest?version=v1", | |
| 293 | + }), | |
| 294 | + googleDiscoveryPreset({ | |
| 295 | + id: "google-keep", | |
| 296 | + name: "Google Keep", | |
| 297 | + summary: "Notes, lists, attachments, and collaborative annotations.", | |
| 298 | + service: "keep", | |
| 299 | + version: "v1", | |
| 300 | + discoveryUrl: "https://keep.googleapis.com/$discovery/rest?version=v1", | |
| 301 | + }), | |
| 302 | + googleDiscoveryPreset({ | |
| 303 | + id: "google-classroom", | |
| 304 | + name: "Google Classroom", | |
| 305 | + summary: "Courses, rosters, coursework, submissions, and grading data.", | |
| 306 | + service: "classroom", | |
| 307 | + version: "v1", | |
| 308 | + discoveryUrl: "https://classroom.googleapis.com/$discovery/rest?version=v1", | |
| 309 | + }), | |
| 310 | + googleDiscoveryPreset({ | |
| 311 | + id: "google-admin-directory", | |
| 312 | + name: "Google Admin Directory", | |
| 313 | + summary: "Users, groups, org units, roles, and domain directory resources.", | |
| 314 | + service: "admin", | |
| 315 | + version: "directory_v1", | |
| 316 | + discoveryUrl: "https://admin.googleapis.com/$discovery/rest?version=directory_v1", | |
| 317 | + }), | |
| 318 | + googleDiscoveryPreset({ | |
| 319 | + id: "google-admin-reports", | |
| 320 | + name: "Google Admin Reports", | |
| 321 | + summary: "Audit events, usage reports, and admin activity logs.", | |
| 322 | + service: "admin", | |
| 323 | + version: "reports_v1", | |
| 324 | + discoveryUrl: "https://admin.googleapis.com/$discovery/rest?version=reports_v1", | |
| 325 | + }), | |
| 326 | + googleDiscoveryPreset({ | |
| 327 | + id: "google-apps-script", | |
| 328 | + name: "Google Apps Script", | |
| 329 | + summary: "Projects, deployments, script execution, and Apps Script metadata.", | |
| 330 | + service: "script", | |
| 331 | + version: "v1", | |
| 332 | + discoveryUrl: "https://script.googleapis.com/$discovery/rest?version=v1", | |
| 333 | + }), | |
| 334 | + googleDiscoveryPreset({ | |
| 335 | + id: "google-bigquery", | |
| 336 | + name: "Google BigQuery", | |
| 337 | + summary: "Datasets, tables, jobs, routines, and analytical query workflows.", | |
| 338 | + service: "bigquery", | |
| 339 | + version: "v2", | |
| 340 | + discoveryUrl: "https://bigquery.googleapis.com/$discovery/rest?version=v2", | |
| 341 | + }), | |
| 342 | + googleDiscoveryPreset({ | |
| 343 | + id: "google-cloud-resource-manager", | |
| 344 | + name: "Google Cloud Resource Manager", | |
| 345 | + summary: "Projects, folders, organizations, and IAM-oriented resource hierarchy.", | |
| 346 | + service: "cloudresourcemanager", | |
| 347 | + version: "v3", | |
| 348 | + discoveryUrl: "https://cloudresourcemanager.googleapis.com/$discovery/rest?version=v3", | |
| 349 | + }), | |
| 350 | + googleDiscoveryPreset({ | |
| 351 | + id: "google-youtube-data", | |
| 352 | + name: "YouTube Data", | |
| 353 | + summary: "Channels, playlists, videos, comments, captions, and uploads.", | |
| 354 | + service: "youtube", | |
| 355 | + version: "v3", | |
| 356 | + discoveryUrl: "https://youtube.googleapis.com/$discovery/rest?version=v3", | |
| 357 | + }), | |
| 358 | +] as const; | |
| 359 | + | |
| 360 | +export const buildSourcePresetSearch = ( | |
| 361 | + preset: SourcePreset, | |
| 362 | +): Record<string, string> => { | |
| 363 | + switch (preset.pluginKey) { | |
| 364 | + case "openapi": | |
| 365 | + return { | |
| 366 | + preset: preset.id, | |
| 367 | + presetName: preset.name, | |
| 368 | + presetBaseUrl: preset.baseUrl, | |
| 369 | + presetSpecUrl: preset.specUrl, | |
| 370 | + }; | |
| 371 | + case "graphql": | |
| 372 | + return { | |
| 373 | + preset: preset.id, | |
| 374 | + presetName: preset.name, | |
| 375 | + presetEndpoint: preset.endpoint, | |
| 376 | + }; | |
| 377 | + case "mcp": | |
| 378 | + return { | |
| 379 | + preset: preset.id, | |
| 380 | + presetName: preset.name, | |
| 381 | + ...(preset.endpoint ? { presetEndpoint: preset.endpoint } : {}), | |
| 382 | + ...(preset.transport ? { presetTransport: preset.transport } : {}), | |
| 383 | + ...(preset.command ? { presetCommand: preset.command } : {}), | |
| 384 | + ...(preset.args ? { presetArgs: JSON.stringify(preset.args) } : {}), | |
| 385 | + ...(preset.env ? { presetEnv: JSON.stringify(preset.env) } : {}), | |
| 386 | + ...(preset.cwd ? { presetCwd: preset.cwd } : {}), | |
| 387 | + }; | |
| 388 | + default: | |
| 389 | + return { | |
| 390 | + preset: preset.id, | |
| 391 | + presetName: preset.name, | |
| 392 | + presetService: preset.service, | |
| 393 | + presetVersion: preset.version, | |
| 394 | + presetDiscoveryUrl: preset.discoveryUrl, | |
| 395 | + }; | |
| 396 | + } | |
| 397 | +}; | |
| 398 | + | |
| 399 | +const normalizeComparableUrl = (value: string): string => { | |
| 400 | + const trimmed = value.trim(); | |
| 401 | + try { | |
| 402 | + const url = new URL(trimmed); | |
| 403 | + if (url.pathname.length > 1 && url.pathname.endsWith("/")) { | |
| 404 | + url.pathname = url.pathname.slice(0, -1); | |
| 405 | + } | |
| 406 | + return url.toString(); | |
| 407 | + } catch { | |
| 408 | + return trimmed; | |
| 409 | + } | |
| 410 | +}; | |
| 411 | + | |
| 412 | +const tryParseUrl = (value: string): URL | null => { | |
| 413 | + try { | |
| 414 | + return new URL(value.trim()); | |
| 415 | + } catch { | |
| 416 | + return null; | |
| 417 | + } | |
| 418 | +}; | |
| 419 | + | |
| 420 | +const sourceNameFromUrl = (url: URL, fallback: string): string => { | |
| 421 | + const hostname = url.hostname.replace(/^www\./, ""); | |
| 422 | + if (hostname.length === 0) { | |
| 423 | + return fallback; | |
| 424 | + } | |
| 425 | + | |
| 426 | + const head = hostname.split(".")[0]?.trim(); | |
| 427 | + if (!head) { | |
| 428 | + return fallback; | |
| 429 | + } | |
| 430 | + | |
| 431 | + return `${head.charAt(0).toUpperCase()}${head.slice(1)} ${fallback}`; | |
| 432 | +}; | |
| 433 | + | |
| 434 | +const parseGoogleDiscoveryUrl = (value: string): { | |
| 435 | + service: string; | |
| 436 | + version: string; | |
| 437 | + discoveryUrl: string; | |
| 438 | +} | null => { | |
| 439 | + const url = tryParseUrl(value); | |
| 440 | + if (!url) { | |
| 441 | + return null; | |
| 442 | + } | |
| 443 | + | |
| 444 | + const byDirectory = url.pathname.match( | |
| 445 | + /^\/discovery\/v1\/apis\/([^/]+)\/([^/]+)\/rest$/, | |
| 446 | + ); | |
| 447 | + if (byDirectory) { | |
| 448 | + return { | |
| 449 | + service: decodeURIComponent(byDirectory[1] ?? ""), | |
| 450 | + version: decodeURIComponent(byDirectory[2] ?? ""), | |
| 451 | + discoveryUrl: url.toString(), | |
| 452 | + }; | |
| 453 | + } | |
| 454 | + | |
| 455 | + const versionParam = url.searchParams.get("version")?.trim(); | |
| 456 | + const isHostScopedDiscovery = | |
| 457 | + url.pathname === "/$discovery/rest" | |
| 458 | + && url.hostname.endsWith(".googleapis.com") | |
| 459 | + && url.hostname !== "www.googleapis.com"; | |
| 460 | + | |
| 461 | + if (versionParam && isHostScopedDiscovery) { | |
| 462 | + const service = url.hostname.split(".")[0]?.trim(); | |
| 463 | + if (!service) { | |
| 464 | + return null; | |
| 465 | + } | |
| 466 | + | |
| 467 | + return { | |
| 468 | + service, | |
| 469 | + version: versionParam, | |
| 470 | + discoveryUrl: url.toString(), | |
| 471 | + }; | |
| 472 | + } | |
| 473 | + | |
| 474 | + return null; | |
| 475 | +}; | |
| 476 | + | |
| 477 | +const isGraphqlUrl = (url: URL): boolean => { | |
| 478 | + const path = url.pathname.toLowerCase(); | |
| 479 | + const hostname = url.hostname.toLowerCase(); | |
| 480 | + return ( | |
| 481 | + path.endsWith("/graphql") | |
| 482 | + || path === "/graphql" | |
| 483 | + || hostname.startsWith("graphql.") | |
| 484 | + || hostname.includes("graphql") | |
| 485 | + ); | |
| 486 | +}; | |
| 487 | + | |
| 488 | +const isMcpUrl = (url: URL): boolean => { | |
| 489 | + const path = url.pathname.toLowerCase(); | |
| 490 | + const hostname = url.hostname.toLowerCase(); | |
| 491 | + return path.endsWith("/mcp") || path.includes("/mcp/") || hostname.startsWith("mcp."); | |
| 492 | +}; | |
| 493 | + | |
| 494 | +const isLikelyOpenApiSpecUrl = (url: URL): boolean => { | |
| 495 | + const path = url.pathname.toLowerCase(); | |
| 496 | + return ( | |
| 497 | + path.endsWith(".json") | |
| 498 | + || path.endsWith(".yaml") | |
| 499 | + || path.endsWith(".yml") | |
| 500 | + || path.includes("openapi") | |
| 501 | + || path.includes("swagger") | |
| 502 | + ); | |
| 503 | +}; | |
| 504 | + | |
| 505 | +const findPresetByInput = (value: string): SourcePreset | null => { | |
| 506 | + const comparable = normalizeComparableUrl(value); | |
| 507 | + for (const preset of sourcePresets) { | |
| 508 | + if (normalizeComparableUrl(preset.previewUrl) === comparable) { | |
| 509 | + return preset; | |
| 510 | + } | |
| 511 | + | |
| 512 | + if (preset.pluginKey === "openapi") { | |
| 513 | + if (normalizeComparableUrl(preset.baseUrl) === comparable) { | |
| 514 | + return preset; | |
| 515 | + } | |
| 516 | + if (normalizeComparableUrl(preset.specUrl) === comparable) { | |
| 517 | + return preset; | |
| 518 | + } | |
| 519 | + continue; | |
| 520 | + } | |
| 521 | + | |
| 522 | + if (preset.pluginKey === "graphql" || preset.pluginKey === "mcp") { | |
| 523 | + if (preset.endpoint && normalizeComparableUrl(preset.endpoint) === comparable) { | |
| 524 | + return preset; | |
| 525 | + } | |
| 526 | + continue; | |
| 527 | + } | |
| 528 | + | |
| 529 | + if (normalizeComparableUrl(preset.discoveryUrl) === comparable) { | |
| 530 | + return preset; | |
| 531 | + } | |
| 532 | + } | |
| 533 | + | |
| 534 | + return null; | |
| 535 | +}; | |
| 536 | + | |
| 537 | +export type ResolvedQuickSourceInput = { | |
| 538 | + pluginKey: SourcePreset["pluginKey"]; | |
| 539 | + search: Record<string, string>; | |
| 540 | +}; | |
| 541 | + | |
| 542 | +export const resolveQuickSourceInput = ( | |
| 543 | + rawValue: string, | |
| 544 | +): ResolvedQuickSourceInput | null => { | |
| 545 | + const trimmed = rawValue.trim(); | |
| 546 | + if (trimmed.length === 0) { | |
| 547 | + return null; | |
| 548 | + } | |
| 549 | + | |
| 550 | + const commandParts = | |
| 551 | + trimmed.match(/[^\s"]+|"([^"]*)"/g)?.map((part) => | |
| 552 | + part.startsWith("\"") && part.endsWith("\"") | |
| 553 | + ? part.slice(1, -1) | |
| 554 | + : part | |
| 555 | + ) ?? null; | |
| 556 | + if (commandParts && commandParts.length > 0) { | |
| 557 | + const [command, ...args] = commandParts; | |
| 558 | + const isKnownCommand = | |
| 559 | + command === "npx" | |
| 560 | + || command === "bunx" | |
| 561 | + || command === "uvx" | |
| 562 | + || (command === "pnpm" && args[0] === "dlx"); | |
| 563 | + | |
| 564 | + if (isKnownCommand) { | |
| 565 | + return { | |
| 566 | + pluginKey: "mcp", | |
| 567 | + search: { | |
| 568 | + presetName: "Local MCP", | |
| 569 | + presetTransport: "stdio", | |
| 570 | + presetCommand: command, | |
| 571 | + ...(args.length > 0 ? { presetArgs: JSON.stringify(args) } : {}), | |
| 572 | + quickInput: trimmed, | |
| 573 | + }, | |
| 574 | + }; | |
| 575 | + } | |
| 576 | + } | |
| 577 | + | |
| 578 | + const preset = findPresetByInput(trimmed); | |
| 579 | + if (preset) { | |
| 580 | + return { | |
| 581 | + pluginKey: preset.pluginKey, | |
| 582 | + search: buildSourcePresetSearch(preset), | |
| 583 | + }; | |
| 584 | + } | |
| 585 | + | |
| 586 | + const googleDiscovery = parseGoogleDiscoveryUrl(trimmed); | |
| 587 | + if (googleDiscovery) { | |
| 588 | + const googlePreset = sourcePresets.find( | |
| 589 | + (presetEntry) => | |
| 590 | + presetEntry.pluginKey === GOOGLE_DISCOVERY_PLUGIN_KEY | |
| 591 | + && presetEntry.service === googleDiscovery.service | |
| 592 | + && presetEntry.version === googleDiscovery.version, | |
| 593 | + ); | |
| 594 | + | |
| 595 | + return { | |
| 596 | + pluginKey: GOOGLE_DISCOVERY_PLUGIN_KEY, | |
| 597 | + search: { | |
| 598 | + ...(googlePreset ? buildSourcePresetSearch(googlePreset) : {}), | |
| 599 | + presetName: | |
| 600 | + googlePreset?.name | |
| 601 | + ?? `Google ${googleDiscovery.service} ${googleDiscovery.version}`, | |
| 602 | + presetService: googleDiscovery.service, | |
| 603 | + presetVersion: googleDiscovery.version, | |
| 604 | + presetDiscoveryUrl: googleDiscovery.discoveryUrl, | |
| 605 | + quickInput: trimmed, | |
| 606 | + }, | |
| 607 | + }; | |
| 608 | + } | |
| 609 | + | |
| 610 | + const url = tryParseUrl(trimmed); | |
| 611 | + if (!url) { | |
| 612 | + return null; | |
| 613 | + } | |
| 614 | + | |
| 615 | + if (isGraphqlUrl(url)) { | |
| 616 | + return { | |
| 617 | + pluginKey: "graphql", | |
| 618 | + search: { | |
| 619 | + presetName: sourceNameFromUrl(url, "GraphQL"), | |
| 620 | + presetEndpoint: trimmed, | |
| 621 | + quickInput: trimmed, | |
| 622 | + }, | |
| 623 | + }; | |
| 624 | + } | |
| 625 | + | |
| 626 | + if (isMcpUrl(url)) { | |
| 627 | + return { | |
| 628 | + pluginKey: "mcp", | |
| 629 | + search: { | |
| 630 | + presetName: sourceNameFromUrl(url, "MCP"), | |
| 631 | + presetEndpoint: trimmed, | |
| 632 | + presetTransport: "auto", | |
| 633 | + quickInput: trimmed, | |
| 634 | + }, | |
| 635 | + }; | |
| 636 | + } | |
| 637 | + | |
| 638 | + if (isLikelyOpenApiSpecUrl(url)) { | |
| 639 | + return { | |
| 640 | + pluginKey: "openapi", | |
| 641 | + search: { | |
| 642 | + presetName: sourceNameFromUrl(url, "OpenAPI"), | |
| 643 | + presetSpecUrl: trimmed, | |
| 644 | + quickInput: trimmed, | |
| 645 | + }, | |
| 646 | + }; | |
| 647 | + } | |
| 648 | + | |
| 649 | + return { | |
| 650 | + pluginKey: "openapi", | |
| 651 | + search: { | |
| 652 | + presetName: sourceNameFromUrl(url, "OpenAPI"), | |
| 653 | + presetBaseUrl: trimmed, | |
| 654 | + quickInput: trimmed, | |
| 655 | + }, | |
| 656 | + }; | |
| 657 | +}; | |
| @@ -1,2534 +0,0 @@ | ||
| 1 | −import { useEffect, useState, type ReactNode } from "react"; | |
| 2 | −import { Link, useNavigate } from "@tanstack/react-router"; | |
| 3 | −import { | |
| 4 | − type CompleteSourceOAuthResult, | |
| 5 | − type ConnectSourceBatchPayload, | |
| 6 | − type ConnectSourcePayload, | |
| 7 | − type ConnectSourceResult, | |
| 8 | − type CreateWorkspaceOauthClientPayload, | |
| 9 | − type DiscoverSourcePayload, | |
| 10 | − type InstanceConfig, | |
| 11 | − type Loadable, | |
| 12 | − type SecretListItem, | |
| 13 | − type Source, | |
| 14 | − type SourceDiscoveryResult, | |
| 15 | − type WorkspaceOauthClient, | |
| 16 | − useConnectSourceBatch, | |
| 17 | − useConnectSource, | |
| 18 | − useCreateWorkspaceOauthClient, | |
| 19 | − useCreateSecret, | |
| 20 | − useDiscoverSource, | |
| 21 | − useInvalidateExecutorQueries, | |
| 22 | − useInstanceConfig, | |
| 23 | − useRefreshSecrets, | |
| 24 | − useSecrets, | |
| 25 | − useWorkspaceOauthClients, | |
| 26 | −} from "@executor/react"; | |
| 27 | −import { Badge } from "../components/ui/badge"; | |
| 28 | −import { Button } from "../components/ui/button"; | |
| 29 | −import { LocalMcpInstallCard } from "../components/local-mcp-install-card"; | |
| 30 | −import { SourceFavicon } from "../components/source-favicon"; | |
| 31 | −import { | |
| 32 | − IconArrowLeft, | |
| 33 | − IconCheck, | |
| 34 | − IconDiscover, | |
| 35 | − IconPlus, | |
| 36 | − IconSpinner, | |
| 37 | −} from "../components/icons"; | |
| 38 | −import { cn } from "../lib/utils"; | |
| 39 | −import { | |
| 40 | − isStdioMcpSourceTemplate, | |
| 41 | − sourceTemplates, | |
| 42 | − type SourceTemplate, | |
| 43 | −} from "./source-templates"; | |
| 44 | −import { | |
| 45 | − asMcpRemoteTransportValue, | |
| 46 | − defaultMcpRemoteTransportFields, | |
| 47 | − defaultMcpStdioTransportFields, | |
| 48 | − setMcpTransportFieldsTransport, | |
| 49 | − type McpRemoteTransportFields, | |
| 50 | − type McpStdioTransportFields, | |
| 51 | − type McpTransportFields, | |
| 52 | − type McpTransportValue, | |
| 53 | −} from "./mcp-transport-state"; | |
| 54 | −import { parseJsonStringArray, parseJsonStringMap } from "./json-form"; | |
| 55 | −import { getDomain } from "tldts"; | |
| 56 | − | |
| 57 | −// --------------------------------------------------------------------------- | |
| 58 | −// State machine | |
| 59 | −// --------------------------------------------------------------------------- | |
| 60 | − | |
| 61 | −type FlowPhase = | |
| 62 | − | "idle" | |
| 63 | − | "discovering" | |
| 64 | − | "editing" | |
| 65 | − | "connecting" | |
| 66 | − | "connected" | |
| 67 | − | "credential_required" | |
| 68 | − | "oauth_required"; | |
| 69 | − | |
| 70 | −type ProbeAuthKind = "none" | "bearer" | "basic" | "headers"; | |
| 71 | − | |
| 72 | −type ProbeAuthState = { | |
| 73 | − kind: ProbeAuthKind; | |
| 74 | − token: string; | |
| 75 | − headerName: string; | |
| 76 | − prefix: string; | |
| 77 | − username: string; | |
| 78 | − password: string; | |
| 79 | − headersText: string; | |
| 80 | −}; | |
| 81 | − | |
| 82 | −type ConnectFormBase = { | |
| 83 | − kind: "mcp" | "openapi" | "graphql" | "google_discovery"; | |
| 84 | − endpoint: string; | |
| 85 | − specUrl: string; | |
| 86 | − service: string; | |
| 87 | − version: string; | |
| 88 | − discoveryUrl: string; | |
| 89 | − name: string; | |
| 90 | − namespace: string; | |
| 91 | − authKind: "none" | "bearer" | "oauth2"; | |
| 92 | − authHeaderName: string; | |
| 93 | − authPrefix: string; | |
| 94 | − bearerToken: string; | |
| 95 | − bearerProviderId: string; | |
| 96 | − bearerHandle: string; | |
| 97 | − workspaceOauthClientId: string; | |
| 98 | − oauthClientId: string; | |
| 99 | − oauthClientSecret: string; | |
| 100 | −}; | |
| 101 | − | |
| 102 | −type ConnectFormState = ConnectFormBase & McpTransportFields; | |
| 103 | − | |
| 104 | −type OAuthRequiredInfo = { | |
| 105 | − source: Source; | |
| 106 | − sessionId: string; | |
| 107 | − authorizationUrl: string; | |
| 108 | −}; | |
| 109 | − | |
| 110 | −type BatchOAuthRequiredInfo = { | |
| 111 | − sessionId: string; | |
| 112 | − authorizationUrl: string; | |
| 113 | − sourceIds: ReadonlyArray<string>; | |
| 114 | −}; | |
| 115 | − | |
| 116 | −// --------------------------------------------------------------------------- | |
| 117 | −// Constants | |
| 118 | −// --------------------------------------------------------------------------- | |
| 119 | − | |
| 120 | −const kindOptions: ReadonlyArray<ConnectFormState["kind"]> = [ | |
| 121 | − "mcp", | |
| 122 | − "openapi", | |
| 123 | − "graphql", | |
| 124 | − "google_discovery", | |
| 125 | −]; | |
| 126 | − | |
| 127 | −const transportOptions: ReadonlyArray<Exclude<McpTransportValue, "">> = [ | |
| 128 | − "auto", | |
| 129 | − "streamable-http", | |
| 130 | − "sse", | |
| 131 | − "stdio", | |
| 132 | −]; | |
| 133 | − | |
| 134 | −const authOptions: ReadonlyArray<ConnectFormState["authKind"]> = [ | |
| 135 | − "none", | |
| 136 | − "bearer", | |
| 137 | − "oauth2", | |
| 138 | −]; | |
| 139 | − | |
| 140 | −const probeAuthOptions: ReadonlyArray<ProbeAuthKind> = [ | |
| 141 | − "none", | |
| 142 | − "bearer", | |
| 143 | − "basic", | |
| 144 | − "headers", | |
| 145 | −]; | |
| 146 | − | |
| 147 | −const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000; | |
| 148 | −const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:"; | |
| 149 | − | |
| 150 | −// --------------------------------------------------------------------------- | |
| 151 | −// Helpers | |
| 152 | −// --------------------------------------------------------------------------- | |
| 153 | − | |
| 154 | −const trimToNull = (value: string): string | null => { | |
| 155 | − const trimmed = value.trim(); | |
| 156 | − return trimmed.length > 0 ? trimmed : null; | |
| 157 | −}; | |
| 158 | − | |
| 159 | −const namespaceFromUrl = (url: string): string => { | |
| 160 | − try { | |
| 161 | − const domain = getDomain(url); | |
| 162 | − if (!domain) return ""; | |
| 163 | − // Strip the TLD: "github.com" -> "github", "linear.app" -> "linear" | |
| 164 | − const dot = domain.indexOf("."); | |
| 165 | − return dot > 0 ? domain.slice(0, dot) : domain; | |
| 166 | − } catch { | |
| 167 | − return ""; | |
| 168 | − } | |
| 169 | −}; | |
| 170 | − | |
| 171 | −const googleDiscoveryNamespace = (service: string): string => | |
| 172 | − `google.${service | |
| 173 | − .trim() | |
| 174 | − .toLowerCase() | |
| 175 | − .replace(/[^a-z0-9]+/g, ".") | |
| 176 | − .replace(/^\.+|\.+$/g, "")}`; | |
| 177 | − | |
| 178 | −const googleDiscoveryDefaultsFromUrl = ( | |
| 179 | − value: string, | |
| 180 | −): { | |
| 181 | − service: string; | |
| 182 | − version: string; | |
| 183 | − discoveryUrl: string; | |
| 184 | −} | null => { | |
| 185 | − try { | |
| 186 | − const url = new URL(value); | |
| 187 | − const byDirectory = url.pathname.match( | |
| 188 | − /^\/discovery\/v1\/apis\/([^/]+)\/([^/]+)\/rest$/, | |
| 189 | − ); | |
| 190 | − if (byDirectory) { | |
| 191 | − return { | |
| 192 | − service: decodeURIComponent(byDirectory[1] ?? ""), | |
| 193 | − version: decodeURIComponent(byDirectory[2] ?? ""), | |
| 194 | − discoveryUrl: url.toString(), | |
| 195 | − }; | |
| 196 | − } | |
| 197 | − | |
| 198 | − const versionParam = url.searchParams.get("version"); | |
| 199 | − const version = versionParam ? trimToNull(versionParam) : null; | |
| 200 | − const isHostScopedDiscovery = | |
| 201 | − url.pathname === "/$discovery/rest" && | |
| 202 | − url.hostname.endsWith(".googleapis.com") && | |
| 203 | − url.hostname !== "www.googleapis.com"; | |
| 204 | − if (version && isHostScopedDiscovery) { | |
| 205 | − return { | |
| 206 | − service: url.hostname.split(".")[0] ?? "", | |
| 207 | − version, | |
| 208 | − discoveryUrl: url.toString(), | |
| 209 | − }; | |
| 210 | − } | |
| 211 | − | |
| 212 | − return null; | |
| 213 | − } catch { | |
| 214 | − return null; | |
| 215 | − } | |
| 216 | −}; | |
| 217 | − | |
| 218 | −const stringifyStringMap = ( | |
| 219 | − value: Readonly<Record<string, string>> | null | undefined, | |
| 220 | −): string => | |
| 221 | − !value || Object.keys(value).length === 0 | |
| 222 | − ? "" | |
| 223 | − : JSON.stringify(value, null, 2); | |
| 224 | − | |
| 225 | −const stringifyStringArray = ( | |
| 226 | − value: ReadonlyArray<string> | null | undefined, | |
| 227 | −): string => | |
| 228 | − !value || value.length === 0 ? "" : JSON.stringify(value, null, 2); | |
| 229 | − | |
| 230 | −const buildSyntheticMcpStdioEndpoint = (input: { | |
| 231 | − name?: string | null; | |
| 232 | − endpoint?: string | null; | |
| 233 | − command?: string | null; | |
| 234 | −}): string => { | |
| 235 | − const label = | |
| 236 | − input.name?.trim() || | |
| 237 | − input.endpoint?.trim() || | |
| 238 | − input.command?.trim() || | |
| 239 | − "mcp"; | |
| 240 | − const slug = label | |
| 241 | − .toLowerCase() | |
| 242 | − .replace(/[^a-z0-9]+/g, "-") | |
| 243 | − .replace(/^-+|-+$/g, ""); | |
| 244 | − | |
| 245 | − return `stdio://local/${slug || "mcp"}`; | |
| 246 | −}; | |
| 247 | − | |
| 248 | −// --------------------------------------------------------------------------- | |
| 249 | −// Derived defaults from discovery result | |
| 250 | −// --------------------------------------------------------------------------- | |
| 251 | − | |
| 252 | −const defaultConnectForm = ( | |
| 253 | − discovery?: SourceDiscoveryResult, | |
| 254 | −): ConnectFormState => { | |
| 255 | − if (!discovery || discovery.detectedKind === "unknown") { | |
| 256 | − return { | |
| 257 | − kind: "openapi", | |
| 258 | − endpoint: discovery?.endpoint ?? "", | |
| 259 | − specUrl: discovery?.specUrl ?? "", | |
| 260 | − service: "", | |
| 261 | − version: "", | |
| 262 | − discoveryUrl: "", | |
| 263 | − name: discovery?.name ?? "", | |
| 264 | − namespace: | |
| 265 | − discovery?.namespace || namespaceFromUrl(discovery?.endpoint ?? ""), | |
| 266 | − authKind: "none", | |
| 267 | − authHeaderName: "Authorization", | |
| 268 | − authPrefix: "Bearer ", | |
| 269 | − bearerToken: "", | |
| 270 | − bearerProviderId: "", | |
| 271 | − bearerHandle: "", | |
| 272 | − workspaceOauthClientId: "", | |
| 273 | − oauthClientId: "", | |
| 274 | − oauthClientSecret: "", | |
| 275 | − ...defaultMcpRemoteTransportFields(), | |
| 276 | − }; | |
| 277 | − } | |
| 278 | − | |
| 279 | − const kind = discovery.detectedKind as ConnectFormState["kind"]; | |
| 280 | − const auth = discovery.authInference; | |
| 281 | − | |
| 282 | − // Map auth suggestion to what backend connect supports | |
| 283 | − let authKind: ConnectFormState["authKind"] = "none"; | |
| 284 | − let authHeaderName = "Authorization"; | |
| 285 | − let authPrefix = "Bearer "; | |
| 286 | − | |
| 287 | − if (auth.supported) { | |
| 288 | − if (auth.suggestedKind === "bearer" || auth.suggestedKind === "apiKey") { | |
| 289 | − authKind = "bearer"; | |
| 290 | − authHeaderName = auth.headerName ?? "Authorization"; | |
| 291 | − authPrefix = auth.prefix ?? "Bearer "; | |
| 292 | − } else if (auth.suggestedKind === "oauth2") { | |
| 293 | − authKind = "oauth2"; | |
| 294 | − authHeaderName = auth.headerName ?? "Authorization"; | |
| 295 | − authPrefix = auth.prefix ?? "Bearer "; | |
| 296 | − } else if (auth.suggestedKind === "basic") { | |
| 297 | − // Backend connect doesn't support basic auth natively; map to bearer | |
| 298 | − authKind = "bearer"; | |
| 299 | − authHeaderName = "Authorization"; | |
| 300 | − authPrefix = "Basic "; | |
| 301 | − } | |
| 302 | − } | |
| 303 | − | |
| 304 | − const googleDiscoveryDefaults = | |
| 305 | − kind === "google_discovery" | |
| 306 | − ? googleDiscoveryDefaultsFromUrl(discovery.specUrl ?? discovery.endpoint) | |
| 307 | − : null; | |
| 308 | − | |
| 309 | − return { | |
| 310 | − kind, | |
| 311 | − endpoint: discovery.endpoint, | |
| 312 | − specUrl: discovery.specUrl ?? "", | |
| 313 | − service: googleDiscoveryDefaults?.service ?? "", | |
| 314 | − version: googleDiscoveryDefaults?.version ?? "", | |
| 315 | − discoveryUrl: googleDiscoveryDefaults?.discoveryUrl ?? "", | |
| 316 | − name: discovery.name ?? "", | |
| 317 | − namespace: discovery.namespace || namespaceFromUrl(discovery.endpoint), | |
| 318 | − authKind, | |
| 319 | − authHeaderName, | |
| 320 | − authPrefix, | |
| 321 | − bearerToken: "", | |
| 322 | − bearerProviderId: "", | |
| 323 | − bearerHandle: "", | |
| 324 | − workspaceOauthClientId: "", | |
| 325 | − oauthClientId: "", | |
| 326 | − oauthClientSecret: "", | |
| 327 | − ...defaultMcpRemoteTransportFields( | |
| 328 | − kind === "mcp" ? asMcpRemoteTransportValue(discovery.transport) : "", | |
| 329 | − ), | |
| 330 | − }; | |
| 331 | −}; | |
| 332 | − | |
| 333 | −const connectFormFromTemplate = ( | |
| 334 | − template: SourceTemplate, | |
| 335 | −): ConnectFormState => ({ | |
| 336 | − ...defaultConnectForm(), | |
| 337 | − kind: template.kind as ConnectFormState["kind"], | |
| 338 | − endpoint: template.endpoint ?? "", | |
| 339 | − specUrl: "specUrl" in template ? template.specUrl : "", | |
| 340 | − service: "service" in template ? template.service : "", | |
| 341 | − version: "version" in template ? template.version : "", | |
| 342 | − discoveryUrl: "discoveryUrl" in template ? template.discoveryUrl : "", | |
| 343 | − name: template.name, | |
| 344 | − namespace: | |
| 345 | − template.namespace ?? | |
| 346 | − ("service" in template | |
| 347 | − ? googleDiscoveryNamespace(template.service) | |
| 348 | − : namespaceFromUrl(template.endpoint ?? "")), | |
| 349 | − authKind: template.kind === "google_discovery" ? "oauth2" : "none", | |
| 350 | − workspaceOauthClientId: "", | |
| 351 | − ...(template.kind === "mcp" && template.connectionType === "command" | |
| 352 | − ? defaultMcpStdioTransportFields({ | |
| 353 | − command: template.command ?? "", | |
| 354 | − argsText: stringifyStringArray(template.args), | |
| 355 | − envText: stringifyStringMap(template.env), | |
| 356 | − cwd: template.cwd ?? "", | |
| 357 | − }) | |
| 358 | − : defaultMcpRemoteTransportFields( | |
| 359 | − template.kind === "mcp" | |
| 360 | − ? asMcpRemoteTransportValue(template.transport) | |
| 361 | − : "", | |
| 362 | − )), | |
| 363 | −}); | |
| 364 | − | |
| 365 | −const authKindForSourceKind = ( | |
| 366 | − currentAuthKind: ConnectFormState["authKind"], | |
| 367 | − nextKind: ConnectFormState["kind"], | |
| 368 | −): ConnectFormState["authKind"] => | |
| 369 | − nextKind === "google_discovery" && currentAuthKind === "none" | |
| 370 | − ? "oauth2" | |
| 371 | − : currentAuthKind; | |
| 372 | − | |
| 373 | −const buildProbeAuth = ( | |
| 374 | − state: ProbeAuthState, | |
| 375 | −): DiscoverSourcePayload["probeAuth"] => { | |
| 376 | − if (state.kind === "none") return { kind: "none" }; | |
| 377 | − if (state.kind === "bearer") { | |
| 378 | − if (!state.token.trim()) | |
| 379 | − throw new Error("Token is required for bearer probe auth."); | |
| 380 | − return { | |
| 381 | − kind: "bearer", | |
| 382 | − headerName: trimToNull(state.headerName), | |
| 383 | − prefix: trimToNull(state.prefix), | |
| 384 | − token: state.token.trim(), | |
| 385 | − }; | |
| 386 | − } | |
| 387 | − if (state.kind === "basic") { | |
| 388 | − if (!state.username.trim()) | |
| 389 | − throw new Error("Username is required for basic probe auth."); | |
| 390 | − return { | |
| 391 | − kind: "basic", | |
| 392 | − username: state.username.trim(), | |
| 393 | − password: state.password, | |
| 394 | − }; | |
| 395 | − } | |
| 396 | − // headers | |
| 397 | − const headers = parseJsonStringMap("Probe headers", state.headersText); | |
| 398 | − if (!headers) | |
| 399 | − throw new Error("At least one header is required for headers probe auth."); | |
| 400 | − return { kind: "headers", headers }; | |
| 401 | −}; | |
| 402 | − | |
| 403 | −const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { | |
| 404 | − if (form.kind === "mcp") { | |
| 405 | − if (form.transport === "stdio") { | |
| 406 | − const endpoint = buildSyntheticMcpStdioEndpoint({ | |
| 407 | − name: form.name, | |
| 408 | − endpoint: form.endpoint, | |
| 409 | − command: form.command, | |
| 410 | − }); | |
| 411 | − if (!form.command.trim()) { | |
| 412 | − throw new Error("Command is required for stdio MCP sources."); | |
| 413 | − } | |
| 414 | − return { | |
| 415 | − kind: "mcp", | |
| 416 | − endpoint, | |
| 417 | − name: trimToNull(form.name), | |
| 418 | − namespace: trimToNull(form.namespace), | |
| 419 | − transport: "stdio", | |
| 420 | − queryParams: null, | |
| 421 | − headers: null, | |
| 422 | − command: form.command.trim(), | |
| 423 | − args: parseJsonStringArray("Args", form.argsText), | |
| 424 | − env: parseJsonStringMap("Environment", form.envText), | |
| 425 | − cwd: trimToNull(form.cwd), | |
| 426 | − }; | |
| 427 | − } | |
| 428 | − | |
| 429 | − const endpoint = form.endpoint.trim(); | |
| 430 | − if (!endpoint) throw new Error("Endpoint is required."); | |
| 431 | − return { | |
| 432 | − kind: "mcp", | |
| 433 | − endpoint, | |
| 434 | − name: trimToNull(form.name), | |
| 435 | − namespace: trimToNull(form.namespace), | |
| 436 | − transport: form.transport === "" ? "auto" : form.transport, | |
| 437 | − queryParams: parseJsonStringMap("Query params", form.queryParamsText), | |
| 438 | − headers: parseJsonStringMap("Request headers", form.headersText), | |
| 439 | − command: null, | |
| 440 | − args: null, | |
| 441 | − env: null, | |
| 442 | − cwd: null, | |
| 443 | − }; | |
| 444 | − } | |
| 445 | − | |
| 446 | − // Build HTTP auth for openapi/graphql | |
| 447 | − const auth = buildHttpAuth(form); | |
| 448 | − | |
| 449 | − if (form.kind === "openapi") { | |
| 450 | − const endpoint = form.endpoint.trim(); | |
| 451 | − if (!endpoint) throw new Error("Endpoint is required."); | |
| 452 | − const specUrl = form.specUrl.trim(); | |
| 453 | − if (!specUrl) throw new Error("OpenAPI sources require a spec URL."); | |
| 454 | − return { | |
| 455 | − kind: "openapi", | |
| 456 | − endpoint, | |
| 457 | − specUrl, | |
| 458 | − name: trimToNull(form.name), | |
| 459 | − namespace: trimToNull(form.namespace), | |
| 460 | − auth, | |
| 461 | − }; | |
| 462 | − } | |
| 463 | − | |
| 464 | − if (form.kind === "google_discovery") { | |
| 465 | − const service = form.service.trim(); | |
| 466 | − const version = form.version.trim(); | |
| 467 | − if (!service) | |
| 468 | − throw new Error("Google Discovery sources require a service name."); | |
| 469 | − if (!version) | |
| 470 | − throw new Error("Google Discovery sources require a version."); | |
| 471 | − if ( | |
| 472 | − form.authKind === "oauth2" && | |
| 473 | − form.workspaceOauthClientId.trim().length === 0 && | |
| 474 | − form.oauthClientId.trim().length === 0 | |
| 475 | − ) { | |
| 476 | − throw new Error( | |
| 477 | − "Google OAuth requires a workspace OAuth client or a new client ID.", | |
| 478 | − ); | |
| 479 | − } | |
| 480 | − return { | |
| 481 | − kind: "google_discovery", | |
| 482 | − service, | |
| 483 | − version, | |
| 484 | − discoveryUrl: trimToNull(form.discoveryUrl), | |
| 485 | − name: trimToNull(form.name), | |
| 486 | − namespace: trimToNull(form.namespace), | |
| 487 | − scopeOauthClientId: | |
| 488 | − form.authKind === "oauth2" && | |
| 489 | − form.workspaceOauthClientId.trim().length > 0 | |
| 490 | − ? form.workspaceOauthClientId.trim() | |
| 491 | − : undefined, | |
| 492 | − oauthClient: | |
| 493 | − form.authKind === "oauth2" && | |
| 494 | − form.workspaceOauthClientId.trim().length === 0 | |
| 495 | − ? { | |
| 496 | − clientId: form.oauthClientId.trim(), | |
| 497 | − clientSecret: trimToNull(form.oauthClientSecret), | |
| 498 | − } | |
| 499 | − : null, | |
| 500 | − auth, | |
| 501 | − }; | |
| 502 | − } | |
| 503 | − | |
| 504 | − const endpoint = form.endpoint.trim(); | |
| 505 | − if (!endpoint) throw new Error("Endpoint is required."); | |
| 506 | − return { | |
| 507 | − kind: "graphql", | |
| 508 | − endpoint, | |
| 509 | − name: trimToNull(form.name), | |
| 510 | − namespace: trimToNull(form.namespace), | |
| 511 | − auth, | |
| 512 | − }; | |
| 513 | −}; | |
| 514 | − | |
| 515 | −const buildHttpAuth = ( | |
| 516 | − form: ConnectFormState, | |
| 517 | −): | |
| 518 | − | { kind: "none" } | |
| 519 | − | { | |
| 520 | − kind: "bearer"; | |
| 521 | − headerName?: string | null; | |
| 522 | − prefix?: string | null; | |
| 523 | − token?: string | null; | |
| 524 | − tokenRef?: { providerId: string; handle: string } | null; | |
| 525 | − } | |
| 526 | − | undefined => { | |
| 527 | − if (form.authKind === "none") return { kind: "none" }; | |
| 528 | − | |
| 529 | − if (form.authKind === "bearer") { | |
| 530 | − const headerName = trimToNull(form.authHeaderName); | |
| 531 | − const prefix = form.authPrefix.length === 0 ? null : form.authPrefix; | |
| 532 | − | |
| 533 | − // Prefer secret ref if set | |
| 534 | − if (form.bearerProviderId.trim() && form.bearerHandle.trim()) { | |
| 535 | − return { | |
| 536 | − kind: "bearer", | |
| 537 | − headerName, | |
| 538 | − prefix, | |
| 539 | − tokenRef: { | |
| 540 | − providerId: form.bearerProviderId.trim(), | |
| 541 | − handle: form.bearerHandle.trim(), | |
| 542 | − }, | |
| 543 | − }; | |
| 544 | − } | |
| 545 | − | |
| 546 | − // Fall back to inline token | |
| 547 | − if (form.bearerToken.trim()) { | |
| 548 | − return { | |
| 549 | − kind: "bearer", | |
| 550 | − headerName, | |
| 551 | − prefix, | |
| 552 | − token: form.bearerToken.trim(), | |
| 553 | − }; | |
| 554 | − } | |
| 555 | − | |
| 556 | − throw new Error( | |
| 557 | − "Bearer auth requires a token. Select or create a secret, or enter a token directly.", | |
| 558 | − ); | |
| 559 | − } | |
| 560 | − | |
| 561 | − // oauth2 is handled via the connect result flow, not pre-filled | |
| 562 | − return undefined; | |
| 563 | −}; | |
| 564 | − | |
| 565 | −// --------------------------------------------------------------------------- | |
| 566 | −// OAuth popup helpers (shared with source-editor.tsx) | |
| 567 | −// --------------------------------------------------------------------------- | |
| 568 | − | |
| 569 | −type SourceOAuthPopupMessage = | |
| 570 | − | { | |
| 571 | − type: "executor:oauth-result"; | |
| 572 | − ok: true; | |
| 573 | − sessionId: string; | |
| 574 | − auth: CompleteSourceOAuthResult["auth"]; | |
| 575 | − } | |
| 576 | − | { | |
| 577 | − type: "executor:oauth-result"; | |
| 578 | − ok: false; | |
| 579 | − sessionId: string | null; | |
| 580 | − error: string; | |
| 581 | − } | |
| 582 | − | { | |
| 583 | − type: "executor:source-oauth-result"; | |
| 584 | − ok: true; | |
| 585 | − sourceId: string; | |
| 586 | − } | |
| 587 | − | { | |
| 588 | − type: "executor:source-oauth-result"; | |
| 589 | − ok: false; | |
| 590 | − error: string; | |
| 591 | − }; | |
| 592 | − | |
| 593 | −const readStoredSourceOAuthPopupResult = ( | |
| 594 | − sessionId: string, | |
| 595 | −): SourceOAuthPopupMessage | null => { | |
| 596 | − if (typeof window === "undefined") return null; | |
| 597 | − const raw = window.localStorage.getItem( | |
| 598 | − `${SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX}${sessionId}`, | |
| 599 | − ); | |
| 600 | − if (!raw) return null; | |
| 601 | − try { | |
| 602 | − return JSON.parse(raw) as SourceOAuthPopupMessage; | |
| 603 | − } catch { | |
| 604 | − return null; | |
| 605 | − } | |
| 606 | −}; | |
| 607 | − | |
| 608 | −const clearStoredSourceOAuthPopupResult = (sessionId: string): void => { | |
| 609 | − if (typeof window === "undefined") return; | |
| 610 | − window.localStorage.removeItem( | |
| 611 | − `${SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX}${sessionId}`, | |
| 612 | − ); | |
| 613 | −}; | |
| 614 | − | |
| 615 | −const startSourceOAuthPopup = async (input: { | |
| 616 | − authorizationUrl: string; | |
| 617 | − sessionId: string; | |
| 618 | −}): Promise<void> => { | |
| 619 | − if (typeof window === "undefined") { | |
| 620 | − throw new Error("OAuth popup is only available in a browser context"); | |
| 621 | − } | |
| 622 | − | |
| 623 | − clearStoredSourceOAuthPopupResult(input.sessionId); | |
| 624 | − | |
| 625 | − const popup = window.open( | |
| 626 | − input.authorizationUrl, | |
| 627 | − "executor-source-oauth", | |
| 628 | − "popup=yes,width=520,height=720", | |
| 629 | − ); | |
| 630 | − | |
| 631 | − if (!popup) { | |
| 632 | − throw new Error("Popup blocked. Allow popups and try again."); | |
| 633 | − } | |
| 634 | − | |
| 635 | − popup.focus(); | |
| 636 | − | |
| 637 | − return await new Promise<void>((resolve, reject) => { | |
| 638 | − let settled = false; | |
| 639 | − let closedPoll = 0; | |
| 640 | − let resultTimeout = 0; | |
| 641 | − | |
| 642 | − const cleanup = () => { | |
| 643 | − window.removeEventListener("message", onMessage); | |
| 644 | − if (closedPoll) window.clearInterval(closedPoll); | |
| 645 | − if (resultTimeout) window.clearTimeout(resultTimeout); | |
| 646 | − if (!popup.closed) popup.close(); | |
| 647 | − clearStoredSourceOAuthPopupResult(input.sessionId); | |
| 648 | − }; | |
| 649 | − | |
| 650 | − const settleWithError = (message: string) => { | |
| 651 | − if (settled) return; | |
| 652 | − settled = true; | |
| 653 | − cleanup(); | |
| 654 | − reject(new Error(message)); | |
| 655 | − }; | |
| 656 | − | |
| 657 | − const settleFromPayload = (data: SourceOAuthPopupMessage) => { | |
| 658 | − if (!data.ok) { | |
| 659 | − settleWithError(data.error || "OAuth failed"); | |
| 660 | − return; | |
| 661 | − } | |
| 662 | − if (settled) return; | |
| 663 | − settled = true; | |
| 664 | − cleanup(); | |
| 665 | − resolve(); | |
| 666 | − }; | |
| 667 | − | |
| 668 | − const onMessage = (event: MessageEvent) => { | |
| 669 | − if (event.origin !== window.location.origin) return; | |
| 670 | − const data = event.data as SourceOAuthPopupMessage | undefined; | |
| 671 | − if (!data) return; | |
| 672 | − if (data.type === "executor:oauth-result") { | |
| 673 | − if (data.ok && data.sessionId !== input.sessionId) return; | |
| 674 | − if ( | |
| 675 | − !data.ok && | |
| 676 | − data.sessionId !== null && | |
| 677 | − data.sessionId !== input.sessionId | |
| 678 | − ) | |
| 679 | − return; | |
| 680 | − } else if (data.type !== "executor:source-oauth-result") { | |
| 681 | − return; | |
| 682 | − } | |
| 683 | − settleFromPayload(data); | |
| 684 | − }; | |
| 685 | − | |
| 686 | − window.addEventListener("message", onMessage); | |
| 687 | − | |
| 688 | − resultTimeout = window.setTimeout(() => { | |
| 689 | − settleWithError( | |
| 690 | − "OAuth popup timed out before completion. Please try again.", | |
| 691 | − ); | |
| 692 | − }, SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS); | |
| 693 | − | |
| 694 | − closedPoll = window.setInterval(() => { | |
| 695 | − const stored = readStoredSourceOAuthPopupResult(input.sessionId); | |
| 696 | − if (stored) { | |
| 697 | − settleFromPayload(stored); | |
| 698 | − return; | |
| 699 | − } | |
| 700 | − if (popup.closed) { | |
| 701 | − // Stop polling — only run one final deferred check to give the | |
| 702 | − // callback page time to write localStorage before we give up. | |
| 703 | − window.clearInterval(closedPoll); | |
| 704 | − closedPoll = 0; | |
| 705 | − window.setTimeout(() => { | |
| 706 | − const delayedStored = readStoredSourceOAuthPopupResult( | |
| 707 | − input.sessionId, | |
| 708 | − ); | |
| 709 | − if (delayedStored) { | |
| 710 | − settleFromPayload(delayedStored); | |
| 711 | − return; | |
| 712 | − } | |
| 713 | − settleWithError("OAuth popup was closed before completion."); | |
| 714 | − }, 1500); | |
| 715 | − } | |
| 716 | − }, 300); | |
| 717 | − }); | |
| 718 | −}; | |
| 719 | − | |
| 720 | −// --------------------------------------------------------------------------- | |
| 721 | −// Confidence badge helper | |
| 722 | −// --------------------------------------------------------------------------- | |
| 723 | − | |
| 724 | −// --------------------------------------------------------------------------- | |
| 725 | −// Page | |
| 726 | −// --------------------------------------------------------------------------- | |
| 727 | − | |
| 728 | −export function AddSourcePage() { | |
| 729 | − const navigate = useNavigate(); | |
| 730 | − const discoverSource = useDiscoverSource(); | |
| 731 | − const connectSource = useConnectSource(); | |
| 732 | − const connectSourceBatch = useConnectSourceBatch(); | |
| 733 | − const createWorkspaceOauthClient = useCreateWorkspaceOauthClient(); | |
| 734 | − const workspaceOauthClients = useWorkspaceOauthClients("google_workspace"); | |
| 735 | − const instanceConfig = useInstanceConfig(); | |
| 736 | − const invalidateExecutorQueries = useInvalidateExecutorQueries(); | |
| 737 | − const secrets = useSecrets(); | |
| 738 | − const refreshSecrets = useRefreshSecrets(); | |
| 739 | − | |
| 740 | − // URL input | |
| 741 | − const [url, setUrl] = useState(""); | |
| 742 | − | |
| 743 | − // Probe auth | |
| 744 | − const [showProbeAuth, setShowProbeAuth] = useState(false); | |
| 745 | − const [probeAuth, setProbeAuth] = useState<ProbeAuthState>({ | |
| 746 | − kind: "none", | |
| 747 | − token: "", | |
| 748 | − headerName: "Authorization", | |
| 749 | − prefix: "Bearer ", | |
| 750 | − username: "", | |
| 751 | − password: "", | |
| 752 | − headersText: "", | |
| 753 | − }); | |
| 754 | − | |
| 755 | − // Phase | |
| 756 | − const [phase, setPhase] = useState<FlowPhase>("idle"); | |
| 757 | − | |
| 758 | − // Discovery result | |
| 759 | − // Editable connect form (populated after discovery) | |
| 760 | − const [connectForm, setConnectForm] = | |
| 761 | − useState<ConnectFormState>(defaultConnectForm()); | |
| 762 | − | |
| 763 | − // Connect result | |
| 764 | − const [connectResult, setConnectResult] = | |
| 765 | − useState<ConnectSourceResult | null>(null); | |
| 766 | − | |
| 767 | − // OAuth required state | |
| 768 | − const [oauthInfo, setOauthInfo] = useState<OAuthRequiredInfo | null>(null); | |
| 769 | − const [batchOauthInfo, setBatchOauthInfo] = | |
| 770 | − useState<BatchOAuthRequiredInfo | null>(null); | |
| 771 | − const [oauthBusy, setOauthBusy] = useState(false); | |
| 772 | − const [batchConnecting, setBatchConnecting] = useState(false); | |
| 773 | − const [selectedGoogleTemplateIds, setSelectedGoogleTemplateIds] = useState< | |
| 774 | − ReadonlyArray<string> | |
| 775 | − >([]); | |
| 776 | − | |
| 777 | − // Status banner | |
| 778 | − const [statusBanner, setStatusBanner] = useState<{ | |
| 779 | − tone: "info" | "success" | "error"; | |
| 780 | − text: string; | |
| 781 | − } | null>(null); | |
| 782 | − | |
| 783 | − const setFormField = <K extends keyof ConnectFormBase>( | |
| 784 | − key: K, | |
| 785 | − value: ConnectFormBase[K], | |
| 786 | − ) => { | |
| 787 | − setConnectForm((current) => ({ ...current, [key]: value })); | |
| 788 | − }; | |
| 789 | − | |
| 790 | − const setTransport = (transport: McpTransportValue) => { | |
| 791 | − setConnectForm((current) => ({ | |
| 792 | − ...current, | |
| 793 | − ...setMcpTransportFieldsTransport(current, transport), | |
| 794 | − })); | |
| 795 | − }; | |
| 796 | − | |
| 797 | − const setRemoteTransportField = < | |
| 798 | − K extends Exclude<keyof McpRemoteTransportFields, "transport">, | |
| 799 | − >( | |
| 800 | − key: K, | |
| 801 | − value: McpRemoteTransportFields[K], | |
| 802 | − ) => { | |
| 803 | − setConnectForm((current) => | |
| 804 | − current.transport === "stdio" ? current : { ...current, [key]: value }, | |
| 805 | − ); | |
| 806 | − }; | |
| 807 | − | |
| 808 | − const setStdioTransportField = < | |
| 809 | − K extends Exclude<keyof McpStdioTransportFields, "transport">, | |
| 810 | − >( | |
| 811 | − key: K, | |
| 812 | − value: McpStdioTransportFields[K], | |
| 813 | − ) => { | |
| 814 | − setConnectForm((current) => | |
| 815 | − current.transport === "stdio" ? { ...current, [key]: value } : current, | |
| 816 | − ); | |
| 817 | − }; | |
| 818 | − | |
| 819 | − const setSourceKind = (kind: ConnectFormState["kind"]) => { | |
| 820 | − setConnectForm((current) => ({ | |
| 821 | − ...current, | |
| 822 | − kind, | |
| 823 | − authKind: authKindForSourceKind(current.authKind, kind), | |
| 824 | − workspaceOauthClientId: | |
| 825 | − kind === "google_discovery" ? current.workspaceOauthClientId : "", | |
| 826 | − })); | |
| 827 | − }; | |
| 828 | − | |
| 829 | − const setProbeField = <K extends keyof ProbeAuthState>( | |
| 830 | − key: K, | |
| 831 | − value: ProbeAuthState[K], | |
| 832 | − ) => { | |
| 833 | − setProbeAuth((current) => ({ ...current, [key]: value })); | |
| 834 | − }; | |
| 835 | − | |
| 836 | − const googleTemplates = sourceTemplates.filter( | |
| 837 | − (template) => template.groupId === "google_workspace" && template.batchable, | |
| 838 | − ); | |
| 839 | − | |
| 840 | − // ------------------------------------------------------------------------- | |
| 841 | − // Handlers | |
| 842 | − // ------------------------------------------------------------------------- | |
| 843 | − | |
| 844 | − const handleDiscover = async () => { | |
| 845 | − setStatusBanner(null); | |
| 846 | − const trimmedUrl = url.trim(); | |
| 847 | − if (!trimmedUrl) { | |
| 848 | − setStatusBanner({ | |
| 849 | − tone: "error", | |
| 850 | − text: "Please enter a URL to discover.", | |
| 851 | − }); | |
| 852 | − return; | |
| 853 | − } | |
| 854 | − | |
| 855 | − setPhase("discovering"); | |
| 856 | − | |
| 857 | − try { | |
| 858 | − const payload: DiscoverSourcePayload = { | |
| 859 | − url: trimmedUrl, | |
| 860 | − ...(showProbeAuth && probeAuth.kind !== "none" | |
| 861 | − ? { probeAuth: buildProbeAuth(probeAuth) } | |
| 862 | − : {}), | |
| 863 | − }; | |
| 864 | − const result = await discoverSource.mutateAsync(payload); | |
| 865 | − setConnectForm(defaultConnectForm(result)); | |
| 866 | − setPhase("editing"); | |
| 867 | − | |
| 868 | − if (result.detectedKind === "unknown") { | |
| 869 | − setStatusBanner({ | |
| 870 | − tone: "info", | |
| 871 | − text: "Could not auto-detect the source type. Please configure manually.", | |
| 872 | − }); | |
| 873 | − } else if (result.warnings.length > 0) { | |
| 874 | − setStatusBanner({ | |
| 875 | − tone: "info", | |
| 876 | − text: result.warnings.join(" "), | |
| 877 | − }); | |
| 878 | − } | |
| 879 | − } catch (error) { | |
| 880 | − setPhase("idle"); | |
| 881 | − setStatusBanner({ | |
| 882 | − tone: "error", | |
| 883 | − text: error instanceof Error ? error.message : "Discovery failed.", | |
| 884 | − }); | |
| 885 | − } | |
| 886 | − }; | |
| 887 | − | |
| 888 | − const handleSkipDiscovery = () => { | |
| 889 | − setConnectForm(defaultConnectForm()); | |
| 890 | − setPhase("editing"); | |
| 891 | − setStatusBanner(null); | |
| 892 | − }; | |
| 893 | − | |
| 894 | − const applyTemplate = async (template: SourceTemplate) => { | |
| 895 | − if (template.batchable) { | |
| 896 | − setSelectedGoogleTemplateIds((current) => | |
| 897 | − current.includes(template.id) | |
| 898 | − ? current.filter((id) => id !== template.id) | |
| 899 | − : [...current, template.id], | |
| 900 | − ); | |
| 901 | − setStatusBanner(null); | |
| 902 | − return; | |
| 903 | − } | |
| 904 | − | |
| 905 | − if (isStdioMcpSourceTemplate(template)) { | |
| 906 | − setUrl(""); | |
| 907 | − setConnectForm(connectFormFromTemplate(template)); | |
| 908 | − setPhase("editing"); | |
| 909 | − setStatusBanner({ | |
| 910 | − tone: "info", | |
| 911 | − text: `${template.name} loaded. Review the local command, then connect.`, | |
| 912 | − }); | |
| 913 | − return; | |
| 914 | − } | |
| 915 | − | |
| 916 | − const discoveryUrl = | |
| 917 | − "specUrl" in template ? template.specUrl : (template.endpoint ?? ""); | |
| 918 | − setUrl(template.endpoint ?? ""); | |
| 919 | − setStatusBanner(null); | |
| 920 | − setPhase("discovering"); | |
| 921 | − | |
| 922 | − try { | |
| 923 | − const result = await discoverSource.mutateAsync({ url: discoveryUrl }); | |
| 924 | − let form: ConnectFormState = { | |
| 925 | − ...defaultConnectForm(result), | |
| 926 | − name: template.name, | |
| 927 | − endpoint: template.endpoint ?? "", | |
| 928 | − namespace: template.namespace ?? namespaceFromUrl(template.endpoint ?? ""), | |
| 929 | − }; | |
| 930 | − if (template.kind === "mcp") { | |
| 931 | − form = { | |
| 932 | − ...form, | |
| 933 | − ...(template.connectionType === "command" | |
| 934 | − ? defaultMcpStdioTransportFields({ | |
| 935 | − command: template.command ?? "", | |
| 936 | − argsText: stringifyStringArray(template.args), | |
| 937 | − envText: stringifyStringMap(template.env), | |
| 938 | − cwd: template.cwd ?? "", | |
| 939 | − }) | |
| 940 | − : defaultMcpRemoteTransportFields( | |
| 941 | − asMcpRemoteTransportValue(template.transport), | |
| 942 | − )), | |
| 943 | − }; | |
| 944 | − } | |
| 945 | − if ("specUrl" in template) { | |
| 946 | − form = { | |
| 947 | − ...form, | |
| 948 | − specUrl: template.specUrl, | |
| 949 | − }; | |
| 950 | − } | |
| 951 | − setConnectForm(form); | |
| 952 | − setPhase("editing"); | |
| 953 | − | |
| 954 | − if (result.warnings.length > 0) { | |
| 955 | − setStatusBanner({ | |
| 956 | − tone: "info", | |
| 957 | − text: result.warnings.join(" "), | |
| 958 | − }); | |
| 959 | − } | |
| 960 | − } catch (error) { | |
| 961 | − // Discovery failed — fall back to just the template basics | |
| 962 | − setConnectForm(connectFormFromTemplate(template)); | |
| 963 | − setPhase("editing"); | |
| 964 | − setStatusBanner({ | |
| 965 | − tone: "error", | |
| 966 | − text: `Discovery failed for ${template.name}: ${error instanceof Error ? error.message : "unknown error"}. Configure manually.`, | |
| 967 | − }); | |
| 968 | − } | |
| 969 | − }; | |
| 970 | − | |
| 971 | − const handleConnect = async () => { | |
| 972 | − setStatusBanner(null); | |
| 973 | − | |
| 974 | − try { | |
| 975 | − const payload = buildConnectPayload(connectForm); | |
| 976 | − setPhase("connecting"); | |
| 977 | − | |
| 978 | − const result = await connectSource.mutateAsync(payload); | |
| 979 | − setConnectResult(result); | |
| 980 | − | |
| 981 | − if (result.kind === "connected") { | |
| 982 | − setBatchOauthInfo(null); | |
| 983 | − setPhase("connected"); | |
| 984 | − setStatusBanner({ | |
| 985 | − tone: "success", | |
| 986 | − text: `"${result.source.name}" connected successfully.`, | |
| 987 | − }); | |
| 988 | − // Navigate to source detail after short delay | |
| 989 | − setTimeout(() => { | |
| 990 | − void navigate({ | |
| 991 | − to: "/sources/$sourceId", | |
| 992 | − params: { sourceId: result.source.id }, | |
| 993 | − search: { tab: "model" }, | |
| 994 | − }); | |
| 995 | − }, 1200); | |
| 996 | − } else if (result.kind === "credential_required") { | |
| 997 | − setBatchOauthInfo(null); | |
| 998 | − setPhase("credential_required"); | |
| 999 | − setStatusBanner({ | |
| 1000 | − tone: "info", | |
| 1001 | − text: "This source requires credentials. Configure auth below, then connect again.", | |
| 1002 | − }); | |
| 1003 | − // Pre-select bearer auth if not already set | |
| 1004 | − if (connectForm.authKind === "none") { | |
| 1005 | − setFormField("authKind", "bearer"); | |
| 1006 | − } | |
| 1007 | − } else if (result.kind === "oauth_required") { | |
| 1008 | − setBatchOauthInfo(null); | |
| 1009 | − setPhase("oauth_required"); | |
| 1010 | − setOauthInfo({ | |
| 1011 | − source: result.source, | |
| 1012 | − sessionId: result.sessionId, | |
| 1013 | − authorizationUrl: result.authorizationUrl, | |
| 1014 | − }); | |
| 1015 | − setStatusBanner({ | |
| 1016 | − tone: "info", | |
| 1017 | − text: "This source requires OAuth authentication. Click the button below to sign in.", | |
| 1018 | − }); | |
| 1019 | − } | |
| 1020 | − } catch (error) { | |
| 1021 | − setPhase("editing"); | |
| 1022 | − setStatusBanner({ | |
| 1023 | − tone: "error", | |
| 1024 | − text: error instanceof Error ? error.message : "Connection failed.", | |
| 1025 | − }); | |
| 1026 | − } | |
| 1027 | − }; | |
| 1028 | − | |
| 1029 | − const handleOAuthPopup = async () => { | |
| 1030 | − if (!oauthInfo && !batchOauthInfo) return; | |
| 1031 | − setStatusBanner(null); | |
| 1032 | − setOauthBusy(true); | |
| 1033 | − | |
| 1034 | − try { | |
| 1035 | − await startSourceOAuthPopup({ | |
| 1036 | − authorizationUrl: | |
| 1037 | − oauthInfo?.authorizationUrl ?? batchOauthInfo!.authorizationUrl, | |
| 1038 | − sessionId: oauthInfo?.sessionId ?? batchOauthInfo!.sessionId, | |
| 1039 | − }); | |
| 1040 | − | |
| 1041 | − refreshSecrets(); | |
| 1042 | − invalidateExecutorQueries(); | |
| 1043 | − setPhase("connected"); | |
| 1044 | − setStatusBanner({ | |
| 1045 | − tone: "success", | |
| 1046 | − text: oauthInfo | |
| 1047 | − ? `"${oauthInfo.source.name}" connected via OAuth.` | |
| 1048 | − : `Connected ${batchOauthInfo?.sourceIds.length ?? 0} Google source${(batchOauthInfo?.sourceIds.length ?? 0) === 1 ? "" : "s"} via OAuth.`, | |
| 1049 | − }); | |
| 1050 | − | |
| 1051 | − setTimeout(() => { | |
| 1052 | − if (oauthInfo) { | |
| 1053 | − void navigate({ | |
| 1054 | − to: "/sources/$sourceId", | |
| 1055 | − params: { sourceId: oauthInfo.source.id }, | |
| 1056 | − search: { tab: "model" }, | |
| 1057 | − }); | |
| 1058 | − return; | |
| 1059 | − } | |
| 1060 | − | |
| 1061 | − void navigate({ | |
| 1062 | − to: "/", | |
| 1063 | − }); | |
| 1064 | − }, 1200); | |
| 1065 | − } catch (error) { | |
| 1066 | − setStatusBanner({ | |
| 1067 | − tone: "error", | |
| 1068 | − text: error instanceof Error ? error.message : "OAuth flow failed.", | |
| 1069 | − }); | |
| 1070 | − } finally { | |
| 1071 | − setOauthBusy(false); | |
| 1072 | − } | |
| 1073 | − }; | |
| 1074 | − | |
| 1075 | − const ensureGoogleWorkspaceOauthClientId = async (): Promise<string> => { | |
| 1076 | − const selectedClientId = connectForm.workspaceOauthClientId.trim(); | |
| 1077 | − if (selectedClientId.length > 0) { | |
| 1078 | − return selectedClientId; | |
| 1079 | − } | |
| 1080 | − | |
| 1081 | − const clientId = connectForm.oauthClientId.trim(); | |
| 1082 | − if (clientId.length === 0) { | |
| 1083 | − throw new Error( | |
| 1084 | − "Choose an existing Google workspace OAuth client or enter a new client ID.", | |
| 1085 | − ); | |
| 1086 | − } | |
| 1087 | − | |
| 1088 | − const created = await createWorkspaceOauthClient.mutateAsync({ | |
| 1089 | − providerKey: "google_workspace", | |
| 1090 | − label: "Google Workspace", | |
| 1091 | − oauthClient: { | |
| 1092 | − clientId, | |
| 1093 | − clientSecret: trimToNull(connectForm.oauthClientSecret), | |
| 1094 | − }, | |
| 1095 | − } satisfies CreateWorkspaceOauthClientPayload); | |
| 1096 | − | |
| 1097 | − setFormField("workspaceOauthClientId", created.id); | |
| 1098 | − return created.id; | |
| 1099 | − }; | |
| 1100 | − | |
| 1101 | − const handleConnectGoogleBatch = async () => { | |
| 1102 | − if (selectedGoogleTemplateIds.length === 0) { | |
| 1103 | − return; | |
| 1104 | − } | |
| 1105 | − | |
| 1106 | − setStatusBanner(null); | |
| 1107 | − | |
| 1108 | − try { | |
| 1109 | − const workspaceOauthClientId = await ensureGoogleWorkspaceOauthClientId(); | |
| 1110 | − const selectedTemplates = googleTemplates.filter( | |
| 1111 | − ( | |
| 1112 | − template, | |
| 1113 | − ): template is Extract<SourceTemplate, { kind: "google_discovery" }> => | |
| 1114 | − selectedGoogleTemplateIds.includes(template.id), | |
| 1115 | − ); | |
| 1116 | − const payload: ConnectSourceBatchPayload = { | |
| 1117 | − scopeOauthClientId: | |
| 1118 | − workspaceOauthClientId as WorkspaceOauthClient["id"], | |
| 1119 | − sources: selectedTemplates.map((template) => ({ | |
| 1120 | − service: template.service, | |
| 1121 | − version: template.version, | |
| 1122 | − discoveryUrl: template.discoveryUrl, | |
| 1123 | − name: template.name, | |
| 1124 | − namespace: googleDiscoveryNamespace(template.service), | |
| 1125 | − })), | |
| 1126 | − }; | |
| 1127 | − | |
| 1128 | − setBatchConnecting(true); | |
| 1129 | − setPhase("connecting"); | |
| 1130 | − const result = await connectSourceBatch.mutateAsync(payload); | |
| 1131 | − if (result.providerOauthSession) { | |
| 1132 | − setBatchOauthInfo({ | |
| 1133 | − sessionId: result.providerOauthSession.sessionId, | |
| 1134 | − authorizationUrl: result.providerOauthSession.authorizationUrl, | |
| 1135 | − sourceIds: result.providerOauthSession.sourceIds, | |
| 1136 | − }); | |
| 1137 | − setOauthInfo(null); | |
| 1138 | − setBatchConnecting(false); | |
| 1139 | − setPhase("oauth_required"); | |
| 1140 | − setStatusBanner(null); | |
| 1141 | − return; | |
| 1142 | − } | |
| 1143 | − | |
| 1144 | − setSelectedGoogleTemplateIds([]); | |
| 1145 | − setBatchOauthInfo(null); | |
| 1146 | − setBatchConnecting(false); | |
| 1147 | − setPhase("connected"); | |
| 1148 | − setStatusBanner({ | |
| 1149 | − tone: "success", | |
| 1150 | − text: `Connected ${result.results.length} Google source${result.results.length === 1 ? "" : "s"}.`, | |
| 1151 | − }); | |
| 1152 | − setTimeout(() => { | |
| 1153 | − void navigate({ to: "/" }); | |
| 1154 | − }, 1200); | |
| 1155 | − } catch (error) { | |
| 1156 | − setBatchConnecting(false); | |
| 1157 | − setPhase("idle"); | |
| 1158 | − setStatusBanner({ | |
| 1159 | − tone: "error", | |
| 1160 | − text: | |
| 1161 | − error instanceof Error ? error.message : "Batch connection failed.", | |
| 1162 | − }); | |
| 1163 | − } | |
| 1164 | − }; | |
| 1165 | − | |
| 1166 | − const handleCredentialConnect = async () => { | |
| 1167 | − // Re-run connect with the auth now configured | |
| 1168 | − await handleConnect(); | |
| 1169 | − }; | |
| 1170 | − | |
| 1171 | − const handleBackToEditing = () => { | |
| 1172 | − setPhase("editing"); | |
| 1173 | − setStatusBanner(null); | |
| 1174 | − setConnectResult(null); | |
| 1175 | − setOauthInfo(null); | |
| 1176 | − }; | |
| 1177 | − | |
| 1178 | − const isDiscovering = phase === "discovering"; | |
| 1179 | − const isConnecting = phase === "connecting"; | |
| 1180 | − const isBusy = | |
| 1181 | − isDiscovering || | |
| 1182 | − isConnecting || | |
| 1183 | − oauthBusy || | |
| 1184 | − connectSourceBatch.status === "pending" || | |
| 1185 | − createWorkspaceOauthClient.status === "pending"; | |
| 1186 | − | |
| 1187 | − // ------------------------------------------------------------------------- | |
| 1188 | − // Render | |
| 1189 | − // ------------------------------------------------------------------------- | |
| 1190 | − | |
| 1191 | − return ( | |
| 1192 | − <div className="flex-1 overflow-y-auto"> | |
| 1193 | − <div className="mx-auto max-w-3xl px-6 py-8 lg:px-10 lg:py-12"> | |
| 1194 | − {/* Back */} | |
| 1195 | − <Link | |
| 1196 | − to="/" | |
| 1197 | − className="inline-flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground mb-6" | |
| 1198 | − > | |
| 1199 | − <IconArrowLeft className="size-3.5" /> | |
| 1200 | − Back | |
| 1201 | − </Link> | |
| 1202 | − | |
| 1203 | − <div className="flex items-center justify-between gap-4 mb-6"> | |
| 1204 | − <h1 className="font-display text-2xl tracking-tight text-foreground lg:text-3xl"> | |
| 1205 | − Add source | |
| 1206 | − </h1> | |
| 1207 | − {phase === "editing" && ( | |
| 1208 | − <Badge variant="outline">{connectForm.kind}</Badge> | |
| 1209 | − )} | |
| 1210 | − </div> | |
| 1211 | − | |
| 1212 | − {/* Step indicator */} | |
| 1213 | − {phase !== "idle" && phase !== "discovering" && ( | |
| 1214 | − <div className="mb-6 flex items-center gap-1.5" aria-label="Progress"> | |
| 1215 | − {(["discover", "configure", "connect"] as const).map((step, i) => { | |
| 1216 | − const stepPhases: Record<string, number> = { | |
| 1217 | − editing: 1, | |
| 1218 | − connecting: 2, | |
| 1219 | − credential_required: 1, | |
| 1220 | − oauth_required: 2, | |
| 1221 | − connected: 2, | |
| 1222 | − }; | |
| 1223 | − const currentStep = stepPhases[phase] ?? 0; | |
| 1224 | − const isComplete = i < currentStep; | |
| 1225 | − const isCurrent = i === currentStep; | |
| 1226 | − return ( | |
| 1227 | − <div key={step} className="flex items-center gap-1.5"> | |
| 1228 | − {i > 0 && ( | |
| 1229 | − <div | |
| 1230 | − className={cn( | |
| 1231 | − "h-px w-6 transition-colors", | |
| 1232 | − isComplete || isCurrent ? "bg-primary/40" : "bg-border", | |
| 1233 | − )} | |
| 1234 | − /> | |
| 1235 | − )} | |
| 1236 | − <div | |
| 1237 | − className={cn( | |
| 1238 | − "flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.06em] transition-colors", | |
| 1239 | − isComplete | |
| 1240 | − ? "text-primary" | |
| 1241 | − : isCurrent | |
| 1242 | − ? "text-foreground bg-muted/50" | |
| 1243 | − : "text-muted-foreground/40", | |
| 1244 | − )} | |
| 1245 | − > | |
| 1246 | − {isComplete ? ( | |
| 1247 | − <IconCheck className="size-2.5" /> | |
| 1248 | − ) : ( | |
| 1249 | − <span className="flex size-3.5 items-center justify-center rounded-full border text-[9px] tabular-nums border-current"> | |
| 1250 | − {i + 1} | |
| 1251 | − </span> | |
| 1252 | − )} | |
| 1253 | − {step} | |
| 1254 | − </div> | |
| 1255 | − </div> | |
| 1256 | − ); | |
| 1257 | − })} | |
| 1258 | − </div> | |
| 1259 | − )} | |
| 1260 | − | |
| 1261 | − {statusBanner && <StatusBanner state={statusBanner} className="mb-6" />} | |
| 1262 | − | |
| 1263 | − {(phase === "idle" || phase === "discovering") && ( | |
| 1264 | − <LocalMcpInstallCard | |
| 1265 | − className="mb-6 rounded-xl border border-border bg-card/80 p-5" | |
| 1266 | − title="Install this executor as MCP" | |
| 1267 | − description="Prefer a one-command setup? Install this local executor server into your MCP client, or add an external MCP source below." | |
| 1268 | − /> | |
| 1269 | − )} | |
| 1270 | − {/* Step 1: Discovery */} | |
| 1271 | − {(phase === "idle" || phase === "discovering") && ( | |
| 1272 | − <div className="space-y-6"> | |
| 1273 | − <Section title="Discover"> | |
| 1274 | − <div className="space-y-4"> | |
| 1275 | − <p className="text-[13px] text-muted-foreground"> | |
| 1276 | − Enter a URL and we'll auto-detect the source type, endpoint, | |
| 1277 | − auth requirements, and more. | |
| 1278 | − </p> | |
| 1279 | − | |
| 1280 | − <Field label="URL"> | |
| 1281 | − <div className="space-y-0"> | |
| 1282 | − <div className="flex gap-2"> | |
| 1283 | − <input | |
| 1284 | − value={url} | |
| 1285 | − onChange={(e) => setUrl(e.target.value)} | |
| 1286 | − placeholder="https://api.example.com or https://mcp.example.com/mcp" | |
| 1287 | − onKeyDown={(e) => { | |
| 1288 | − if (e.key === "Enter" && !isDiscovering) { | |
| 1289 | − void handleDiscover(); | |
| 1290 | − } | |
| 1291 | − }} | |
| 1292 | − className="h-9 flex-1 rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 1293 | − /> | |
| 1294 | − <Button onClick={handleDiscover} disabled={isBusy}> | |
| 1295 | − {isDiscovering ? ( | |
| 1296 | − <IconSpinner className="size-3.5" /> | |
| 1297 | − ) : ( | |
| 1298 | − <IconDiscover className="size-3.5" /> | |
| 1299 | − )} | |
| 1300 | − {isDiscovering ? "Discovering\u2026" : "Discover"} | |
| 1301 | − </Button> | |
| 1302 | − </div> | |
| 1303 | − {isDiscovering && ( | |
| 1304 | − <div className="mt-1.5 h-0.5 w-full overflow-hidden rounded-full bg-muted"> | |
| 1305 | − <div | |
| 1306 | − className="h-full w-1/4 rounded-full bg-primary/60" | |
| 1307 | − style={{ | |
| 1308 | − animation: | |
| 1309 | − "indeterminate-progress 1.2s ease-in-out infinite", | |
| 1310 | − }} | |
| 1311 | − /> | |
| 1312 | − </div> | |
| 1313 | − )} | |
| 1314 | − </div> | |
| 1315 | − </Field> | |
| 1316 | − | |
| 1317 | − {/* Probe auth toggle */} | |
| 1318 | − <div> | |
| 1319 | − <button | |
| 1320 | − type="button" | |
| 1321 | − onClick={() => setShowProbeAuth(!showProbeAuth)} | |
| 1322 | − className="text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground" | |
| 1323 | − > | |
| 1324 | − {showProbeAuth | |
| 1325 | − ? "Hide probe auth" | |
| 1326 | − : "Need auth to discover?"} | |
| 1327 | − </button> | |
| 1328 | − </div> | |
| 1329 | − | |
| 1330 | − {showProbeAuth && ( | |
| 1331 | − <div className="rounded-lg border border-border bg-card/70 p-4 space-y-3"> | |
| 1332 | − <Field label="Auth type"> | |
| 1333 | − <SelectInput | |
| 1334 | − value={probeAuth.kind} | |
| 1335 | − onChange={(v) => | |
| 1336 | − setProbeField("kind", v as ProbeAuthKind) | |
| 1337 | − } | |
| 1338 | − options={probeAuthOptions.map((v) => ({ | |
| 1339 | − value: v, | |
| 1340 | − label: v, | |
| 1341 | − }))} | |
| 1342 | − /> | |
| 1343 | − </Field> | |
| 1344 | − | |
| 1345 | − {probeAuth.kind === "bearer" && ( | |
| 1346 | − <div className="grid gap-3 sm:grid-cols-2"> | |
| 1347 | − <Field label="Header name"> | |
| 1348 | − <TextInput | |
| 1349 | − value={probeAuth.headerName} | |
| 1350 | − onChange={(v) => setProbeField("headerName", v)} | |
| 1351 | − placeholder="Authorization" | |
| 1352 | − /> | |
| 1353 | − </Field> | |
| 1354 | − <Field label="Prefix"> | |
| 1355 | − <TextInput | |
| 1356 | − value={probeAuth.prefix} | |
| 1357 | − onChange={(v) => setProbeField("prefix", v)} | |
| 1358 | − placeholder="Bearer " | |
| 1359 | − /> | |
| 1360 | − </Field> | |
| 1361 | − <Field label="Token" className="sm:col-span-2"> | |
| 1362 | − <TextInput | |
| 1363 | − value={probeAuth.token} | |
| 1364 | − onChange={(v) => setProbeField("token", v)} | |
| 1365 | − placeholder="sk-..." | |
| 1366 | − mono | |
| 1367 | − /> | |
| 1368 | − </Field> | |
| 1369 | − </div> | |
| 1370 | − )} | |
| 1371 | − | |
| 1372 | − {probeAuth.kind === "basic" && ( | |
| 1373 | − <div className="grid gap-3 sm:grid-cols-2"> | |
| 1374 | − <Field label="Username"> | |
| 1375 | − <TextInput | |
| 1376 | − value={probeAuth.username} | |
| 1377 | − onChange={(v) => setProbeField("username", v)} | |
| 1378 | − placeholder="user" | |
| 1379 | − /> | |
| 1380 | − </Field> | |
| 1381 | − <Field label="Password"> | |
| 1382 | − <TextInput | |
| 1383 | − value={probeAuth.password} | |
| 1384 | − onChange={(v) => setProbeField("password", v)} | |
| 1385 | − placeholder="pass" | |
| 1386 | − /> | |
| 1387 | − </Field> | |
| 1388 | − </div> | |
| 1389 | − )} | |
| 1390 | − | |
| 1391 | − {probeAuth.kind === "headers" && ( | |
| 1392 | − <Field label="Headers (JSON)"> | |
| 1393 | − <CodeEditor | |
| 1394 | − value={probeAuth.headersText} | |
| 1395 | − onChange={(v) => setProbeField("headersText", v)} | |
| 1396 | − placeholder={'{\n "x-api-key": "..."\n}'} | |
| 1397 | − /> | |
| 1398 | − </Field> | |
| 1399 | − )} | |
| 1400 | − </div> | |
| 1401 | − )} | |
| 1402 | − | |
| 1403 | − {/* Skip discovery link */} | |
| 1404 | − <div className="flex items-center justify-end border-t border-border pt-4"> | |
| 1405 | − <button | |
| 1406 | − type="button" | |
| 1407 | − onClick={handleSkipDiscovery} | |
| 1408 | − disabled={isBusy} | |
| 1409 | − className="text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground" | |
| 1410 | − > | |
| 1411 | − Skip discovery and configure manually | |
| 1412 | − </button> | |
| 1413 | − </div> | |
| 1414 | − | |
| 1415 | − {/* ---- Template catalogue ---- */} | |
| 1416 | − <div className="space-y-5 border-t border-border pt-5"> | |
| 1417 | − <div> | |
| 1418 | − <p className="text-[13px] font-medium text-foreground"> | |
| 1419 | − Start from a template | |
| 1420 | − </p> | |
| 1421 | − <p className="mt-0.5 text-[12px] text-muted-foreground"> | |
| 1422 | − Pick a known source to skip discovery, or select multiple | |
| 1423 | − Google APIs for batch connect. | |
| 1424 | − </p> | |
| 1425 | − </div> | |
| 1426 | − | |
| 1427 | − {/* -- Popular / non-Google templates -- */} | |
| 1428 | − {(() => { | |
| 1429 | − const popularTemplates = sourceTemplates.filter( | |
| 1430 | − (t) => t.groupId !== "google_workspace", | |
| 1431 | − ); | |
| 1432 | − return ( | |
| 1433 | − popularTemplates.length > 0 && ( | |
| 1434 | − <div className="space-y-2.5"> | |
| 1435 | − <p className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/50"> | |
| 1436 | − Popular | |
| 1437 | − </p> | |
| 1438 | − <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3"> | |
| 1439 | − {popularTemplates.map((template) => ( | |
| 1440 | − <button | |
| 1441 | − key={template.id} | |
| 1442 | − type="button" | |
| 1443 | − onClick={() => applyTemplate(template)} | |
| 1444 | − disabled={isBusy} | |
| 1445 | − className="group rounded-xl border border-border bg-card/70 px-4 py-3 text-left transition-all hover:bg-accent/50 hover:border-primary/20 hover:shadow-sm disabled:opacity-60" | |
| 1446 | − > | |
| 1447 | − <div className="mb-1.5 flex items-center gap-2.5"> | |
| 1448 | − <div className="flex size-5 shrink-0 items-center justify-center rounded-md bg-muted/50"> | |
| 1449 | − <SourceFavicon | |
| 1450 | − endpoint={template.endpoint} | |
| 1451 | − kind={template.kind} | |
| 1452 | − className="size-3.5" | |
| 1453 | − /> | |
| 1454 | − </div> | |
| 1455 | − <span className="flex-1 truncate text-[13px] font-medium text-foreground group-hover:text-primary transition-colors"> | |
| 1456 | − {template.name} | |
| 1457 | − </span> | |
| 1458 | − <Badge | |
| 1459 | − variant="outline" | |
| 1460 | − className="text-[9px] opacity-60 group-hover:opacity-100 transition-opacity" | |
| 1461 | − > | |
| 1462 | − {template.kind} | |
| 1463 | − </Badge> | |
| 1464 | − </div> | |
| 1465 | − <span className="line-clamp-1 text-[11px] text-muted-foreground/70"> | |
| 1466 | − {template.summary} | |
| 1467 | − </span> | |
| 1468 | − </button> | |
| 1469 | − ))} | |
| 1470 | − </div> | |
| 1471 | − </div> | |
| 1472 | − ) | |
| 1473 | − ); | |
| 1474 | − })()} | |
| 1475 | − | |
| 1476 | − {/* -- Google Workspace batch section -- */} | |
| 1477 | − <div className="space-y-3"> | |
| 1478 | − <div className="flex items-center justify-between gap-3"> | |
| 1479 | − <p className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/50"> | |
| 1480 | − Google Workspace | |
| 1481 | − </p> | |
| 1482 | − {googleTemplates.length > 0 && ( | |
| 1483 | − <div className="flex items-center gap-2"> | |
| 1484 | − {selectedGoogleTemplateIds.length > 0 && ( | |
| 1485 | − <button | |
| 1486 | − type="button" | |
| 1487 | − onClick={() => setSelectedGoogleTemplateIds([])} | |
| 1488 | − disabled={isBusy} | |
| 1489 | − className="text-[11px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground" | |
| 1490 | − > | |
| 1491 | − Clear | |
| 1492 | − </button> | |
| 1493 | − )} | |
| 1494 | − <button | |
| 1495 | − type="button" | |
| 1496 | − onClick={() => | |
| 1497 | − setSelectedGoogleTemplateIds( | |
| 1498 | − selectedGoogleTemplateIds.length === | |
| 1499 | − googleTemplates.length | |
| 1500 | − ? [] | |
| 1501 | − : googleTemplates.map((t) => t.id), | |
| 1502 | − ) | |
| 1503 | − } | |
| 1504 | − disabled={isBusy} | |
| 1505 | − className="text-[11px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground" | |
| 1506 | − > | |
| 1507 | − {selectedGoogleTemplateIds.length === | |
| 1508 | − googleTemplates.length | |
| 1509 | − ? "Deselect all" | |
| 1510 | − : "Select all"} | |
| 1511 | − </button> | |
| 1512 | − </div> | |
| 1513 | − )} | |
| 1514 | − </div> | |
| 1515 | − <div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3"> | |
| 1516 | − {googleTemplates.map((template) => { | |
| 1517 | − const selected = selectedGoogleTemplateIds.includes( | |
| 1518 | − template.id, | |
| 1519 | − ); | |
| 1520 | − return ( | |
| 1521 | − <button | |
| 1522 | − key={template.id} | |
| 1523 | − type="button" | |
| 1524 | − onClick={() => applyTemplate(template)} | |
| 1525 | − disabled={isBusy} | |
| 1526 | − className={cn( | |
| 1527 | − "group flex items-center gap-3 rounded-lg border px-3.5 py-2.5 text-left transition-all disabled:opacity-60", | |
| 1528 | − selected | |
| 1529 | − ? "border-primary/40 bg-primary/6 shadow-sm" | |
| 1530 | − : "border-border bg-card/50 hover:bg-accent/40 hover:border-border", | |
| 1531 | − )} | |
| 1532 | − > | |
| 1533 | − <div | |
| 1534 | − className={cn( | |
| 1535 | − "flex size-4 shrink-0 items-center justify-center rounded transition-colors", | |
| 1536 | − selected | |
| 1537 | − ? "text-primary" | |
| 1538 | − : "text-muted-foreground/30 group-hover:text-muted-foreground/50", | |
| 1539 | − )} | |
| 1540 | − > | |
| 1541 | − {selected ? ( | |
| 1542 | − <IconCheck className="size-3.5" /> | |
| 1543 | − ) : ( | |
| 1544 | − <SourceFavicon | |
| 1545 | − endpoint={template.endpoint} | |
| 1546 | − kind={template.kind} | |
| 1547 | − className="size-3.5" | |
| 1548 | − /> | |
| 1549 | − )} | |
| 1550 | − </div> | |
| 1551 | − <div className="min-w-0 flex-1"> | |
| 1552 | − <span | |
| 1553 | − className={cn( | |
| 1554 | − "block truncate text-[12px] font-medium transition-colors", | |
| 1555 | − selected | |
| 1556 | − ? "text-foreground" | |
| 1557 | − : "text-foreground/80", | |
| 1558 | − )} | |
| 1559 | − > | |
| 1560 | − {template.name} | |
| 1561 | − </span> | |
| 1562 | − <span className="block truncate text-[10px] text-muted-foreground/60"> | |
| 1563 | − {template.summary} | |
| 1564 | − </span> | |
| 1565 | − </div> | |
| 1566 | − </button> | |
| 1567 | − ); | |
| 1568 | − })} | |
| 1569 | − </div> | |
| 1570 | − | |
| 1571 | − {/* -- Batch connect panel (slides in when templates selected) -- */} | |
| 1572 | − {selectedGoogleTemplateIds.length > 0 && ( | |
| 1573 | − <div className="rounded-xl border border-primary/20 bg-gradient-to-b from-primary/4 to-transparent overflow-hidden"> | |
| 1574 | − {/* Header strip */} | |
| 1575 | − <div className="flex items-center justify-between gap-3 border-b border-primary/10 px-5 py-3"> | |
| 1576 | − <div className="flex items-center gap-2.5"> | |
| 1577 | − <div className="flex size-6 items-center justify-center rounded-md bg-primary/10 text-primary"> | |
| 1578 | − <span className="text-[11px] font-bold tabular-nums"> | |
| 1579 | − {selectedGoogleTemplateIds.length} | |
| 1580 | − </span> | |
| 1581 | − </div> | |
| 1582 | − <p className="text-[13px] font-medium text-foreground"> | |
| 1583 | − Connect Google API | |
| 1584 | − {selectedGoogleTemplateIds.length === 1 | |
| 1585 | − ? "" | |
| 1586 | − : "s"} | |
| 1587 | − </p> | |
| 1588 | − </div> | |
| 1589 | − <p className="hidden text-[11px] text-muted-foreground/60 sm:block"> | |
| 1590 | − One consent screen for all | |
| 1591 | − </p> | |
| 1592 | − </div> | |
| 1593 | − | |
| 1594 | − {/* Body */} | |
| 1595 | − <div className="px-5 py-4 space-y-4"> | |
| 1596 | − <Field label="Workspace OAuth client"> | |
| 1597 | − <select | |
| 1598 | − value={connectForm.workspaceOauthClientId} | |
| 1599 | − onChange={(event) => | |
| 1600 | − setFormField( | |
| 1601 | − "workspaceOauthClientId", | |
| 1602 | − event.target.value, | |
| 1603 | − ) | |
| 1604 | − } | |
| 1605 | − className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 1606 | − > | |
| 1607 | − <option value=""> | |
| 1608 | − Enter new client credentials | |
| 1609 | − </option> | |
| 1610 | − {workspaceOauthClients.status === "ready" && | |
| 1611 | − workspaceOauthClients.data.map((client) => ( | |
| 1612 | − <option key={client.id} value={client.id}> | |
| 1613 | − {client.label ?? client.clientId} | |
| 1614 | − </option> | |
| 1615 | − ))} | |
| 1616 | − </select> | |
| 1617 | − </Field> | |
| 1618 | − {connectForm.workspaceOauthClientId.trim().length === | |
| 1619 | − 0 && ( | |
| 1620 | − <div className="grid gap-3 sm:grid-cols-2"> | |
| 1621 | − <Field | |
| 1622 | − label="Client ID" | |
| 1623 | − className="sm:col-span-2" | |
| 1624 | − > | |
| 1625 | − <TextInput | |
| 1626 | − type="password" | |
| 1627 | − value={connectForm.oauthClientId} | |
| 1628 | − onChange={(v) => | |
| 1629 | − setFormField("oauthClientId", v) | |
| 1630 | − } | |
| 1631 | − placeholder="1234567890-abcdef.apps.googleusercontent.com" | |
| 1632 | − /> | |
| 1633 | − </Field> | |
| 1634 | − <Field | |
| 1635 | − label="Client secret" | |
| 1636 | − className="sm:col-span-2" | |
| 1637 | − > | |
| 1638 | − <TextInput | |
| 1639 | − type="password" | |
| 1640 | − value={connectForm.oauthClientSecret} | |
| 1641 | − onChange={(v) => | |
| 1642 | − setFormField("oauthClientSecret", v) | |
| 1643 | − } | |
| 1644 | − placeholder="GOCSPX-..." | |
| 1645 | − /> | |
| 1646 | − </Field> | |
| 1647 | − </div> | |
| 1648 | − )} | |
| 1649 | − <div className="flex items-center justify-end pt-1"> | |
| 1650 | − <Button | |
| 1651 | − type="button" | |
| 1652 | − onClick={handleConnectGoogleBatch} | |
| 1653 | − disabled={isBusy} | |
| 1654 | − > | |
| 1655 | − {isBusy ? ( | |
| 1656 | − <IconSpinner className="size-3.5" /> | |
| 1657 | − ) : ( | |
| 1658 | − <IconPlus className="size-3.5" /> | |
| 1659 | − )} | |
| 1660 | − Connect {selectedGoogleTemplateIds.length} source | |
| 1661 | − {selectedGoogleTemplateIds.length === 1 | |
| 1662 | − ? "" | |
| 1663 | − : "s"} | |
| 1664 | − </Button> | |
| 1665 | − </div> | |
| 1666 | − </div> | |
| 1667 | − </div> | |
| 1668 | − )} | |
| 1669 | − </div> | |
| 1670 | − </div> | |
| 1671 | − </div> | |
| 1672 | − </Section> | |
| 1673 | − </div> | |
| 1674 | − )} | |
| 1675 | − | |
| 1676 | − {/* Batch connecting interstitial */} | |
| 1677 | − {batchConnecting && phase === "connecting" && ( | |
| 1678 | − <div className="flex flex-col items-center justify-center py-20"> | |
| 1679 | − <div className="space-y-5 text-center"> | |
| 1680 | − <div className="mx-auto flex size-14 items-center justify-center rounded-2xl border border-primary/15 bg-primary/6"> | |
| 1681 | − <IconSpinner className="size-6 text-primary" /> | |
| 1682 | − </div> | |
| 1683 | − <div className="space-y-1.5"> | |
| 1684 | − <h2 className="font-display text-xl tracking-tight text-foreground"> | |
| 1685 | − Connecting {selectedGoogleTemplateIds.length} source | |
| 1686 | − {selectedGoogleTemplateIds.length === 1 ? "" : "s"} | |
| 1687 | − </h2> | |
| 1688 | − <p className="text-[13px] text-muted-foreground/70"> | |
| 1689 | − Setting up Google Workspace APIs\u2026 | |
| 1690 | − </p> | |
| 1691 | − </div> | |
| 1692 | − </div> | |
| 1693 | − </div> | |
| 1694 | − )} | |
| 1695 | − | |
| 1696 | − {/* Step 2-4: Editing / Connecting */} | |
| 1697 | − {(phase === "editing" || | |
| 1698 | − (phase === "connecting" && !batchConnecting) || | |
| 1699 | − phase === "credential_required") && ( | |
| 1700 | − <div className="space-y-6"> | |
| 1701 | − <Section title="Configuration"> | |
| 1702 | − <div className="grid gap-4 sm:grid-cols-2"> | |
| 1703 | − <Field label="Name"> | |
| 1704 | − <TextInput | |
| 1705 | − value={connectForm.name} | |
| 1706 | − onChange={(v) => setFormField("name", v)} | |
| 1707 | − placeholder="My API" | |
| 1708 | − /> | |
| 1709 | − </Field> | |
| 1710 | − <Field label="Kind"> | |
| 1711 | − <SelectInput | |
| 1712 | − value={connectForm.kind} | |
| 1713 | − onChange={(v) => | |
| 1714 | − setSourceKind(v as ConnectFormState["kind"]) | |
| 1715 | − } | |
| 1716 | − options={kindOptions.map((v) => ({ value: v, label: v }))} | |
| 1717 | − /> | |
| 1718 | − </Field> | |
| 1719 | − {connectForm.kind !== "google_discovery" && | |
| 1720 | − !( | |
| 1721 | − connectForm.kind === "mcp" && | |
| 1722 | − connectForm.transport === "stdio" | |
| 1723 | − ) && ( | |
| 1724 | − <Field label="Endpoint" className="sm:col-span-2"> | |
| 1725 | − <TextInput | |
| 1726 | − value={connectForm.endpoint} | |
| 1727 | − onChange={(v) => setFormField("endpoint", v)} | |
| 1728 | − placeholder="https://api.example.com" | |
| 1729 | − mono | |
| 1730 | − /> | |
| 1731 | − </Field> | |
| 1732 | − )} | |
| 1733 | − <Field label="Namespace"> | |
| 1734 | − <TextInput | |
| 1735 | − value={connectForm.namespace} | |
| 1736 | − onChange={(v) => setFormField("namespace", v)} | |
| 1737 | − placeholder="example" | |
| 1738 | − /> | |
| 1739 | − </Field> | |
| 1740 | − {connectForm.kind === "openapi" && ( | |
| 1741 | − <Field label="Spec URL" className="sm:col-span-2"> | |
| 1742 | − <TextInput | |
| 1743 | − value={connectForm.specUrl} | |
| 1744 | − onChange={(v) => setFormField("specUrl", v)} | |
| 1745 | − placeholder="https://example.com/openapi.yaml" | |
| 1746 | − mono | |
| 1747 | − /> | |
| 1748 | − </Field> | |
| 1749 | − )} | |
| 1750 | − {connectForm.kind === "google_discovery" && ( | |
| 1751 | − <> | |
| 1752 | − <Field label="Service"> | |
| 1753 | − <TextInput | |
| 1754 | − value={connectForm.service} | |
| 1755 | − onChange={(v) => setFormField("service", v)} | |
| 1756 | − placeholder="sheets" | |
| 1757 | − /> | |
| 1758 | − </Field> | |
| 1759 | − <Field label="Version"> | |
| 1760 | − <TextInput | |
| 1761 | − value={connectForm.version} | |
| 1762 | − onChange={(v) => setFormField("version", v)} | |
| 1763 | − placeholder="v4" | |
| 1764 | − /> | |
| 1765 | − </Field> | |
| 1766 | − <Field | |
| 1767 | − label="Discovery URL (optional)" | |
| 1768 | − className="sm:col-span-2" | |
| 1769 | − > | |
| 1770 | − <TextInput | |
| 1771 | − value={connectForm.discoveryUrl} | |
| 1772 | − onChange={(v) => setFormField("discoveryUrl", v)} | |
| 1773 | − placeholder="https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest" | |
| 1774 | − mono | |
| 1775 | − /> | |
| 1776 | − </Field> | |
| 1777 | − <div className="sm:col-span-2 rounded-lg border border-border bg-card/60 px-3 py-2 text-[12px] text-muted-foreground"> | |
| 1778 | − Common Google API versions: Gmail `v1`, Sheets `v4`, Drive | |
| 1779 | − `v3`, Calendar `v3`, Docs `v1`. | |
| 1780 | − </div> | |
| 1781 | − </> | |
| 1782 | − )} | |
| 1783 | − </div> | |
| 1784 | − </Section> | |
| 1785 | − | |
| 1786 | − {/* MCP Transport */} | |
| 1787 | − {connectForm.kind === "mcp" && ( | |
| 1788 | − <Section title="Transport"> | |
| 1789 | − <div className="grid gap-4 sm:grid-cols-2"> | |
| 1790 | − <Field label="Transport mode"> | |
| 1791 | − <SelectInput | |
| 1792 | − value={connectForm.transport || "auto"} | |
| 1793 | − onChange={(v) => setTransport(v as McpTransportValue)} | |
| 1794 | − options={transportOptions.map((v) => ({ | |
| 1795 | − value: v, | |
| 1796 | − label: v, | |
| 1797 | − }))} | |
| 1798 | − /> | |
| 1799 | − </Field> | |
| 1800 | − {connectForm.transport === "stdio" ? ( | |
| 1801 | − <div className="sm:col-span-2 grid gap-4 sm:grid-cols-2"> | |
| 1802 | − <Field label="Command"> | |
| 1803 | − <TextInput | |
| 1804 | − value={connectForm.command} | |
| 1805 | − onChange={(v) => setStdioTransportField("command", v)} | |
| 1806 | − placeholder="npx" | |
| 1807 | − mono | |
| 1808 | − /> | |
| 1809 | − </Field> | |
| 1810 | − <Field label="Working directory (optional)"> | |
| 1811 | − <TextInput | |
| 1812 | − value={connectForm.cwd} | |
| 1813 | − onChange={(v) => setStdioTransportField("cwd", v)} | |
| 1814 | − placeholder="/path/to/project" | |
| 1815 | − mono | |
| 1816 | − /> | |
| 1817 | − </Field> | |
| 1818 | − <Field label="Args (JSON)" className="sm:col-span-2"> | |
| 1819 | − <CodeEditor | |
| 1820 | − value={connectForm.argsText} | |
| 1821 | − onChange={(v) => | |
| 1822 | − setStdioTransportField("argsText", v) | |
| 1823 | − } | |
| 1824 | − placeholder={ | |
| 1825 | − '[\n "-y",\n "chrome-devtools-mcp@latest"\n]' | |
| 1826 | − } | |
| 1827 | − /> | |
| 1828 | − </Field> | |
| 1829 | − <Field | |
| 1830 | − label="Environment (JSON)" | |
| 1831 | − className="sm:col-span-2" | |
| 1832 | − > | |
| 1833 | − <CodeEditor | |
| 1834 | − value={connectForm.envText} | |
| 1835 | − onChange={(v) => setStdioTransportField("envText", v)} | |
| 1836 | − placeholder={ | |
| 1837 | − '{\n "CHROME_PATH": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"\n}' | |
| 1838 | − } | |
| 1839 | − /> | |
| 1840 | − </Field> | |
| 1841 | − </div> | |
| 1842 | − ) : ( | |
| 1843 | − <div className="sm:col-span-2 grid gap-4 sm:grid-cols-2"> | |
| 1844 | − <Field label="Query params (JSON)"> | |
| 1845 | − <CodeEditor | |
| 1846 | − value={connectForm.queryParamsText} | |
| 1847 | − onChange={(v) => | |
| 1848 | − setRemoteTransportField("queryParamsText", v) | |
| 1849 | − } | |
| 1850 | − placeholder={'{\n "workspace": "demo"\n}'} | |
| 1851 | − /> | |
| 1852 | − </Field> | |
| 1853 | − <Field label="Headers (JSON)"> | |
| 1854 | − <CodeEditor | |
| 1855 | − value={connectForm.headersText} | |
| 1856 | − onChange={(v) => | |
| 1857 | − setRemoteTransportField("headersText", v) | |
| 1858 | − } | |
| 1859 | − placeholder={'{\n "x-api-key": "..."\n}'} | |
| 1860 | − /> | |
| 1861 | − </Field> | |
| 1862 | − </div> | |
| 1863 | − )} | |
| 1864 | − </div> | |
| 1865 | − </Section> | |
| 1866 | − )} | |
| 1867 | − | |
| 1868 | − {/* Auth section (for non-MCP kinds, or when credential_required) */} | |
| 1869 | − {phase === "credential_required" && ( | |
| 1870 | − <div className="flex items-start gap-3 rounded-xl border border-amber-300/40 bg-amber-50/50 px-4 py-3.5 dark:border-amber-500/20 dark:bg-amber-950/20"> | |
| 1871 | − <div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-amber-100/80 dark:bg-amber-900/40"> | |
| 1872 | − <svg | |
| 1873 | − className="size-3.5 text-amber-600 dark:text-amber-400" | |
| 1874 | − viewBox="0 0 24 24" | |
| 1875 | − fill="none" | |
| 1876 | − stroke="currentColor" | |
| 1877 | − strokeWidth="2" | |
| 1878 | − strokeLinecap="round" | |
| 1879 | − strokeLinejoin="round" | |
| 1880 | − > | |
| 1881 | − <path d="M12 9v4" /> | |
| 1882 | − <path d="M12 17h.01" /> | |
| 1883 | − <path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /> | |
| 1884 | − </svg> | |
| 1885 | − </div> | |
| 1886 | − <div className="space-y-0.5"> | |
| 1887 | − <p className="text-[12px] font-medium text-amber-900 dark:text-amber-200"> | |
| 1888 | − Credentials required | |
| 1889 | − </p> | |
| 1890 | − <p className="text-[11px] leading-relaxed text-amber-800/70 dark:text-amber-300/60"> | |
| 1891 | − The server responded that this source requires | |
| 1892 | − authentication. Add a token or secret below, then try | |
| 1893 | − connecting again. | |
| 1894 | − </p> | |
| 1895 | − </div> | |
| 1896 | − </div> | |
| 1897 | − )} | |
| 1898 | − {connectForm.kind !== "mcp" && ( | |
| 1899 | − <Section title="Authentication"> | |
| 1900 | − <div className="grid gap-4 sm:grid-cols-2"> | |
| 1901 | − <Field label="Auth mode"> | |
| 1902 | − <SelectInput | |
| 1903 | − value={connectForm.authKind} | |
| 1904 | − onChange={(v) => | |
| 1905 | − setFormField( | |
| 1906 | − "authKind", | |
| 1907 | − v as ConnectFormState["authKind"], | |
| 1908 | − ) | |
| 1909 | − } | |
| 1910 | − options={authOptions.map((v) => ({ value: v, label: v }))} | |
| 1911 | − /> | |
| 1912 | − </Field> | |
| 1913 | − {connectForm.kind === "google_discovery" && ( | |
| 1914 | − <div className="sm:col-span-2 rounded-lg border border-border bg-card/60 px-3 py-2 text-[12px] text-muted-foreground"> | |
| 1915 | − {connectForm.authKind === "oauth2" | |
| 1916 | − ? "OAuth starts after you click Connect. Choose an existing workspace OAuth client or enter a new client once, then reuse it across Google sources." | |
| 1917 | − : "Choosing auth mode 'none' skips the Google OAuth popup. Use 'oauth2' if you want executor to start the sign-in flow for you."} | |
| 1918 | − </div> | |
| 1919 | − )} | |
| 1920 | − {connectForm.kind === "google_discovery" && | |
| 1921 | − connectForm.authKind === "oauth2" && ( | |
| 1922 | − <> | |
| 1923 | − <Field | |
| 1924 | − label="Workspace OAuth client" | |
| 1925 | − className="sm:col-span-2" | |
| 1926 | − > | |
| 1927 | − <SelectInput | |
| 1928 | − value={connectForm.workspaceOauthClientId} | |
| 1929 | − onChange={(value) => | |
| 1930 | − setFormField("workspaceOauthClientId", value) | |
| 1931 | − } | |
| 1932 | − options={[ | |
| 1933 | − { value: "", label: "Enter new client" }, | |
| 1934 | − ...(workspaceOauthClients.status === "ready" | |
| 1935 | − ? workspaceOauthClients.data.map((client) => ({ | |
| 1936 | − value: client.id, | |
| 1937 | − label: client.label ?? client.clientId, | |
| 1938 | − })) | |
| 1939 | − : []), | |
| 1940 | − ]} | |
| 1941 | − /> | |
| 1942 | − </Field> | |
| 1943 | − {connectForm.workspaceOauthClientId.trim().length === | |
| 1944 | − 0 && ( | |
| 1945 | − <> | |
| 1946 | − <Field | |
| 1947 | − label="New client ID" | |
| 1948 | − className="sm:col-span-2" | |
| 1949 | − > | |
| 1950 | − <TextInput | |
| 1951 | − type="password" | |
| 1952 | − value={connectForm.oauthClientId} | |
| 1953 | − onChange={(v) => | |
| 1954 | − setFormField("oauthClientId", v) | |
| 1955 | − } | |
| 1956 | − placeholder="1234567890-abcdef.apps.googleusercontent.com" | |
| 1957 | − /> | |
| 1958 | − </Field> | |
| 1959 | − <Field | |
| 1960 | − label="New client secret" | |
| 1961 | − className="sm:col-span-2" | |
| 1962 | − > | |
| 1963 | − <TextInput | |
| 1964 | − type="password" | |
| 1965 | − value={connectForm.oauthClientSecret} | |
| 1966 | − onChange={(v) => | |
| 1967 | − setFormField("oauthClientSecret", v) | |
| 1968 | − } | |
| 1969 | − placeholder="GOCSPX-..." | |
| 1970 | − /> | |
| 1971 | − </Field> | |
| 1972 | − </> | |
| 1973 | − )} | |
| 1974 | − </> | |
| 1975 | − )} | |
| 1976 | − {connectForm.authKind !== "none" && ( | |
| 1977 | − <> | |
| 1978 | − <Field label="Header name"> | |
| 1979 | − <TextInput | |
| 1980 | − value={connectForm.authHeaderName} | |
| 1981 | − onChange={(v) => setFormField("authHeaderName", v)} | |
| 1982 | − placeholder="Authorization" | |
| 1983 | − /> | |
| 1984 | − </Field> | |
| 1985 | − <Field label="Prefix"> | |
| 1986 | − <TextInput | |
| 1987 | − value={connectForm.authPrefix} | |
| 1988 | − onChange={(v) => setFormField("authPrefix", v)} | |
| 1989 | − placeholder="Bearer " | |
| 1990 | − /> | |
| 1991 | − </Field> | |
| 1992 | − </> | |
| 1993 | − )} | |
| 1994 | − {connectForm.authKind === "bearer" && ( | |
| 1995 | − <Field label="Token" className="sm:col-span-2"> | |
| 1996 | − <SecretOrTokenInput | |
| 1997 | − instanceConfig={instanceConfig} | |
| 1998 | − secrets={secrets} | |
| 1999 | − handle={connectForm.bearerHandle} | |
| 2000 | − inlineToken={connectForm.bearerToken} | |
| 2001 | − onSelectSecret={(providerId, handle) => { | |
| 2002 | − setFormField("bearerProviderId", providerId); | |
| 2003 | − setFormField("bearerHandle", handle); | |
| 2004 | − setFormField("bearerToken", ""); | |
| 2005 | − }} | |
| 2006 | − onChangeToken={(token) => { | |
| 2007 | − setFormField("bearerToken", token); | |
| 2008 | − setFormField("bearerProviderId", ""); | |
| 2009 | − setFormField("bearerHandle", ""); | |
| 2010 | − }} | |
| 2011 | − /> | |
| 2012 | − </Field> | |
| 2013 | − )} | |
| 2014 | − </div> | |
| 2015 | − </Section> | |
| 2016 | − )} | |
| 2017 | − | |
| 2018 | − {/* Actions */} | |
| 2019 | − <div className="flex items-center justify-end gap-3 border-t border-border pt-5"> | |
| 2020 | − {phase === "credential_required" && ( | |
| 2021 | − <Button | |
| 2022 | − variant="ghost" | |
| 2023 | − type="button" | |
| 2024 | − onClick={handleBackToEditing} | |
| 2025 | − > | |
| 2026 | − Back to edit | |
| 2027 | − </Button> | |
| 2028 | − )} | |
| 2029 | − <Link to="/" className="inline-flex"> | |
| 2030 | − <Button variant="ghost" type="button"> | |
| 2031 | − Cancel | |
| 2032 | − </Button> | |
| 2033 | − </Link> | |
| 2034 | − <Button | |
| 2035 | − onClick={ | |
| 2036 | − phase === "credential_required" | |
| 2037 | − ? handleCredentialConnect | |
| 2038 | − : handleConnect | |
| 2039 | − } | |
| 2040 | − disabled={isBusy} | |
| 2041 | − > | |
| 2042 | − {isConnecting ? ( | |
| 2043 | − <IconSpinner className="size-3.5" /> | |
| 2044 | − ) : ( | |
| 2045 | − <IconPlus className="size-3.5" /> | |
| 2046 | − )} | |
| 2047 | − {isConnecting ? "Connecting\u2026" : "Connect"} | |
| 2048 | − </Button> | |
| 2049 | − </div> | |
| 2050 | − </div> | |
| 2051 | − )} | |
| 2052 | − | |
| 2053 | − {/* Step 5a: OAuth required */} | |
| 2054 | − {phase === "oauth_required" && (oauthInfo || batchOauthInfo) && ( | |
| 2055 | − <div className="flex flex-col items-center justify-center py-16"> | |
| 2056 | − <div className="w-full max-w-sm space-y-6 text-center"> | |
| 2057 | − {/* Visual anchor */} | |
| 2058 | − <div className="mx-auto flex size-16 items-center justify-center rounded-2xl border border-primary/20 bg-gradient-to-b from-primary/10 to-primary/4"> | |
| 2059 | − {oauthBusy ? ( | |
| 2060 | − <IconSpinner className="size-6 text-primary" /> | |
| 2061 | − ) : ( | |
| 2062 | − <svg | |
| 2063 | − className="size-6 text-primary" | |
| 2064 | − viewBox="0 0 24 24" | |
| 2065 | − fill="none" | |
| 2066 | − stroke="currentColor" | |
| 2067 | − strokeWidth="1.5" | |
| 2068 | − strokeLinecap="round" | |
| 2069 | − strokeLinejoin="round" | |
| 2070 | − > | |
| 2071 | − <rect x="3" y="11" width="18" height="11" rx="2" ry="2" /> | |
| 2072 | − <path d="M7 11V7a5 5 0 0110 0v4" /> | |
| 2073 | − </svg> | |
| 2074 | − )} | |
| 2075 | − </div> | |
| 2076 | − | |
| 2077 | − <div className="space-y-2"> | |
| 2078 | − <h2 className="font-display text-xl tracking-tight text-foreground"> | |
| 2079 | − {oauthBusy | |
| 2080 | − ? "Waiting for sign-in\u2026" | |
| 2081 | − : "Sign in to continue"} | |
| 2082 | − </h2> | |
| 2083 | − <p className="text-[13px] leading-relaxed text-muted-foreground"> | |
| 2084 | − {oauthInfo ? ( | |
| 2085 | − <> | |
| 2086 | − <strong className="text-foreground"> | |
| 2087 | − {oauthInfo.source.name} | |
| 2088 | − </strong>{" "} | |
| 2089 | − requires OAuth authentication. A popup will open for you | |
| 2090 | − to authorize access. | |
| 2091 | − </> | |
| 2092 | − ) : ( | |
| 2093 | − <> | |
| 2094 | − {batchOauthInfo!.sourceIds.length} Google source | |
| 2095 | − {batchOauthInfo!.sourceIds.length === 1 ? "" : "s"} need | |
| 2096 | − {batchOauthInfo!.sourceIds.length === 1 ? "s" : ""} OAuth. | |
| 2097 | − One consent screen covers all selected APIs. | |
| 2098 | − </> | |
| 2099 | − )} | |
| 2100 | − </p> | |
| 2101 | − </div> | |
| 2102 | − | |
| 2103 | − <div className="flex flex-col items-center gap-3"> | |
| 2104 | − <Button | |
| 2105 | − onClick={handleOAuthPopup} | |
| 2106 | − disabled={oauthBusy} | |
| 2107 | − className="w-full max-w-xs" | |
| 2108 | − > | |
| 2109 | − {oauthBusy ? <IconSpinner className="size-3.5" /> : null} | |
| 2110 | − {oauthBusy ? "Authenticating\u2026" : "Open sign-in"} | |
| 2111 | − </Button> | |
| 2112 | − {!oauthBusy && ( | |
| 2113 | − <button | |
| 2114 | − type="button" | |
| 2115 | − onClick={handleBackToEditing} | |
| 2116 | − className="text-[12px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground" | |
| 2117 | − > | |
| 2118 | − Back to configuration | |
| 2119 | − </button> | |
| 2120 | − )} | |
| 2121 | − </div> | |
| 2122 | − </div> | |
| 2123 | − </div> | |
| 2124 | − )} | |
| 2125 | − | |
| 2126 | − {/* Step 5b: Connected */} | |
| 2127 | − {phase === "connected" && ( | |
| 2128 | − <div className="flex flex-col items-center justify-center py-20"> | |
| 2129 | − <div className="space-y-5 text-center"> | |
| 2130 | − <div className="mx-auto flex size-14 items-center justify-center rounded-full bg-primary/10 ring-4 ring-primary/5"> | |
| 2131 | − <IconCheck className="size-6 text-primary" /> | |
| 2132 | − </div> | |
| 2133 | − <div className="space-y-1.5"> | |
| 2134 | − <h2 className="font-display text-xl tracking-tight text-foreground"> | |
| 2135 | − {connectResult | |
| 2136 | − ? connectResult.source.name | |
| 2137 | − : batchOauthInfo | |
| 2138 | − ? `${batchOauthInfo.sourceIds.length} source${batchOauthInfo.sourceIds.length === 1 ? "" : "s"}` | |
| 2139 | − : "Source"}{" "} | |
| 2140 | − connected | |
| 2141 | − </h2> | |
| 2142 | − <p className="text-[13px] text-muted-foreground/70"> | |
| 2143 | − Redirecting\u2026 | |
| 2144 | − </p> | |
| 2145 | − </div> | |
| 2146 | − </div> | |
| 2147 | − </div> | |
| 2148 | − )} | |
| 2149 | − </div> | |
| 2150 | − </div> | |
| 2151 | − ); | |
| 2152 | −} | |
| 2153 | − | |
| 2154 | −// --------------------------------------------------------------------------- | |
| 2155 | −// Form building blocks | |
| 2156 | −// --------------------------------------------------------------------------- | |
| 2157 | − | |
| 2158 | −function Section(props: { | |
| 2159 | − title: string; | |
| 2160 | − children: ReactNode; | |
| 2161 | − className?: string; | |
| 2162 | −}) { | |
| 2163 | − return ( | |
| 2164 | − <section | |
| 2165 | − className={cn( | |
| 2166 | − "rounded-xl border border-border bg-card/80", | |
| 2167 | − props.className, | |
| 2168 | − )} | |
| 2169 | − > | |
| 2170 | − <div className="border-b border-border px-5 py-3"> | |
| 2171 | − <h2 className="text-sm font-semibold text-foreground">{props.title}</h2> | |
| 2172 | − </div> | |
| 2173 | − <div className="p-5">{props.children}</div> | |
| 2174 | − </section> | |
| 2175 | − ); | |
| 2176 | −} | |
| 2177 | − | |
| 2178 | −function Field(props: { | |
| 2179 | − label: string; | |
| 2180 | − className?: string; | |
| 2181 | − children: ReactNode; | |
| 2182 | −}) { | |
| 2183 | − return ( | |
| 2184 | − <label className={cn("block space-y-1.5", props.className)}> | |
| 2185 | − <span className="text-[12px] font-medium text-foreground"> | |
| 2186 | − {props.label} | |
| 2187 | − </span> | |
| 2188 | − {props.children} | |
| 2189 | − </label> | |
| 2190 | − ); | |
| 2191 | −} | |
| 2192 | − | |
| 2193 | −function TextInput(props: { | |
| 2194 | − type?: "text" | "password"; | |
| 2195 | − value: string; | |
| 2196 | − onChange: (value: string) => void; | |
| 2197 | − placeholder?: string; | |
| 2198 | − mono?: boolean; | |
| 2199 | −}) { | |
| 2200 | − return ( | |
| 2201 | − <input | |
| 2202 | − type={props.type || "text"} | |
| 2203 | − value={props.value} | |
| 2204 | − onChange={(event) => props.onChange(event.target.value)} | |
| 2205 | − placeholder={props.placeholder} | |
| 2206 | − className={cn( | |
| 2207 | − "h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25", | |
| 2208 | − props.mono && "font-mono text-[12px]", | |
| 2209 | − )} | |
| 2210 | − /> | |
| 2211 | − ); | |
| 2212 | −} | |
| 2213 | − | |
| 2214 | −function SelectInput(props: { | |
| 2215 | − value: string; | |
| 2216 | − onChange: (value: string) => void; | |
| 2217 | − options: ReadonlyArray<{ value: string; label: string }>; | |
| 2218 | −}) { | |
| 2219 | − return ( | |
| 2220 | − <select | |
| 2221 | − value={props.value} | |
| 2222 | − onChange={(event) => props.onChange(event.target.value)} | |
| 2223 | − className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 2224 | − > | |
| 2225 | − {props.options.map((option) => ( | |
| 2226 | − <option key={option.value} value={option.value}> | |
| 2227 | − {option.label} | |
| 2228 | − </option> | |
| 2229 | − ))} | |
| 2230 | − </select> | |
| 2231 | − ); | |
| 2232 | −} | |
| 2233 | − | |
| 2234 | −function CodeEditor(props: { | |
| 2235 | − value: string; | |
| 2236 | − onChange: (value: string) => void; | |
| 2237 | − placeholder?: string; | |
| 2238 | −}) { | |
| 2239 | − return ( | |
| 2240 | − <textarea | |
| 2241 | − value={props.value} | |
| 2242 | − onChange={(event) => props.onChange(event.target.value)} | |
| 2243 | − placeholder={props.placeholder} | |
| 2244 | − spellCheck={false} | |
| 2245 | − className="min-h-28 w-full rounded-lg border border-input bg-background px-3 py-2.5 font-mono text-[12px] leading-relaxed text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 2246 | − /> | |
| 2247 | − ); | |
| 2248 | −} | |
| 2249 | − | |
| 2250 | −function StatusBanner(props: { | |
| 2251 | − state: { tone: "info" | "success" | "error"; text: string }; | |
| 2252 | − className?: string; | |
| 2253 | −}) { | |
| 2254 | − return ( | |
| 2255 | − <div | |
| 2256 | − className={cn( | |
| 2257 | − "flex items-start gap-2.5 rounded-lg border px-4 py-3 text-[13px]", | |
| 2258 | − props.state.tone === "success" && | |
| 2259 | − "border-primary/30 bg-primary/8 text-foreground", | |
| 2260 | − props.state.tone === "info" && | |
| 2261 | − "border-border bg-card text-muted-foreground", | |
| 2262 | − props.state.tone === "error" && | |
| 2263 | − "border-destructive/30 bg-destructive/8 text-destructive", | |
| 2264 | − props.className, | |
| 2265 | − )} | |
| 2266 | − > | |
| 2267 | − <span className="mt-0.5 shrink-0"> | |
| 2268 | − {props.state.tone === "success" && ( | |
| 2269 | − <IconCheck className="size-3.5 text-primary" /> | |
| 2270 | − )} | |
| 2271 | − {props.state.tone === "error" && ( | |
| 2272 | − <svg | |
| 2273 | − className="size-3.5" | |
| 2274 | − viewBox="0 0 24 24" | |
| 2275 | − fill="none" | |
| 2276 | − stroke="currentColor" | |
| 2277 | − strokeWidth="2" | |
| 2278 | − strokeLinecap="round" | |
| 2279 | − strokeLinejoin="round" | |
| 2280 | − > | |
| 2281 | − <circle cx="12" cy="12" r="10" /> | |
| 2282 | − <path d="M15 9l-6 6" /> | |
| 2283 | − <path d="M9 9l6 6" /> | |
| 2284 | − </svg> | |
| 2285 | − )} | |
| 2286 | − {props.state.tone === "info" && ( | |
| 2287 | − <svg | |
| 2288 | − className="size-3.5" | |
| 2289 | − viewBox="0 0 24 24" | |
| 2290 | − fill="none" | |
| 2291 | − stroke="currentColor" | |
| 2292 | − strokeWidth="2" | |
| 2293 | − strokeLinecap="round" | |
| 2294 | − strokeLinejoin="round" | |
| 2295 | − > | |
| 2296 | − <circle cx="12" cy="12" r="10" /> | |
| 2297 | − <path d="M12 16v-4" /> | |
| 2298 | − <path d="M12 8h.01" /> | |
| 2299 | − </svg> | |
| 2300 | − )} | |
| 2301 | − </span> | |
| 2302 | − <span>{props.state.text}</span> | |
| 2303 | − </div> | |
| 2304 | − ); | |
| 2305 | −} | |
| 2306 | − | |
| 2307 | −// --------------------------------------------------------------------------- | |
| 2308 | −// Secret or inline token picker | |
| 2309 | −// --------------------------------------------------------------------------- | |
| 2310 | − | |
| 2311 | −const CREATE_NEW_VALUE = "__create_new__"; | |
| 2312 | −const INLINE_TOKEN_VALUE = "__inline_token__"; | |
| 2313 | − | |
| 2314 | −function SecretOrTokenInput(props: { | |
| 2315 | − instanceConfig: Loadable<InstanceConfig>; | |
| 2316 | − secrets: Loadable<ReadonlyArray<SecretListItem>>; | |
| 2317 | − handle: string; | |
| 2318 | − inlineToken: string; | |
| 2319 | − onSelectSecret: (providerId: string, handle: string) => void; | |
| 2320 | − onChangeToken: (token: string) => void; | |
| 2321 | −}) { | |
| 2322 | − const { | |
| 2323 | − instanceConfig, | |
| 2324 | − secrets, | |
| 2325 | − handle, | |
| 2326 | − inlineToken, | |
| 2327 | − onSelectSecret, | |
| 2328 | − onChangeToken, | |
| 2329 | − } = props; | |
| 2330 | − const createSecret = useCreateSecret(); | |
| 2331 | − const [showCreate, setShowCreate] = useState(false); | |
| 2332 | − const [useInline, setUseInline] = useState(false); | |
| 2333 | − const [newName, setNewName] = useState(""); | |
| 2334 | − const [newValue, setNewValue] = useState(""); | |
| 2335 | − const [newProviderId, setNewProviderId] = useState(""); | |
| 2336 | − const [createError, setCreateError] = useState<string | null>(null); | |
| 2337 | − const storableProviders = | |
| 2338 | − instanceConfig.status === "ready" | |
| 2339 | − ? instanceConfig.data.secretProviders.filter( | |
| 2340 | − (provider) => provider.canStore, | |
| 2341 | − ) | |
| 2342 | − : []; | |
| 2343 | − | |
| 2344 | − useEffect(() => { | |
| 2345 | − if (newProviderId.length > 0) { | |
| 2346 | − return; | |
| 2347 | − } | |
| 2348 | − if (instanceConfig.status === "ready") { | |
| 2349 | − setNewProviderId(instanceConfig.data.defaultSecretStoreProvider); | |
| 2350 | − } | |
| 2351 | − }, [instanceConfig, newProviderId]); | |
| 2352 | − | |
| 2353 | − const selectedValue = showCreate | |
| 2354 | − ? CREATE_NEW_VALUE | |
| 2355 | − : useInline | |
| 2356 | − ? INLINE_TOKEN_VALUE | |
| 2357 | − : handle || ""; | |
| 2358 | − | |
| 2359 | − const handleSelectChange = (value: string) => { | |
| 2360 | − if (value === CREATE_NEW_VALUE) { | |
| 2361 | − setShowCreate(true); | |
| 2362 | − setUseInline(false); | |
| 2363 | − setNewName(""); | |
| 2364 | − setNewValue(""); | |
| 2365 | − setNewProviderId( | |
| 2366 | − instanceConfig.status === "ready" | |
| 2367 | − ? instanceConfig.data.defaultSecretStoreProvider | |
| 2368 | − : "", | |
| 2369 | − ); | |
| 2370 | − setCreateError(null); | |
| 2371 | − return; | |
| 2372 | − } | |
| 2373 | − if (value === INLINE_TOKEN_VALUE) { | |
| 2374 | − setUseInline(true); | |
| 2375 | − setShowCreate(false); | |
| 2376 | − onSelectSecret("", ""); | |
| 2377 | − return; | |
| 2378 | − } | |
| 2379 | − if (value === "") { | |
| 2380 | − setUseInline(false); | |
| 2381 | − setShowCreate(false); | |
| 2382 | − onSelectSecret("", ""); | |
| 2383 | − return; | |
| 2384 | − } | |
| 2385 | − setUseInline(false); | |
| 2386 | − setShowCreate(false); | |
| 2387 | − const matchedSecret = | |
| 2388 | − secrets.status === "ready" | |
| 2389 | − ? secrets.data.find((secret) => secret.id === value) | |
| 2390 | − : null; | |
| 2391 | − onSelectSecret(matchedSecret?.providerId ?? "local", value); | |
| 2392 | − }; | |
| 2393 | − | |
| 2394 | − const handleCreate = async () => { | |
| 2395 | − setCreateError(null); | |
| 2396 | − const trimmedName = newName.trim(); | |
| 2397 | − if (!trimmedName) { | |
| 2398 | − setCreateError("Name is required."); | |
| 2399 | − return; | |
| 2400 | − } | |
| 2401 | − if (!newValue) { | |
| 2402 | − setCreateError("Value is required."); | |
| 2403 | − return; | |
| 2404 | − } | |
| 2405 | − | |
| 2406 | − try { | |
| 2407 | − const result = await createSecret.mutateAsync({ | |
| 2408 | − name: trimmedName, | |
| 2409 | − value: newValue, | |
| 2410 | − ...(newProviderId ? { providerId: newProviderId } : {}), | |
| 2411 | − }); | |
| 2412 | − onSelectSecret(result.providerId, result.id); | |
| 2413 | − setShowCreate(false); | |
| 2414 | − } catch (err) { | |
| 2415 | − setCreateError( | |
| 2416 | − err instanceof Error ? err.message : "Failed creating secret.", | |
| 2417 | − ); | |
| 2418 | − } | |
| 2419 | − }; | |
| 2420 | − | |
| 2421 | − if (secrets.status !== "ready") { | |
| 2422 | − return ( | |
| 2423 | − <select | |
| 2424 | − disabled | |
| 2425 | − className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-muted-foreground outline-none opacity-60" | |
| 2426 | − > | |
| 2427 | − <option>Loading...</option> | |
| 2428 | − </select> | |
| 2429 | − ); | |
| 2430 | − } | |
| 2431 | − | |
| 2432 | − const items = secrets.data; | |
| 2433 | − | |
| 2434 | − return ( | |
| 2435 | − <div className="space-y-2"> | |
| 2436 | − <select | |
| 2437 | − value={selectedValue} | |
| 2438 | − onChange={(e) => handleSelectChange(e.target.value)} | |
| 2439 | − className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 2440 | − > | |
| 2441 | − <option value="">Select a secret...</option> | |
| 2442 | − {items.map((secret) => ( | |
| 2443 | − <option key={secret.id} value={secret.id}> | |
| 2444 | − {secret.name || secret.id} ({secret.providerId}) | |
| 2445 | − </option> | |
| 2446 | − ))} | |
| 2447 | − <option value={INLINE_TOKEN_VALUE}>Paste token directly</option> | |
| 2448 | − <option value={CREATE_NEW_VALUE}>+ Create new secret</option> | |
| 2449 | − </select> | |
| 2450 | − | |
| 2451 | − {useInline && ( | |
| 2452 | − <input | |
| 2453 | − type="password" | |
| 2454 | − value={inlineToken} | |
| 2455 | − onChange={(e) => onChangeToken(e.target.value)} | |
| 2456 | − placeholder="sk-... or ghp_..." | |
| 2457 | − className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 2458 | − autoFocus | |
| 2459 | − /> | |
| 2460 | − )} | |
| 2461 | − | |
| 2462 | − {showCreate && ( | |
| 2463 | − <div className="rounded-lg border border-primary/20 bg-card/80 p-3 space-y-3"> | |
| 2464 | − {createError && ( | |
| 2465 | − <div className="rounded-md border border-destructive/30 bg-destructive/8 px-3 py-2 text-[12px] text-destructive"> | |
| 2466 | − {createError} | |
| 2467 | − </div> | |
| 2468 | − )} | |
| 2469 | − <div className="grid gap-3 sm:grid-cols-2"> | |
| 2470 | − <label className="block space-y-1"> | |
| 2471 | − <span className="text-[11px] font-medium text-muted-foreground"> | |
| 2472 | − Name | |
| 2473 | − </span> | |
| 2474 | − <input | |
| 2475 | − value={newName} | |
| 2476 | − onChange={(e) => setNewName(e.target.value)} | |
| 2477 | − placeholder="API Key" | |
| 2478 | − className="h-8 w-full rounded-lg border border-input bg-background px-3 text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 2479 | − autoFocus | |
| 2480 | − /> | |
| 2481 | − </label> | |
| 2482 | − <label className="block space-y-1"> | |
| 2483 | − <span className="text-[11px] font-medium text-muted-foreground"> | |
| 2484 | − Value | |
| 2485 | − </span> | |
| 2486 | − <input | |
| 2487 | − type="password" | |
| 2488 | − value={newValue} | |
| 2489 | − onChange={(e) => setNewValue(e.target.value)} | |
| 2490 | − placeholder="sk-..." | |
| 2491 | − className="h-8 w-full rounded-lg border border-input bg-background px-3 font-mono text-[11px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 2492 | − /> | |
| 2493 | − </label> | |
| 2494 | − <label className="block space-y-1"> | |
| 2495 | − <span className="text-[11px] font-medium text-muted-foreground"> | |
| 2496 | − Store in | |
| 2497 | − </span> | |
| 2498 | − <select | |
| 2499 | − value={newProviderId} | |
| 2500 | − onChange={(e) => setNewProviderId(e.target.value)} | |
| 2501 | − className="h-8 w-full rounded-lg border border-input bg-background px-3 text-[12px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 2502 | − > | |
| 2503 | − {storableProviders.map((provider) => ( | |
| 2504 | − <option key={provider.id} value={provider.id}> | |
| 2505 | − {provider.name} | |
| 2506 | − </option> | |
| 2507 | − ))} | |
| 2508 | − </select> | |
| 2509 | − </label> | |
| 2510 | − </div> | |
| 2511 | − <div className="flex items-center justify-end gap-2"> | |
| 2512 | − <button | |
| 2513 | − type="button" | |
| 2514 | − onClick={() => setShowCreate(false)} | |
| 2515 | − className="rounded-md px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground" | |
| 2516 | − > | |
| 2517 | − Cancel | |
| 2518 | − </button> | |
| 2519 | − <Button | |
| 2520 | − size="sm" | |
| 2521 | − onClick={handleCreate} | |
| 2522 | − disabled={createSecret.status === "pending"} | |
| 2523 | − > | |
| 2524 | − {createSecret.status === "pending" && ( | |
| 2525 | − <IconSpinner className="size-3" /> | |
| 2526 | − )} | |
| 2527 | − Store & use | |
| 2528 | − </Button> | |
| 2529 | − </div> | |
| 2530 | − </div> | |
| 2531 | − )} | |
| 2532 | − </div> | |
| 2533 | − ); | |
| 2534 | −} | |
| @@ -1,17 +1,28 @@ | ||
| 1 | 1 | import { Link } from "@tanstack/react-router"; |
| 2 | −import { useSources, type Source } from "@executor/react"; | |
| 2 | +import { useSources } from "@executor/react"; | |
| 3 | +import { sourcePluginsIndexPath } from "@executor/react/plugins"; | |
| 3 | 4 | import { LoadableBlock } from "../components/loadable"; |
| 4 | 5 | import { LocalMcpInstallCard } from "../components/local-mcp-install-card"; |
| 5 | 6 | import { SourceFavicon } from "../components/source-favicon"; |
| 6 | 7 | import { Badge } from "../components/ui/badge"; |
| 7 | 8 | import { Button } from "../components/ui/button"; |
| 8 | 9 | import { IconSources, IconPlus } from "../components/icons"; |
| 10 | +import { | |
| 11 | + getSourceFrontendPaths, | |
| 12 | +} from "../plugins"; | |
| 13 | + | |
| 14 | +const statusVariant = (status: string) => | |
| 15 | + status === "connected" | |
| 16 | + ? "default" as const | |
| 17 | + : status === "error" | |
| 18 | + ? "destructive" as const | |
| 19 | + : "muted" as const; | |
| 9 | 20 | |
| 10 | 21 | export function HomePage() { |
| 11 | 22 | const sources = useSources(); |
| 12 | 23 | |
| 13 | 24 | return ( |
| 14 | − <div className="flex-1 overflow-y-auto"> | |
| 25 | + <div className="min-h-0 flex-1 overflow-y-auto"> | |
| 15 | 26 | <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14"> |
| 16 | 27 | {/* Header */} |
| 17 | 28 | <div className="flex items-end justify-between mb-8"> |
| @@ -23,7 +34,7 @@ export function HomePage() { · 5 unmodified lines | ||
| 23 | 34 | Connected tool providers in this workspace. |
| 24 | 35 | </p> |
| 25 | 36 | </div> |
| 26 | − <Link to="/sources/add"> | |
| 37 | + <Link to={sourcePluginsIndexPath}> | |
| 27 | 38 | <Button size="sm"> |
| 28 | 39 | <IconPlus className="size-3.5" /> |
| 29 | 40 | Add source |
| @@ -36,7 +47,11 @@ export function HomePage() { · 6 unmodified lines | ||
| 36 | 47 | {/* Source list */} |
| 37 | 48 | <LoadableBlock loadable={sources} loading="Loading sources..."> |
| 38 | 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 | 55 | <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20"> |
| 41 | 56 | <div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground mb-4"> |
| 42 | 57 | <IconSources className="size-5" /> |
| @@ -47,7 +62,7 @@ export function HomePage() { · 4 unmodified lines | ||
| 47 | 62 | <p className="text-[13px] text-muted-foreground/60 mb-5"> |
| 48 | 63 | Add a source to get started. |
| 49 | 64 | </p> |
| 50 | − <Link to="/sources/add"> | |
| 65 | + <Link to={sourcePluginsIndexPath}> | |
| 51 | 66 | <Button size="sm"> |
| 52 | 67 | <IconPlus className="size-3.5" /> |
| 53 | 68 | Add source |
| @@ -56,9 +71,49 @@ export function HomePage() { · 2 unmodified lines | ||
| 56 | 71 | </div> |
| 57 | 72 | ) : ( |
| 58 | 73 | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> |
| 59 | − {items.map((source) => ( | |
| 60 | − <SourceCard key={source.id} source={source} /> | |
| 61 | − ))} | |
| 74 | + {items.map((source) => { | |
| 75 | + const paths = getSourceFrontendPaths(source.kind); | |
| 76 | + const card = ( | |
| 77 | + <div className="flex h-full flex-col rounded-2xl border border-border bg-card px-5 py-4 transition-colors hover:border-primary/25 hover:bg-card/90"> | |
| 78 | + <div className="flex items-start gap-3"> | |
| 79 | + <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground"> | |
| 80 | + <SourceFavicon source={source} className="size-4" /> | |
| 81 | + </div> | |
| 82 | + <div className="min-w-0 flex-1"> | |
| 83 | + <div className="flex items-start justify-between gap-2"> | |
| 84 | + <div className="truncate text-sm font-semibold text-foreground"> | |
| 85 | + {source.name} | |
| 86 | + </div> | |
| 87 | + <Badge variant={statusVariant(source.status)} className="shrink-0"> | |
| 88 | + {source.status} | |
| 89 | + </Badge> | |
| 90 | + </div> | |
| 91 | + <div className="mt-0.5 text-xs text-muted-foreground"> | |
| 92 | + {source.kind} | |
| 93 | + </div> | |
| 94 | + </div> | |
| 95 | + </div> | |
| 96 | + </div> | |
| 97 | + ); | |
| 98 | + | |
| 99 | + if (!paths) { | |
| 100 | + return ( | |
| 101 | + <div key={source.id}> | |
| 102 | + {card} | |
| 103 | + </div> | |
| 104 | + ); | |
| 105 | + } | |
| 106 | + | |
| 107 | + return ( | |
| 108 | + <Link | |
| 109 | + key={source.id} | |
| 110 | + to={paths.detail(source.id)} | |
| 111 | + search={{ tab: "model" }} | |
| 112 | + > | |
| 113 | + {card} | |
| 114 | + </Link> | |
| 115 | + ); | |
| 116 | + })} | |
| 62 | 117 | </div> |
| 63 | 118 | ) |
| 64 | 119 | } |
| @@ -67,29 +122,3 @@ export function HomePage() { · 2 unmodified lines | ||
| 67 | 122 | </div> |
| 68 | 123 | ); |
| 69 | 124 | } |
| 70 | − | |
| 71 | −function SourceCard({ source }: { source: Source }) { | |
| 72 | − return ( | |
| 73 | − <Link | |
| 74 | − to="/sources/$sourceId" | |
| 75 | − params={{ sourceId: source.id }} | |
| 76 | − search={{ tab: "model" }} | |
| 77 | − className="group flex flex-col rounded-xl border border-border bg-card p-4 transition-all hover:border-primary/30 hover:shadow-[0_2px_12px_-4px_rgba(0,0,0,0.12)]" | |
| 78 | − > | |
| 79 | − <div className="flex items-start justify-between gap-3 mb-3"> | |
| 80 | − <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary"> | |
| 81 | − <SourceFavicon endpoint={source.endpoint} kind={source.kind} className="size-4.5" /> | |
| 82 | − </div> | |
| 83 | − <Badge variant={source.status === "connected" ? "default" : source.status === "error" ? "destructive" : "muted"} className="shrink-0"> | |
| 84 | − {source.status} | |
| 85 | − </Badge> | |
| 86 | − </div> | |
| 87 | − <h3 className="text-[14px] font-semibold text-foreground group-hover:text-primary transition-colors mb-0.5"> | |
| 88 | − {source.name} | |
| 89 | − </h3> | |
| 90 | − <div className="flex items-center gap-2 mt-auto pt-2"> | |
| 91 | − <Badge variant="outline" className="text-[9px]">{source.kind}</Badge> | |
| 92 | − </div> | |
| 93 | − </Link> | |
| 94 | − ); | |
| 95 | −} | |
| @@ -1,74 +0,0 @@ | ||
| 1 | −import * as Either from "effect/Either"; | |
| 2 | −import * as ParseResult from "effect/ParseResult"; | |
| 3 | −import * as Schema from "effect/Schema"; | |
| 4 | − | |
| 5 | −const JsonStringMapSchema = Schema.parseJson( | |
| 6 | − Schema.Record({ | |
| 7 | − key: Schema.String, | |
| 8 | − value: Schema.String, | |
| 9 | − }), | |
| 10 | −); | |
| 11 | − | |
| 12 | −const JsonStringArraySchema = Schema.parseJson( | |
| 13 | − Schema.Array(Schema.String), | |
| 14 | −); | |
| 15 | − | |
| 16 | −const formatJsonFieldError = ( | |
| 17 | − label: string, | |
| 18 | − error: ParseResult.ParseError, | |
| 19 | −): Error => | |
| 20 | − new Error( | |
| 21 | − `${label} is invalid: ${ParseResult.TreeFormatter.formatErrorSync(error)}`, | |
| 22 | − ); | |
| 23 | − | |
| 24 | −const decodeJsonField = <A>(input: { | |
| 25 | − label: string; | |
| 26 | − text: string; | |
| 27 | − schema: Schema.Schema<A, string>; | |
| 28 | −}): A | null => { | |
| 29 | − const trimmed = input.text.trim(); | |
| 30 | − if (trimmed.length === 0) { | |
| 31 | − return null; | |
| 32 | − } | |
| 33 | − | |
| 34 | − const decoded = Schema.decodeUnknownEither(input.schema)(trimmed); | |
| 35 | − if (Either.isLeft(decoded)) { | |
| 36 | − throw formatJsonFieldError(input.label, decoded.left); | |
| 37 | − } | |
| 38 | − | |
| 39 | − return decoded.right; | |
| 40 | −}; | |
| 41 | − | |
| 42 | −export const parseJsonStringMap = ( | |
| 43 | − label: string, | |
| 44 | − text: string, | |
| 45 | −): Record<string, string> | null => { | |
| 46 | − const decoded = decodeJsonField({ | |
| 47 | − label, | |
| 48 | − text, | |
| 49 | − schema: JsonStringMapSchema, | |
| 50 | − }); | |
| 51 | − | |
| 52 | − return decoded && Object.keys(decoded).length > 0 ? decoded : null; | |
| 53 | −}; | |
| 54 | − | |
| 55 | −export const parseJsonStringArray = ( | |
| 56 | − label: string, | |
| 57 | − text: string, | |
| 58 | −): Array<string> | null => { | |
| 59 | − const decoded = decodeJsonField({ | |
| 60 | − label, | |
| 61 | − text, | |
| 62 | − schema: JsonStringArraySchema, | |
| 63 | − }); | |
| 64 | − | |
| 65 | − if (!decoded) { | |
| 66 | − return null; | |
| 67 | − } | |
| 68 | − | |
| 69 | − const normalized = decoded | |
| 70 | − .map((value) => value.trim()) | |
| 71 | − .filter((value) => value.length > 0); | |
| 72 | − | |
| 73 | − return normalized.length > 0 ? normalized : null; | |
| 74 | −}; | |
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | import { useEffect, useState, type ReactNode } from "react"; |
| 2 | −import { Link } from "@tanstack/react-router"; | |
| 3 | 2 | import { |
| 4 | 3 | type InstanceConfig, |
| 5 | 4 | type SecretListItem, |
| @@ -42,7 +41,7 @@ export function SecretsPage() { · 36 unmodified lines | ||
| 42 | 41 | : null; |
| 43 | 42 | |
| 44 | 43 | return ( |
| 45 | − <div className="flex-1 overflow-y-auto"> | |
| 44 | + <div className="min-h-0 flex-1 overflow-y-auto"> | |
| 46 | 45 | <div className="mx-auto max-w-3xl px-6 py-10 lg:px-10 lg:py-14"> |
| 47 | 46 | {/* Header */} |
| 48 | 47 | <div className="flex items-end justify-between mb-8"> |
| @@ -259,19 +258,16 @@ function SecretRow(props: { · 210 unmodified lines | ||
| 259 | 258 | const { secret, providerLabel, isEditing } = props; |
| 260 | 259 | const deleteSecret = useDeleteSecret(); |
| 261 | 260 | const [isDeleting, setIsDeleting] = useState(false); |
| 261 | + const [confirmDelete, setConfirmDelete] = useState(false); | |
| 262 | 262 | |
| 263 | 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 | 264 | setIsDeleting(true); |
| 270 | 265 | try { |
| 271 | 266 | await deleteSecret.mutateAsync(secret.id); |
| 272 | 267 | } catch { |
| 273 | 268 | // refresh will show the secret still there |
| 274 | 269 | } finally { |
| 270 | + setConfirmDelete(false); | |
| 275 | 271 | setIsDeleting(false); |
| 276 | 272 | } |
| 277 | 273 | }; |
| @@ -313,15 +309,12 @@ function SecretRow(props: { · 35 unmodified lines | ||
| 313 | 309 | <div className="flex items-center gap-1.5 mt-1 flex-wrap"> |
| 314 | 310 | <span className="text-[10px] text-muted-foreground/50">Used by</span> |
| 315 | 311 | {secret.linkedSources.map((ls) => ( |
| 316 | − <Link | |
| 312 | + <span | |
| 317 | 313 | key={ls.sourceId} |
| 318 | − to="/sources/$sourceId" | |
| 319 | − params={{ sourceId: ls.sourceId }} | |
| 320 | − search={{ tab: "model" }} | |
| 321 | − className="inline-flex items-center gap-1 rounded-md bg-accent/60 px-1.5 py-0.5 text-[10px] font-medium text-foreground/70 transition-colors hover:bg-accent hover:text-foreground" | |
| 314 | + className="inline-flex items-center gap-1 rounded-md bg-accent/60 px-1.5 py-0.5 text-[10px] font-medium text-foreground/70" | |
| 322 | 315 | > |
| 323 | 316 | {ls.sourceName} |
| 324 | − </Link> | |
| 317 | + </span> | |
| 325 | 318 | ))} |
| 326 | 319 | </div> |
| 327 | 320 | )} |
| @@ -341,15 +334,42 @@ function SecretRow(props: { · 13 unmodified lines | ||
| 341 | 334 | <IconPencil className="size-3" /> |
| 342 | 335 | Edit |
| 343 | 336 | </button> |
| 344 | − <button | |
| 345 | − type="button" | |
| 346 | − onClick={handleDelete} | |
| 347 | − disabled={isDeleting} | |
| 348 | − className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:text-destructive hover:bg-destructive/8 disabled:opacity-50" | |
| 349 | − > | |
| 350 | − {isDeleting ? <IconSpinner className="size-3" /> : <IconTrash className="size-3" />} | |
| 351 | − Delete | |
| 352 | − </button> | |
| 337 | + {confirmDelete ? ( | |
| 338 | + <> | |
| 339 | + <span className="text-[10px] font-medium text-destructive"> | |
| 340 | + Confirm delete? | |
| 341 | + </span> | |
| 342 | + <button | |
| 343 | + type="button" | |
| 344 | + onClick={() => setConfirmDelete(false)} | |
| 345 | + disabled={isDeleting} | |
| 346 | + className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-50" | |
| 347 | + > | |
| 348 | + Cancel | |
| 349 | + </button> | |
| 350 | + <button | |
| 351 | + type="button" | |
| 352 | + onClick={() => { | |
| 353 | + void handleDelete(); | |
| 354 | + }} | |
| 355 | + disabled={isDeleting} | |
| 356 | + className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-destructive transition-colors hover:bg-destructive/8 disabled:opacity-50" | |
| 357 | + > | |
| 358 | + {isDeleting ? <IconSpinner className="size-3" /> : <IconTrash className="size-3" />} | |
| 359 | + {isDeleting ? "Deleting..." : "Delete"} | |
| 360 | + </button> | |
| 361 | + </> | |
| 362 | + ) : ( | |
| 363 | + <button | |
| 364 | + type="button" | |
| 365 | + onClick={() => setConfirmDelete(true)} | |
| 366 | + disabled={isDeleting} | |
| 367 | + className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:text-destructive hover:bg-destructive/8 disabled:opacity-50" | |
| 368 | + > | |
| 369 | + {isDeleting ? <IconSpinner className="size-3" /> : <IconTrash className="size-3" />} | |
| 370 | + Delete | |
| 371 | + </button> | |
| 372 | + )} | |
| 353 | 373 | </div> |
| 354 | 374 | </div> |
| 355 | 375 | |
| @@ -1,1172 +0,0 @@ | ||
| 1 | −import { useState, useEffect, useCallback, useRef, useMemo } from "react"; | |
| 2 | −import { Link } from "@tanstack/react-router"; | |
| 3 | −import { | |
| 4 | − type ConnectSourcePayload, | |
| 5 | − useSource, | |
| 6 | − useSources, | |
| 7 | − useConnectSource, | |
| 8 | − useSourceInspection, | |
| 9 | − useSourceToolDetail, | |
| 10 | − useSourceDiscovery, | |
| 11 | − type Loadable, | |
| 12 | − type Source, | |
| 13 | − type SourceInspection, | |
| 14 | − type SourceInspectionToolDetail, | |
| 15 | − type SourceInspectionDiscoverResult, | |
| 16 | −} from "@executor/react"; | |
| 17 | −import { cn } from "../lib/utils"; | |
| 18 | −import { Badge, MethodBadge } from "../components/ui/badge"; | |
| 19 | −import { Button } from "../components/ui/button"; | |
| 20 | −import { LoadableBlock, EmptyState } from "../components/loadable"; | |
| 21 | −import { DocumentPanel } from "../components/document-panel"; | |
| 22 | −import { SourceNotFoundState } from "../components/source-not-found-state"; | |
| 23 | −import { SourceRecoveryState } from "../components/source-recovery-state"; | |
| 24 | −import { | |
| 25 | − IconSearch, | |
| 26 | − IconChevron, | |
| 27 | − IconTool, | |
| 28 | − IconFolder, | |
| 29 | − IconCopy, | |
| 30 | − IconCheck, | |
| 31 | − IconClose, | |
| 32 | − IconPencil, | |
| 33 | − IconSpinner, | |
| 34 | −} from "../components/icons"; | |
| 35 | −import { Markdown } from "../components/markdown"; | |
| 36 | − | |
| 37 | −// --------------------------------------------------------------------------- | |
| 38 | −// Types | |
| 39 | −// --------------------------------------------------------------------------- | |
| 40 | − | |
| 41 | −type SourceRouteSearch = { | |
| 42 | − tab: "model" | "discover"; | |
| 43 | − tool?: string; | |
| 44 | − query?: string; | |
| 45 | −}; | |
| 46 | − | |
| 47 | −const visibleTabs: Array<{ id: SourceRouteSearch["tab"]; label: string }> = [ | |
| 48 | − { id: "model", label: "Tools" }, | |
| 49 | − { id: "discover", label: "Search" }, | |
| 50 | −]; | |
| 51 | − | |
| 52 | −const isSourceNotFoundError = (loadable: Loadable<unknown>): boolean => | |
| 53 | − loadable.status === "error" | |
| 54 | − && loadable.error.message.toLowerCase().includes("source not found"); | |
| 55 | − | |
| 56 | −const isMissingCatalogArtifactError = (loadable: Loadable<unknown>): boolean => | |
| 57 | − loadable.status === "error" | |
| 58 | − && loadable.error.message.toLowerCase().includes("catalog artifact missing for source"); | |
| 59 | − | |
| 60 | −const listExcludesSource = ( | |
| 61 | − sources: Loadable<ReadonlyArray<Source>>, | |
| 62 | − sourceId: string, | |
| 63 | −): boolean => sources.status === "ready" && !sources.data.some((source) => source.id === sourceId); | |
| 64 | − | |
| 65 | −type SourceDetailPageState = | |
| 66 | − | { kind: "source_not_found" } | |
| 67 | − | { kind: "tools_missing"; source: Source } | |
| 68 | − | { kind: "ready" }; | |
| 69 | − | |
| 70 | −const resolveSourceDetailPageState = (input: { | |
| 71 | − sources: Loadable<ReadonlyArray<Source>>; | |
| 72 | − source: Loadable<Source>; | |
| 73 | − inspection: Loadable<SourceInspection>; | |
| 74 | − sourceId: string; | |
| 75 | −}): SourceDetailPageState => { | |
| 76 | − if ( | |
| 77 | − listExcludesSource(input.sources, input.sourceId) | |
| 78 | − || isSourceNotFoundError(input.source) | |
| 79 | − || isSourceNotFoundError(input.inspection) | |
| 80 | − ) { | |
| 81 | − return { kind: "source_not_found" }; | |
| 82 | − } | |
| 83 | − | |
| 84 | − if (input.source.status === "ready" && isMissingCatalogArtifactError(input.inspection)) { | |
| 85 | − return { | |
| 86 | − kind: "tools_missing", | |
| 87 | − source: input.source.data, | |
| 88 | − }; | |
| 89 | − } | |
| 90 | − | |
| 91 | − return { kind: "ready" }; | |
| 92 | −}; | |
| 93 | − | |
| 94 | −const readBindingString = (source: Source, key: string): string | null => | |
| 95 | − typeof source.binding[key] === "string" && source.binding[key].trim().length > 0 | |
| 96 | − ? String(source.binding[key]) | |
| 97 | − : null; | |
| 98 | − | |
| 99 | −const readBindingStringMap = (source: Source, key: string): Record<string, string> | null => { | |
| 100 | − const candidate = source.binding[key]; | |
| 101 | − if (candidate === null || candidate === undefined || typeof candidate !== "object" || Array.isArray(candidate)) { | |
| 102 | − return null; | |
| 103 | − } | |
| 104 | − | |
| 105 | − const entries = Object.entries(candidate as Record<string, unknown>); | |
| 106 | − return entries.every(([, value]) => typeof value === "string") | |
| 107 | − ? Object.fromEntries(entries as ReadonlyArray<readonly [string, string]>) | |
| 108 | − : null; | |
| 109 | −}; | |
| 110 | − | |
| 111 | −const readBindingStringArray = (source: Source, key: string): Array<string> | undefined => { | |
| 112 | − const candidate = source.binding[key]; | |
| 113 | − return Array.isArray(candidate) && candidate.every((value) => typeof value === "string") | |
| 114 | − ? [...candidate] | |
| 115 | − : undefined; | |
| 116 | −}; | |
| 117 | − | |
| 118 | −const readBindingTransport = (source: Source): "auto" | "streamable-http" | "sse" | "stdio" | undefined => { | |
| 119 | − const candidate = source.binding.transport; | |
| 120 | − return candidate === "auto" || candidate === "streamable-http" || candidate === "sse" || candidate === "stdio" | |
| 121 | − ? candidate | |
| 122 | − : undefined; | |
| 123 | −}; | |
| 124 | − | |
| 125 | −type RefreshableHttpAuth = | |
| 126 | − | { kind: "none" } | |
| 127 | − | { | |
| 128 | − kind: "bearer"; | |
| 129 | − headerName?: string | null; | |
| 130 | − prefix?: string | null; | |
| 131 | − token?: string | null; | |
| 132 | − tokenRef?: { providerId: string; handle: string } | null; | |
| 133 | − } | |
| 134 | − | { | |
| 135 | − kind: "oauth2"; | |
| 136 | − headerName?: string | null; | |
| 137 | − prefix?: string | null; | |
| 138 | − accessToken?: string | null; | |
| 139 | − accessTokenRef?: { providerId: string; handle: string } | null; | |
| 140 | − refreshToken?: string | null; | |
| 141 | − refreshTokenRef?: { providerId: string; handle: string } | null; | |
| 142 | − }; | |
| 143 | − | |
| 144 | −const refreshableHttpAuth = ( | |
| 145 | − auth: Source["auth"], | |
| 146 | −): RefreshableHttpAuth | undefined => { | |
| 147 | − switch (auth.kind) { | |
| 148 | − case "none": | |
| 149 | − return { kind: "none" }; | |
| 150 | − case "bearer": | |
| 151 | − return { | |
| 152 | − kind: "bearer", | |
| 153 | − headerName: auth.headerName, | |
| 154 | − prefix: auth.prefix, | |
| 155 | − tokenRef: auth.token, | |
| 156 | − }; | |
| 157 | − case "oauth2": | |
| 158 | − return { | |
| 159 | − kind: "oauth2", | |
| 160 | − headerName: auth.headerName, | |
| 161 | − prefix: auth.prefix, | |
| 162 | − accessTokenRef: auth.accessToken, | |
| 163 | − refreshTokenRef: auth.refreshToken, | |
| 164 | − }; | |
| 165 | − default: | |
| 166 | − return undefined; | |
| 167 | − } | |
| 168 | −}; | |
| 169 | − | |
| 170 | −const refreshPayloadFromSource = (source: Source): ConnectSourcePayload | null => { | |
| 171 | − switch (source.kind) { | |
| 172 | − case "mcp": | |
| 173 | − return { | |
| 174 | − kind: "mcp", | |
| 175 | − endpoint: source.endpoint, | |
| 176 | − name: source.name, | |
| 177 | − namespace: source.namespace, | |
| 178 | − ...(readBindingTransport(source) ? { transport: readBindingTransport(source) } : {}), | |
| 179 | − queryParams: readBindingStringMap(source, "queryParams"), | |
| 180 | − headers: readBindingStringMap(source, "headers"), | |
| 181 | − command: readBindingString(source, "command"), | |
| 182 | − args: readBindingStringArray(source, "args"), | |
| 183 | − env: readBindingStringMap(source, "env"), | |
| 184 | − cwd: readBindingString(source, "cwd"), | |
| 185 | − }; | |
| 186 | − case "openapi": { | |
| 187 | − const specUrl = readBindingString(source, "specUrl"); | |
| 188 | − if (!specUrl) { | |
| 189 | − return null; | |
| 190 | − } | |
| 191 | − | |
| 192 | − return { | |
| 193 | − kind: "openapi", | |
| 194 | − endpoint: source.endpoint, | |
| 195 | − specUrl, | |
| 196 | − name: source.name, | |
| 197 | − namespace: source.namespace, | |
| 198 | − importAuthPolicy: source.importAuthPolicy, | |
| 199 | − importAuth: refreshableHttpAuth(source.importAuth), | |
| 200 | − auth: refreshableHttpAuth(source.auth), | |
| 201 | − }; | |
| 202 | − } | |
| 203 | − case "graphql": | |
| 204 | − return { | |
| 205 | − kind: "graphql", | |
| 206 | − endpoint: source.endpoint, | |
| 207 | − name: source.name, | |
| 208 | − namespace: source.namespace, | |
| 209 | − importAuthPolicy: source.importAuthPolicy, | |
| 210 | − importAuth: refreshableHttpAuth(source.importAuth), | |
| 211 | − auth: refreshableHttpAuth(source.auth), | |
| 212 | − }; | |
| 213 | − case "google_discovery": { | |
| 214 | − const service = readBindingString(source, "service"); | |
| 215 | − const version = readBindingString(source, "version"); | |
| 216 | − if (!service || !version) { | |
| 217 | − return null; | |
| 218 | − } | |
| 219 | − | |
| 220 | − return { | |
| 221 | − kind: "google_discovery", | |
| 222 | − service, | |
| 223 | − version, | |
| 224 | − discoveryUrl: readBindingString(source, "discoveryUrl"), | |
| 225 | − scopes: readBindingStringArray(source, "scopes"), | |
| 226 | − oauthClient: null, | |
| 227 | − name: source.name, | |
| 228 | − namespace: source.namespace, | |
| 229 | − importAuthPolicy: source.importAuthPolicy, | |
| 230 | − importAuth: refreshableHttpAuth(source.importAuth), | |
| 231 | − auth: refreshableHttpAuth(source.auth), | |
| 232 | − }; | |
| 233 | − } | |
| 234 | − default: | |
| 235 | − return null; | |
| 236 | − } | |
| 237 | −}; | |
| 238 | − | |
| 239 | −// --------------------------------------------------------------------------- | |
| 240 | −// SourceDetailPage (main export) | |
| 241 | −// --------------------------------------------------------------------------- | |
| 242 | − | |
| 243 | −export function SourceDetailPage(props: { | |
| 244 | − sourceId: string; | |
| 245 | − search: SourceRouteSearch; | |
| 246 | − navigate: (opts: { search: (prev: SourceRouteSearch) => SourceRouteSearch; replace?: boolean }) => void; | |
| 247 | −}) { | |
| 248 | − const { sourceId, search, navigate } = props; | |
| 249 | − const sources = useSources(); | |
| 250 | − const source = useSource(sourceId); | |
| 251 | − const connectSource = useConnectSource(); | |
| 252 | − const inspection = useSourceInspection(sourceId); | |
| 253 | − const [refreshFeedback, setRefreshFeedback] = useState<{ | |
| 254 | − tone: "success" | "error"; | |
| 255 | − text: string; | |
| 256 | − } | null>(null); | |
| 257 | − const sourceDetailPageState = resolveSourceDetailPageState({ | |
| 258 | − sources, | |
| 259 | − source, | |
| 260 | − inspection, | |
| 261 | − sourceId, | |
| 262 | − }); | |
| 263 | − | |
| 264 | − const selectedToolPath = | |
| 265 | − search.tool | |
| 266 | − ?? (inspection.status === "ready" ? inspection.data.tools[0]?.path : undefined); | |
| 267 | − | |
| 268 | − const toolDetail = useSourceToolDetail( | |
| 269 | − sourceId, | |
| 270 | − search.tab === "model" ? selectedToolPath ?? null : null, | |
| 271 | − ); | |
| 272 | − | |
| 273 | − const discovery = useSourceDiscovery({ | |
| 274 | − sourceId, | |
| 275 | − query: search.query ?? "", | |
| 276 | − limit: 12, | |
| 277 | − }); | |
| 278 | − | |
| 279 | − const handleRefresh = useCallback(async (currentSource: Source) => { | |
| 280 | − const payload = refreshPayloadFromSource(currentSource); | |
| 281 | − if (!payload) { | |
| 282 | − setRefreshFeedback({ | |
| 283 | − tone: "error", | |
| 284 | − text: "Refresh is unavailable until this source has complete connection details.", | |
| 285 | − }); | |
| 286 | − return; | |
| 287 | − } | |
| 288 | − | |
| 289 | − setRefreshFeedback(null); | |
| 290 | − try { | |
| 291 | − await connectSource.mutateAsync(payload); | |
| 292 | − setRefreshFeedback({ | |
| 293 | − tone: "success", | |
| 294 | − text: `Refreshed ${currentSource.name}.`, | |
| 295 | − }); | |
| 296 | − } catch (error) { | |
| 297 | − setRefreshFeedback({ | |
| 298 | − tone: "error", | |
| 299 | − text: error instanceof Error ? error.message : "Failed to refresh source.", | |
| 300 | − }); | |
| 301 | − } | |
| 302 | − }, [connectSource]); | |
| 303 | − | |
| 304 | − // Auto-select first tool | |
| 305 | − useEffect(() => { | |
| 306 | − if (search.tab !== "model" || search.tool || inspection.status !== "ready") return; | |
| 307 | − const firstTool = inspection.data.tools[0]?.path; | |
| 308 | − if (!firstTool) return; | |
| 309 | − void navigate({ search: (prev) => ({ ...prev, tool: firstTool }), replace: true }); | |
| 310 | − }, [inspection, navigate, search.tab, search.tool]); | |
| 311 | − | |
| 312 | − if (sourceDetailPageState.kind === "source_not_found") { | |
| 313 | − return <SourceNotFoundState />; | |
| 314 | − } | |
| 315 | − | |
| 316 | − if (sourceDetailPageState.kind === "tools_missing") { | |
| 317 | − const refreshPayload = refreshPayloadFromSource(sourceDetailPageState.source); | |
| 318 | − | |
| 319 | − return ( | |
| 320 | − <SourceRecoveryState | |
| 321 | − source={sourceDetailPageState.source} | |
| 322 | − title="Tools need to be rebuilt" | |
| 323 | − description="This source exists in your workspace, but its generated tool catalog is missing. Refresh to reconnect and rebuild the tools." | |
| 324 | − refreshLabel="Refresh source" | |
| 325 | − refreshTitle={ | |
| 326 | − refreshPayload === null | |
| 327 | − ? "Refresh is unavailable for this source configuration" | |
| 328 | − : "Reconnect and rebuild this source" | |
| 329 | − } | |
| 330 | − refreshDisabled={connectSource.status === "pending" || refreshPayload === null} | |
| 331 | − refreshPending={connectSource.status === "pending"} | |
| 332 | − feedback={refreshFeedback} | |
| 333 | − onRefresh={() => void handleRefresh(sourceDetailPageState.source)} | |
| 334 | − /> | |
| 335 | − ); | |
| 336 | − } | |
| 337 | − | |
| 338 | − return ( | |
| 339 | − <LoadableBlock loadable={inspection} loading="Loading source..."> | |
| 340 | − {(bundle) => { | |
| 341 | − const selectedTool = | |
| 342 | − bundle.tools.find((t) => t.path === selectedToolPath) ?? bundle.tools[0] ?? null; | |
| 343 | − | |
| 344 | − return ( | |
| 345 | − <div className="flex h-full flex-col overflow-hidden"> | |
| 346 | − {/* Header bar */} | |
| 347 | − <div className="flex shrink-0 items-center justify-between border-b border-border bg-background/95 backdrop-blur-sm px-4 h-12"> | |
| 348 | − <div className="flex items-center gap-3 min-w-0"> | |
| 349 | − <h2 className="truncate text-sm font-semibold text-foreground"> | |
| 350 | − {bundle.source.name} | |
| 351 | − </h2> | |
| 352 | − <Badge variant="outline">{bundle.source.kind}</Badge> | |
| 353 | − <span className="hidden text-[11px] tabular-nums text-muted-foreground/50 sm:block"> | |
| 354 | − {bundle.toolCount} {bundle.toolCount === 1 ? "tool" : "tools"} | |
| 355 | − </span> | |
| 356 | − </div> | |
| 357 | − <div className="flex items-center gap-2"> | |
| 358 | − {refreshFeedback && ( | |
| 359 | − <span | |
| 360 | − className={cn( | |
| 361 | − "hidden max-w-72 truncate text-[11px] font-medium sm:inline-flex sm:items-center sm:gap-1.5", | |
| 362 | − refreshFeedback.tone === "success" | |
| 363 | − ? "text-primary" | |
| 364 | − : "text-destructive", | |
| 365 | − )} | |
| 366 | − title={refreshFeedback.text} | |
| 367 | − > | |
| 368 | − {refreshFeedback.tone === "success" && ( | |
| 369 | − <IconCheck className="size-3 shrink-0" /> | |
| 370 | − )} | |
| 371 | − {refreshFeedback.text} | |
| 372 | − </span> | |
| 373 | − )} | |
| 374 | − <div className="flex items-center gap-0.5 rounded-lg bg-muted/50 p-0.5"> | |
| 375 | − {visibleTabs.map((tab) => ( | |
| 376 | − <button | |
| 377 | − key={tab.id} | |
| 378 | − type="button" | |
| 379 | − onClick={() => void navigate({ search: (prev) => ({ ...prev, tab: tab.id }) })} | |
| 380 | − className={cn( | |
| 381 | − "rounded-md px-3 py-1 text-[12px] font-medium transition-colors", | |
| 382 | − tab.id === search.tab | |
| 383 | − ? "bg-background text-foreground shadow-sm" | |
| 384 | − : "text-muted-foreground hover:text-foreground", | |
| 385 | − )} | |
| 386 | − > | |
| 387 | − {tab.label} | |
| 388 | − </button> | |
| 389 | − ))} | |
| 390 | − </div> | |
| 391 | − <Button | |
| 392 | − variant="outline" | |
| 393 | − size="sm" | |
| 394 | − onClick={() => void handleRefresh(bundle.source)} | |
| 395 | − disabled={connectSource.status === "pending" || refreshPayloadFromSource(bundle.source) === null} | |
| 396 | − title={ | |
| 397 | − refreshPayloadFromSource(bundle.source) === null | |
| 398 | − ? "Refresh is unavailable for this source configuration" | |
| 399 | − : "Reconnect and resync this source" | |
| 400 | − } | |
| 401 | − > | |
| 402 | − {connectSource.status === "pending" | |
| 403 | − ? <IconSpinner className="size-3" /> | |
| 404 | − : null} | |
| 405 | − Refresh | |
| 406 | − </Button> | |
| 407 | − <Link | |
| 408 | − to="/sources/$sourceId/edit" | |
| 409 | − params={{ sourceId }} | |
| 410 | − className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-accent/50" | |
| 411 | − > | |
| 412 | − <IconPencil className="size-3" /> | |
| 413 | − Edit | |
| 414 | − </Link> | |
| 415 | − </div> | |
| 416 | − </div> | |
| 417 | − | |
| 418 | − {/* Tab content */} | |
| 419 | − <div className="flex flex-1 min-h-0 overflow-hidden"> | |
| 420 | − {search.tab === "model" && ( | |
| 421 | − <ModelView | |
| 422 | − bundle={bundle} | |
| 423 | − detail={toolDetail} | |
| 424 | − selectedToolPath={selectedTool?.path ?? null} | |
| 425 | − onSelectTool={(toolPath) => | |
| 426 | − void navigate({ search: (prev) => ({ ...prev, tool: toolPath, tab: "model" }) }) | |
| 427 | − } | |
| 428 | − sourceId={sourceId} | |
| 429 | − /> | |
| 430 | − )} | |
| 431 | − {search.tab === "discover" && ( | |
| 432 | − <DiscoveryView | |
| 433 | − bundle={bundle} | |
| 434 | − discovery={discovery} | |
| 435 | − initialQuery={search.query ?? ""} | |
| 436 | − onSubmitQuery={(query) => | |
| 437 | − void navigate({ search: (prev) => ({ ...prev, query, tab: "discover" }) }) | |
| 438 | − } | |
| 439 | − onOpenTool={(toolPath) => | |
| 440 | − void navigate({ search: (prev) => ({ ...prev, tab: "model", tool: toolPath }) }) | |
| 441 | − } | |
| 442 | − /> | |
| 443 | − )} | |
| 444 | − </div> | |
| 445 | − </div> | |
| 446 | − ); | |
| 447 | − }} | |
| 448 | − </LoadableBlock> | |
| 449 | − ); | |
| 450 | −} | |
| 451 | − | |
| 452 | −// --------------------------------------------------------------------------- | |
| 453 | −// ModelView — two-panel: tool list + tool detail | |
| 454 | −// --------------------------------------------------------------------------- | |
| 455 | − | |
| 456 | −function ModelView(props: { | |
| 457 | − bundle: SourceInspection; | |
| 458 | − detail: Loadable<SourceInspectionToolDetail | null>; | |
| 459 | − selectedToolPath: string | null; | |
| 460 | − onSelectTool: (toolPath: string) => void; | |
| 461 | − sourceId: string; | |
| 462 | −}) { | |
| 463 | − const [search, setSearch] = useState(""); | |
| 464 | − const searchRef = useRef<HTMLInputElement>(null); | |
| 465 | − const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); | |
| 466 | − | |
| 467 | − const filteredTools = props.bundle.tools.filter((tool) => { | |
| 468 | − if (terms.length === 0) return true; | |
| 469 | − const corpus = [ | |
| 470 | − tool.path, | |
| 471 | − tool.method ?? "", | |
| 472 | − tool.inputTypePreview ?? "", | |
| 473 | − tool.outputTypePreview ?? "", | |
| 474 | − ] | |
| 475 | − .join(" ") | |
| 476 | − .toLowerCase(); | |
| 477 | − return terms.every((t) => corpus.includes(t)); | |
| 478 | − }); | |
| 479 | − | |
| 480 | − // Keyboard shortcut: / to focus search | |
| 481 | − useEffect(() => { | |
| 482 | − const handler = (e: KeyboardEvent) => { | |
| 483 | − if (e.key === "/" && document.activeElement?.tagName !== "INPUT") { | |
| 484 | − e.preventDefault(); | |
| 485 | − searchRef.current?.focus(); | |
| 486 | − } | |
| 487 | − if (e.key === "Escape") { | |
| 488 | − searchRef.current?.blur(); | |
| 489 | − if (search.length > 0) setSearch(""); | |
| 490 | − } | |
| 491 | − }; | |
| 492 | − document.addEventListener("keydown", handler); | |
| 493 | − return () => document.removeEventListener("keydown", handler); | |
| 494 | − }, [search]); | |
| 495 | − | |
| 496 | − return ( | |
| 497 | − <> | |
| 498 | − {/* Left panel: tool list */} | |
| 499 | − <div className="flex w-72 shrink-0 flex-col border-r border-border bg-card/30 lg:w-80 xl:w-[22rem]"> | |
| 500 | − {/* Search */} | |
| 501 | − <div className="shrink-0 border-b border-border px-3 py-2"> | |
| 502 | − <div className="relative"> | |
| 503 | − <IconSearch className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/40" /> | |
| 504 | − <input | |
| 505 | − ref={searchRef} | |
| 506 | − value={search} | |
| 507 | − onChange={(e) => setSearch(e.target.value)} | |
| 508 | − placeholder={`Filter ${props.bundle.toolCount} tools\u2026`} | |
| 509 | − className="h-8 w-full rounded-md border border-input bg-background pl-8 pr-8 text-[13px] outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/30" | |
| 510 | − /> | |
| 511 | − {search.length > 0 ? ( | |
| 512 | − <button | |
| 513 | − type="button" | |
| 514 | − onClick={() => setSearch("")} | |
| 515 | − className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground/40 hover:text-foreground" | |
| 516 | − > | |
| 517 | − <IconClose /> | |
| 518 | − </button> | |
| 519 | − ) : ( | |
| 520 | − <kbd className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded border border-border bg-muted px-1 py-px text-[10px] text-muted-foreground/50"> | |
| 521 | − / | |
| 522 | − </kbd> | |
| 523 | − )} | |
| 524 | − </div> | |
| 525 | − </div> | |
| 526 | − | |
| 527 | − {/* Tool list */} | |
| 528 | − <div className="flex-1 overflow-y-auto"> | |
| 529 | − {filteredTools.length === 0 ? ( | |
| 530 | − <div className="p-4 text-center text-[13px] text-muted-foreground/50"> | |
| 531 | − {terms.length > 0 ? "No tools match your filter" : "No tools available"} | |
| 532 | − </div> | |
| 533 | − ) : ( | |
| 534 | − <div className="p-1.5"> | |
| 535 | − <ToolTree | |
| 536 | − tools={filteredTools} | |
| 537 | − selectedToolPath={props.selectedToolPath} | |
| 538 | − onSelectTool={props.onSelectTool} | |
| 539 | − search={search} | |
| 540 | − isFiltered={terms.length > 0} | |
| 541 | − /> | |
| 542 | − </div> | |
| 543 | − )} | |
| 544 | − </div> | |
| 545 | − </div> | |
| 546 | − | |
| 547 | − {/* Right panel: tool detail */} | |
| 548 | − <div className="flex flex-1 flex-col min-w-0 overflow-hidden"> | |
| 549 | − <LoadableBlock loadable={props.detail} loading="Loading tool..."> | |
| 550 | − {(detail) => | |
| 551 | − detail ? ( | |
| 552 | − <ToolDetailPanel detail={detail} /> | |
| 553 | − ) : ( | |
| 554 | − <EmptyState | |
| 555 | − title={props.bundle.toolCount > 0 ? "Select a tool" : "No tools available"} | |
| 556 | − description={props.bundle.toolCount > 0 ? "Choose from the list or press / to search" : undefined} | |
| 557 | − /> | |
| 558 | − ) | |
| 559 | − } | |
| 560 | − </LoadableBlock> | |
| 561 | − </div> | |
| 562 | − </> | |
| 563 | − ); | |
| 564 | −} | |
| 565 | − | |
| 566 | −// --------------------------------------------------------------------------- | |
| 567 | −// Tool Tree (nested by . segments) | |
| 568 | −// --------------------------------------------------------------------------- | |
| 569 | − | |
| 570 | −type ToolTreeNode = { | |
| 571 | − segment: string; | |
| 572 | − tool?: SourceInspection["tools"][number]; | |
| 573 | − children: Map<string, ToolTreeNode>; | |
| 574 | −}; | |
| 575 | − | |
| 576 | −function buildToolTree(tools: SourceInspection["tools"]): ToolTreeNode { | |
| 577 | − const root: ToolTreeNode = { segment: "", children: new Map() }; | |
| 578 | − for (const tool of tools) { | |
| 579 | − const parts = tool.path.split("."); | |
| 580 | − let node = root; | |
| 581 | − for (const part of parts) { | |
| 582 | − if (!node.children.has(part)) { | |
| 583 | − node.children.set(part, { segment: part, children: new Map() }); | |
| 584 | − } | |
| 585 | − node = node.children.get(part)!; | |
| 586 | − } | |
| 587 | − node.tool = tool; | |
| 588 | − } | |
| 589 | − return root; | |
| 590 | −} | |
| 591 | − | |
| 592 | −function ToolTree(props: { | |
| 593 | − tools: SourceInspection["tools"]; | |
| 594 | − selectedToolPath: string | null; | |
| 595 | − onSelectTool: (path: string) => void; | |
| 596 | − search: string; | |
| 597 | − isFiltered: boolean; | |
| 598 | −}) { | |
| 599 | − const tree = useMemo(() => buildToolTree(props.tools), [props.tools]); | |
| 600 | − const entries = [...tree.children.values()].sort((a, b) => | |
| 601 | − a.segment.localeCompare(b.segment), | |
| 602 | − ); | |
| 603 | − | |
| 604 | − return ( | |
| 605 | − <div className="flex flex-col gap-px"> | |
| 606 | − {entries.map((node) => ( | |
| 607 | − <ToolTreeNodeView | |
| 608 | − key={node.segment} | |
| 609 | − node={node} | |
| 610 | − depth={0} | |
| 611 | − selectedToolPath={props.selectedToolPath} | |
| 612 | − onSelectTool={props.onSelectTool} | |
| 613 | − search={props.search} | |
| 614 | − defaultOpen={props.isFiltered} | |
| 615 | − /> | |
| 616 | − ))} | |
| 617 | − </div> | |
| 618 | − ); | |
| 619 | −} | |
| 620 | − | |
| 621 | −function ToolTreeNodeView(props: { | |
| 622 | − node: ToolTreeNode; | |
| 623 | − depth: number; | |
| 624 | − selectedToolPath: string | null; | |
| 625 | − onSelectTool: (path: string) => void; | |
| 626 | − search: string; | |
| 627 | − defaultOpen: boolean; | |
| 628 | −}) { | |
| 629 | − const { node, depth, selectedToolPath, onSelectTool, search, defaultOpen } = props; | |
| 630 | − const hasChildren = node.children.size > 0; | |
| 631 | − const isLeaf = !!node.tool && !hasChildren; | |
| 632 | − | |
| 633 | − const hasSelectedDescendant = useMemo(() => { | |
| 634 | − if (!selectedToolPath) return false; | |
| 635 | − function check(n: ToolTreeNode): boolean { | |
| 636 | − if (n.tool?.path === selectedToolPath) return true; | |
| 637 | − for (const child of n.children.values()) { | |
| 638 | − if (check(child)) return true; | |
| 639 | − } | |
| 640 | − return false; | |
| 641 | − } | |
| 642 | − return check(node); | |
| 643 | − }, [node, selectedToolPath]); | |
| 644 | − | |
| 645 | − const [open, setOpen] = useState(defaultOpen || hasSelectedDescendant); | |
| 646 | − | |
| 647 | − useEffect(() => { | |
| 648 | − if (defaultOpen || hasSelectedDescendant) setOpen(true); | |
| 649 | − }, [defaultOpen, hasSelectedDescendant]); | |
| 650 | − | |
| 651 | − if (isLeaf) { | |
| 652 | − return ( | |
| 653 | − <ToolListItem | |
| 654 | − tool={node.tool!} | |
| 655 | − active={node.tool!.path === selectedToolPath} | |
| 656 | − onSelect={() => onSelectTool(node.tool!.path)} | |
| 657 | − search={search} | |
| 658 | − depth={depth} | |
| 659 | − /> | |
| 660 | − ); | |
| 661 | − } | |
| 662 | − | |
| 663 | − const paddingLeft = 8 + depth * 16; | |
| 664 | − const sortedChildren = [...node.children.values()].sort((a, b) => | |
| 665 | − a.segment.localeCompare(b.segment), | |
| 666 | − ); | |
| 667 | − | |
| 668 | − const leafCount = countToolLeaves(node); | |
| 669 | − | |
| 670 | − return ( | |
| 671 | − <div> | |
| 672 | − {node.tool ? ( | |
| 673 | − <div className="flex items-center"> | |
| 674 | − <button | |
| 675 | − type="button" | |
| 676 | − onClick={() => setOpen((o) => !o)} | |
| 677 | − className="shrink-0 rounded p-0.5 text-muted-foreground/30 hover:text-muted-foreground" | |
| 678 | − style={{ marginLeft: paddingLeft }} | |
| 679 | − > | |
| 680 | − <IconChevron | |
| 681 | − className={cn( | |
| 682 | − "size-2.5 transition-transform duration-150", | |
| 683 | − open && "rotate-90", | |
| 684 | − )} | |
| 685 | − /> | |
| 686 | − </button> | |
| 687 | − <ToolListItem | |
| 688 | − tool={node.tool} | |
| 689 | − active={node.tool.path === selectedToolPath} | |
| 690 | − onSelect={() => onSelectTool(node.tool!.path)} | |
| 691 | − search={search} | |
| 692 | − depth={-1} | |
| 693 | − className="flex-1 pl-1" | |
| 694 | − /> | |
| 695 | − </div> | |
| 696 | − ) : ( | |
| 697 | − <button | |
| 698 | − type="button" | |
| 699 | − onClick={() => setOpen((o) => !o)} | |
| 700 | − className={cn( | |
| 701 | − "group flex w-full items-center gap-1.5 rounded-md py-1 pr-2.5 text-[12px] transition-colors hover:bg-accent/40", | |
| 702 | − open ? "text-foreground/80" : "text-muted-foreground/60", | |
| 703 | − )} | |
| 704 | − style={{ paddingLeft }} | |
| 705 | − > | |
| 706 | − <IconChevron | |
| 707 | − className={cn( | |
| 708 | − "size-2.5 shrink-0 text-muted-foreground/30 transition-transform duration-150", | |
| 709 | − open && "rotate-90", | |
| 710 | − )} | |
| 711 | − /> | |
| 712 | − <IconFolder className={cn( | |
| 713 | − "size-3 shrink-0", | |
| 714 | − open ? "text-primary/60" : "text-muted-foreground/30", | |
| 715 | − )} /> | |
| 716 | − <span className="flex-1 truncate text-left font-mono"> | |
| 717 | − {highlightMatch(node.segment, search)} | |
| 718 | − </span> | |
| 719 | − <span className="shrink-0 tabular-nums text-[10px] text-muted-foreground/25"> | |
| 720 | − {leafCount} | |
| 721 | − </span> | |
| 722 | − </button> | |
| 723 | − )} | |
| 724 | − | |
| 725 | − {open && hasChildren && ( | |
| 726 | − <div className="relative flex flex-col gap-px"> | |
| 727 | − <span | |
| 728 | − className="absolute top-0 bottom-1 w-px bg-border/40" | |
| 729 | − style={{ left: paddingLeft + 5 }} | |
| 730 | − aria-hidden | |
| 731 | − /> | |
| 732 | − {sortedChildren.map((child) => ( | |
| 733 | − <ToolTreeNodeView | |
| 734 | − key={child.segment} | |
| 735 | − node={child} | |
| 736 | − depth={depth + 1} | |
| 737 | − selectedToolPath={selectedToolPath} | |
| 738 | − onSelectTool={onSelectTool} | |
| 739 | − search={search} | |
| 740 | − defaultOpen={defaultOpen} | |
| 741 | − /> | |
| 742 | − ))} | |
| 743 | − </div> | |
| 744 | − )} | |
| 745 | − </div> | |
| 746 | − ); | |
| 747 | −} | |
| 748 | − | |
| 749 | −function countToolLeaves(node: ToolTreeNode): number { | |
| 750 | − let count = node.tool ? 1 : 0; | |
| 751 | − for (const child of node.children.values()) { | |
| 752 | − count += countToolLeaves(child); | |
| 753 | − } | |
| 754 | − return count; | |
| 755 | −} | |
| 756 | − | |
| 757 | −// --------------------------------------------------------------------------- | |
| 758 | −// ToolListItem | |
| 759 | −// --------------------------------------------------------------------------- | |
| 760 | − | |
| 761 | −function ToolListItem(props: { | |
| 762 | − tool: SourceInspection["tools"][number]; | |
| 763 | − active: boolean; | |
| 764 | − onSelect: () => void; | |
| 765 | − search: string; | |
| 766 | − depth: number; | |
| 767 | − className?: string; | |
| 768 | −}) { | |
| 769 | − const ref = useRef<HTMLButtonElement>(null); | |
| 770 | − const paddingLeft = props.depth >= 0 ? 8 + props.depth * 16 + 8 : undefined; | |
| 771 | − | |
| 772 | − useEffect(() => { | |
| 773 | − if (props.active && ref.current) { | |
| 774 | − ref.current.scrollIntoView({ block: "nearest" }); | |
| 775 | − } | |
| 776 | − }, [props.active]); | |
| 777 | − | |
| 778 | − const label = props.depth >= 0 | |
| 779 | − ? props.tool.path.split(".").pop() ?? props.tool.path | |
| 780 | − : props.tool.path; | |
| 781 | − | |
| 782 | − return ( | |
| 783 | − <button | |
| 784 | − ref={ref} | |
| 785 | − type="button" | |
| 786 | − onClick={props.onSelect} | |
| 787 | − className={cn( | |
| 788 | − "group flex w-full items-center gap-2 rounded-md py-1.5 pr-2.5 text-left transition-colors", | |
| 789 | − props.active | |
| 790 | − ? "bg-primary/10 text-foreground border-l-2 border-l-primary" | |
| 791 | − : "hover:bg-accent/50 text-foreground/70 hover:text-foreground", | |
| 792 | − props.className, | |
| 793 | − )} | |
| 794 | − style={paddingLeft != null ? { paddingLeft } : undefined} | |
| 795 | − > | |
| 796 | − <IconTool className="size-3 shrink-0 text-muted-foreground/40" /> | |
| 797 | − <span className="flex-1 truncate font-mono text-[12px]"> | |
| 798 | − {highlightMatch(label, props.search)} | |
| 799 | − </span> | |
| 800 | − {props.tool.method && <MethodBadge method={props.tool.method} />} | |
| 801 | − </button> | |
| 802 | − ); | |
| 803 | −} | |
| 804 | − | |
| 805 | −// --------------------------------------------------------------------------- | |
| 806 | −// ToolDetailPanel | |
| 807 | −// --------------------------------------------------------------------------- | |
| 808 | − | |
| 809 | −function ToolDetailPanel(props: { detail: SourceInspectionToolDetail }) { | |
| 810 | − const { detail } = props; | |
| 811 | − const [copiedField, setCopiedField] = useState<string | null>(null); | |
| 812 | − | |
| 813 | − const copy = useCallback((text: string, field: string) => { | |
| 814 | − void navigator.clipboard.writeText(text).then(() => { | |
| 815 | − setCopiedField(field); | |
| 816 | − setTimeout(() => setCopiedField(null), 1500); | |
| 817 | − }); | |
| 818 | − }, []); | |
| 819 | − | |
| 820 | − return ( | |
| 821 | − <div className="flex h-full flex-col overflow-hidden"> | |
| 822 | − {/* Header */} | |
| 823 | − <div className="shrink-0 border-b border-border bg-background/95 backdrop-blur-sm"> | |
| 824 | − <div className="flex items-start gap-3 px-5 py-3.5"> | |
| 825 | − <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"> | |
| 826 | − <IconTool className="size-4" /> | |
| 827 | − </div> | |
| 828 | − <div className="min-w-0 flex-1"> | |
| 829 | − <div className="flex items-center gap-2"> | |
| 830 | − <h3 className="truncate text-sm font-semibold text-foreground"> | |
| 831 | − {detail.summary.path} | |
| 832 | − </h3> | |
| 833 | − <CopyButton text={detail.summary.path} field="path" copiedField={copiedField} onCopy={copy} /> | |
| 834 | − </div> | |
| 835 | − <div className="mt-1 flex flex-wrap items-center gap-1.5"> | |
| 836 | − {detail.summary.method && <MethodBadge method={detail.summary.method} />} | |
| 837 | − {detail.summary.pathTemplate && ( | |
| 838 | − <span className="font-mono text-[11px] text-muted-foreground/60"> | |
| 839 | − {detail.summary.pathTemplate} | |
| 840 | − </span> | |
| 841 | − )} | |
| 842 | − </div> | |
| 843 | − <div className="mt-2 rounded-md border border-border bg-card/70 px-2.5 py-2"> | |
| 844 | − <div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60"> | |
| 845 | − TypeScript Signature | |
| 846 | − </div> | |
| 847 | − <pre className="max-h-40 overflow-auto whitespace-pre-wrap font-mono text-[12px] text-foreground"> | |
| 848 | − {detail.contract.callDeclaration} | |
| 849 | − </pre> | |
| 850 | − </div> | |
| 851 | − </div> | |
| 852 | − </div> | |
| 853 | − </div> | |
| 854 | − | |
| 855 | − {/* Body */} | |
| 856 | − <div className="flex-1 overflow-y-auto"> | |
| 857 | − <div className="px-5 py-4 space-y-4"> | |
| 858 | − <section className="overflow-hidden rounded-lg border border-border bg-card/60"> | |
| 859 | − <div className="border-b border-border px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70"> | |
| 860 | − Contract | |
| 861 | − </div> | |
| 862 | − <div className="grid grid-cols-1 gap-3 p-3 xl:grid-cols-2"> | |
| 863 | − <DocumentPanel | |
| 864 | − title="Input Type" | |
| 865 | − body={detail.contract.input.typeDeclaration} | |
| 866 | − lang="typescript" | |
| 867 | − empty="No input type." | |
| 868 | − /> | |
| 869 | − <DocumentPanel | |
| 870 | − title="Output Type" | |
| 871 | − body={detail.contract.output.typeDeclaration} | |
| 872 | − lang="typescript" | |
| 873 | − empty="No output type." | |
| 874 | − /> | |
| 875 | − </div> | |
| 876 | − </section> | |
| 877 | − | |
| 878 | − <section className="overflow-hidden rounded-lg border border-border bg-card/60"> | |
| 879 | − <div className="border-b border-border px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70"> | |
| 880 | − Schemas | |
| 881 | − </div> | |
| 882 | − <div className="grid grid-cols-1 gap-3 p-3 xl:grid-cols-2"> | |
| 883 | − <DocumentPanel | |
| 884 | − title="Input Schema" | |
| 885 | − body={detail.contract.input.schemaJson} | |
| 886 | − lang="json" | |
| 887 | − empty="No input schema." | |
| 888 | − compact | |
| 889 | − /> | |
| 890 | − <DocumentPanel | |
| 891 | − title="Output Schema" | |
| 892 | − body={detail.contract.output.schemaJson} | |
| 893 | − lang="json" | |
| 894 | − empty="No output schema." | |
| 895 | − compact | |
| 896 | − /> | |
| 897 | − {detail.contract.input.exampleJson && ( | |
| 898 | − <DocumentPanel | |
| 899 | − title="Example Request" | |
| 900 | − body={detail.contract.input.exampleJson} | |
| 901 | − lang="json" | |
| 902 | − empty="" | |
| 903 | − compact | |
| 904 | − /> | |
| 905 | − )} | |
| 906 | − {detail.contract.output.exampleJson && ( | |
| 907 | − <DocumentPanel | |
| 908 | − title="Example Response" | |
| 909 | − body={detail.contract.output.exampleJson} | |
| 910 | − lang="json" | |
| 911 | − empty="" | |
| 912 | − compact | |
| 913 | − /> | |
| 914 | − )} | |
| 915 | − </div> | |
| 916 | − </section> | |
| 917 | − | |
| 918 | − {detail.sections.map((section, index) => { | |
| 919 | − if (section.kind === "facts") { | |
| 920 | − return ( | |
| 921 | − <section | |
| 922 | − key={`${section.kind}:${section.title}:${String(index)}`} | |
| 923 | − className="overflow-hidden rounded-lg border border-border bg-card/60" | |
| 924 | − > | |
| 925 | − <div className="border-b border-border px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70"> | |
| 926 | − {section.title} | |
| 927 | − </div> | |
| 928 | − <div className="grid grid-cols-1 gap-3 p-3 md:grid-cols-2"> | |
| 929 | − {section.items.map((item) => ( | |
| 930 | − <div key={`${section.title}:${item.label}`} className="min-w-0"> | |
| 931 | − <div className="text-[11px] uppercase tracking-wider text-muted-foreground/60"> | |
| 932 | − {item.label} | |
| 933 | − </div> | |
| 934 | − <div className={cn( | |
| 935 | − "mt-1 text-sm text-foreground", | |
| 936 | − item.mono && "font-mono text-[12px]", | |
| 937 | − )} | |
| 938 | − > | |
| 939 | − {item.value} | |
| 940 | − </div> | |
| 941 | − </div> | |
| 942 | − ))} | |
| 943 | − </div> | |
| 944 | − </section> | |
| 945 | − ); | |
| 946 | − } | |
| 947 | − | |
| 948 | − if (section.kind === "markdown") { | |
| 949 | − return ( | |
| 950 | − <section | |
| 951 | − key={`${section.kind}:${section.title}:${String(index)}`} | |
| 952 | − className="overflow-hidden rounded-lg border border-border bg-card/60" | |
| 953 | − > | |
| 954 | − <div className="border-b border-border px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70"> | |
| 955 | − {section.title} | |
| 956 | − </div> | |
| 957 | − <div className="p-4"> | |
| 958 | − <Markdown>{section.body}</Markdown> | |
| 959 | − </div> | |
| 960 | − </section> | |
| 961 | − ); | |
| 962 | − } | |
| 963 | − | |
| 964 | − return ( | |
| 965 | − <DocumentPanel | |
| 966 | − key={`${section.kind}:${section.title}:${String(index)}`} | |
| 967 | − title={section.title} | |
| 968 | − body={section.body} | |
| 969 | − lang={section.language === "text" ? undefined : section.language} | |
| 970 | − empty="" | |
| 971 | − compact | |
| 972 | − /> | |
| 973 | − ); | |
| 974 | − })} | |
| 975 | − </div> | |
| 976 | − </div> | |
| 977 | − </div> | |
| 978 | − ); | |
| 979 | −} | |
| 980 | − | |
| 981 | −// --------------------------------------------------------------------------- | |
| 982 | −// DiscoveryView | |
| 983 | −// --------------------------------------------------------------------------- | |
| 984 | − | |
| 985 | −function DiscoveryView(props: { | |
| 986 | − bundle: SourceInspection; | |
| 987 | − discovery: Loadable<SourceInspectionDiscoverResult>; | |
| 988 | − initialQuery: string; | |
| 989 | − onSubmitQuery: (query: string) => void; | |
| 990 | − onOpenTool: (toolPath: string) => void; | |
| 991 | −}) { | |
| 992 | − const [draftQuery, setDraftQuery] = useState(props.initialQuery); | |
| 993 | − | |
| 994 | − useEffect(() => { | |
| 995 | − setDraftQuery(props.initialQuery); | |
| 996 | − }, [props.initialQuery]); | |
| 997 | − | |
| 998 | − return ( | |
| 999 | − <div className="flex flex-1 flex-col overflow-hidden"> | |
| 1000 | − {/* Search bar */} | |
| 1001 | − <div className="shrink-0 border-b border-border px-4 py-3"> | |
| 1002 | − <form | |
| 1003 | − className="flex items-center gap-2 max-w-2xl" | |
| 1004 | − onSubmit={(e) => { | |
| 1005 | − e.preventDefault(); | |
| 1006 | − props.onSubmitQuery(draftQuery.trim()); | |
| 1007 | − }} | |
| 1008 | − > | |
| 1009 | − <div className="relative flex-1"> | |
| 1010 | − <IconSearch className="absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/40" /> | |
| 1011 | − <input | |
| 1012 | − value={draftQuery} | |
| 1013 | − onChange={(e) => setDraftQuery(e.target.value)} | |
| 1014 | − placeholder="Search tools\u2026" | |
| 1015 | − className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/30" | |
| 1016 | − /> | |
| 1017 | − </div> | |
| 1018 | − <Button type="submit" size="sm">Search</Button> | |
| 1019 | − </form> | |
| 1020 | − </div> | |
| 1021 | − | |
| 1022 | − {/* Results */} | |
| 1023 | − <div className="flex-1 overflow-y-auto p-4"> | |
| 1024 | − <LoadableBlock loadable={props.discovery} loading="Searching\u2026"> | |
| 1025 | − {(result) => | |
| 1026 | − result.query.length === 0 ? ( | |
| 1027 | − <EmptyState | |
| 1028 | − title="Search your tools" | |
| 1029 | − description="Type a query to find matching tools across this source." | |
| 1030 | − /> | |
| 1031 | − ) : result.results.length === 0 ? ( | |
| 1032 | − <EmptyState | |
| 1033 | − title="No results" | |
| 1034 | − description="Try different search terms." | |
| 1035 | − /> | |
| 1036 | − ) : ( | |
| 1037 | − <div className="max-w-3xl space-y-2"> | |
| 1038 | − {result.results.map((item, index) => ( | |
| 1039 | − <button | |
| 1040 | − key={item.path} | |
| 1041 | − type="button" | |
| 1042 | − onClick={() => props.onOpenTool(item.path)} | |
| 1043 | − className="group w-full rounded-lg border border-border bg-card/60 p-3.5 text-left transition-all hover:border-primary/30 hover:shadow-sm" | |
| 1044 | − > | |
| 1045 | − <div className="flex items-center justify-between gap-3 mb-1.5"> | |
| 1046 | − <div className="flex items-center gap-2.5 min-w-0"> | |
| 1047 | − <span className="flex size-5 shrink-0 items-center justify-center rounded-md bg-muted text-[10px] font-mono tabular-nums text-muted-foreground/60"> | |
| 1048 | − {index + 1} | |
| 1049 | − </span> | |
| 1050 | − <h4 className="truncate font-mono text-[13px] font-medium text-foreground group-hover:text-primary transition-colors"> | |
| 1051 | − {item.path} | |
| 1052 | − </h4> | |
| 1053 | − </div> | |
| 1054 | − <span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground/50"> | |
| 1055 | − {item.score.toFixed(2)} | |
| 1056 | − </span> | |
| 1057 | − </div> | |
| 1058 | − {item.description && ( | |
| 1059 | − <p className="text-[12px] text-muted-foreground leading-relaxed line-clamp-2"> | |
| 1060 | − {item.description} | |
| 1061 | − </p> | |
| 1062 | − )} | |
| 1063 | − {(item.inputTypePreview || item.outputTypePreview) && ( | |
| 1064 | − <div className="mt-2 grid grid-cols-1 gap-2 md:grid-cols-2"> | |
| 1065 | − {item.inputTypePreview && ( | |
| 1066 | − <div className="rounded-md border border-border/70 bg-background/70 px-2.5 py-2"> | |
| 1067 | − <div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/55"> | |
| 1068 | − Input | |
| 1069 | − </div> | |
| 1070 | − <div className="font-mono text-[11px] text-foreground/85 line-clamp-3"> | |
| 1071 | − {item.inputTypePreview} | |
| 1072 | − </div> | |
| 1073 | − </div> | |
| 1074 | − )} | |
| 1075 | − {item.outputTypePreview && ( | |
| 1076 | − <div className="rounded-md border border-border/70 bg-background/70 px-2.5 py-2"> | |
| 1077 | − <div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/55"> | |
| 1078 | − Output | |
| 1079 | − </div> | |
| 1080 | − <div className="font-mono text-[11px] text-foreground/85 line-clamp-3"> | |
| 1081 | − {item.outputTypePreview} | |
| 1082 | − </div> | |
| 1083 | − </div> | |
| 1084 | − )} | |
| 1085 | − </div> | |
| 1086 | − )} | |
| 1087 | − </button> | |
| 1088 | − ))} | |
| 1089 | − </div> | |
| 1090 | − ) | |
| 1091 | − } | |
| 1092 | − </LoadableBlock> | |
| 1093 | − </div> | |
| 1094 | − </div> | |
| 1095 | − ); | |
| 1096 | −} | |
| 1097 | − | |
| 1098 | −// --------------------------------------------------------------------------- | |
| 1099 | −// Shared sub-components | |
| 1100 | −// --------------------------------------------------------------------------- | |
| 1101 | − | |
| 1102 | −function CopyButton(props: { | |
| 1103 | − text: string; | |
| 1104 | − field: string; | |
| 1105 | − copiedField: string | null; | |
| 1106 | − onCopy: (text: string, field: string) => void; | |
| 1107 | −}) { | |
| 1108 | − return ( | |
| 1109 | − <button | |
| 1110 | − type="button" | |
| 1111 | − onClick={() => props.onCopy(props.text, props.field)} | |
| 1112 | − className="shrink-0 rounded p-1 text-muted-foreground/30 transition-colors hover:text-muted-foreground" | |
| 1113 | − title={`Copy ${props.field}`} | |
| 1114 | − > | |
| 1115 | − {props.copiedField === props.field ? <IconCheck /> : <IconCopy />} | |
| 1116 | − </button> | |
| 1117 | − ); | |
| 1118 | −} | |
| 1119 | − | |
| 1120 | −function highlightMatch(text: string, search: string) { | |
| 1121 | − if (!search.trim()) return text; | |
| 1122 | − const terms = search.trim().toLowerCase().split(/\s+/); | |
| 1123 | − const lowerText = text.toLowerCase(); | |
| 1124 | − const ranges: Array<[number, number]> = []; | |
| 1125 | − | |
| 1126 | − for (const term of terms) { | |
| 1127 | − let idx = 0; | |
| 1128 | − while (idx < lowerText.length) { | |
| 1129 | − const found = lowerText.indexOf(term, idx); | |
| 1130 | − if (found === -1) break; | |
| 1131 | − ranges.push([found, found + term.length]); | |
| 1132 | − idx = found + 1; | |
| 1133 | − } | |
| 1134 | − } | |
| 1135 | − | |
| 1136 | − if (ranges.length === 0) return text; | |
| 1137 | − | |
| 1138 | − ranges.sort((a, b) => a[0] - b[0]); | |
| 1139 | − const merged: Array<[number, number]> = [ranges[0]!]; | |
| 1140 | − for (let i = 1; i < ranges.length; i++) { | |
| 1141 | − const last = merged[merged.length - 1]!; | |
| 1142 | − const current = ranges[i]!; | |
| 1143 | − if (current[0] <= last[1]) { | |
| 1144 | − last[1] = Math.max(last[1], current[1]); | |
| 1145 | − } else { | |
| 1146 | − merged.push(current); | |
| 1147 | − } | |
| 1148 | − } | |
| 1149 | − | |
| 1150 | − const parts: Array<{ text: string; hl: boolean }> = []; | |
| 1151 | − let cursor = 0; | |
| 1152 | − for (const [start, end] of merged) { | |
| 1153 | − if (cursor < start) parts.push({ text: text.slice(cursor, start), hl: false }); | |
| 1154 | − parts.push({ text: text.slice(start, end), hl: true }); | |
| 1155 | − cursor = end; | |
| 1156 | − } | |
| 1157 | − if (cursor < text.length) parts.push({ text: text.slice(cursor), hl: false }); | |
| 1158 | − | |
| 1159 | − return ( | |
| 1160 | − <> | |
| 1161 | − {parts.map((part, i) => | |
| 1162 | − part.hl ? ( | |
| 1163 | − <mark key={i} className="rounded-sm bg-primary/20 text-foreground px-px"> | |
| 1164 | − {part.text} | |
| 1165 | − </mark> | |
| 1166 | − ) : ( | |
| 1167 | − <span key={i}>{part.text}</span> | |
| 1168 | − ), | |
| 1169 | − )} | |
| 1170 | − </> | |
| 1171 | − ); | |
| 1172 | −} | |
| @@ -1,1919 +0,0 @@ | ||
| 1 | −import { useEffect, useState, type ReactNode } from "react"; | |
| 2 | −import { Link, useNavigate } from "@tanstack/react-router"; | |
| 3 | −import { | |
| 4 | − type CompleteSourceOAuthResult, | |
| 5 | − type CreateSourcePayload, | |
| 6 | − type InstanceConfig, | |
| 7 | − type Loadable, | |
| 8 | − type StartSourceOAuthPayload, | |
| 9 | − type SecretListItem, | |
| 10 | − type Source, | |
| 11 | − type UpdateSourcePayload, | |
| 12 | − useCreateSecret, | |
| 13 | − useCreateSource, | |
| 14 | − useInstanceConfig, | |
| 15 | − useRefreshSecrets, | |
| 16 | − useRemoveProviderAuthGrant, | |
| 17 | − useRemoveSource, | |
| 18 | − useSecrets, | |
| 19 | − useSources, | |
| 20 | − useStartSourceOAuth, | |
| 21 | − useSource, | |
| 22 | − useUpdateSource, | |
| 23 | −} from "@executor/react"; | |
| 24 | −import { Badge } from "../components/ui/badge"; | |
| 25 | −import { Button } from "../components/ui/button"; | |
| 26 | −import { LoadableBlock } from "../components/loadable"; | |
| 27 | −import { SourceNotFoundState } from "../components/source-not-found-state"; | |
| 28 | −import { SourceFavicon } from "../components/source-favicon"; | |
| 29 | −import { | |
| 30 | − IconArrowLeft, | |
| 31 | − IconPencil, | |
| 32 | − IconPlus, | |
| 33 | − IconSpinner, | |
| 34 | − IconTrash, | |
| 35 | −} from "../components/icons"; | |
| 36 | −import { cn } from "../lib/utils"; | |
| 37 | −import { | |
| 38 | − asMcpRemoteTransportValue, | |
| 39 | − defaultMcpRemoteTransportFields, | |
| 40 | − defaultMcpStdioTransportFields, | |
| 41 | − setMcpTransportFieldsTransport, | |
| 42 | − type McpRemoteTransportFields, | |
| 43 | − type McpStdioTransportFields, | |
| 44 | − type McpTransportFields, | |
| 45 | − type McpTransportValue, | |
| 46 | −} from "./mcp-transport-state"; | |
| 47 | −import { parseJsonStringArray, parseJsonStringMap } from "./json-form"; | |
| 48 | −import { sourceTemplates, type SourceTemplate } from "./source-templates"; | |
| 49 | −import { getDomain } from "tldts"; | |
| 50 | − | |
| 51 | −type StatusBannerState = { | |
| 52 | − tone: "info" | "success" | "error"; | |
| 53 | − text: string; | |
| 54 | −}; | |
| 55 | − | |
| 56 | −type SourceOAuthPopupMessage = | |
| 57 | − | { | |
| 58 | − type: "executor:oauth-result"; | |
| 59 | − ok: true; | |
| 60 | − sessionId: string; | |
| 61 | − auth: CompleteSourceOAuthResult["auth"]; | |
| 62 | − } | |
| 63 | − | { | |
| 64 | − type: "executor:oauth-result"; | |
| 65 | − ok: false; | |
| 66 | − sessionId: string | null; | |
| 67 | − error: string; | |
| 68 | − }; | |
| 69 | − | |
| 70 | −const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000; | |
| 71 | −const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:"; | |
| 72 | − | |
| 73 | −const isSourceNotFoundLoadable = (loadable: Loadable<unknown>): boolean => | |
| 74 | − loadable.status === "error" && | |
| 75 | − loadable.error.message.toLowerCase().includes("source not found"); | |
| 76 | − | |
| 77 | −type SourceFormBase = { | |
| 78 | − name: string; | |
| 79 | − kind: Source["kind"]; | |
| 80 | − endpoint: string; | |
| 81 | − namespace: string; | |
| 82 | − enabled: boolean; | |
| 83 | − specUrl: string; | |
| 84 | − service: string; | |
| 85 | − version: string; | |
| 86 | − defaultHeadersText: string; | |
| 87 | − authKind: Source["auth"]["kind"]; | |
| 88 | − authHeaderName: string; | |
| 89 | − authPrefix: string; | |
| 90 | − bearerProviderId: string; | |
| 91 | − bearerHandle: string; | |
| 92 | − oauthAccessProviderId: string; | |
| 93 | − oauthAccessHandle: string; | |
| 94 | − oauthRefreshProviderId: string; | |
| 95 | − oauthRefreshHandle: string; | |
| 96 | − managedAuth: Extract< | |
| 97 | − Source["auth"], | |
| 98 | − { kind: "provider_grant_ref" | "mcp_oauth" } | |
| 99 | − > | null; | |
| 100 | −}; | |
| 101 | − | |
| 102 | −type SourceFormState = SourceFormBase & McpTransportFields; | |
| 103 | − | |
| 104 | −const kindOptions: ReadonlyArray<Source["kind"]> = [ | |
| 105 | − "mcp", | |
| 106 | − "openapi", | |
| 107 | − "graphql", | |
| 108 | − "google_discovery", | |
| 109 | − "internal", | |
| 110 | −]; | |
| 111 | − | |
| 112 | −const transportOptions: ReadonlyArray<Exclude<McpTransportValue, "">> = [ | |
| 113 | − "auto", | |
| 114 | − "streamable-http", | |
| 115 | − "sse", | |
| 116 | − "stdio", | |
| 117 | −]; | |
| 118 | − | |
| 119 | −const authOptions: ReadonlyArray<Source["auth"]["kind"]> = [ | |
| 120 | − "none", | |
| 121 | − "bearer", | |
| 122 | − "oauth2", | |
| 123 | − "provider_grant_ref", | |
| 124 | − "mcp_oauth", | |
| 125 | −]; | |
| 126 | − | |
| 127 | −const trimToNull = (value: string): string | null => { | |
| 128 | − const trimmed = value.trim(); | |
| 129 | − return trimmed.length > 0 ? trimmed : null; | |
| 130 | −}; | |
| 131 | − | |
| 132 | −const namespaceFromUrl = (url: string): string => { | |
| 133 | − try { | |
| 134 | − const domain = getDomain(url); | |
| 135 | − if (!domain) return ""; | |
| 136 | − const dot = domain.indexOf("."); | |
| 137 | − return dot > 0 ? domain.slice(0, dot) : domain; | |
| 138 | − } catch { | |
| 139 | − return ""; | |
| 140 | − } | |
| 141 | −}; | |
| 142 | − | |
| 143 | −const googleDiscoveryNamespace = (service: string): string => | |
| 144 | − `google.${service | |
| 145 | − .trim() | |
| 146 | − .toLowerCase() | |
| 147 | − .replace(/[^a-z0-9]+/g, ".") | |
| 148 | − .replace(/^\.+|\.+$/g, "")}`; | |
| 149 | − | |
| 150 | −const readStoredSourceOAuthPopupResult = ( | |
| 151 | − sessionId: string, | |
| 152 | −): SourceOAuthPopupMessage | null => { | |
| 153 | − if (typeof window === "undefined") { | |
| 154 | − return null; | |
| 155 | − } | |
| 156 | − | |
| 157 | − const raw = window.localStorage.getItem( | |
| 158 | − `${SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX}${sessionId}`, | |
| 159 | − ); | |
| 160 | − if (!raw) { | |
| 161 | − return null; | |
| 162 | − } | |
| 163 | − | |
| 164 | − try { | |
| 165 | − return JSON.parse(raw) as SourceOAuthPopupMessage; | |
| 166 | − } catch { | |
| 167 | − return null; | |
| 168 | − } | |
| 169 | −}; | |
| 170 | − | |
| 171 | −const clearStoredSourceOAuthPopupResult = (sessionId: string): void => { | |
| 172 | − if (typeof window === "undefined") { | |
| 173 | − return; | |
| 174 | − } | |
| 175 | − | |
| 176 | − window.localStorage.removeItem( | |
| 177 | − `${SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX}${sessionId}`, | |
| 178 | − ); | |
| 179 | −}; | |
| 180 | − | |
| 181 | −const startSourceOAuthPopup = async (input: { | |
| 182 | − authorizationUrl: string; | |
| 183 | − sessionId: string; | |
| 184 | −}): Promise<CompleteSourceOAuthResult["auth"]> => { | |
| 185 | − if (typeof window === "undefined") { | |
| 186 | − throw new Error("OAuth popup is only available in a browser context"); | |
| 187 | − } | |
| 188 | − | |
| 189 | − clearStoredSourceOAuthPopupResult(input.sessionId); | |
| 190 | − | |
| 191 | − const popup = window.open( | |
| 192 | − input.authorizationUrl, | |
| 193 | − "executor-source-oauth", | |
| 194 | − "popup=yes,width=520,height=720", | |
| 195 | − ); | |
| 196 | − | |
| 197 | − if (!popup) { | |
| 198 | − throw new Error("Popup blocked. Allow popups and try again."); | |
| 199 | − } | |
| 200 | − | |
| 201 | − popup.focus(); | |
| 202 | − | |
| 203 | − return await new Promise<CompleteSourceOAuthResult["auth"]>( | |
| 204 | − (resolve, reject) => { | |
| 205 | − let settled = false; | |
| 206 | − let closedPoll = 0; | |
| 207 | − let resultTimeout = 0; | |
| 208 | − | |
| 209 | − const cleanup = () => { | |
| 210 | − window.removeEventListener("message", onMessage); | |
| 211 | − if (closedPoll) { | |
| 212 | − window.clearInterval(closedPoll); | |
| 213 | − } | |
| 214 | − if (resultTimeout) { | |
| 215 | − window.clearTimeout(resultTimeout); | |
| 216 | − } | |
| 217 | − if (!popup.closed) { | |
| 218 | − popup.close(); | |
| 219 | − } | |
| 220 | − clearStoredSourceOAuthPopupResult(input.sessionId); | |
| 221 | − }; | |
| 222 | − | |
| 223 | − const settleWithError = (message: string) => { | |
| 224 | − if (settled) { | |
| 225 | − return; | |
| 226 | − } | |
| 227 | − | |
| 228 | − settled = true; | |
| 229 | − cleanup(); | |
| 230 | − reject(new Error(message)); | |
| 231 | − }; | |
| 232 | − | |
| 233 | − const settleFromPayload = (data: SourceOAuthPopupMessage) => { | |
| 234 | − if (!data.ok) { | |
| 235 | − settleWithError(data.error || "OAuth failed"); | |
| 236 | − return; | |
| 237 | − } | |
| 238 | − | |
| 239 | − if (settled) { | |
| 240 | − return; | |
| 241 | − } | |
| 242 | − | |
| 243 | − settled = true; | |
| 244 | − cleanup(); | |
| 245 | − resolve(data.auth); | |
| 246 | − }; | |
| 247 | − | |
| 248 | − const onMessage = (event: MessageEvent) => { | |
| 249 | − if (event.origin !== window.location.origin) { | |
| 250 | − return; | |
| 251 | − } | |
| 252 | − | |
| 253 | − const data = event.data as SourceOAuthPopupMessage | undefined; | |
| 254 | − if (!data || data.type !== "executor:oauth-result") { | |
| 255 | − return; | |
| 256 | − } | |
| 257 | − | |
| 258 | − if (data.ok && data.sessionId !== input.sessionId) { | |
| 259 | − return; | |
| 260 | − } | |
| 261 | − | |
| 262 | − if ( | |
| 263 | − !data.ok && | |
| 264 | − data.sessionId !== null && | |
| 265 | − data.sessionId !== input.sessionId | |
| 266 | − ) { | |
| 267 | − return; | |
| 268 | − } | |
| 269 | − | |
| 270 | − settleFromPayload(data); | |
| 271 | − }; | |
| 272 | − | |
| 273 | − window.addEventListener("message", onMessage); | |
| 274 | − | |
| 275 | − resultTimeout = window.setTimeout(() => { | |
| 276 | − settleWithError( | |
| 277 | − "OAuth popup timed out before completion. Please try again.", | |
| 278 | − ); | |
| 279 | − }, SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS); | |
| 280 | − | |
| 281 | − closedPoll = window.setInterval(() => { | |
| 282 | − const stored = readStoredSourceOAuthPopupResult(input.sessionId); | |
| 283 | − if (stored) { | |
| 284 | − settleFromPayload(stored); | |
| 285 | − return; | |
| 286 | − } | |
| 287 | − if (popup.closed) { | |
| 288 | − // Stop polling — only run one final deferred check to give the | |
| 289 | − // callback page time to write localStorage before we give up. | |
| 290 | − window.clearInterval(closedPoll); | |
| 291 | − closedPoll = 0; | |
| 292 | − window.setTimeout(() => { | |
| 293 | − const delayedStored = readStoredSourceOAuthPopupResult( | |
| 294 | − input.sessionId, | |
| 295 | − ); | |
| 296 | − if (delayedStored) { | |
| 297 | − settleFromPayload(delayedStored); | |
| 298 | − return; | |
| 299 | − } | |
| 300 | − settleWithError("OAuth popup was closed before completion."); | |
| 301 | − }, 1500); | |
| 302 | − } | |
| 303 | − }, 300); | |
| 304 | − }, | |
| 305 | − ); | |
| 306 | −}; | |
| 307 | − | |
| 308 | −const stringMapToEditor = (value: Record<string, string> | null): string => | |
| 309 | − value === null ? "" : JSON.stringify(value, null, 2); | |
| 310 | − | |
| 311 | −const stringArrayToEditor = ( | |
| 312 | − value: ReadonlyArray<string> | null | undefined, | |
| 313 | −): string => | |
| 314 | − !value || value.length === 0 ? "" : JSON.stringify(value, null, 2); | |
| 315 | − | |
| 316 | −const readBindingStringMap = ( | |
| 317 | − source: Source, | |
| 318 | − key: string, | |
| 319 | −): Record<string, string> | null => { | |
| 320 | − const candidate = source.binding[key]; | |
| 321 | − if ( | |
| 322 | − candidate === null || | |
| 323 | − candidate === undefined || | |
| 324 | − typeof candidate !== "object" || | |
| 325 | − Array.isArray(candidate) | |
| 326 | − ) { | |
| 327 | − return null; | |
| 328 | − } | |
| 329 | − | |
| 330 | − const entries = Object.entries(candidate as Record<string, unknown>); | |
| 331 | − return entries.every(([, value]) => typeof value === "string") | |
| 332 | − ? Object.fromEntries(entries as ReadonlyArray<readonly [string, string]>) | |
| 333 | − : null; | |
| 334 | −}; | |
| 335 | − | |
| 336 | −const readBindingStringArray = ( | |
| 337 | − source: Source, | |
| 338 | − key: string, | |
| 339 | −): Array<string> | null => { | |
| 340 | − const candidate = source.binding[key]; | |
| 341 | − return Array.isArray(candidate) && | |
| 342 | − candidate.every((value) => typeof value === "string") | |
| 343 | − ? [...candidate] | |
| 344 | − : null; | |
| 345 | −}; | |
| 346 | − | |
| 347 | −const readBindingString = (source: Source, key: string): string => | |
| 348 | − typeof source.binding[key] === "string" ? String(source.binding[key]) : ""; | |
| 349 | − | |
| 350 | −const readBindingTransport = (source: Source): McpTransportValue => { | |
| 351 | − const candidate = source.binding.transport; | |
| 352 | − return typeof candidate === "string" && | |
| 353 | − (candidate === "auto" || | |
| 354 | − candidate === "streamable-http" || | |
| 355 | − candidate === "sse" || | |
| 356 | − candidate === "stdio") | |
| 357 | − ? candidate | |
| 358 | − : ""; | |
| 359 | −}; | |
| 360 | − | |
| 361 | −const buildSyntheticMcpStdioEndpoint = (input: { | |
| 362 | − name?: string | null; | |
| 363 | − endpoint?: string | null; | |
| 364 | − command?: string | null; | |
| 365 | −}): string => { | |
| 366 | − const label = | |
| 367 | − input.name?.trim() || | |
| 368 | − input.endpoint?.trim() || | |
| 369 | − input.command?.trim() || | |
| 370 | − "mcp"; | |
| 371 | − const slug = label | |
| 372 | − .toLowerCase() | |
| 373 | − .replace(/[^a-z0-9]+/g, "-") | |
| 374 | − .replace(/^-+|-+$/g, ""); | |
| 375 | − | |
| 376 | − return `stdio://local/${slug || "mcp"}`; | |
| 377 | −}; | |
| 378 | − | |
| 379 | −const defaultFormState = (template?: SourceTemplate): SourceFormState => ({ | |
| 380 | − name: template?.name ?? "", | |
| 381 | − kind: template?.kind ?? "openapi", | |
| 382 | − endpoint: template?.endpoint ?? "", | |
| 383 | − namespace: template | |
| 384 | − ? (template.namespace ?? | |
| 385 | − ("service" in template | |
| 386 | − ? googleDiscoveryNamespace(template.service) | |
| 387 | − : namespaceFromUrl(template.endpoint ?? ""))) | |
| 388 | − : "", | |
| 389 | − enabled: true, | |
| 390 | − specUrl: template && "specUrl" in template ? template.specUrl : "", | |
| 391 | − service: template && "service" in template ? template.service : "", | |
| 392 | − version: template && "version" in template ? template.version : "", | |
| 393 | − defaultHeadersText: "", | |
| 394 | − authKind: template?.kind === "google_discovery" ? "oauth2" : "none", | |
| 395 | − authHeaderName: "Authorization", | |
| 396 | − authPrefix: "Bearer ", | |
| 397 | − bearerProviderId: "", | |
| 398 | − bearerHandle: "", | |
| 399 | − oauthAccessProviderId: "", | |
| 400 | − oauthAccessHandle: "", | |
| 401 | − oauthRefreshProviderId: "", | |
| 402 | − oauthRefreshHandle: "", | |
| 403 | − managedAuth: null, | |
| 404 | − ...(template?.kind === "mcp" && template.connectionType === "command" | |
| 405 | − ? defaultMcpStdioTransportFields({ | |
| 406 | − command: template.command ?? "", | |
| 407 | − argsText: stringArrayToEditor(template.args), | |
| 408 | − envText: stringMapToEditor(template.env ?? null), | |
| 409 | − cwd: template.cwd ?? "", | |
| 410 | − }) | |
| 411 | − : defaultMcpRemoteTransportFields( | |
| 412 | − template?.kind === "mcp" | |
| 413 | − ? asMcpRemoteTransportValue(template.transport) | |
| 414 | − : "", | |
| 415 | − )), | |
| 416 | −}); | |
| 417 | − | |
| 418 | −const formStateFromSource = (source: Source): SourceFormState => ({ | |
| 419 | − name: source.name, | |
| 420 | − kind: source.kind, | |
| 421 | − endpoint: source.endpoint, | |
| 422 | − namespace: source.namespace ?? "", | |
| 423 | − enabled: source.enabled, | |
| 424 | − specUrl: readBindingString(source, "specUrl"), | |
| 425 | − service: readBindingString(source, "service"), | |
| 426 | − version: readBindingString(source, "version"), | |
| 427 | − defaultHeadersText: stringMapToEditor( | |
| 428 | − readBindingStringMap(source, "defaultHeaders"), | |
| 429 | − ), | |
| 430 | − authKind: source.auth.kind, | |
| 431 | − authHeaderName: | |
| 432 | − source.auth.kind === "none" || source.auth.kind === "mcp_oauth" | |
| 433 | − ? "Authorization" | |
| 434 | − : source.auth.headerName, | |
| 435 | − authPrefix: | |
| 436 | − source.auth.kind === "none" || source.auth.kind === "mcp_oauth" | |
| 437 | − ? "Bearer " | |
| 438 | − : source.auth.prefix, | |
| 439 | − bearerProviderId: | |
| 440 | − source.auth.kind === "bearer" ? source.auth.token.providerId : "", | |
| 441 | − bearerHandle: source.auth.kind === "bearer" ? source.auth.token.handle : "", | |
| 442 | − oauthAccessProviderId: | |
| 443 | − source.auth.kind === "oauth2" ? source.auth.accessToken.providerId : "", | |
| 444 | − oauthAccessHandle: | |
| 445 | − source.auth.kind === "oauth2" ? source.auth.accessToken.handle : "", | |
| 446 | − oauthRefreshProviderId: | |
| 447 | − source.auth.kind === "oauth2" && source.auth.refreshToken !== null | |
| 448 | − ? source.auth.refreshToken.providerId | |
| 449 | − : "", | |
| 450 | − oauthRefreshHandle: | |
| 451 | − source.auth.kind === "oauth2" && source.auth.refreshToken !== null | |
| 452 | − ? source.auth.refreshToken.handle | |
| 453 | − : "", | |
| 454 | − managedAuth: | |
| 455 | − source.auth.kind === "provider_grant_ref" || | |
| 456 | − source.auth.kind === "mcp_oauth" | |
| 457 | − ? source.auth | |
| 458 | − : null, | |
| 459 | − ...(source.kind === "mcp" && readBindingTransport(source) === "stdio" | |
| 460 | − ? defaultMcpStdioTransportFields({ | |
| 461 | − command: readBindingString(source, "command"), | |
| 462 | − argsText: stringArrayToEditor(readBindingStringArray(source, "args")), | |
| 463 | − envText: stringMapToEditor(readBindingStringMap(source, "env")), | |
| 464 | − cwd: readBindingString(source, "cwd"), | |
| 465 | − }) | |
| 466 | − : defaultMcpRemoteTransportFields( | |
| 467 | − source.kind === "mcp" | |
| 468 | − ? asMcpRemoteTransportValue(readBindingTransport(source) || "auto") | |
| 469 | − : "", | |
| 470 | − )), | |
| 471 | − ...(source.kind === "mcp" && readBindingTransport(source) !== "stdio" | |
| 472 | − ? { | |
| 473 | − queryParamsText: stringMapToEditor( | |
| 474 | − readBindingStringMap(source, "queryParams"), | |
| 475 | − ), | |
| 476 | − headersText: stringMapToEditor(readBindingStringMap(source, "headers")), | |
| 477 | − } | |
| 478 | − : {}), | |
| 479 | −}); | |
| 480 | − | |
| 481 | −const buildAuthPayload = ( | |
| 482 | − state: SourceFormState, | |
| 483 | −): CreateSourcePayload["auth"] => { | |
| 484 | − if (state.authKind === "none") { | |
| 485 | − return { kind: "none" }; | |
| 486 | − } | |
| 487 | − | |
| 488 | − if ( | |
| 489 | − (state.authKind === "provider_grant_ref" || | |
| 490 | − state.authKind === "mcp_oauth") && | |
| 491 | − state.managedAuth !== null | |
| 492 | − ) { | |
| 493 | − return state.managedAuth; | |
| 494 | − } | |
| 495 | − | |
| 496 | − const headerName = state.authHeaderName.trim() || "Authorization"; | |
| 497 | − const prefix = state.authPrefix; | |
| 498 | − | |
| 499 | − if (state.authKind === "bearer") { | |
| 500 | − const providerId = state.bearerProviderId.trim(); | |
| 501 | − const handle = state.bearerHandle.trim(); | |
| 502 | − if (!providerId || !handle) { | |
| 503 | − throw new Error( | |
| 504 | − "Bearer auth requires a token. Select or create a secret.", | |
| 505 | − ); | |
| 506 | − } | |
| 507 | − | |
| 508 | − return { | |
| 509 | − kind: "bearer", | |
| 510 | − headerName, | |
| 511 | − prefix, | |
| 512 | − token: { | |
| 513 | − providerId, | |
| 514 | − handle, | |
| 515 | − }, | |
| 516 | − }; | |
| 517 | − } | |
| 518 | − | |
| 519 | − const accessProviderId = state.oauthAccessProviderId.trim(); | |
| 520 | − const accessHandle = state.oauthAccessHandle.trim(); | |
| 521 | − if (!accessProviderId || !accessHandle) { | |
| 522 | − throw new Error( | |
| 523 | − "OAuth2 auth requires an access token. Select or create a secret.", | |
| 524 | − ); | |
| 525 | − } | |
| 526 | − | |
| 527 | − const refreshProviderId = trimToNull(state.oauthRefreshProviderId); | |
| 528 | − const refreshHandle = trimToNull(state.oauthRefreshHandle); | |
| 529 | − if ((refreshProviderId === null) !== (refreshHandle === null)) { | |
| 530 | − throw new Error( | |
| 531 | − "OAuth2 refresh token provider ID and handle must be set together.", | |
| 532 | − ); | |
| 533 | − } | |
| 534 | − | |
| 535 | − return { | |
| 536 | − kind: "oauth2", | |
| 537 | − headerName, | |
| 538 | − prefix, | |
| 539 | − accessToken: { | |
| 540 | − providerId: accessProviderId, | |
| 541 | − handle: accessHandle, | |
| 542 | − }, | |
| 543 | − refreshToken: | |
| 544 | − refreshProviderId === null || refreshHandle === null | |
| 545 | − ? null | |
| 546 | − : { | |
| 547 | − providerId: refreshProviderId, | |
| 548 | − handle: refreshHandle, | |
| 549 | − }, | |
| 550 | − }; | |
| 551 | −}; | |
| 552 | − | |
| 553 | −const buildRequestedSourceStatus = ( | |
| 554 | − state: SourceFormState, | |
| 555 | −): CreateSourcePayload["status"] => { | |
| 556 | − if ( | |
| 557 | − state.kind !== "mcp" && | |
| 558 | − state.kind !== "openapi" && | |
| 559 | − state.kind !== "graphql" | |
| 560 | − ) { | |
| 561 | − return undefined; | |
| 562 | − } | |
| 563 | − | |
| 564 | − return state.enabled ? "connected" : "draft"; | |
| 565 | −}; | |
| 566 | − | |
| 567 | −const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { | |
| 568 | − const name = state.name.trim(); | |
| 569 | − const isMcpStdio = state.kind === "mcp" && state.transport === "stdio"; | |
| 570 | − const endpoint = | |
| 571 | − state.kind === "mcp" && state.transport === "stdio" | |
| 572 | − ? buildSyntheticMcpStdioEndpoint({ | |
| 573 | − name: state.name, | |
| 574 | − endpoint: state.endpoint, | |
| 575 | − command: state.command, | |
| 576 | − }) | |
| 577 | − : state.endpoint.trim(); | |
| 578 | − | |
| 579 | − if (!name) { | |
| 580 | − throw new Error("Source name is required."); | |
| 581 | − } | |
| 582 | − | |
| 583 | − if (!endpoint) { | |
| 584 | − throw new Error("Source endpoint is required."); | |
| 585 | − } | |
| 586 | − if (isMcpStdio && !state.command.trim()) { | |
| 587 | − throw new Error("MCP stdio transport requires a command."); | |
| 588 | − } | |
| 589 | − | |
| 590 | − const shared = { | |
| 591 | − name, | |
| 592 | − kind: state.kind, | |
| 593 | − endpoint, | |
| 594 | − status: buildRequestedSourceStatus(state), | |
| 595 | − enabled: state.enabled, | |
| 596 | − namespace: trimToNull(state.namespace), | |
| 597 | − auth: isMcpStdio ? { kind: "none" as const } : buildAuthPayload(state), | |
| 598 | − } satisfies Pick< | |
| 599 | − CreateSourcePayload, | |
| 600 | − "name" | "kind" | "endpoint" | "status" | "enabled" | "namespace" | "auth" | |
| 601 | − >; | |
| 602 | − | |
| 603 | − if (state.kind === "mcp") { | |
| 604 | − if (state.transport === "stdio") { | |
| 605 | − return { | |
| 606 | − ...shared, | |
| 607 | − binding: { | |
| 608 | − transport: "stdio", | |
| 609 | − queryParams: null, | |
| 610 | − headers: null, | |
| 611 | − command: state.command.trim(), | |
| 612 | − args: parseJsonStringArray("Args", state.argsText), | |
| 613 | − env: parseJsonStringMap("Environment", state.envText), | |
| 614 | − cwd: trimToNull(state.cwd), | |
| 615 | − }, | |
| 616 | − }; | |
| 617 | − } | |
| 618 | − | |
| 619 | − return { | |
| 620 | − ...shared, | |
| 621 | − binding: { | |
| 622 | − transport: state.transport === "" ? "auto" : state.transport, | |
| 623 | − queryParams: parseJsonStringMap("Query params", state.queryParamsText), | |
| 624 | − headers: parseJsonStringMap("Request headers", state.headersText), | |
| 625 | − command: null, | |
| 626 | − args: null, | |
| 627 | − env: null, | |
| 628 | − cwd: null, | |
| 629 | − }, | |
| 630 | − }; | |
| 631 | − } | |
| 632 | − | |
| 633 | − if (state.kind === "openapi") { | |
| 634 | − const specUrl = state.specUrl.trim(); | |
| 635 | − if (!specUrl) { | |
| 636 | − throw new Error("OpenAPI sources require a spec URL."); | |
| 637 | − } | |
| 638 | − | |
| 639 | − return { | |
| 640 | − ...shared, | |
| 641 | − binding: { | |
| 642 | − specUrl, | |
| 643 | − defaultHeaders: parseJsonStringMap( | |
| 644 | − "Default headers", | |
| 645 | − state.defaultHeadersText, | |
| 646 | − ), | |
| 647 | − }, | |
| 648 | − }; | |
| 649 | − } | |
| 650 | − | |
| 651 | − if (state.kind === "graphql") { | |
| 652 | − return { | |
| 653 | − ...shared, | |
| 654 | − binding: { | |
| 655 | − defaultHeaders: parseJsonStringMap( | |
| 656 | − "Default headers", | |
| 657 | − state.defaultHeadersText, | |
| 658 | − ), | |
| 659 | − }, | |
| 660 | − }; | |
| 661 | − } | |
| 662 | − | |
| 663 | − if (state.kind === "google_discovery") { | |
| 664 | − const service = state.service.trim(); | |
| 665 | − const version = state.version.trim(); | |
| 666 | − if (!service) { | |
| 667 | − throw new Error("Google Discovery sources require a service."); | |
| 668 | − } | |
| 669 | − if (!version) { | |
| 670 | − throw new Error("Google Discovery sources require a version."); | |
| 671 | − } | |
| 672 | − | |
| 673 | − return { | |
| 674 | − ...shared, | |
| 675 | − binding: { | |
| 676 | − service, | |
| 677 | − version, | |
| 678 | − discoveryUrl: endpoint, | |
| 679 | − defaultHeaders: parseJsonStringMap( | |
| 680 | − "Default headers", | |
| 681 | − state.defaultHeadersText, | |
| 682 | − ), | |
| 683 | − }, | |
| 684 | − }; | |
| 685 | − } | |
| 686 | − | |
| 687 | − return { | |
| 688 | − ...shared, | |
| 689 | − binding: {}, | |
| 690 | − }; | |
| 691 | −}; | |
| 692 | − | |
| 693 | −const buildUpdatePayload = (state: SourceFormState): UpdateSourcePayload => ({ | |
| 694 | − ...buildSourcePayload(state), | |
| 695 | −}); | |
| 696 | − | |
| 697 | −const buildStartSourceOAuthPayload = ( | |
| 698 | − state: SourceFormState, | |
| 699 | −): StartSourceOAuthPayload => { | |
| 700 | − if (state.kind !== "mcp") { | |
| 701 | − throw new Error("OAuth sign-in is only available for MCP sources."); | |
| 702 | − } | |
| 703 | − if (state.transport === "stdio") { | |
| 704 | − throw new Error("OAuth sign-in is not available for MCP stdio sources."); | |
| 705 | − } | |
| 706 | − | |
| 707 | − const endpoint = state.endpoint.trim(); | |
| 708 | − if (!endpoint) { | |
| 709 | − throw new Error("Source endpoint is required before starting OAuth."); | |
| 710 | − } | |
| 711 | − | |
| 712 | − return { | |
| 713 | − provider: "mcp", | |
| 714 | − name: trimToNull(state.name), | |
| 715 | − endpoint, | |
| 716 | − transport: state.transport === "" ? "auto" : state.transport, | |
| 717 | − queryParams: parseJsonStringMap("Query params", state.queryParamsText), | |
| 718 | − headers: parseJsonStringMap("Request headers", state.headersText), | |
| 719 | − }; | |
| 720 | −}; | |
| 721 | − | |
| 722 | −export function NewSourcePage() { | |
| 723 | − return <SourceEditor key="create" mode="create" />; | |
| 724 | −} | |
| 725 | − | |
| 726 | −export function EditSourcePage(props: { sourceId: string }) { | |
| 727 | − const sources = useSources(); | |
| 728 | − const source = useSource(props.sourceId); | |
| 729 | − const missingSource = | |
| 730 | − (sources.status === "ready" && | |
| 731 | − !sources.data.some((candidate) => candidate.id === props.sourceId)) || | |
| 732 | − isSourceNotFoundLoadable(source); | |
| 733 | − | |
| 734 | − if (missingSource) { | |
| 735 | − return <SourceNotFoundState />; | |
| 736 | − } | |
| 737 | − | |
| 738 | − return ( | |
| 739 | − <LoadableBlock loadable={source} loading="Loading source..."> | |
| 740 | − {(loadedSource) => ( | |
| 741 | − <SourceEditor | |
| 742 | − key={`${loadedSource.id}:${loadedSource.updatedAt}`} | |
| 743 | − mode="edit" | |
| 744 | − source={loadedSource} | |
| 745 | − /> | |
| 746 | − )} | |
| 747 | − </LoadableBlock> | |
| 748 | − ); | |
| 749 | −} | |
| 750 | − | |
| 751 | −function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { | |
| 752 | − const navigate = useNavigate(); | |
| 753 | − const createSource = useCreateSource(); | |
| 754 | − const startSourceOAuth = useStartSourceOAuth(); | |
| 755 | − const updateSource = useUpdateSource(); | |
| 756 | − const removeSource = useRemoveSource(); | |
| 757 | − const removeProviderAuthGrant = useRemoveProviderAuthGrant(); | |
| 758 | − const instanceConfig = useInstanceConfig(); | |
| 759 | − const secrets = useSecrets(); | |
| 760 | − const refreshSecrets = useRefreshSecrets(); | |
| 761 | − const [formState, setFormState] = useState<SourceFormState>(() => | |
| 762 | − props.source ? formStateFromSource(props.source) : defaultFormState(), | |
| 763 | − ); | |
| 764 | − const [statusBanner, setStatusBanner] = useState<StatusBannerState | null>( | |
| 765 | − null, | |
| 766 | − ); | |
| 767 | − const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>( | |
| 768 | − null, | |
| 769 | − ); | |
| 770 | − const [oauthPopupBusy, setOauthPopupBusy] = useState(false); | |
| 771 | − const [expandedOauthSecretRefTarget, setExpandedOauthSecretRefTarget] = | |
| 772 | − useState<string | null>(null); | |
| 773 | − | |
| 774 | − const isSubmitting = | |
| 775 | − createSource.status === "pending" || updateSource.status === "pending"; | |
| 776 | − const isDeleting = removeSource.status === "pending"; | |
| 777 | − const isRevokingGrant = removeProviderAuthGrant.status === "pending"; | |
| 778 | − const isOAuthSubmitting = | |
| 779 | − startSourceOAuth.status === "pending" || oauthPopupBusy; | |
| 780 | − const oauthSecretRefTarget = | |
| 781 | − formState.authKind === "oauth2" && | |
| 782 | − formState.oauthAccessHandle.trim().length > 0 | |
| 783 | − ? `${formState.oauthAccessProviderId}:${formState.oauthAccessHandle}:${formState.oauthRefreshProviderId}:${formState.oauthRefreshHandle}` | |
| 784 | − : null; | |
| 785 | − const showOauthSecretRefs = | |
| 786 | − oauthSecretRefTarget !== null && | |
| 787 | − expandedOauthSecretRefTarget === oauthSecretRefTarget; | |
| 788 | − | |
| 789 | − const setField = <K extends keyof SourceFormBase>( | |
| 790 | − key: K, | |
| 791 | − value: SourceFormBase[K], | |
| 792 | − ) => { | |
| 793 | − setFormState((current) => ({ ...current, [key]: value })); | |
| 794 | − }; | |
| 795 | − | |
| 796 | − const setTransport = (transport: McpTransportValue) => { | |
| 797 | − setFormState((current) => ({ | |
| 798 | − ...current, | |
| 799 | − ...setMcpTransportFieldsTransport(current, transport), | |
| 800 | − })); | |
| 801 | − }; | |
| 802 | − | |
| 803 | − const setRemoteTransportField = < | |
| 804 | − K extends Exclude<keyof McpRemoteTransportFields, "transport">, | |
| 805 | − >( | |
| 806 | − key: K, | |
| 807 | − value: McpRemoteTransportFields[K], | |
| 808 | − ) => { | |
| 809 | − setFormState((current) => | |
| 810 | − current.transport === "stdio" ? current : { ...current, [key]: value }, | |
| 811 | − ); | |
| 812 | − }; | |
| 813 | − | |
| 814 | − const setStdioTransportField = < | |
| 815 | − K extends Exclude<keyof McpStdioTransportFields, "transport">, | |
| 816 | − >( | |
| 817 | − key: K, | |
| 818 | − value: McpStdioTransportFields[K], | |
| 819 | − ) => { | |
| 820 | − setFormState((current) => | |
| 821 | − current.transport === "stdio" ? { ...current, [key]: value } : current, | |
| 822 | − ); | |
| 823 | − }; | |
| 824 | − | |
| 825 | − const applyTemplate = (template: SourceTemplate) => { | |
| 826 | − setSelectedTemplateId(template.id); | |
| 827 | − setFormState((current) => ({ | |
| 828 | − ...defaultFormState(template), | |
| 829 | − name: current.name.trim().length > 0 ? current.name : template.name, | |
| 830 | − enabled: current.enabled, | |
| 831 | − })); | |
| 832 | − setStatusBanner({ | |
| 833 | − tone: "info", | |
| 834 | − text: `${template.name} loaded. Add auth if needed, then save.`, | |
| 835 | − }); | |
| 836 | − }; | |
| 837 | − | |
| 838 | − const handleSubmit = async () => { | |
| 839 | − setStatusBanner(null); | |
| 840 | − | |
| 841 | − try { | |
| 842 | − if (props.mode === "create") { | |
| 843 | − const createdSource = await createSource.mutateAsync( | |
| 844 | − buildSourcePayload(formState), | |
| 845 | − ); | |
| 846 | − void navigate({ | |
| 847 | − to: "/sources/$sourceId", | |
| 848 | − params: { sourceId: createdSource.id }, | |
| 849 | − search: { tab: "model" }, | |
| 850 | − }); | |
| 851 | − return; | |
| 852 | − } | |
| 853 | − | |
| 854 | − if (!props.source) { | |
| 855 | − throw new Error("Cannot update a source before it has loaded."); | |
| 856 | − } | |
| 857 | − | |
| 858 | − const updatedSource = await updateSource.mutateAsync({ | |
| 859 | − sourceId: props.source.id, | |
| 860 | − payload: buildUpdatePayload(formState), | |
| 861 | − }); | |
| 862 | − void navigate({ | |
| 863 | − to: "/sources/$sourceId", | |
| 864 | − params: { sourceId: updatedSource.id }, | |
| 865 | − search: { tab: "model" }, | |
| 866 | − }); | |
| 867 | − } catch (error) { | |
| 868 | − setStatusBanner({ | |
| 869 | − tone: "error", | |
| 870 | − text: error instanceof Error ? error.message : "Failed saving source.", | |
| 871 | − }); | |
| 872 | − } | |
| 873 | − }; | |
| 874 | − | |
| 875 | − const handleMcpOAuthConnect = async () => { | |
| 876 | − setStatusBanner(null); | |
| 877 | − | |
| 878 | − try { | |
| 879 | − const result = await startSourceOAuth.mutateAsync( | |
| 880 | − buildStartSourceOAuthPayload(formState), | |
| 881 | − ); | |
| 882 | − | |
| 883 | − setStatusBanner({ | |
| 884 | − tone: "info", | |
| 885 | − text: "Finish OAuth in the popup. Saving will create the source and connect its tools.", | |
| 886 | − }); | |
| 887 | − | |
| 888 | − setOauthPopupBusy(true); | |
| 889 | − const auth = await startSourceOAuthPopup({ | |
| 890 | − authorizationUrl: result.authorizationUrl, | |
| 891 | − sessionId: result.sessionId, | |
| 892 | − }); | |
| 893 | − setOauthPopupBusy(false); | |
| 894 | − refreshSecrets(); | |
| 895 | − setExpandedOauthSecretRefTarget(null); | |
| 896 | − setFormState((current) => ({ | |
| 897 | − ...current, | |
| 898 | − authKind: "oauth2", | |
| 899 | − authHeaderName: auth.headerName, | |
| 900 | − authPrefix: auth.prefix, | |
| 901 | − oauthAccessProviderId: auth.accessToken.providerId, | |
| 902 | − oauthAccessHandle: auth.accessToken.handle, | |
| 903 | − oauthRefreshProviderId: auth.refreshToken?.providerId ?? "", | |
| 904 | − oauthRefreshHandle: auth.refreshToken?.handle ?? "", | |
| 905 | − managedAuth: null, | |
| 906 | − })); | |
| 907 | − setStatusBanner({ | |
| 908 | − tone: "success", | |
| 909 | − text: "OAuth credentials are ready. Save the source to connect and index tools.", | |
| 910 | − }); | |
| 911 | − } catch (error) { | |
| 912 | − setOauthPopupBusy(false); | |
| 913 | − setStatusBanner({ | |
| 914 | − tone: "error", | |
| 915 | − text: error instanceof Error ? error.message : "Failed starting OAuth.", | |
| 916 | − }); | |
| 917 | − } | |
| 918 | − }; | |
| 919 | − | |
| 920 | − const handleRemove = async () => { | |
| 921 | − if (!props.source || isDeleting) { | |
| 922 | − return; | |
| 923 | − } | |
| 924 | − | |
| 925 | − const confirmed = window.confirm( | |
| 926 | − `Remove "${props.source.name}" and its indexed tools?`, | |
| 927 | − ); | |
| 928 | − if (!confirmed) { | |
| 929 | − return; | |
| 930 | − } | |
| 931 | − | |
| 932 | − setStatusBanner(null); | |
| 933 | − | |
| 934 | − try { | |
| 935 | − const result = await removeSource.mutateAsync(props.source.id); | |
| 936 | − if (!result.removed) { | |
| 937 | − throw new Error("Source was not removed."); | |
| 938 | − } | |
| 939 | − void navigate({ to: "/" }); | |
| 940 | − } catch (error) { | |
| 941 | − setStatusBanner({ | |
| 942 | − tone: "error", | |
| 943 | − text: | |
| 944 | − error instanceof Error ? error.message : "Failed removing source.", | |
| 945 | − }); | |
| 946 | − } | |
| 947 | − }; | |
| 948 | − | |
| 949 | − const handleRevokeProviderGrant = async () => { | |
| 950 | − if ( | |
| 951 | − !props.source || | |
| 952 | − isRevokingGrant || | |
| 953 | − formState.managedAuth?.kind !== "provider_grant_ref" | |
| 954 | − ) { | |
| 955 | − return; | |
| 956 | − } | |
| 957 | − | |
| 958 | − const confirmed = window.confirm( | |
| 959 | − `Revoke the shared Google auth for "${props.source.name}"? This disconnects every source using the same shared grant.`, | |
| 960 | − ); | |
| 961 | − if (!confirmed) { | |
| 962 | − return; | |
| 963 | − } | |
| 964 | − | |
| 965 | − setStatusBanner(null); | |
| 966 | − | |
| 967 | − try { | |
| 968 | − const result = await removeProviderAuthGrant.mutateAsync( | |
| 969 | − formState.managedAuth.grantId, | |
| 970 | − ); | |
| 971 | − if (!result.removed) { | |
| 972 | − throw new Error("Shared provider grant was not removed."); | |
| 973 | − } | |
| 974 | − | |
| 975 | − setStatusBanner({ | |
| 976 | − tone: "success", | |
| 977 | − text: "Shared provider grant revoked. Linked sources now require authentication again.", | |
| 978 | − }); | |
| 979 | − } catch (error) { | |
| 980 | − setStatusBanner({ | |
| 981 | − tone: "error", | |
| 982 | − text: | |
| 983 | − error instanceof Error | |
| 984 | − ? error.message | |
| 985 | − : "Failed revoking shared provider grant.", | |
| 986 | − }); | |
| 987 | − } | |
| 988 | − }; | |
| 989 | − | |
| 990 | − const backLink = | |
| 991 | − props.mode === "edit" && props.source | |
| 992 | − ? { | |
| 993 | − to: "/sources/$sourceId" as const, | |
| 994 | − params: { sourceId: props.source.id }, | |
| 995 | − search: { tab: "model" as const }, | |
| 996 | − } | |
| 997 | − : { to: "/" as const }; | |
| 998 | − | |
| 999 | − return ( | |
| 1000 | − <div className="flex-1 overflow-y-auto"> | |
| 1001 | − <div className="mx-auto max-w-3xl px-6 py-8 lg:px-10 lg:py-12"> | |
| 1002 | − {/* Back + title */} | |
| 1003 | − <Link | |
| 1004 | − {...backLink} | |
| 1005 | − className="inline-flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground mb-6" | |
| 1006 | − > | |
| 1007 | − <IconArrowLeft className="size-3.5" /> | |
| 1008 | − {props.mode === "edit" ? "Back to source" : "Back"} | |
| 1009 | − </Link> | |
| 1010 | − | |
| 1011 | − <div className="flex items-center justify-between gap-4 mb-6"> | |
| 1012 | − <h1 className="font-display text-2xl tracking-tight text-foreground lg:text-3xl"> | |
| 1013 | − {props.mode === "edit" ? "Edit source" : "New source"} | |
| 1014 | − </h1> | |
| 1015 | − <div className="flex items-center gap-2"> | |
| 1016 | − <Badge variant="outline">{formState.kind}</Badge> | |
| 1017 | − <Badge variant={formState.enabled ? "default" : "muted"}> | |
| 1018 | − {formState.enabled ? "enabled" : "disabled"} | |
| 1019 | − </Badge> | |
| 1020 | − </div> | |
| 1021 | − </div> | |
| 1022 | − | |
| 1023 | − {statusBanner && <StatusBanner state={statusBanner} className="mb-6" />} | |
| 1024 | − | |
| 1025 | − {/* Templates (create mode) */} | |
| 1026 | − {props.mode === "create" && ( | |
| 1027 | − <Section title="Templates" className="mb-6"> | |
| 1028 | − <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3"> | |
| 1029 | − {sourceTemplates.map((template) => ( | |
| 1030 | − <button | |
| 1031 | − key={template.id} | |
| 1032 | − type="button" | |
| 1033 | − onClick={() => applyTemplate(template)} | |
| 1034 | − className={cn( | |
| 1035 | − "rounded-xl border px-4 py-3 text-left transition-colors", | |
| 1036 | − selectedTemplateId === template.id | |
| 1037 | − ? "border-primary/40 bg-primary/8" | |
| 1038 | − : "border-border bg-card/70 hover:bg-accent/50", | |
| 1039 | − )} | |
| 1040 | − > | |
| 1041 | − <div className="mb-1 flex items-center justify-between gap-2"> | |
| 1042 | − <div className="flex min-w-0 items-center gap-2"> | |
| 1043 | − <div className="flex size-4 shrink-0 items-center justify-center text-muted-foreground"> | |
| 1044 | − <SourceFavicon | |
| 1045 | − endpoint={template.endpoint} | |
| 1046 | − kind={template.kind} | |
| 1047 | − className="size-4" | |
| 1048 | − /> | |
| 1049 | − </div> | |
| 1050 | − <span className="truncate text-[13px] font-medium text-foreground"> | |
| 1051 | − {template.name} | |
| 1052 | − </span> | |
| 1053 | − </div> | |
| 1054 | − <Badge variant="outline" className="text-[9px]"> | |
| 1055 | − {template.kind} | |
| 1056 | − </Badge> | |
| 1057 | − </div> | |
| 1058 | − <span className="text-[11px] text-muted-foreground line-clamp-1"> | |
| 1059 | − {template.summary} | |
| 1060 | − </span> | |
| 1061 | − </button> | |
| 1062 | − ))} | |
| 1063 | − </div> | |
| 1064 | − </Section> | |
| 1065 | − )} | |
| 1066 | − | |
| 1067 | − {/* Form */} | |
| 1068 | − <div className="space-y-6"> | |
| 1069 | − <Section title="Basics"> | |
| 1070 | − <div className="grid gap-4 sm:grid-cols-2"> | |
| 1071 | − <Field label="Name"> | |
| 1072 | − <TextInput | |
| 1073 | − value={formState.name} | |
| 1074 | − onChange={(value) => setField("name", value)} | |
| 1075 | − placeholder="GitHub REST" | |
| 1076 | − /> | |
| 1077 | − </Field> | |
| 1078 | − <Field label="Kind"> | |
| 1079 | − <SelectInput | |
| 1080 | − value={formState.kind} | |
| 1081 | − onChange={(value) => | |
| 1082 | − setField("kind", value as Source["kind"]) | |
| 1083 | − } | |
| 1084 | − options={kindOptions.map((value) => ({ | |
| 1085 | − value, | |
| 1086 | − label: value, | |
| 1087 | − }))} | |
| 1088 | − /> | |
| 1089 | − </Field> | |
| 1090 | − {!( | |
| 1091 | − formState.kind === "mcp" && formState.transport === "stdio" | |
| 1092 | − ) && ( | |
| 1093 | − <Field label="Endpoint" className="sm:col-span-2"> | |
| 1094 | − <TextInput | |
| 1095 | − value={formState.endpoint} | |
| 1096 | − onChange={(value) => setField("endpoint", value)} | |
| 1097 | − placeholder={ | |
| 1098 | − formState.kind === "openapi" | |
| 1099 | − ? "https://api.github.com" | |
| 1100 | − : formState.kind === "graphql" | |
| 1101 | − ? "https://api.linear.app/graphql" | |
| 1102 | − : formState.kind === "google_discovery" | |
| 1103 | − ? "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest" | |
| 1104 | − : "https://mcp.deepwiki.com/mcp" | |
| 1105 | − } | |
| 1106 | − mono | |
| 1107 | − /> | |
| 1108 | − </Field> | |
| 1109 | − )} | |
| 1110 | − <Field label="Namespace"> | |
| 1111 | − <TextInput | |
| 1112 | − value={formState.namespace} | |
| 1113 | − onChange={(value) => setField("namespace", value)} | |
| 1114 | − placeholder="github" | |
| 1115 | − /> | |
| 1116 | − </Field> | |
| 1117 | − <Field label="Status"> | |
| 1118 | − <ToggleButton | |
| 1119 | − checked={formState.enabled} | |
| 1120 | − onChange={(checked) => setField("enabled", checked)} | |
| 1121 | − /> | |
| 1122 | − </Field> | |
| 1123 | − </div> | |
| 1124 | − </Section> | |
| 1125 | − | |
| 1126 | − {formState.kind === "mcp" && ( | |
| 1127 | − <Section title="Transport"> | |
| 1128 | − <div className="grid gap-4 sm:grid-cols-2"> | |
| 1129 | − <Field label="Transport mode"> | |
| 1130 | − <SelectInput | |
| 1131 | − value={formState.transport || "auto"} | |
| 1132 | − onChange={(value) => setTransport(value as McpTransportValue)} | |
| 1133 | − options={transportOptions.map((value) => ({ | |
| 1134 | − value, | |
| 1135 | − label: value, | |
| 1136 | − }))} | |
| 1137 | − /> | |
| 1138 | − </Field> | |
| 1139 | − {formState.transport === "stdio" ? ( | |
| 1140 | − <div className="sm:col-span-2 grid gap-4 sm:grid-cols-2"> | |
| 1141 | − <Field label="Command"> | |
| 1142 | − <TextInput | |
| 1143 | − value={formState.command} | |
| 1144 | − onChange={(value) => | |
| 1145 | − setStdioTransportField("command", value) | |
| 1146 | − } | |
| 1147 | − placeholder="npx" | |
| 1148 | − mono | |
| 1149 | − /> | |
| 1150 | − </Field> | |
| 1151 | − <Field label="Working directory (optional)"> | |
| 1152 | − <TextInput | |
| 1153 | − value={formState.cwd} | |
| 1154 | − onChange={(value) => | |
| 1155 | − setStdioTransportField("cwd", value) | |
| 1156 | − } | |
| 1157 | − placeholder="/path/to/project" | |
| 1158 | − mono | |
| 1159 | − /> | |
| 1160 | − </Field> | |
| 1161 | − <Field label="Args (JSON)" className="sm:col-span-2"> | |
| 1162 | − <CodeEditor | |
| 1163 | − value={formState.argsText} | |
| 1164 | − onChange={(value) => | |
| 1165 | − setStdioTransportField("argsText", value) | |
| 1166 | − } | |
| 1167 | − placeholder={ | |
| 1168 | − '[\n "-y",\n "chrome-devtools-mcp@latest"\n]' | |
| 1169 | − } | |
| 1170 | − /> | |
| 1171 | − </Field> | |
| 1172 | − <Field label="Environment (JSON)" className="sm:col-span-2"> | |
| 1173 | − <CodeEditor | |
| 1174 | − value={formState.envText} | |
| 1175 | − onChange={(value) => | |
| 1176 | − setStdioTransportField("envText", value) | |
| 1177 | − } | |
| 1178 | − placeholder={ | |
| 1179 | − '{\n "CHROME_PATH": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"\n}' | |
| 1180 | − } | |
| 1181 | − /> | |
| 1182 | − </Field> | |
| 1183 | − </div> | |
| 1184 | − ) : ( | |
| 1185 | − <div className="sm:col-span-2 grid gap-4 sm:grid-cols-2"> | |
| 1186 | − <Field label="Query params (JSON)"> | |
| 1187 | − <CodeEditor | |
| 1188 | − value={formState.queryParamsText} | |
| 1189 | − onChange={(value) => | |
| 1190 | − setRemoteTransportField("queryParamsText", value) | |
| 1191 | − } | |
| 1192 | − placeholder={'{\n "workspace": "demo"\n}'} | |
| 1193 | − /> | |
| 1194 | − </Field> | |
| 1195 | − <Field label="Headers (JSON)"> | |
| 1196 | − <CodeEditor | |
| 1197 | − value={formState.headersText} | |
| 1198 | − onChange={(value) => | |
| 1199 | − setRemoteTransportField("headersText", value) | |
| 1200 | − } | |
| 1201 | − placeholder={'{\n "x-api-key": "..."\n}'} | |
| 1202 | − /> | |
| 1203 | − </Field> | |
| 1204 | − </div> | |
| 1205 | − )} | |
| 1206 | − </div> | |
| 1207 | − </Section> | |
| 1208 | − )} | |
| 1209 | − | |
| 1210 | − {formState.kind === "openapi" && ( | |
| 1211 | − <Section title="OpenAPI"> | |
| 1212 | − <div className="grid gap-4"> | |
| 1213 | − <Field label="Spec URL"> | |
| 1214 | − <TextInput | |
| 1215 | − value={formState.specUrl} | |
| 1216 | − onChange={(value) => setField("specUrl", value)} | |
| 1217 | − placeholder="https://raw.githubusercontent.com/.../openapi.yaml" | |
| 1218 | − mono | |
| 1219 | − /> | |
| 1220 | − </Field> | |
| 1221 | − <Field label="Default headers (JSON)"> | |
| 1222 | − <CodeEditor | |
| 1223 | − value={formState.defaultHeadersText} | |
| 1224 | − onChange={(value) => setField("defaultHeadersText", value)} | |
| 1225 | − placeholder={'{\n "x-api-version": "2026-03-01"\n}'} | |
| 1226 | − /> | |
| 1227 | − </Field> | |
| 1228 | − </div> | |
| 1229 | − </Section> | |
| 1230 | − )} | |
| 1231 | − | |
| 1232 | − {formState.kind === "graphql" && ( | |
| 1233 | − <Section title="GraphQL"> | |
| 1234 | − <div className="grid gap-4"> | |
| 1235 | − <Field label="Default headers (JSON)"> | |
| 1236 | − <CodeEditor | |
| 1237 | − value={formState.defaultHeadersText} | |
| 1238 | − onChange={(value) => setField("defaultHeadersText", value)} | |
| 1239 | − placeholder={'{\n "x-api-version": "2026-03-01"\n}'} | |
| 1240 | − /> | |
| 1241 | − </Field> | |
| 1242 | − </div> | |
| 1243 | − </Section> | |
| 1244 | − )} | |
| 1245 | − | |
| 1246 | − {formState.kind === "google_discovery" && ( | |
| 1247 | − <Section title="Google Discovery"> | |
| 1248 | − <div className="grid gap-4 sm:grid-cols-2"> | |
| 1249 | − <Field label="Service"> | |
| 1250 | − <TextInput | |
| 1251 | − value={formState.service} | |
| 1252 | − onChange={(value) => setField("service", value)} | |
| 1253 | − placeholder="sheets" | |
| 1254 | − /> | |
| 1255 | − </Field> | |
| 1256 | − <Field label="Version"> | |
| 1257 | − <TextInput | |
| 1258 | − value={formState.version} | |
| 1259 | − onChange={(value) => setField("version", value)} | |
| 1260 | − placeholder="v4" | |
| 1261 | − /> | |
| 1262 | − </Field> | |
| 1263 | − <Field label="Default headers (JSON)" className="sm:col-span-2"> | |
| 1264 | − <CodeEditor | |
| 1265 | − value={formState.defaultHeadersText} | |
| 1266 | − onChange={(value) => setField("defaultHeadersText", value)} | |
| 1267 | − placeholder={'{\n "x-goog-user-project": "my-project"\n}'} | |
| 1268 | − /> | |
| 1269 | − </Field> | |
| 1270 | − </div> | |
| 1271 | − </Section> | |
| 1272 | − )} | |
| 1273 | − | |
| 1274 | − <Section title="Authentication"> | |
| 1275 | − <div className="grid gap-4 sm:grid-cols-2"> | |
| 1276 | − {formState.kind === "mcp" && formState.transport !== "stdio" && ( | |
| 1277 | − <div className="sm:col-span-2 rounded-xl border border-border bg-gradient-to-b from-card/90 to-card/50 overflow-hidden"> | |
| 1278 | − <div className="flex items-start gap-3 px-4 py-3.5"> | |
| 1279 | − <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary mt-0.5"> | |
| 1280 | − <svg | |
| 1281 | − className="size-4" | |
| 1282 | − viewBox="0 0 24 24" | |
| 1283 | − fill="none" | |
| 1284 | − stroke="currentColor" | |
| 1285 | − strokeWidth="1.5" | |
| 1286 | − strokeLinecap="round" | |
| 1287 | − strokeLinejoin="round" | |
| 1288 | − > | |
| 1289 | − <rect | |
| 1290 | − x="3" | |
| 1291 | − y="11" | |
| 1292 | − width="18" | |
| 1293 | − height="11" | |
| 1294 | − rx="2" | |
| 1295 | − ry="2" | |
| 1296 | − /> | |
| 1297 | − <path d="M7 11V7a5 5 0 0110 0v4" /> | |
| 1298 | − </svg> | |
| 1299 | − </div> | |
| 1300 | − <div className="flex-1 min-w-0 space-y-2"> | |
| 1301 | − <div className="space-y-0.5"> | |
| 1302 | − <p className="text-[12px] font-medium text-foreground"> | |
| 1303 | − MCP OAuth | |
| 1304 | − </p> | |
| 1305 | − <p className="text-[11px] text-muted-foreground"> | |
| 1306 | − Opens the server's built-in OAuth flow to authenticate | |
| 1307 | − this source. | |
| 1308 | − </p> | |
| 1309 | − </div> | |
| 1310 | − <Button | |
| 1311 | − type="button" | |
| 1312 | − variant="outline" | |
| 1313 | − size="sm" | |
| 1314 | − onClick={handleMcpOAuthConnect} | |
| 1315 | − disabled={ | |
| 1316 | − isSubmitting || isDeleting || isOAuthSubmitting | |
| 1317 | − } | |
| 1318 | − > | |
| 1319 | − {isOAuthSubmitting ? ( | |
| 1320 | − <IconSpinner className="size-3" /> | |
| 1321 | − ) : null} | |
| 1322 | − {formState.authKind === "oauth2" && | |
| 1323 | − formState.oauthAccessHandle.trim().length > 0 | |
| 1324 | − ? "Reconnect" | |
| 1325 | − : "Sign in with OAuth"} | |
| 1326 | − </Button> | |
| 1327 | − </div> | |
| 1328 | − </div> | |
| 1329 | − {formState.authKind === "oauth2" && | |
| 1330 | − formState.oauthAccessHandle.trim().length > 0 && ( | |
| 1331 | − <div className="flex items-center justify-between gap-3 border-t border-border/50 px-4 py-2.5"> | |
| 1332 | − <p className="text-[11px] text-muted-foreground/70"> | |
| 1333 | − Token refs attached to this draft | |
| 1334 | − </p> | |
| 1335 | − <button | |
| 1336 | − type="button" | |
| 1337 | − className="text-[11px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground" | |
| 1338 | − onClick={() => | |
| 1339 | − setExpandedOauthSecretRefTarget((current) => | |
| 1340 | − current === oauthSecretRefTarget | |
| 1341 | − ? null | |
| 1342 | − : oauthSecretRefTarget, | |
| 1343 | − ) | |
| 1344 | − } | |
| 1345 | − > | |
| 1346 | − {showOauthSecretRefs ? "Hide refs" : "Show refs"} | |
| 1347 | − </button> | |
| 1348 | − </div> | |
| 1349 | − )} | |
| 1350 | − </div> | |
| 1351 | − )} | |
| 1352 | − {formState.kind === "mcp" && formState.transport === "stdio" ? ( | |
| 1353 | − <div className="sm:col-span-2 rounded-xl border border-border bg-muted/40 px-4 py-3 text-[12px] text-muted-foreground"> | |
| 1354 | − Local MCP stdio sources do not use executor-managed HTTP or | |
| 1355 | − OAuth credentials. | |
| 1356 | − </div> | |
| 1357 | − ) : ( | |
| 1358 | − <Field label="Auth mode"> | |
| 1359 | − <SelectInput | |
| 1360 | − value={formState.authKind} | |
| 1361 | − onChange={(value) => | |
| 1362 | − setField("authKind", value as Source["auth"]["kind"]) | |
| 1363 | − } | |
| 1364 | − disabled={formState.managedAuth !== null} | |
| 1365 | − options={authOptions.map((value) => ({ | |
| 1366 | − value, | |
| 1367 | − label: value, | |
| 1368 | − }))} | |
| 1369 | − /> | |
| 1370 | − </Field> | |
| 1371 | − )} | |
| 1372 | − {formState.managedAuth !== null && | |
| 1373 | − !( | |
| 1374 | − formState.kind === "mcp" && formState.transport === "stdio" | |
| 1375 | − ) && ( | |
| 1376 | − <div className="sm:col-span-2 rounded-xl border border-border bg-gradient-to-b from-muted/40 to-transparent overflow-hidden"> | |
| 1377 | − <div className="flex items-start gap-3 px-4 py-3.5"> | |
| 1378 | − <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary mt-0.5"> | |
| 1379 | − {formState.managedAuth.kind === "provider_grant_ref" ? ( | |
| 1380 | − <svg | |
| 1381 | − className="size-4" | |
| 1382 | − viewBox="0 0 24 24" | |
| 1383 | − fill="none" | |
| 1384 | − stroke="currentColor" | |
| 1385 | − strokeWidth="1.5" | |
| 1386 | − strokeLinecap="round" | |
| 1387 | − strokeLinejoin="round" | |
| 1388 | − > | |
| 1389 | − <path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" /> | |
| 1390 | − <path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" /> | |
| 1391 | − </svg> | |
| 1392 | − ) : ( | |
| 1393 | − <svg | |
| 1394 | − className="size-4" | |
| 1395 | − viewBox="0 0 24 24" | |
| 1396 | − fill="none" | |
| 1397 | − stroke="currentColor" | |
| 1398 | − strokeWidth="1.5" | |
| 1399 | − strokeLinecap="round" | |
| 1400 | − strokeLinejoin="round" | |
| 1401 | − > | |
| 1402 | − <rect | |
| 1403 | − x="3" | |
| 1404 | − y="11" | |
| 1405 | − width="18" | |
| 1406 | − height="11" | |
| 1407 | − rx="2" | |
| 1408 | − ry="2" | |
| 1409 | − /> | |
| 1410 | − <path d="M7 11V7a5 5 0 0110 0v4" /> | |
| 1411 | − </svg> | |
| 1412 | − )} | |
| 1413 | − </div> | |
| 1414 | − <div className="flex-1 min-w-0 space-y-1"> | |
| 1415 | − <p className="text-[12px] font-medium text-foreground"> | |
| 1416 | − {formState.managedAuth.kind === "provider_grant_ref" | |
| 1417 | − ? "Shared provider grant" | |
| 1418 | − : "Managed MCP OAuth"} | |
| 1419 | − </p> | |
| 1420 | − <p className="text-[11px] leading-relaxed text-muted-foreground"> | |
| 1421 | − {formState.managedAuth.kind === "provider_grant_ref" | |
| 1422 | − ? "This source uses a shared Google auth grant. Reconnect from Add Source to change the linked account or scopes." | |
| 1423 | − : "Authenticated through a persisted MCP OAuth session. Reconnect the source to refresh or replace the binding."} | |
| 1424 | − </p> | |
| 1425 | − </div> | |
| 1426 | − </div> | |
| 1427 | − {formState.managedAuth.kind === "provider_grant_ref" && | |
| 1428 | − props.mode === "edit" && ( | |
| 1429 | − <div className="flex items-center justify-end border-t border-border/50 px-4 py-2.5"> | |
| 1430 | − <Button | |
| 1431 | − type="button" | |
| 1432 | − variant="ghost" | |
| 1433 | − size="sm" | |
| 1434 | − className="text-destructive/70 hover:text-destructive hover:bg-destructive/8" | |
| 1435 | − onClick={handleRevokeProviderGrant} | |
| 1436 | − disabled={isRevokingGrant} | |
| 1437 | − > | |
| 1438 | − <IconTrash className="size-3" /> | |
| 1439 | − {isRevokingGrant | |
| 1440 | − ? "Revoking\u2026" | |
| 1441 | − : "Revoke shared auth"} | |
| 1442 | − </Button> | |
| 1443 | − </div> | |
| 1444 | − )} | |
| 1445 | − </div> | |
| 1446 | − )} | |
| 1447 | − {formState.authKind !== "none" && | |
| 1448 | − formState.managedAuth === null && | |
| 1449 | − !( | |
| 1450 | − formState.kind === "mcp" && formState.transport === "stdio" | |
| 1451 | − ) && ( | |
| 1452 | − <> | |
| 1453 | − <Field label="Header name"> | |
| 1454 | − <TextInput | |
| 1455 | − value={formState.authHeaderName} | |
| 1456 | − onChange={(value) => setField("authHeaderName", value)} | |
| 1457 | − placeholder="Authorization" | |
| 1458 | − /> | |
| 1459 | − </Field> | |
| 1460 | − <Field label="Prefix"> | |
| 1461 | − <TextInput | |
| 1462 | − value={formState.authPrefix} | |
| 1463 | − onChange={(value) => setField("authPrefix", value)} | |
| 1464 | − placeholder="Bearer " | |
| 1465 | − /> | |
| 1466 | − </Field> | |
| 1467 | − </> | |
| 1468 | − )} | |
| 1469 | − | |
| 1470 | − {formState.authKind === "bearer" && | |
| 1471 | − formState.managedAuth === null && | |
| 1472 | − !( | |
| 1473 | − formState.kind === "mcp" && formState.transport === "stdio" | |
| 1474 | − ) && ( | |
| 1475 | − <Field label="Token" className="sm:col-span-2"> | |
| 1476 | − <SecretPicker | |
| 1477 | − instanceConfig={instanceConfig} | |
| 1478 | − secrets={secrets} | |
| 1479 | − providerId={formState.bearerProviderId} | |
| 1480 | − handle={formState.bearerHandle} | |
| 1481 | − onSelect={(providerId, handle) => { | |
| 1482 | − setField("bearerProviderId", providerId); | |
| 1483 | − setField("bearerHandle", handle); | |
| 1484 | − }} | |
| 1485 | − /> | |
| 1486 | − </Field> | |
| 1487 | − )} | |
| 1488 | − | |
| 1489 | − {formState.authKind === "oauth2" && | |
| 1490 | − formState.managedAuth === null && | |
| 1491 | − !( | |
| 1492 | − formState.kind === "mcp" && formState.transport === "stdio" | |
| 1493 | − ) && | |
| 1494 | − (formState.kind !== "mcp" || | |
| 1495 | − formState.oauthAccessHandle.trim().length === 0 || | |
| 1496 | − showOauthSecretRefs) && ( | |
| 1497 | − <> | |
| 1498 | − <Field label="Access token" className="sm:col-span-2"> | |
| 1499 | − <SecretPicker | |
| 1500 | − instanceConfig={instanceConfig} | |
| 1501 | − secrets={secrets} | |
| 1502 | − providerId={formState.oauthAccessProviderId} | |
| 1503 | − handle={formState.oauthAccessHandle} | |
| 1504 | − onSelect={(providerId, handle) => { | |
| 1505 | − setField("oauthAccessProviderId", providerId); | |
| 1506 | − setField("oauthAccessHandle", handle); | |
| 1507 | − }} | |
| 1508 | − /> | |
| 1509 | − </Field> | |
| 1510 | − <Field | |
| 1511 | − label="Refresh token (optional)" | |
| 1512 | − className="sm:col-span-2" | |
| 1513 | − > | |
| 1514 | − <SecretPicker | |
| 1515 | − instanceConfig={instanceConfig} | |
| 1516 | − secrets={secrets} | |
| 1517 | − providerId={formState.oauthRefreshProviderId} | |
| 1518 | − handle={formState.oauthRefreshHandle} | |
| 1519 | − onSelect={(providerId, handle) => { | |
| 1520 | − setField("oauthRefreshProviderId", providerId); | |
| 1521 | − setField("oauthRefreshHandle", handle); | |
| 1522 | − }} | |
| 1523 | − allowEmpty | |
| 1524 | − /> | |
| 1525 | − </Field> | |
| 1526 | − </> | |
| 1527 | − )} | |
| 1528 | − </div> | |
| 1529 | − </Section> | |
| 1530 | − | |
| 1531 | − {/* Danger zone (edit mode) */} | |
| 1532 | − {props.mode === "edit" && props.source && ( | |
| 1533 | − <Section title="Danger zone"> | |
| 1534 | − <Button | |
| 1535 | − variant="outline" | |
| 1536 | − className="border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive" | |
| 1537 | − onClick={handleRemove} | |
| 1538 | − disabled={isDeleting || isRevokingGrant} | |
| 1539 | − > | |
| 1540 | − <IconTrash className="size-3.5" /> | |
| 1541 | − {isDeleting ? "Removing\u2026" : "Remove source"} | |
| 1542 | − </Button> | |
| 1543 | − </Section> | |
| 1544 | − )} | |
| 1545 | − | |
| 1546 | − {/* Actions */} | |
| 1547 | − <div className="flex items-center justify-end gap-3 border-t border-border pt-5"> | |
| 1548 | − <Link {...backLink} className="inline-flex"> | |
| 1549 | − <Button variant="ghost" type="button"> | |
| 1550 | − Cancel | |
| 1551 | − </Button> | |
| 1552 | − </Link> | |
| 1553 | − <Button | |
| 1554 | − onClick={handleSubmit} | |
| 1555 | − disabled={isSubmitting || isRevokingGrant} | |
| 1556 | − > | |
| 1557 | − {props.mode === "edit" ? ( | |
| 1558 | − <IconPencil className="size-3.5" /> | |
| 1559 | − ) : ( | |
| 1560 | − <IconPlus className="size-3.5" /> | |
| 1561 | − )} | |
| 1562 | − {isSubmitting | |
| 1563 | − ? props.mode === "edit" | |
| 1564 | − ? "Saving\u2026" | |
| 1565 | − : "Creating\u2026" | |
| 1566 | − : props.mode === "edit" | |
| 1567 | − ? "Save" | |
| 1568 | − : "Create source"} | |
| 1569 | − </Button> | |
| 1570 | − </div> | |
| 1571 | − </div> | |
| 1572 | − </div> | |
| 1573 | − </div> | |
| 1574 | − ); | |
| 1575 | −} | |
| 1576 | − | |
| 1577 | −// --------------------------------------------------------------------------- | |
| 1578 | −// Form building blocks | |
| 1579 | −// --------------------------------------------------------------------------- | |
| 1580 | − | |
| 1581 | −function Section(props: { | |
| 1582 | − title: string; | |
| 1583 | − children: ReactNode; | |
| 1584 | − className?: string; | |
| 1585 | −}) { | |
| 1586 | − return ( | |
| 1587 | − <section | |
| 1588 | − className={cn( | |
| 1589 | − "rounded-xl border border-border bg-card/80", | |
| 1590 | − props.className, | |
| 1591 | − )} | |
| 1592 | − > | |
| 1593 | − <div className="border-b border-border px-5 py-3"> | |
| 1594 | − <h2 className="text-sm font-semibold text-foreground">{props.title}</h2> | |
| 1595 | − </div> | |
| 1596 | − <div className="p-5">{props.children}</div> | |
| 1597 | − </section> | |
| 1598 | − ); | |
| 1599 | −} | |
| 1600 | − | |
| 1601 | −function Field(props: { | |
| 1602 | − label: string; | |
| 1603 | − className?: string; | |
| 1604 | − children: ReactNode; | |
| 1605 | −}) { | |
| 1606 | − return ( | |
| 1607 | − <label className={cn("block space-y-1.5", props.className)}> | |
| 1608 | − <span className="text-[12px] font-medium text-foreground"> | |
| 1609 | − {props.label} | |
| 1610 | − </span> | |
| 1611 | − {props.children} | |
| 1612 | − </label> | |
| 1613 | − ); | |
| 1614 | −} | |
| 1615 | − | |
| 1616 | −function TextInput(props: { | |
| 1617 | − value: string; | |
| 1618 | − onChange: (value: string) => void; | |
| 1619 | − placeholder?: string; | |
| 1620 | − mono?: boolean; | |
| 1621 | −}) { | |
| 1622 | − return ( | |
| 1623 | − <input | |
| 1624 | − value={props.value} | |
| 1625 | − onChange={(event) => props.onChange(event.target.value)} | |
| 1626 | − placeholder={props.placeholder} | |
| 1627 | − className={cn( | |
| 1628 | − "h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25", | |
| 1629 | − props.mono && "font-mono text-[12px]", | |
| 1630 | − )} | |
| 1631 | − /> | |
| 1632 | − ); | |
| 1633 | −} | |
| 1634 | − | |
| 1635 | −function SelectInput(props: { | |
| 1636 | − value: string; | |
| 1637 | − onChange: (value: string) => void; | |
| 1638 | − options: ReadonlyArray<{ value: string; label: string }>; | |
| 1639 | − disabled?: boolean; | |
| 1640 | −}) { | |
| 1641 | − return ( | |
| 1642 | − <select | |
| 1643 | − value={props.value} | |
| 1644 | − disabled={props.disabled} | |
| 1645 | − onChange={(event) => props.onChange(event.target.value)} | |
| 1646 | − className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 1647 | − > | |
| 1648 | − {props.options.map((option) => ( | |
| 1649 | − <option key={option.value} value={option.value}> | |
| 1650 | − {option.label} | |
| 1651 | − </option> | |
| 1652 | − ))} | |
| 1653 | − </select> | |
| 1654 | − ); | |
| 1655 | −} | |
| 1656 | − | |
| 1657 | −function CodeEditor(props: { | |
| 1658 | − value: string; | |
| 1659 | − onChange: (value: string) => void; | |
| 1660 | − placeholder?: string; | |
| 1661 | −}) { | |
| 1662 | − return ( | |
| 1663 | − <textarea | |
| 1664 | − value={props.value} | |
| 1665 | − onChange={(event) => props.onChange(event.target.value)} | |
| 1666 | − placeholder={props.placeholder} | |
| 1667 | − spellCheck={false} | |
| 1668 | − className="min-h-28 w-full rounded-lg border border-input bg-background px-3 py-2.5 font-mono text-[12px] leading-relaxed text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 1669 | − /> | |
| 1670 | − ); | |
| 1671 | −} | |
| 1672 | − | |
| 1673 | −function ToggleButton(props: { | |
| 1674 | − checked: boolean; | |
| 1675 | − onChange: (checked: boolean) => void; | |
| 1676 | −}) { | |
| 1677 | − return ( | |
| 1678 | − <button | |
| 1679 | − type="button" | |
| 1680 | − onClick={() => props.onChange(!props.checked)} | |
| 1681 | − className={cn( | |
| 1682 | − "flex h-9 w-full items-center justify-between rounded-lg border px-3 text-[13px] transition-colors", | |
| 1683 | − props.checked | |
| 1684 | − ? "border-primary/40 bg-primary/8 text-foreground" | |
| 1685 | − : "border-input bg-background text-muted-foreground hover:text-foreground", | |
| 1686 | − )} | |
| 1687 | − > | |
| 1688 | − <span>{props.checked ? "Enabled" : "Disabled"}</span> | |
| 1689 | − <span | |
| 1690 | − className={cn( | |
| 1691 | − "size-2 rounded-full", | |
| 1692 | − props.checked ? "bg-primary" : "bg-muted-foreground/30", | |
| 1693 | − )} | |
| 1694 | − /> | |
| 1695 | − </button> | |
| 1696 | − ); | |
| 1697 | −} | |
| 1698 | − | |
| 1699 | −function StatusBanner(props: { state: StatusBannerState; className?: string }) { | |
| 1700 | − return ( | |
| 1701 | − <div | |
| 1702 | − className={cn( | |
| 1703 | − "rounded-lg border px-4 py-3 text-[13px]", | |
| 1704 | − props.state.tone === "success" && | |
| 1705 | − "border-primary/30 bg-primary/8 text-foreground", | |
| 1706 | − props.state.tone === "info" && | |
| 1707 | − "border-border bg-card text-muted-foreground", | |
| 1708 | − props.state.tone === "error" && | |
| 1709 | − "border-destructive/30 bg-destructive/8 text-destructive", | |
| 1710 | − props.className, | |
| 1711 | − )} | |
| 1712 | − > | |
| 1713 | − {props.state.text} | |
| 1714 | − </div> | |
| 1715 | − ); | |
| 1716 | −} | |
| 1717 | − | |
| 1718 | −const CREATE_NEW_VALUE = "__create_new__"; | |
| 1719 | − | |
| 1720 | −function SecretPicker(props: { | |
| 1721 | − instanceConfig: Loadable<InstanceConfig>; | |
| 1722 | − secrets: Loadable<ReadonlyArray<SecretListItem>>; | |
| 1723 | − providerId: string; | |
| 1724 | − handle: string; | |
| 1725 | − onSelect: (providerId: string, handle: string) => void; | |
| 1726 | − allowEmpty?: boolean; | |
| 1727 | −}) { | |
| 1728 | − const { instanceConfig, secrets, providerId, handle, onSelect, allowEmpty } = | |
| 1729 | − props; | |
| 1730 | − const createSecret = useCreateSecret(); | |
| 1731 | − const [showCreate, setShowCreate] = useState(false); | |
| 1732 | − const [newName, setNewName] = useState(""); | |
| 1733 | − const [newValue, setNewValue] = useState(""); | |
| 1734 | − const [newProviderId, setNewProviderId] = useState(""); | |
| 1735 | − const [createError, setCreateError] = useState<string | null>(null); | |
| 1736 | − const storableProviders = | |
| 1737 | − instanceConfig.status === "ready" | |
| 1738 | − ? instanceConfig.data.secretProviders.filter( | |
| 1739 | − (provider) => provider.canStore, | |
| 1740 | − ) | |
| 1741 | − : []; | |
| 1742 | − | |
| 1743 | − useEffect(() => { | |
| 1744 | − if (newProviderId.length > 0) { | |
| 1745 | − return; | |
| 1746 | − } | |
| 1747 | − if (instanceConfig.status === "ready") { | |
| 1748 | − setNewProviderId(instanceConfig.data.defaultSecretStoreProvider); | |
| 1749 | − } | |
| 1750 | − }, [instanceConfig, newProviderId]); | |
| 1751 | − | |
| 1752 | − // Build the selected value key: if handle is set, use it (it's the secret ID) | |
| 1753 | − const selectedValue = handle || ""; | |
| 1754 | − | |
| 1755 | − const handleSelectChange = (value: string) => { | |
| 1756 | − if (value === CREATE_NEW_VALUE) { | |
| 1757 | − setShowCreate(true); | |
| 1758 | − setNewName(""); | |
| 1759 | − setNewValue(""); | |
| 1760 | − setNewProviderId( | |
| 1761 | − instanceConfig.status === "ready" | |
| 1762 | − ? instanceConfig.data.defaultSecretStoreProvider | |
| 1763 | − : "", | |
| 1764 | − ); | |
| 1765 | − setCreateError(null); | |
| 1766 | − return; | |
| 1767 | − } | |
| 1768 | − if (value === "") { | |
| 1769 | − onSelect("", ""); | |
| 1770 | − return; | |
| 1771 | − } | |
| 1772 | − const matchedSecret = | |
| 1773 | − secrets.status === "ready" | |
| 1774 | − ? secrets.data.find((secret) => secret.id === value) | |
| 1775 | − : null; | |
| 1776 | − onSelect(matchedSecret?.providerId ?? "local", value); | |
| 1777 | − }; | |
| 1778 | − | |
| 1779 | − const handleCreate = async () => { | |
| 1780 | − setCreateError(null); | |
| 1781 | − const trimmedName = newName.trim(); | |
| 1782 | − if (!trimmedName) { | |
| 1783 | − setCreateError("Name is required."); | |
| 1784 | − return; | |
| 1785 | − } | |
| 1786 | − if (!newValue) { | |
| 1787 | − setCreateError("Value is required."); | |
| 1788 | − return; | |
| 1789 | − } | |
| 1790 | − | |
| 1791 | − try { | |
| 1792 | − const result = await createSecret.mutateAsync({ | |
| 1793 | − name: trimmedName, | |
| 1794 | − value: newValue, | |
| 1795 | − ...(newProviderId ? { providerId: newProviderId } : {}), | |
| 1796 | − }); | |
| 1797 | − onSelect(result.providerId, result.id); | |
| 1798 | − setShowCreate(false); | |
| 1799 | − } catch (err) { | |
| 1800 | − setCreateError( | |
| 1801 | − err instanceof Error ? err.message : "Failed creating secret.", | |
| 1802 | − ); | |
| 1803 | − } | |
| 1804 | − }; | |
| 1805 | − | |
| 1806 | − if (secrets.status !== "ready") { | |
| 1807 | − return ( | |
| 1808 | − <select | |
| 1809 | − disabled | |
| 1810 | − className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-muted-foreground outline-none opacity-60" | |
| 1811 | − > | |
| 1812 | − <option>Loading…</option> | |
| 1813 | − </select> | |
| 1814 | − ); | |
| 1815 | − } | |
| 1816 | − | |
| 1817 | − const items = secrets.data; | |
| 1818 | − | |
| 1819 | − // Check if the current handle matches a known secret | |
| 1820 | − const matchedSecret = items.find((s) => s.id === handle); | |
| 1821 | − const isExternalRef = handle && !matchedSecret && providerId !== "local"; | |
| 1822 | − | |
| 1823 | − return ( | |
| 1824 | − <div className="space-y-2"> | |
| 1825 | − <select | |
| 1826 | − value={showCreate ? CREATE_NEW_VALUE : selectedValue} | |
| 1827 | − onChange={(e) => handleSelectChange(e.target.value)} | |
| 1828 | − className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 1829 | − > | |
| 1830 | − {allowEmpty && <option value="">None</option>} | |
| 1831 | − {!allowEmpty && !selectedValue && ( | |
| 1832 | − <option value="">Select a secret…</option> | |
| 1833 | − )} | |
| 1834 | − {items.map((secret) => ( | |
| 1835 | − <option key={secret.id} value={secret.id}> | |
| 1836 | − {secret.name || secret.id} ({secret.providerId}) | |
| 1837 | − </option> | |
| 1838 | − ))} | |
| 1839 | − {isExternalRef && ( | |
| 1840 | − <option value={handle}> | |
| 1841 | − {providerId}:{handle} | |
| 1842 | − </option> | |
| 1843 | − )} | |
| 1844 | − <option value={CREATE_NEW_VALUE}>+ Create new secret</option> | |
| 1845 | − </select> | |
| 1846 | − | |
| 1847 | − {showCreate && ( | |
| 1848 | − <div className="rounded-lg border border-primary/20 bg-card/80 p-3 space-y-3"> | |
| 1849 | − {createError && ( | |
| 1850 | − <div className="rounded-md border border-destructive/30 bg-destructive/8 px-3 py-2 text-[12px] text-destructive"> | |
| 1851 | − {createError} | |
| 1852 | − </div> | |
| 1853 | − )} | |
| 1854 | − <div className="grid gap-3 sm:grid-cols-2"> | |
| 1855 | − <label className="block space-y-1"> | |
| 1856 | − <span className="text-[11px] font-medium text-muted-foreground"> | |
| 1857 | − Name | |
| 1858 | − </span> | |
| 1859 | − <input | |
| 1860 | − value={newName} | |
| 1861 | − onChange={(e) => setNewName(e.target.value)} | |
| 1862 | − placeholder="GitHub PAT" | |
| 1863 | − className="h-8 w-full rounded-lg border border-input bg-background px-3 text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 1864 | − autoFocus | |
| 1865 | − /> | |
| 1866 | − </label> | |
| 1867 | − <label className="block space-y-1"> | |
| 1868 | − <span className="text-[11px] font-medium text-muted-foreground"> | |
| 1869 | − Value | |
| 1870 | − </span> | |
| 1871 | − <input | |
| 1872 | − type="password" | |
| 1873 | − value={newValue} | |
| 1874 | − onChange={(e) => setNewValue(e.target.value)} | |
| 1875 | − placeholder="ghp_..." | |
| 1876 | − className="h-8 w-full rounded-lg border border-input bg-background px-3 font-mono text-[11px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 1877 | − /> | |
| 1878 | − </label> | |
| 1879 | − <label className="block space-y-1"> | |
| 1880 | − <span className="text-[11px] font-medium text-muted-foreground"> | |
| 1881 | − Store in | |
| 1882 | − </span> | |
| 1883 | − <select | |
| 1884 | − value={newProviderId} | |
| 1885 | − onChange={(e) => setNewProviderId(e.target.value)} | |
| 1886 | − className="h-8 w-full rounded-lg border border-input bg-background px-3 text-[12px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 1887 | − > | |
| 1888 | − {storableProviders.map((provider) => ( | |
| 1889 | − <option key={provider.id} value={provider.id}> | |
| 1890 | − {provider.name} | |
| 1891 | − </option> | |
| 1892 | − ))} | |
| 1893 | − </select> | |
| 1894 | − </label> | |
| 1895 | − </div> | |
| 1896 | − <div className="flex items-center justify-end gap-2"> | |
| 1897 | − <button | |
| 1898 | − type="button" | |
| 1899 | − onClick={() => setShowCreate(false)} | |
| 1900 | − className="rounded-md px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground" | |
| 1901 | − > | |
| 1902 | − Cancel | |
| 1903 | − </button> | |
| 1904 | − <Button | |
| 1905 | − size="sm" | |
| 1906 | − onClick={handleCreate} | |
| 1907 | − disabled={createSecret.status === "pending"} | |
| 1908 | − > | |
| 1909 | − {createSecret.status === "pending" && ( | |
| 1910 | − <IconSpinner className="size-3" /> | |
| 1911 | − )} | |
| 1912 | − Store & use | |
| 1913 | − </Button> | |
| 1914 | − </div> | |
| 1915 | − </div> | |
| 1916 | − )} | |
| 1917 | − </div> | |
| 1918 | − ); | |
| 1919 | −} | |
| @@ -1,358 +0,0 @@ | ||
| 1 | −type SourceTemplateBase = { | |
| 2 | − id: string; | |
| 3 | − name: string; | |
| 4 | − summary: string; | |
| 5 | − endpoint?: string; | |
| 6 | − namespace?: string; | |
| 7 | − groupId?: string; | |
| 8 | − groupLabel?: string; | |
| 9 | − batchable?: boolean; | |
| 10 | −}; | |
| 11 | − | |
| 12 | −export type McpSourceTemplate = SourceTemplateBase & { | |
| 13 | − kind: "mcp"; | |
| 14 | − connectionType?: "endpoint" | "command"; | |
| 15 | − transport?: "auto" | "streamable-http" | "sse" | "stdio"; | |
| 16 | − command?: string; | |
| 17 | − args?: ReadonlyArray<string>; | |
| 18 | − env?: Record<string, string>; | |
| 19 | − cwd?: string; | |
| 20 | −}; | |
| 21 | − | |
| 22 | −export type OpenApiSourceTemplate = SourceTemplateBase & { | |
| 23 | − kind: "openapi"; | |
| 24 | − specUrl: string; | |
| 25 | −}; | |
| 26 | − | |
| 27 | −export type GoogleDiscoverySourceTemplate = SourceTemplateBase & { | |
| 28 | − kind: "google_discovery"; | |
| 29 | − service: string; | |
| 30 | − version: string; | |
| 31 | − discoveryUrl: string; | |
| 32 | −}; | |
| 33 | − | |
| 34 | −export type GraphqlSourceTemplate = SourceTemplateBase & { | |
| 35 | − kind: "graphql"; | |
| 36 | −}; | |
| 37 | − | |
| 38 | −export type SourceTemplate = | |
| 39 | − | McpSourceTemplate | |
| 40 | − | OpenApiSourceTemplate | |
| 41 | − | GoogleDiscoverySourceTemplate | |
| 42 | − | GraphqlSourceTemplate; | |
| 43 | − | |
| 44 | −export const isStdioMcpSourceTemplate = ( | |
| 45 | − template: SourceTemplate, | |
| 46 | −): template is McpSourceTemplate & { transport: "stdio" } => | |
| 47 | − template.kind === "mcp" && | |
| 48 | − template.connectionType === "command" && | |
| 49 | − template.transport === "stdio"; | |
| 50 | − | |
| 51 | −const googleDiscoveryUrl = (service: string, version: string): string => | |
| 52 | − `https://www.googleapis.com/discovery/v1/apis/${encodeURIComponent(service)}/${encodeURIComponent(version)}/rest`; | |
| 53 | − | |
| 54 | −const googleDiscoveryTemplate = (input: { | |
| 55 | − id: string; | |
| 56 | − name: string; | |
| 57 | − summary: string; | |
| 58 | − service: string; | |
| 59 | − version: string; | |
| 60 | − discoveryUrl?: string; | |
| 61 | −}): GoogleDiscoverySourceTemplate => { | |
| 62 | − const discoveryUrl = | |
| 63 | − input.discoveryUrl ?? googleDiscoveryUrl(input.service, input.version); | |
| 64 | − return { | |
| 65 | − id: input.id, | |
| 66 | − name: input.name, | |
| 67 | − summary: input.summary, | |
| 68 | − kind: "google_discovery", | |
| 69 | − endpoint: discoveryUrl, | |
| 70 | − groupId: "google_workspace", | |
| 71 | − groupLabel: "Google Workspace", | |
| 72 | − batchable: true, | |
| 73 | − service: input.service, | |
| 74 | − version: input.version, | |
| 75 | − discoveryUrl, | |
| 76 | − }; | |
| 77 | −}; | |
| 78 | − | |
| 79 | −export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ | |
| 80 | − { | |
| 81 | − id: "deepwiki-mcp", | |
| 82 | − name: "DeepWiki MCP", | |
| 83 | − summary: "Repository docs and knowledge graphs via MCP.", | |
| 84 | − kind: "mcp", | |
| 85 | − endpoint: "https://mcp.deepwiki.com/mcp", | |
| 86 | − }, | |
| 87 | − { | |
| 88 | − id: "axiom-mcp", | |
| 89 | − name: "Axiom MCP", | |
| 90 | − summary: "Query, stream, and analyze logs, traces, and event data.", | |
| 91 | − kind: "mcp", | |
| 92 | − endpoint: "https://mcp.axiom.co/mcp", | |
| 93 | − }, | |
| 94 | − { | |
| 95 | − id: "neon-mcp", | |
| 96 | − name: "Neon MCP", | |
| 97 | − summary: "Manage Postgres databases, branches, and queries via MCP.", | |
| 98 | − kind: "mcp", | |
| 99 | − endpoint: "https://mcp.neon.tech/mcp", | |
| 100 | − }, | |
| 101 | − { | |
| 102 | − id: "chrome-devtools-mcp", | |
| 103 | − name: "Chrome DevTools MCP", | |
| 104 | − summary: | |
| 105 | − "Debug a live Chrome browser session over a local MCP stdio transport.", | |
| 106 | − kind: "mcp", | |
| 107 | − namespace: "chrome.devtools", | |
| 108 | − connectionType: "command", | |
| 109 | − transport: "stdio", | |
| 110 | − command: "npx", | |
| 111 | − args: ["-y", "chrome-devtools-mcp@latest"], | |
| 112 | − }, | |
| 113 | − { | |
| 114 | − id: "neon-api", | |
| 115 | − name: "Neon API", | |
| 116 | − summary: "Projects, branches, endpoints, databases, and API keys.", | |
| 117 | − kind: "openapi", | |
| 118 | − endpoint: "https://console.neon.tech/api/v2", | |
| 119 | − specUrl: "https://neon.com/api_spec/release/v2.json", | |
| 120 | − }, | |
| 121 | − { | |
| 122 | − id: "github-rest", | |
| 123 | − name: "GitHub REST API", | |
| 124 | − summary: "Repos, issues, pull requests, actions, and org settings.", | |
| 125 | − kind: "openapi", | |
| 126 | − endpoint: "https://api.github.com", | |
| 127 | − specUrl: | |
| 128 | − "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml", | |
| 129 | − groupId: "github", | |
| 130 | − groupLabel: "GitHub", | |
| 131 | − batchable: false, | |
| 132 | − }, | |
| 133 | − { | |
| 134 | − id: "github-graphql", | |
| 135 | − name: "GitHub GraphQL", | |
| 136 | − summary: | |
| 137 | − "Issues, pull requests, discussions, and repository objects via GraphQL.", | |
| 138 | − kind: "graphql", | |
| 139 | − endpoint: "https://api.github.com/graphql", | |
| 140 | − groupId: "github", | |
| 141 | − groupLabel: "GitHub", | |
| 142 | − batchable: false, | |
| 143 | − }, | |
| 144 | − { | |
| 145 | − id: "gitlab-graphql", | |
| 146 | − name: "GitLab GraphQL", | |
| 147 | − summary: "Projects, merge requests, issues, CI pipelines, and users.", | |
| 148 | − kind: "graphql", | |
| 149 | − endpoint: "https://gitlab.com/api/graphql", | |
| 150 | − }, | |
| 151 | − { | |
| 152 | − id: "openai-api", | |
| 153 | − name: "OpenAI API", | |
| 154 | − summary: "Models, files, responses, and fine-tuning.", | |
| 155 | − kind: "openapi", | |
| 156 | − endpoint: "https://api.openai.com/v1", | |
| 157 | − specUrl: | |
| 158 | − "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml", | |
| 159 | − }, | |
| 160 | − { | |
| 161 | − id: "vercel-api", | |
| 162 | − name: "Vercel API", | |
| 163 | − summary: "Deployments, projects, domains, and environments.", | |
| 164 | − kind: "openapi", | |
| 165 | − endpoint: "https://api.vercel.com", | |
| 166 | − specUrl: "https://openapi.vercel.sh", | |
| 167 | − }, | |
| 168 | − { | |
| 169 | − id: "stripe-api", | |
| 170 | − name: "Stripe API", | |
| 171 | − summary: "Payments, billing, subscriptions, and invoices.", | |
| 172 | − kind: "openapi", | |
| 173 | − endpoint: "https://api.stripe.com", | |
| 174 | − specUrl: | |
| 175 | − "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", | |
| 176 | − }, | |
| 177 | − { | |
| 178 | − id: "linear-graphql", | |
| 179 | − name: "Linear GraphQL", | |
| 180 | − summary: "Issues, teams, cycles, and projects.", | |
| 181 | − kind: "graphql", | |
| 182 | − endpoint: "https://api.linear.app/graphql", | |
| 183 | − }, | |
| 184 | − { | |
| 185 | − id: "monday-graphql", | |
| 186 | − name: "Monday GraphQL", | |
| 187 | − summary: "Boards, items, updates, users, and workspace metadata.", | |
| 188 | − kind: "graphql", | |
| 189 | − endpoint: "https://api.monday.com/v2", | |
| 190 | − }, | |
| 191 | − { | |
| 192 | − id: "anilist-graphql", | |
| 193 | − name: "AniList GraphQL", | |
| 194 | − summary: "Anime, manga, characters, media lists, and recommendations.", | |
| 195 | − kind: "graphql", | |
| 196 | − endpoint: "https://graphql.anilist.co", | |
| 197 | − }, | |
| 198 | − googleDiscoveryTemplate({ | |
| 199 | − id: "google-calendar", | |
| 200 | − name: "Google Calendar", | |
| 201 | − summary: "Calendars, events, ACLs, and scheduling workflows.", | |
| 202 | − service: "calendar", | |
| 203 | − version: "v3", | |
| 204 | − discoveryUrl: | |
| 205 | − "https://calendar-json.googleapis.com/$discovery/rest?version=v3", | |
| 206 | − }), | |
| 207 | − googleDiscoveryTemplate({ | |
| 208 | − id: "google-drive", | |
| 209 | − name: "Google Drive", | |
| 210 | − summary: "Files, folders, permissions, comments, and shared drives.", | |
| 211 | − service: "drive", | |
| 212 | − version: "v3", | |
| 213 | − }), | |
| 214 | − googleDiscoveryTemplate({ | |
| 215 | − id: "google-gmail", | |
| 216 | − name: "Gmail", | |
| 217 | − summary: "Messages, threads, labels, drafts, and mailbox automation.", | |
| 218 | − service: "gmail", | |
| 219 | − version: "v1", | |
| 220 | − discoveryUrl: "https://gmail.googleapis.com/$discovery/rest?version=v1", | |
| 221 | − }), | |
| 222 | − googleDiscoveryTemplate({ | |
| 223 | − id: "google-docs", | |
| 224 | − name: "Google Docs", | |
| 225 | − summary: "Documents, structural edits, text ranges, and formatting.", | |
| 226 | − service: "docs", | |
| 227 | − version: "v1", | |
| 228 | − discoveryUrl: "https://docs.googleapis.com/$discovery/rest?version=v1", | |
| 229 | − }), | |
| 230 | − googleDiscoveryTemplate({ | |
| 231 | − id: "google-sheets", | |
| 232 | − name: "Google Sheets", | |
| 233 | − summary: "Spreadsheets, values, ranges, formatting, and batch updates.", | |
| 234 | − service: "sheets", | |
| 235 | − version: "v4", | |
| 236 | − discoveryUrl: "https://sheets.googleapis.com/$discovery/rest?version=v4", | |
| 237 | − }), | |
| 238 | − googleDiscoveryTemplate({ | |
| 239 | − id: "google-slides", | |
| 240 | − name: "Google Slides", | |
| 241 | − summary: "Presentations, slides, page elements, and deck updates.", | |
| 242 | − service: "slides", | |
| 243 | − version: "v1", | |
| 244 | − discoveryUrl: "https://slides.googleapis.com/$discovery/rest?version=v1", | |
| 245 | − }), | |
| 246 | − googleDiscoveryTemplate({ | |
| 247 | − id: "google-forms", | |
| 248 | − name: "Google Forms", | |
| 249 | − summary: "Forms, questions, responses, quizzes, and form metadata.", | |
| 250 | − service: "forms", | |
| 251 | − version: "v1", | |
| 252 | − discoveryUrl: "https://forms.googleapis.com/$discovery/rest?version=v1", | |
| 253 | − }), | |
| 254 | − googleDiscoveryTemplate({ | |
| 255 | − id: "google-search-console", | |
| 256 | − name: "Google Search Console", | |
| 257 | − summary: | |
| 258 | − "Sites, sitemaps, URL inspection, and search and Discover performance.", | |
| 259 | − service: "searchconsole", | |
| 260 | − version: "v1", | |
| 261 | − discoveryUrl: | |
| 262 | − "https://searchconsole.googleapis.com/$discovery/rest?version=v1", | |
| 263 | − }), | |
| 264 | − googleDiscoveryTemplate({ | |
| 265 | − id: "google-people", | |
| 266 | − name: "Google People", | |
| 267 | − summary: "Contacts, profiles, directory people, and contact groups.", | |
| 268 | − service: "people", | |
| 269 | − version: "v1", | |
| 270 | − discoveryUrl: "https://people.googleapis.com/$discovery/rest?version=v1", | |
| 271 | − }), | |
| 272 | − googleDiscoveryTemplate({ | |
| 273 | − id: "google-tasks", | |
| 274 | − name: "Google Tasks", | |
| 275 | − summary: "Task lists, task items, notes, and due dates.", | |
| 276 | − service: "tasks", | |
| 277 | − version: "v1", | |
| 278 | − discoveryUrl: "https://tasks.googleapis.com/$discovery/rest?version=v1", | |
| 279 | − }), | |
| 280 | − googleDiscoveryTemplate({ | |
| 281 | − id: "google-chat", | |
| 282 | − name: "Google Chat", | |
| 283 | − summary: "Spaces, messages, members, reactions, and chat workflows.", | |
| 284 | − service: "chat", | |
| 285 | − version: "v1", | |
| 286 | − discoveryUrl: "https://chat.googleapis.com/$discovery/rest?version=v1", | |
| 287 | − }), | |
| 288 | − googleDiscoveryTemplate({ | |
| 289 | − id: "google-keep", | |
| 290 | − name: "Google Keep", | |
| 291 | − summary: "Notes, lists, attachments, and collaborative annotations.", | |
| 292 | − service: "keep", | |
| 293 | − version: "v1", | |
| 294 | − discoveryUrl: "https://keep.googleapis.com/$discovery/rest?version=v1", | |
| 295 | − }), | |
| 296 | − googleDiscoveryTemplate({ | |
| 297 | − id: "google-classroom", | |
| 298 | − name: "Google Classroom", | |
| 299 | − summary: "Courses, rosters, coursework, submissions, and grading data.", | |
| 300 | − service: "classroom", | |
| 301 | − version: "v1", | |
| 302 | − discoveryUrl: "https://classroom.googleapis.com/$discovery/rest?version=v1", | |
| 303 | − }), | |
| 304 | − googleDiscoveryTemplate({ | |
| 305 | − id: "google-admin-directory", | |
| 306 | − name: "Google Admin Directory", | |
| 307 | − summary: "Users, groups, org units, roles, and domain directory resources.", | |
| 308 | − service: "admin", | |
| 309 | − version: "directory_v1", | |
| 310 | − discoveryUrl: | |
| 311 | − "https://admin.googleapis.com/$discovery/rest?version=directory_v1", | |
| 312 | − }), | |
| 313 | − googleDiscoveryTemplate({ | |
| 314 | − id: "google-admin-reports", | |
| 315 | − name: "Google Admin Reports", | |
| 316 | − summary: "Audit events, usage reports, and admin activity logs.", | |
| 317 | − service: "admin", | |
| 318 | − version: "reports_v1", | |
| 319 | − discoveryUrl: | |
| 320 | − "https://admin.googleapis.com/$discovery/rest?version=reports_v1", | |
| 321 | − }), | |
| 322 | − googleDiscoveryTemplate({ | |
| 323 | − id: "google-apps-script", | |
| 324 | − name: "Google Apps Script", | |
| 325 | − summary: | |
| 326 | − "Projects, deployments, script execution, and Apps Script metadata.", | |
| 327 | − service: "script", | |
| 328 | − version: "v1", | |
| 329 | − discoveryUrl: "https://script.googleapis.com/$discovery/rest?version=v1", | |
| 330 | − }), | |
| 331 | − googleDiscoveryTemplate({ | |
| 332 | − id: "google-bigquery", | |
| 333 | − name: "Google BigQuery", | |
| 334 | − summary: | |
| 335 | − "Datasets, tables, jobs, routines, and analytical query workflows.", | |
| 336 | − service: "bigquery", | |
| 337 | − version: "v2", | |
| 338 | − discoveryUrl: "https://bigquery.googleapis.com/$discovery/rest?version=v2", | |
| 339 | − }), | |
| 340 | − googleDiscoveryTemplate({ | |
| 341 | − id: "google-cloud-resource-manager", | |
| 342 | − name: "Google Cloud Resource Manager", | |
| 343 | − summary: | |
| 344 | − "Projects, folders, organizations, and IAM-oriented resource hierarchy.", | |
| 345 | − service: "cloudresourcemanager", | |
| 346 | − version: "v3", | |
| 347 | − discoveryUrl: | |
| 348 | − "https://cloudresourcemanager.googleapis.com/$discovery/rest?version=v3", | |
| 349 | − }), | |
| 350 | − googleDiscoveryTemplate({ | |
| 351 | − id: "google-youtube-data", | |
| 352 | − name: "YouTube Data", | |
| 353 | − summary: "Channels, playlists, videos, comments, captions, and uploads.", | |
| 354 | − service: "youtube", | |
| 355 | − version: "v3", | |
| 356 | − discoveryUrl: "https://youtube.googleapis.com/$discovery/rest?version=v3", | |
| 357 | − }), | |
| 358 | −]; | |
| @@ -23,6 +23,16 @@ const githubUrl = (executorPackage.homepage ?? repositoryUrl ?? "https://github. · 22 unmodified lines | ||
| 23 | 23 | .replace(/^git\+/, "") |
| 24 | 24 | .replace(/\.git$/, ""); |
| 25 | 25 | |
| 26 | +const webPackage = JSON.parse( | |
| 27 | + readFileSync(new URL("./package.json", import.meta.url), "utf8"), | |
| 28 | +) as { | |
| 29 | + dependencies?: Record<string, string>; | |
| 30 | +}; | |
| 31 | + | |
| 32 | +const workspaceExecutorPackages = Object.keys(webPackage.dependencies ?? {}).filter( | |
| 33 | + (name) => name.startsWith("@executor/"), | |
| 34 | +); | |
| 35 | + | |
| 26 | 36 | export default defineConfig({ |
| 27 | 37 | root: "src", |
| 28 | 38 | plugins: [ |
| @@ -47,7 +57,15 @@ export default defineConfig({ · 18 unmodified lines | ||
| 47 | 57 | outDir: "../dist", |
| 48 | 58 | emptyOutDir: true, |
| 49 | 59 | }, |
| 60 | + optimizeDeps: { | |
| 61 | + exclude: workspaceExecutorPackages, | |
| 62 | + }, | |
| 50 | 63 | server: { |
| 51 | 64 | port: 8788, |
| 65 | + watch: { | |
| 66 | + ignored: [ | |
| 67 | + "!**/node_modules/@executor/**", | |
| 68 | + ], | |
| 69 | + }, | |
| 52 | 70 | }, |
| 53 | 71 | }); |
| @@ -18,7 +18,7 @@ · 17 unmodified lines | ||
| 18 | 18 | }, |
| 19 | 19 | "apps/executor": { |
| 20 | 20 | "name": "executor", |
| 21 | − "version": "1.2.4-beta.4", | |
| 21 | + "version": "1.2.6", | |
| 22 | 22 | "bin": { |
| 23 | 23 | "executor": "bin/executor", |
| 24 | 24 | }, |
| @@ -27,10 +27,14 @@ · 2 unmodified lines | ||
| 27 | 27 | "@effect/platform": "catalog:", |
| 28 | 28 | "@effect/platform-node": "catalog:", |
| 29 | 29 | "@executor/codemode-core": "workspace:*", |
| 30 | + "@executor/executor-mcp": "workspace:*", | |
| 30 | 31 | "@executor/platform-api": "workspace:*", |
| 31 | − "@executor/platform-internal": "workspace:*", | |
| 32 | 32 | "@executor/platform-sdk": "workspace:*", |
| 33 | 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 | 38 | "@executor/server": "workspace:*", |
| 35 | 39 | "effect": "catalog:", |
| 36 | 40 | }, |
| @@ -50,6 +54,11 @@ · 13 unmodified lines | ||
| 50 | 54 | "name": "@executor/web", |
| 51 | 55 | "version": "0.0.0", |
| 52 | 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 | 62 | "@executor/react": "workspace:*", |
| 54 | 63 | "@executor/server": "workspace:*", |
| 55 | 64 | "@shikijs/langs": "^4.0.1", |
| @@ -114,41 +123,25 @@ · 58 unmodified lines | ||
| 114 | 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 | 126 | "packages/clients/react": { |
| 141 | 127 | "name": "@executor/react", |
| 142 | 128 | "version": "0.0.0", |
| 143 | 129 | "dependencies": { |
| 144 | 130 | "@effect-atom/atom": "0.5.3", |
| 145 | 131 | "@effect-atom/atom-react": "0.5.0", |
| 132 | + "@effect/platform": "catalog:", | |
| 146 | 133 | "@executor/platform-api": "workspace:*", |
| 147 | 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 | 139 | "effect": "catalog:", |
| 140 | + "shiki": "^4.0.1", | |
| 141 | + "streamdown": "^2.3.0", | |
| 142 | + "tailwind-merge": "^3.5.0", | |
| 149 | 143 | }, |
| 150 | 144 | "devDependencies": { |
| 151 | − "@effect/platform": "catalog:", | |
| 152 | 145 | "@effect/platform-node": "catalog:", |
| 153 | 146 | "@effect/vitest": "catalog:", |
| 154 | 147 | "@executor/effect-test-utils": "workspace:*", |
| @@ -189,7 +182,6 @@ · 34 unmodified lines | ||
| 189 | 182 | "@executor/codemode-core": "workspace:*", |
| 190 | 183 | "@executor/platform-sdk": "workspace:*", |
| 191 | 184 | "@executor/runtime-ses": "workspace:*", |
| 192 | − "@executor/source-openapi": "workspace:*", | |
| 193 | 185 | "effect": "catalog:", |
| 194 | 186 | }, |
| 195 | 187 | "devDependencies": { |
| @@ -316,39 +308,18 @@ · 120 unmodified lines | ||
| 316 | 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 | 311 | "packages/platform/sdk": { |
| 334 | 312 | "name": "@executor/platform-sdk", |
| 335 | 313 | "version": "0.0.0", |
| 336 | 314 | "dependencies": { |
| 337 | 315 | "@effect/platform": "catalog:", |
| 338 | 316 | "@effect/platform-node": "catalog:", |
| 339 | − "@executor/auth-mcp-oauth": "workspace:*", | |
| 340 | − "@executor/auth-oauth2": "workspace:*", | |
| 341 | 317 | "@executor/codemode-core": "workspace:*", |
| 342 | 318 | "@executor/ir": "workspace:*", |
| 343 | 319 | "@executor/runtime-deno-subprocess": "workspace:*", |
| 344 | 320 | "@executor/runtime-quickjs": "workspace:*", |
| 345 | 321 | "@executor/runtime-ses": "workspace:*", |
| 346 | − "@executor/source-builtins": "workspace:*", | |
| 347 | 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 | 323 | "effect": "catalog:", |
| 353 | 324 | }, |
| 354 | 325 | "devDependencies": { |
| @@ -374,8 +345,6 @@ · 19 unmodified lines | ||
| 374 | 345 | "@executor/ir": "workspace:*", |
| 375 | 346 | "@executor/platform-sdk": "workspace:*", |
| 376 | 347 | "@executor/source-core": "workspace:*", |
| 377 | − "@executor/source-graphql": "workspace:*", | |
| 378 | − "@executor/source-openapi": "workspace:*", | |
| 379 | 348 | "effect": "catalog:", |
| 380 | 349 | "jsonc-parser": "3.3.1", |
| 381 | 350 | }, |
| @@ -396,9 +365,20 @@ · 14 unmodified lines | ||
| 396 | 365 | "@effect/platform-node": "catalog:", |
| 397 | 366 | "@executor/executor-mcp": "workspace:*", |
| 398 | 367 | "@executor/platform-api": "workspace:*", |
| 399 | − "@executor/platform-internal": "workspace:*", | |
| 400 | 368 | "@executor/platform-sdk": "workspace:*", |
| 401 | 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 | 382 | "@opentelemetry/api": "^1.9.0", |
| 403 | 383 | "@opentelemetry/exporter-trace-otlp-grpc": "^0.213.0", |
| 404 | 384 | "@opentelemetry/resources": "^2.6.0", |
| @@ -408,33 +388,52 @@ · 3 unmodified lines | ||
| 408 | 388 | "effect": "catalog:", |
| 409 | 389 | }, |
| 410 | 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 | 396 | "@types/node": "catalog:", |
| 412 | 397 | "bun-types": "catalog:", |
| 398 | + "vitest": "catalog:", | |
| 399 | + "zod": "catalog:", | |
| 413 | 400 | }, |
| 414 | 401 | }, |
| 415 | − "packages/sources/builtins": { | |
| 416 | − "name": "@executor/source-builtins", | |
| 402 | + "packages/sources/core": { | |
| 403 | + "name": "@executor/source-core", | |
| 417 | 404 | "version": "0.0.0", |
| 418 | 405 | "dependencies": { |
| 419 | − "@executor/source-core": "workspace:*", | |
| 420 | − "@executor/source-google-discovery": "workspace:*", | |
| 421 | − "@executor/source-graphql": "workspace:*", | |
| 422 | − "@executor/source-mcp": "workspace:*", | |
| 423 | − "@executor/source-openapi": "workspace:*", | |
| 406 | + "@executor/codemode-core": "workspace:*", | |
| 407 | + "@executor/ir": "workspace:*", | |
| 408 | + "@modelcontextprotocol/sdk": "^1.26.0", | |
| 409 | + "effect": "catalog:", | |
| 424 | 410 | }, |
| 425 | 411 | "devDependencies": { |
| 426 | 412 | "@types/node": "catalog:", |
| 427 | 413 | "bun-types": "catalog:", |
| 428 | 414 | }, |
| 429 | 415 | }, |
| 430 | − "packages/sources/core": { | |
| 431 | − "name": "@executor/source-core", | |
| 416 | + "plugins/execution-history/react": { | |
| 417 | + "name": "@executor/plugin-execution-history-react", | |
| 432 | 418 | "version": "0.0.0", |
| 433 | 419 | "dependencies": { |
| 434 | − "@executor/auth-oauth2": "workspace:*", | |
| 435 | − "@executor/codemode-core": "workspace:*", | |
| 436 | − "@executor/ir": "workspace:*", | |
| 437 | − "@modelcontextprotocol/sdk": "^1.26.0", | |
| 420 | + "@executor/react": "workspace:*", | |
| 421 | + "react": "^19.2.0", | |
| 422 | + }, | |
| 423 | + "devDependencies": { | |
| 424 | + "@types/node": "catalog:", | |
| 425 | + "@types/react": "^19.2.10", | |
| 426 | + "bun-types": "catalog:", | |
| 427 | + }, | |
| 428 | + }, | |
| 429 | + "plugins/google-discovery/http": { | |
| 430 | + "name": "@executor/plugin-google-discovery-http", | |
| 431 | + "version": "0.0.0", | |
| 432 | + "dependencies": { | |
| 433 | + "@effect/platform": "catalog:", | |
| 434 | + "@executor/platform-api": "workspace:*", | |
| 435 | + "@executor/platform-sdk": "workspace:*", | |
| 436 | + "@executor/plugin-google-discovery-shared": "workspace:*", | |
| 438 | 437 | "effect": "catalog:", |
| 439 | 438 | }, |
| 440 | 439 | "devDependencies": { |
| @@ -442,13 +441,30 @@ · 1 unmodified line | ||
| 442 | 441 | "bun-types": "catalog:", |
| 443 | 442 | }, |
| 444 | 443 | }, |
| 445 | − "packages/sources/google-discovery": { | |
| 446 | − "name": "@executor/source-google-discovery", | |
| 444 | + "plugins/google-discovery/react": { | |
| 445 | + "name": "@executor/plugin-google-discovery-react", | |
| 446 | + "version": "0.0.0", | |
| 447 | + "dependencies": { | |
| 448 | + "@executor/plugin-google-discovery-http": "workspace:*", | |
| 449 | + "@executor/plugin-google-discovery-shared": "workspace:*", | |
| 450 | + "@executor/react": "workspace:*", | |
| 451 | + "react": "^19.2.0", | |
| 452 | + }, | |
| 453 | + "devDependencies": { | |
| 454 | + "@types/node": "catalog:", | |
| 455 | + "@types/react": "^19.2.10", | |
| 456 | + "bun-types": "catalog:", | |
| 457 | + }, | |
| 458 | + }, | |
| 459 | + "plugins/google-discovery/sdk": { | |
| 460 | + "name": "@executor/plugin-google-discovery-sdk", | |
| 447 | 461 | "version": "0.0.0", |
| 448 | 462 | "dependencies": { |
| 449 | 463 | "@effect/platform": "catalog:", |
| 450 | 464 | "@executor/codemode-core": "workspace:*", |
| 451 | 465 | "@executor/ir": "workspace:*", |
| 466 | + "@executor/platform-sdk": "workspace:*", | |
| 467 | + "@executor/plugin-google-discovery-shared": "workspace:*", | |
| 452 | 468 | "@executor/source-core": "workspace:*", |
| 453 | 469 | "effect": "catalog:", |
| 454 | 470 | }, |
| @@ -459,60 +475,210 @@ · 4 unmodified lines | ||
| 459 | 475 | "vitest": "catalog:", |
| 460 | 476 | }, |
| 461 | 477 | }, |
| 462 | − "packages/sources/graphql": { | |
| 463 | − "name": "@executor/source-graphql", | |
| 478 | + "plugins/google-discovery/shared": { | |
| 479 | + "name": "@executor/plugin-google-discovery-shared", | |
| 480 | + "version": "0.0.0", | |
| 481 | + "dependencies": { | |
| 482 | + "@executor/platform-sdk": "workspace:*", | |
| 483 | + "@executor/source-core": "workspace:*", | |
| 484 | + "effect": "catalog:", | |
| 485 | + }, | |
| 486 | + "devDependencies": { | |
| 487 | + "@types/node": "catalog:", | |
| 488 | + "bun-types": "catalog:", | |
| 489 | + }, | |
| 490 | + }, | |
| 491 | + "plugins/graphql/http": { | |
| 492 | + "name": "@executor/plugin-graphql-http", | |
| 493 | + "version": "0.0.0", | |
| 494 | + "dependencies": { | |
| 495 | + "@effect/platform": "catalog:", | |
| 496 | + "@executor/platform-api": "workspace:*", | |
| 497 | + "@executor/platform-sdk": "workspace:*", | |
| 498 | + "@executor/plugin-graphql-shared": "workspace:*", | |
| 499 | + "effect": "catalog:", | |
| 500 | + }, | |
| 501 | + "devDependencies": { | |
| 502 | + "@types/node": "catalog:", | |
| 503 | + "bun-types": "catalog:", | |
| 504 | + }, | |
| 505 | + }, | |
| 506 | + "plugins/graphql/react": { | |
| 507 | + "name": "@executor/plugin-graphql-react", | |
| 508 | + "version": "0.0.0", | |
| 509 | + "dependencies": { | |
| 510 | + "@executor/plugin-graphql-http": "workspace:*", | |
| 511 | + "@executor/plugin-graphql-shared": "workspace:*", | |
| 512 | + "@executor/react": "workspace:*", | |
| 513 | + "react": "^19.2.0", | |
| 514 | + }, | |
| 515 | + "devDependencies": { | |
| 516 | + "@types/node": "catalog:", | |
| 517 | + "@types/react": "^19.2.10", | |
| 518 | + "bun-types": "catalog:", | |
| 519 | + }, | |
| 520 | + }, | |
| 521 | + "plugins/graphql/sdk": { | |
| 522 | + "name": "@executor/plugin-graphql-sdk", | |
| 464 | 523 | "version": "0.0.0", |
| 465 | 524 | "dependencies": { |
| 466 | 525 | "@executor/codemode-core": "workspace:*", |
| 467 | 526 | "@executor/ir": "workspace:*", |
| 527 | + "@executor/platform-sdk": "workspace:*", | |
| 528 | + "@executor/plugin-graphql-shared": "workspace:*", | |
| 468 | 529 | "@executor/source-core": "workspace:*", |
| 469 | 530 | "effect": "catalog:", |
| 470 | 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 | 548 | "devDependencies": { |
| 473 | 549 | "@types/node": "catalog:", |
| 474 | 550 | "bun-types": "catalog:", |
| 475 | 551 | }, |
| 476 | 552 | }, |
| 477 | − "packages/sources/mcp": { | |
| 478 | − "name": "@executor/source-mcp", | |
| 553 | + "plugins/mcp/http": { | |
| 554 | + "name": "@executor/plugin-mcp-http", | |
| 555 | + "version": "0.0.0", | |
| 556 | + "dependencies": { | |
| 557 | + "@effect/platform": "catalog:", | |
| 558 | + "@executor/platform-api": "workspace:*", | |
| 559 | + "@executor/platform-sdk": "workspace:*", | |
| 560 | + "@executor/plugin-mcp-shared": "workspace:*", | |
| 561 | + "effect": "catalog:", | |
| 562 | + }, | |
| 563 | + "devDependencies": { | |
| 564 | + "@types/node": "catalog:", | |
| 565 | + "bun-types": "catalog:", | |
| 566 | + }, | |
| 567 | + }, | |
| 568 | + "plugins/mcp/react": { | |
| 569 | + "name": "@executor/plugin-mcp-react", | |
| 570 | + "version": "0.0.0", | |
| 571 | + "dependencies": { | |
| 572 | + "@executor/plugin-mcp-http": "workspace:*", | |
| 573 | + "@executor/plugin-mcp-shared": "workspace:*", | |
| 574 | + "@executor/react": "workspace:*", | |
| 575 | + "react": "^19.2.0", | |
| 576 | + }, | |
| 577 | + "devDependencies": { | |
| 578 | + "@types/node": "catalog:", | |
| 579 | + "@types/react": "^19.2.10", | |
| 580 | + "bun-types": "catalog:", | |
| 581 | + }, | |
| 582 | + }, | |
| 583 | + "plugins/mcp/sdk": { | |
| 584 | + "name": "@executor/plugin-mcp-sdk", | |
| 479 | 585 | "version": "0.0.0", |
| 480 | 586 | "dependencies": { |
| 481 | − "@executor/auth-mcp-oauth": "workspace:*", | |
| 482 | 587 | "@executor/codemode-core": "workspace:*", |
| 483 | 588 | "@executor/ir": "workspace:*", |
| 484 | − "@executor/runtime-ses": "workspace:*", | |
| 589 | + "@executor/platform-sdk": "workspace:*", | |
| 590 | + "@executor/plugin-mcp-shared": "workspace:*", | |
| 485 | 591 | "@executor/source-core": "workspace:*", |
| 486 | 592 | "@modelcontextprotocol/sdk": "^1.26.0", |
| 487 | 593 | "effect": "catalog:", |
| 488 | 594 | }, |
| 489 | 595 | "devDependencies": { |
| 490 | 596 | "@effect/vitest": "catalog:", |
| 597 | + "@executor/runtime-ses": "workspace:*", | |
| 491 | 598 | "@types/node": "catalog:", |
| 492 | 599 | "bun-types": "catalog:", |
| 493 | 600 | "vitest": "catalog:", |
| 494 | 601 | "zod": "catalog:", |
| 495 | 602 | }, |
| 496 | 603 | }, |
| 497 | − "packages/sources/openapi": { | |
| 498 | − "name": "@executor/source-openapi", | |
| 604 | + "plugins/mcp/shared": { | |
| 605 | + "name": "@executor/plugin-mcp-shared", | |
| 606 | + "version": "0.0.0", | |
| 607 | + "dependencies": { | |
| 608 | + "@executor/platform-sdk": "workspace:*", | |
| 609 | + "@executor/source-core": "workspace:*", | |
| 610 | + "effect": "catalog:", | |
| 611 | + }, | |
| 612 | + "devDependencies": { | |
| 613 | + "@types/node": "catalog:", | |
| 614 | + "bun-types": "catalog:", | |
| 615 | + }, | |
| 616 | + }, | |
| 617 | + "plugins/openapi/http": { | |
| 618 | + "name": "@executor/plugin-openapi-http", | |
| 499 | 619 | "version": "0.0.0", |
| 500 | 620 | "dependencies": { |
| 501 | 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 | 651 | "@executor/codemode-core": "workspace:*", |
| 503 | 652 | "@executor/ir": "workspace:*", |
| 653 | + "@executor/platform-sdk": "workspace:*", | |
| 654 | + "@executor/plugin-openapi-shared": "workspace:*", | |
| 504 | 655 | "@executor/source-core": "workspace:*", |
| 505 | 656 | "effect": "catalog:", |
| 506 | 657 | "yaml": "^2.8.1", |
| 507 | 658 | }, |
| 508 | 659 | "devDependencies": { |
| 509 | − "@effect/platform-node": "^0.104.1", | |
| 510 | − "@effect/vitest": "catalog:", | |
| 660 | + "@effect/platform": "catalog:", | |
| 661 | + "@effect/platform-node": "catalog:", | |
| 662 | + "@executor/effect-test-utils": "workspace:*", | |
| 511 | 663 | "@types/node": "catalog:", |
| 512 | 664 | "bun-types": "catalog:", |
| 513 | 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 | 683 | "catalog": { |
| 518 | 684 | "@effect/cli": "^0.73.2", |
| @@ -718,10 +884,6 @@ · 199 unmodified lines | ||
| 718 | 884 | |
| 719 | 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 | 887 | "@executor/codemode-core": ["@executor/codemode-core@workspace:packages/kernel/core"], |
| 726 | 888 | |
| 727 | 889 | "@executor/docs": ["@executor/docs@workspace:apps/docs"], |
| @@ -738,12 +900,44 @@ · 10 unmodified lines | ||
| 738 | 900 | |
| 739 | 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 | 903 | "@executor/platform-sdk": ["@executor/platform-sdk@workspace:packages/platform/sdk"], |
| 744 | 904 | |
| 745 | 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 | 941 | "@executor/react": ["@executor/react@workspace:packages/clients/react"], |
| 748 | 942 | |
| 749 | 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 | 951 | "@executor/server": ["@executor/server@workspace:packages/platform/server"], |
| 758 | 952 | |
| 759 | − "@executor/source-builtins": ["@executor/source-builtins@workspace:packages/sources/builtins"], | |
| 760 | − | |
| 761 | 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 | 955 | "@executor/sqlite-sdk-consumer-example": ["@executor/sqlite-sdk-consumer-example@workspace:examples/sqlite-sdk-consumer"], |
| 772 | 956 | |
| 773 | 957 | "@executor/web": ["@executor/web@workspace:apps/web"], |
| @@ -20,19 +20,13 @@ import { · 19 unmodified lines | ||
| 20 | 20 | type ExecutorWorkspaceSourceArtifactRepository, |
| 21 | 21 | } from "@executor/platform-sdk"; |
| 22 | 22 | import type { |
| 23 | − AuthArtifact, | |
| 24 | − AuthLease, | |
| 25 | 23 | Execution, |
| 26 | 24 | ExecutionInteraction, |
| 27 | 25 | ExecutionStep, |
| 28 | 26 | LocalExecutorConfig, |
| 29 | 27 | LocalInstallation, |
| 30 | − ProviderAuthGrant, | |
| 31 | − ScopeOauthClient, | |
| 32 | − ScopedSourceOauthClient, | |
| 33 | 28 | SecretMaterial, |
| 34 | 29 | SecretRef, |
| 35 | − SourceAuthSession, | |
| 36 | 30 | } from "@executor/platform-sdk/schema"; |
| 37 | 31 | import { |
| 38 | 32 | ScopeIdSchema, |
| @@ -94,54 +88,6 @@ const sourceArtifacts = sqliteTable("source_artifacts", { · 55 unmodified lines | ||
| 94 | 88 | artifactJson: text("artifact_json").notNull(), |
| 95 | 89 | }); |
| 96 | 90 | |
| 97 | −const authArtifacts = sqliteTable("auth_artifacts", { | |
| 98 | − id: text("id").primaryKey(), | |
| 99 | − scopeId: text("scope_id").notNull(), | |
| 100 | − sourceId: text("source_id").notNull(), | |
| 101 | − actorScopeId: text("actor_scope_id"), | |
| 102 | − slot: text("slot").notNull(), | |
| 103 | − json: text("json").notNull(), | |
| 104 | −}); | |
| 105 | − | |
| 106 | −const authLeases = sqliteTable("auth_leases", { | |
| 107 | − authArtifactId: text("auth_artifact_id").primaryKey(), | |
| 108 | − json: text("json").notNull(), | |
| 109 | −}); | |
| 110 | − | |
| 111 | −const sourceOauthClients = sqliteTable("source_oauth_clients", { | |
| 112 | − id: text("id").primaryKey(), | |
| 113 | − scopeId: text("scope_id").notNull(), | |
| 114 | − sourceId: text("source_id").notNull(), | |
| 115 | − providerKey: text("provider_key").notNull(), | |
| 116 | − json: text("json").notNull(), | |
| 117 | −}); | |
| 118 | − | |
| 119 | −const scopeOauthClients = sqliteTable("scope_oauth_clients", { | |
| 120 | − id: text("id").primaryKey(), | |
| 121 | − scopeId: text("scope_id").notNull(), | |
| 122 | − providerKey: text("provider_key").notNull(), | |
| 123 | − json: text("json").notNull(), | |
| 124 | −}); | |
| 125 | − | |
| 126 | −const providerAuthGrants = sqliteTable("provider_auth_grants", { | |
| 127 | − id: text("id").primaryKey(), | |
| 128 | − scopeId: text("scope_id").notNull(), | |
| 129 | − actorScopeId: text("actor_scope_id"), | |
| 130 | − providerKey: text("provider_key").notNull(), | |
| 131 | − json: text("json").notNull(), | |
| 132 | −}); | |
| 133 | − | |
| 134 | −const sourceAuthSessions = sqliteTable("source_auth_sessions", { | |
| 135 | − id: text("id").primaryKey(), | |
| 136 | − scopeId: text("scope_id").notNull(), | |
| 137 | − sourceId: text("source_id").notNull(), | |
| 138 | − actorScopeId: text("actor_scope_id"), | |
| 139 | − state: text("state").notNull(), | |
| 140 | − status: text("status").notNull(), | |
| 141 | − credentialSlot: text("credential_slot"), | |
| 142 | − json: text("json").notNull(), | |
| 143 | −}); | |
| 144 | − | |
| 145 | 91 | const secretMaterials = sqliteTable("secret_materials", { |
| 146 | 92 | id: text("id").primaryKey(), |
| 147 | 93 | providerId: text("provider_id").notNull(), |
| @@ -175,25 +121,15 @@ const executionSteps = sqliteTable("execution_steps", { · 27 unmodified lines | ||
| 175 | 121 | const makeHash = (value: string): string => |
| 176 | 122 | createHash("sha256").update(value).digest("hex").slice(0, 24); |
| 177 | 123 | |
| 178 | −const sameActor = ( | |
| 179 | − left: string | null | undefined, | |
| 180 | − right: string | null | undefined, | |
| 181 | −): boolean => (left ?? null) === (right ?? null); | |
| 182 | − | |
| 183 | 124 | const parseJson = <T>(value: string): T => JSON.parse(value) as T; |
| 184 | 125 | |
| 185 | 126 | const createSourceArtifact = (input: SourceArtifactBuildInput): SourceArtifact => { |
| 186 | 127 | const snapshot = snapshotFromSourceCatalogSyncResult(input.syncResult); |
| 187 | 128 | const sourceConfigJson = JSON.stringify({ |
| 188 | 129 | kind: input.source.kind, |
| 189 | − endpoint: input.source.endpoint, | |
| 190 | 130 | namespace: input.source.namespace, |
| 191 | 131 | name: input.source.name, |
| 192 | 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 | 134 | const importMetadataJson = JSON.stringify(snapshot.import); |
| 199 | 135 | const catalogId = SourceCatalogIdSchema.make(`src_catalog_${makeHash(sourceConfigJson)}`); |
| @@ -273,54 +209,6 @@ const openSqliteStore = (databasePath: string) => { · 73 unmodified lines | ||
| 273 | 209 | 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 | 212 | CREATE TABLE IF NOT EXISTS secret_materials ( |
| 325 | 213 | id TEXT PRIMARY KEY NOT NULL, |
| 326 | 214 | provider_id TEXT NOT NULL, |
| @@ -363,358 +251,6 @@ const openSqliteStore = (databasePath: string) => { · 36 unmodified lines | ||
| 363 | 251 | type SqliteStore = ReturnType<typeof openSqliteStore>; |
| 364 | 252 | |
| 365 | 253 | const createStorageDomains = (store: SqliteStore) => ({ |
| 366 | − auth: { | |
| 367 | − artifacts: { | |
| 368 | − listByScopeId: (scopeId: AuthArtifact["scopeId"]) => | |
| 369 | − store.db.select().from(authArtifacts).where(eq(authArtifacts.scopeId, scopeId)).all() | |
| 370 | − .map((row) => parseJson<AuthArtifact>(row.json)), | |
| 371 | − listByScopeAndSourceId: (input: { | |
| 372 | − scopeId: AuthArtifact["scopeId"]; | |
| 373 | − sourceId: AuthArtifact["sourceId"]; | |
| 374 | − }) => | |
| 375 | − store.db.select().from(authArtifacts).where( | |
| 376 | − and( | |
| 377 | − eq(authArtifacts.scopeId, input.scopeId), | |
| 378 | − eq(authArtifacts.sourceId, input.sourceId), | |
| 379 | − ), | |
| 380 | − ).all().map((row) => parseJson<AuthArtifact>(row.json)), | |
| 381 | − getByScopeSourceAndActor: (input: { | |
| 382 | − scopeId: AuthArtifact["scopeId"]; | |
| 383 | − sourceId: AuthArtifact["sourceId"]; | |
| 384 | − actorScopeId: AuthArtifact["actorScopeId"]; | |
| 385 | − slot: AuthArtifact["slot"]; | |
| 386 | − }) => | |
| 387 | − store.db.select().from(authArtifacts).where( | |
| 388 | − and( | |
| 389 | − eq(authArtifacts.scopeId, input.scopeId), | |
| 390 | − eq(authArtifacts.sourceId, input.sourceId), | |
| 391 | − eq(authArtifacts.slot, input.slot), | |
| 392 | − ), | |
| 393 | − ).all().map((row) => parseJson<AuthArtifact>(row.json)) | |
| 394 | − .find((item) => sameActor(item.actorScopeId, input.actorScopeId)) ?? null, | |
| 395 | − upsert: (artifact: AuthArtifact) => | |
| 396 | − store.db.insert(authArtifacts).values({ | |
| 397 | − id: artifact.id, | |
| 398 | − scopeId: artifact.scopeId, | |
| 399 | − sourceId: artifact.sourceId, | |
| 400 | − actorScopeId: artifact.actorScopeId, | |
| 401 | − slot: artifact.slot, | |
| 402 | − json: JSON.stringify(artifact), | |
| 403 | − }).onConflictDoUpdate({ | |
| 404 | − target: authArtifacts.id, | |
| 405 | − set: { | |
| 406 | − scopeId: artifact.scopeId, | |
| 407 | − sourceId: artifact.sourceId, | |
| 408 | − actorScopeId: artifact.actorScopeId, | |
| 409 | − slot: artifact.slot, | |
| 410 | − json: JSON.stringify(artifact), | |
| 411 | − }, | |
| 412 | − }).run(), | |
| 413 | − removeByScopeSourceAndActor: (input: { | |
| 414 | − scopeId: AuthArtifact["scopeId"]; | |
| 415 | − sourceId: AuthArtifact["sourceId"]; | |
| 416 | − actorScopeId: AuthArtifact["actorScopeId"]; | |
| 417 | − slot?: AuthArtifact["slot"]; | |
| 418 | − }) => { | |
| 419 | − const rows = store.db.select().from(authArtifacts).where( | |
| 420 | − and( | |
| 421 | − eq(authArtifacts.scopeId, input.scopeId), | |
| 422 | − eq(authArtifacts.sourceId, input.sourceId), | |
| 423 | − ), | |
| 424 | − ).all(); | |
| 425 | − const match = rows.find((row) => { | |
| 426 | − const item = parseJson<AuthArtifact>(row.json); | |
| 427 | − return sameActor(item.actorScopeId, input.actorScopeId) | |
| 428 | − && (input.slot === undefined || item.slot === input.slot); | |
| 429 | − }); | |
| 430 | − if (!match) return false; | |
| 431 | − store.db.delete(authArtifacts).where(eq(authArtifacts.id, match.id)).run(); | |
| 432 | − return true; | |
| 433 | − }, | |
| 434 | − removeByScopeAndSourceId: (input: { | |
| 435 | − scopeId: AuthArtifact["scopeId"]; | |
| 436 | − sourceId: AuthArtifact["sourceId"]; | |
| 437 | − }) => { | |
| 438 | − const rows = store.db.select({ id: authArtifacts.id }).from(authArtifacts).where( | |
| 439 | − and( | |
| 440 | − eq(authArtifacts.scopeId, input.scopeId), | |
| 441 | − eq(authArtifacts.sourceId, input.sourceId), | |
| 442 | − ), | |
| 443 | − ).all(); | |
| 444 | − store.db.delete(authArtifacts).where( | |
| 445 | − and( | |
| 446 | − eq(authArtifacts.scopeId, input.scopeId), | |
| 447 | − eq(authArtifacts.sourceId, input.sourceId), | |
| 448 | − ), | |
| 449 | − ).run(); | |
| 450 | − return rows.length; | |
| 451 | − }, | |
| 452 | − }, | |
| 453 | − leases: { | |
| 454 | − listAll: () => | |
| 455 | − store.db.select().from(authLeases).all().map((row) => parseJson<AuthLease>(row.json)), | |
| 456 | − getByAuthArtifactId: (authArtifactId: AuthLease["authArtifactId"]) => { | |
| 457 | − const row = store.db.select().from(authLeases).where( | |
| 458 | − eq(authLeases.authArtifactId, authArtifactId), | |
| 459 | − ).get(); | |
| 460 | − return row ? parseJson<AuthLease>(row.json) : null; | |
| 461 | − }, | |
| 462 | − upsert: (lease: AuthLease) => | |
| 463 | − store.db.insert(authLeases).values({ | |
| 464 | − authArtifactId: lease.authArtifactId, | |
| 465 | − json: JSON.stringify(lease), | |
| 466 | − }).onConflictDoUpdate({ | |
| 467 | − target: authLeases.authArtifactId, | |
| 468 | − set: { json: JSON.stringify(lease) }, | |
| 469 | − }).run(), | |
| 470 | − removeByAuthArtifactId: (authArtifactId: AuthLease["authArtifactId"]) => { | |
| 471 | − const row = store.db.select({ id: authLeases.authArtifactId }).from(authLeases).where( | |
| 472 | − eq(authLeases.authArtifactId, authArtifactId), | |
| 473 | − ).get(); | |
| 474 | − if (!row) return false; | |
| 475 | − store.db.delete(authLeases).where(eq(authLeases.authArtifactId, authArtifactId)).run(); | |
| 476 | − return true; | |
| 477 | − }, | |
| 478 | − }, | |
| 479 | − sourceOauthClients: { | |
| 480 | − getByScopeSourceAndProvider: (input: { | |
| 481 | − scopeId: ScopedSourceOauthClient["scopeId"]; | |
| 482 | − sourceId: ScopedSourceOauthClient["sourceId"]; | |
| 483 | − providerKey: string; | |
| 484 | − }) => { | |
| 485 | − const row = store.db.select().from(sourceOauthClients).where( | |
| 486 | − and( | |
| 487 | − eq(sourceOauthClients.scopeId, input.scopeId), | |
| 488 | − eq(sourceOauthClients.sourceId, input.sourceId), | |
| 489 | − eq(sourceOauthClients.providerKey, input.providerKey), | |
| 490 | − ), | |
| 491 | − ).get(); | |
| 492 | − return row ? parseJson<ScopedSourceOauthClient>(row.json) : null; | |
| 493 | − }, | |
| 494 | − upsert: (oauthClient: ScopedSourceOauthClient) => | |
| 495 | − store.db.insert(sourceOauthClients).values({ | |
| 496 | − id: oauthClient.id, | |
| 497 | − scopeId: oauthClient.scopeId, | |
| 498 | − sourceId: oauthClient.sourceId, | |
| 499 | − providerKey: oauthClient.providerKey, | |
| 500 | − json: JSON.stringify(oauthClient), | |
| 501 | − }).onConflictDoUpdate({ | |
| 502 | − target: sourceOauthClients.id, | |
| 503 | − set: { | |
| 504 | − scopeId: oauthClient.scopeId, | |
| 505 | − sourceId: oauthClient.sourceId, | |
| 506 | − providerKey: oauthClient.providerKey, | |
| 507 | − json: JSON.stringify(oauthClient), | |
| 508 | − }, | |
| 509 | − }).run(), | |
| 510 | − removeByScopeAndSourceId: (input: { | |
| 511 | − scopeId: ScopedSourceOauthClient["scopeId"]; | |
| 512 | − sourceId: ScopedSourceOauthClient["sourceId"]; | |
| 513 | − }) => { | |
| 514 | − const rows = store.db.select({ id: sourceOauthClients.id }).from(sourceOauthClients).where( | |
| 515 | − and( | |
| 516 | − eq(sourceOauthClients.scopeId, input.scopeId), | |
| 517 | − eq(sourceOauthClients.sourceId, input.sourceId), | |
| 518 | − ), | |
| 519 | − ).all(); | |
| 520 | − store.db.delete(sourceOauthClients).where( | |
| 521 | − and( | |
| 522 | − eq(sourceOauthClients.scopeId, input.scopeId), | |
| 523 | − eq(sourceOauthClients.sourceId, input.sourceId), | |
| 524 | − ), | |
| 525 | − ).run(); | |
| 526 | − return rows.length; | |
| 527 | − }, | |
| 528 | − }, | |
| 529 | − scopeOauthClients: { | |
| 530 | − listByScopeAndProvider: (input: { | |
| 531 | − scopeId: ScopeOauthClient["scopeId"]; | |
| 532 | − providerKey: string; | |
| 533 | − }) => | |
| 534 | − store.db.select().from(scopeOauthClients).where( | |
| 535 | − and( | |
| 536 | − eq(scopeOauthClients.scopeId, input.scopeId), | |
| 537 | − eq(scopeOauthClients.providerKey, input.providerKey), | |
| 538 | − ), | |
| 539 | − ).all().map((row) => parseJson<ScopeOauthClient>(row.json)), | |
| 540 | − getById: (id: ScopeOauthClient["id"]) => { | |
| 541 | − const row = store.db.select().from(scopeOauthClients).where(eq(scopeOauthClients.id, id)).get(); | |
| 542 | − return row ? parseJson<ScopeOauthClient>(row.json) : null; | |
| 543 | − }, | |
| 544 | − upsert: (oauthClient: ScopeOauthClient) => | |
| 545 | − store.db.insert(scopeOauthClients).values({ | |
| 546 | − id: oauthClient.id, | |
| 547 | − scopeId: oauthClient.scopeId, | |
| 548 | − providerKey: oauthClient.providerKey, | |
| 549 | − json: JSON.stringify(oauthClient), | |
| 550 | − }).onConflictDoUpdate({ | |
| 551 | − target: scopeOauthClients.id, | |
| 552 | − set: { | |
| 553 | − scopeId: oauthClient.scopeId, | |
| 554 | − providerKey: oauthClient.providerKey, | |
| 555 | − json: JSON.stringify(oauthClient), | |
| 556 | − }, | |
| 557 | − }).run(), | |
| 558 | − removeById: (id: ScopeOauthClient["id"]) => { | |
| 559 | − const row = store.db.select({ id: scopeOauthClients.id }).from(scopeOauthClients).where( | |
| 560 | − eq(scopeOauthClients.id, id), | |
| 561 | − ).get(); | |
| 562 | − if (!row) return false; | |
| 563 | − store.db.delete(scopeOauthClients).where(eq(scopeOauthClients.id, id)).run(); | |
| 564 | − return true; | |
| 565 | − }, | |
| 566 | − }, | |
| 567 | − providerGrants: { | |
| 568 | − listByScopeId: (scopeId: ProviderAuthGrant["scopeId"]) => | |
| 569 | − store.db.select().from(providerAuthGrants).where( | |
| 570 | − eq(providerAuthGrants.scopeId, scopeId), | |
| 571 | − ).all().map((row) => parseJson<ProviderAuthGrant>(row.json)), | |
| 572 | − listByScopeActorAndProvider: (input: { | |
| 573 | − scopeId: ProviderAuthGrant["scopeId"]; | |
| 574 | − actorScopeId: ProviderAuthGrant["actorScopeId"]; | |
| 575 | − providerKey: string; | |
| 576 | − }) => | |
| 577 | − store.db.select().from(providerAuthGrants).where( | |
| 578 | − and( | |
| 579 | − eq(providerAuthGrants.scopeId, input.scopeId), | |
| 580 | − eq(providerAuthGrants.providerKey, input.providerKey), | |
| 581 | − ), | |
| 582 | − ).all().map((row) => parseJson<ProviderAuthGrant>(row.json)) | |
| 583 | − .filter((item) => sameActor(item.actorScopeId, input.actorScopeId)), | |
| 584 | − getById: (id: ProviderAuthGrant["id"]) => { | |
| 585 | − const row = store.db.select().from(providerAuthGrants).where( | |
| 586 | − eq(providerAuthGrants.id, id), | |
| 587 | − ).get(); | |
| 588 | − return row ? parseJson<ProviderAuthGrant>(row.json) : null; | |
| 589 | − }, | |
| 590 | − upsert: (grant: ProviderAuthGrant) => | |
| 591 | − store.db.insert(providerAuthGrants).values({ | |
| 592 | − id: grant.id, | |
| 593 | − scopeId: grant.scopeId, | |
| 594 | − actorScopeId: grant.actorScopeId, | |
| 595 | − providerKey: grant.providerKey, | |
| 596 | − json: JSON.stringify(grant), | |
| 597 | − }).onConflictDoUpdate({ | |
| 598 | − target: providerAuthGrants.id, | |
| 599 | − set: { | |
| 600 | − scopeId: grant.scopeId, | |
| 601 | − actorScopeId: grant.actorScopeId, | |
| 602 | − providerKey: grant.providerKey, | |
| 603 | − json: JSON.stringify(grant), | |
| 604 | − }, | |
| 605 | − }).run(), | |
| 606 | − removeById: (id: ProviderAuthGrant["id"]) => { | |
| 607 | − const row = store.db.select({ id: providerAuthGrants.id }).from(providerAuthGrants).where( | |
| 608 | − eq(providerAuthGrants.id, id), | |
| 609 | − ).get(); | |
| 610 | − if (!row) return false; | |
| 611 | − store.db.delete(providerAuthGrants).where(eq(providerAuthGrants.id, id)).run(); | |
| 612 | − return true; | |
| 613 | − }, | |
| 614 | − }, | |
| 615 | − sourceSessions: { | |
| 616 | − listAll: () => | |
| 617 | − store.db.select().from(sourceAuthSessions).all().map((row) => parseJson<SourceAuthSession>(row.json)), | |
| 618 | − listByScopeId: (scopeId: SourceAuthSession["scopeId"]) => | |
| 619 | − store.db.select().from(sourceAuthSessions).where( | |
| 620 | − eq(sourceAuthSessions.scopeId, scopeId), | |
| 621 | − ).all().map((row) => parseJson<SourceAuthSession>(row.json)), | |
| 622 | − getById: (id: SourceAuthSession["id"]) => { | |
| 623 | − const row = store.db.select().from(sourceAuthSessions).where(eq(sourceAuthSessions.id, id)).get(); | |
| 624 | − return row ? parseJson<SourceAuthSession>(row.json) : null; | |
| 625 | − }, | |
| 626 | − getByState: (state: SourceAuthSession["state"]) => { | |
| 627 | − const row = store.db.select().from(sourceAuthSessions).where( | |
| 628 | − eq(sourceAuthSessions.state, state), | |
| 629 | − ).get(); | |
| 630 | − return row ? parseJson<SourceAuthSession>(row.json) : null; | |
| 631 | − }, | |
| 632 | − getPendingByScopeSourceAndActor: (input: { | |
| 633 | − scopeId: SourceAuthSession["scopeId"]; | |
| 634 | − sourceId: SourceAuthSession["sourceId"]; | |
| 635 | − actorScopeId: SourceAuthSession["actorScopeId"]; | |
| 636 | − credentialSlot?: SourceAuthSession["credentialSlot"]; | |
| 637 | − }) => | |
| 638 | − store.db.select().from(sourceAuthSessions).where( | |
| 639 | − and( | |
| 640 | − eq(sourceAuthSessions.scopeId, input.scopeId), | |
| 641 | − eq(sourceAuthSessions.sourceId, input.sourceId), | |
| 642 | − eq(sourceAuthSessions.status, "pending"), | |
| 643 | − ), | |
| 644 | − ).all().map((row) => parseJson<SourceAuthSession>(row.json)) | |
| 645 | − .find((item) => | |
| 646 | − sameActor(item.actorScopeId, input.actorScopeId) | |
| 647 | − && (input.credentialSlot === undefined || item.credentialSlot === input.credentialSlot) | |
| 648 | − ) ?? null, | |
| 649 | − insert: (session: SourceAuthSession) => | |
| 650 | − store.db.insert(sourceAuthSessions).values({ | |
| 651 | − id: session.id, | |
| 652 | − scopeId: session.scopeId, | |
| 653 | − sourceId: session.sourceId, | |
| 654 | − actorScopeId: session.actorScopeId, | |
| 655 | − state: session.state, | |
| 656 | − status: session.status, | |
| 657 | − credentialSlot: session.credentialSlot, | |
| 658 | − json: JSON.stringify(session), | |
| 659 | − }).run(), | |
| 660 | − update: ( | |
| 661 | − id: SourceAuthSession["id"], | |
| 662 | − patch: Partial<Omit<SourceAuthSession, "id" | "scopeId" | "sourceId" | "createdAt">>, | |
| 663 | − ) => { | |
| 664 | − const current = store.db.select().from(sourceAuthSessions).where(eq(sourceAuthSessions.id, id)).get(); | |
| 665 | − if (!current) return null; | |
| 666 | − const next = { ...parseJson<SourceAuthSession>(current.json), ...patch }; | |
| 667 | − store.db.update(sourceAuthSessions).set({ | |
| 668 | − state: next.state, | |
| 669 | − status: next.status, | |
| 670 | − credentialSlot: next.credentialSlot, | |
| 671 | − actorScopeId: next.actorScopeId, | |
| 672 | − json: JSON.stringify(next), | |
| 673 | − }).where(eq(sourceAuthSessions.id, id)).run(); | |
| 674 | − return next; | |
| 675 | − }, | |
| 676 | − upsert: (session: SourceAuthSession) => | |
| 677 | − store.db.insert(sourceAuthSessions).values({ | |
| 678 | − id: session.id, | |
| 679 | − scopeId: session.scopeId, | |
| 680 | − sourceId: session.sourceId, | |
| 681 | − actorScopeId: session.actorScopeId, | |
| 682 | − state: session.state, | |
| 683 | − status: session.status, | |
| 684 | − credentialSlot: session.credentialSlot, | |
| 685 | − json: JSON.stringify(session), | |
| 686 | − }).onConflictDoUpdate({ | |
| 687 | − target: sourceAuthSessions.id, | |
| 688 | − set: { | |
| 689 | − scopeId: session.scopeId, | |
| 690 | − sourceId: session.sourceId, | |
| 691 | − actorScopeId: session.actorScopeId, | |
| 692 | − state: session.state, | |
| 693 | − status: session.status, | |
| 694 | − credentialSlot: session.credentialSlot, | |
| 695 | − json: JSON.stringify(session), | |
| 696 | − }, | |
| 697 | − }).run(), | |
| 698 | − removeByScopeAndSourceId: ( | |
| 699 | − scopeId: SourceAuthSession["scopeId"], | |
| 700 | − sourceId: SourceAuthSession["sourceId"], | |
| 701 | − ) => { | |
| 702 | − const rows = store.db.select({ id: sourceAuthSessions.id }).from(sourceAuthSessions).where( | |
| 703 | − and( | |
| 704 | − eq(sourceAuthSessions.scopeId, scopeId), | |
| 705 | − eq(sourceAuthSessions.sourceId, sourceId), | |
| 706 | − ), | |
| 707 | − ).all(); | |
| 708 | − store.db.delete(sourceAuthSessions).where( | |
| 709 | − and( | |
| 710 | − eq(sourceAuthSessions.scopeId, scopeId), | |
| 711 | − eq(sourceAuthSessions.sourceId, sourceId), | |
| 712 | − ), | |
| 713 | − ).run(); | |
| 714 | − return rows.length > 0; | |
| 715 | − }, | |
| 716 | − }, | |
| 717 | − }, | |
| 718 | 254 | secrets: { |
| 719 | 255 | getById: (id: SecretMaterial["id"]) => { |
| 720 | 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 | 327 | executions: { |
| 792 | 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 | 335 | getById: (executionId: Execution["id"]) => { |
| 794 | 336 | const row = store.db.select().from(executions).where(eq(executions.id, executionId)).get(); |
| 795 | 337 | return row ? parseJson<Execution>(row.json) : null; |
| @@ -929,7 +471,7 @@ export const createSqliteExecutorBackend = ( · 133 unmodified lines | ||
| 929 | 471 | return createExecutorBackend({ |
| 930 | 472 | loadRepositories: () => { |
| 931 | 473 | const store = openSqliteStore(databasePath); |
| 932 | − const { auth, secrets, executions } = createStorageDomains(store); | |
| 474 | + const { secrets, executions } = createStorageDomains(store); | |
| 933 | 475 | |
| 934 | 476 | return { |
| 935 | 477 | scope: { |
| @@ -1042,7 +584,6 @@ export const createSqliteExecutorBackend = ( · 106 unmodified lines | ||
| 1042 | 584 | store.db.delete(sourceArtifacts).where(eq(sourceArtifacts.sourceId, sourceId)).run(); |
| 1043 | 585 | }, |
| 1044 | 586 | }, |
| 1045 | − sourceAuth: auth, | |
| 1046 | 587 | }, |
| 1047 | 588 | secrets: { |
| 1048 | 589 | ...secrets, |
| @@ -9,9 +9,6 @@ · 8 unmodified lines | ||
| 9 | 9 | "ignoreIssues": { |
| 10 | 10 | "packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs": [ |
| 11 | 11 | "files" |
| 12 | − ], | |
| 13 | − "packages/sources/openapi/src/openapi-extractor-wasm/openapi_extractor.js": [ | |
| 14 | − "exports" | |
| 15 | 12 | ] |
| 16 | 13 | }, |
| 17 | 14 | "workspaces": { |
| @@ -1,10 +1,17 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "$schema": "https://opencode.ai/config.json", |
| 3 | 3 | "mcp": { |
| 4 | − "executor": { | |
| 5 | − "type": "remote", | |
| 6 | − "url": "http://localhost:8788/mcp", | |
| 7 | − "enabled": true | |
| 4 | + "executor-stdio": { | |
| 5 | + "type": "local", | |
| 6 | + "command": [ | |
| 7 | + "bun", | |
| 8 | + "run", | |
| 9 | + "executor", | |
| 10 | + "mcp", | |
| 11 | + "--stdio" | |
| 12 | + ], | |
| 13 | + "enabled": true, | |
| 14 | + "environment": {} | |
| 8 | 15 | } |
| 9 | 16 | } |
| 10 | 17 | } |
| @@ -5,6 +5,7 @@ · 4 unmodified lines | ||
| 5 | 5 | "packageManager": "bun@1.2.22", |
| 6 | 6 | "workspaces": [ |
| 7 | 7 | "packages/*/*", |
| 8 | + "plugins/*/*", | |
| 8 | 9 | "apps/*", |
| 9 | 10 | "examples/*", |
| 10 | 11 | "apps/docs" |
| @@ -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 | −} | |
| @@ -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 | −} | |
| @@ -3,7 +3,9 @@ · 2 unmodified lines | ||
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 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 | 10 | "scripts": { |
| 9 | 11 | "typecheck": "bunx tsc --noEmit -p tsconfig.json", |
| @@ -11,17 +13,24 @@ · 1 unmodified line | ||
| 11 | 13 | "test:watch": "bunx vitest" |
| 12 | 14 | }, |
| 13 | 15 | "dependencies": { |
| 16 | + "@shikijs/langs": "^4.0.1", | |
| 17 | + "@shikijs/themes": "^4.0.1", | |
| 14 | 18 | "@effect-atom/atom": "0.5.3", |
| 15 | 19 | "@effect-atom/atom-react": "0.5.0", |
| 20 | + "@effect/platform": "catalog:", | |
| 16 | 21 | "@executor/platform-api": "workspace:*", |
| 17 | 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 | 30 | "peerDependencies": { |
| 21 | 31 | "react": ">=18 <20" |
| 22 | 32 | }, |
| 23 | 33 | "devDependencies": { |
| 24 | − "@effect/platform": "catalog:", | |
| 25 | 34 | "@effect/platform-node": "catalog:", |
| 26 | 35 | "@effect/vitest": "catalog:", |
| 27 | 36 | "@executor/effect-test-utils": "workspace:*", |
| @@ -0,0 +1,191 @@ | ||
| 1 | +import { | |
| 2 | + Atom, | |
| 3 | +} from "@effect-atom/atom-react"; | |
| 4 | +import type { | |
| 5 | + InstanceConfig, | |
| 6 | + LocalInstallation, | |
| 7 | + SecretListItem, | |
| 8 | +} from "@executor/platform-api"; | |
| 9 | +import type { | |
| 10 | + Execution, | |
| 11 | + ExecutionEnvelope, | |
| 12 | + Source, | |
| 13 | + SourceInspection, | |
| 14 | + SourceInspectionDiscoverResult, | |
| 15 | + SourceInspectionToolDetail, | |
| 16 | +} from "@executor/platform-sdk/schema"; | |
| 17 | +import * as Effect from "effect/Effect"; | |
| 18 | + | |
| 19 | +import { getExecutorApiBaseUrl } from "./base-url"; | |
| 20 | +import { getExecutorApiHttpClient } from "./http-client"; | |
| 21 | +import { | |
| 22 | + executionReactivityKey, | |
| 23 | + executionsReactivityKey, | |
| 24 | + instanceConfigReactivityKey, | |
| 25 | + localInstallationReactivityKey, | |
| 26 | + secretsReactivityKey, | |
| 27 | + sourceDiscoveryReactivityKey, | |
| 28 | + sourceInspectionReactivityKey, | |
| 29 | + sourceInspectionToolReactivityKey, | |
| 30 | + sourceReactivityKey, | |
| 31 | + sourcesReactivityKey, | |
| 32 | +} from "./reactivity"; | |
| 33 | +import { disabledAtom } from "./loadable"; | |
| 34 | + | |
| 35 | +export const localInstallationAtom = (baseUrl: string = getExecutorApiBaseUrl()) => | |
| 36 | + getExecutorApiHttpClient(baseUrl).query("local", "installation", { | |
| 37 | + reactivityKeys: localInstallationReactivityKey(), | |
| 38 | + timeToLive: "5 minutes", | |
| 39 | + }); | |
| 40 | + | |
| 41 | +export const instanceConfigAtom = (baseUrl: string = getExecutorApiBaseUrl()) => | |
| 42 | + getExecutorApiHttpClient(baseUrl).query("local", "config", { | |
| 43 | + reactivityKeys: instanceConfigReactivityKey(), | |
| 44 | + timeToLive: "5 minutes", | |
| 45 | + }); | |
| 46 | + | |
| 47 | +export const secretsAtom = (baseUrl: string = getExecutorApiBaseUrl()) => | |
| 48 | + getExecutorApiHttpClient(baseUrl).query("local", "listSecrets", { | |
| 49 | + reactivityKeys: secretsReactivityKey(), | |
| 50 | + timeToLive: "1 minute", | |
| 51 | + }); | |
| 52 | + | |
| 53 | +export const executionsAtom = (workspaceId: Execution["scopeId"]) => | |
| 54 | + getExecutorApiHttpClient().query("executions", "list", { | |
| 55 | + path: { | |
| 56 | + workspaceId, | |
| 57 | + }, | |
| 58 | + reactivityKeys: executionsReactivityKey(workspaceId), | |
| 59 | + timeToLive: "15 seconds", | |
| 60 | + }); | |
| 61 | + | |
| 62 | +export const executionAtom = ( | |
| 63 | + workspaceId: Execution["scopeId"], | |
| 64 | + executionId: Execution["id"], | |
| 65 | +) => | |
| 66 | + getExecutorApiHttpClient().query("executions", "get", { | |
| 67 | + path: { | |
| 68 | + workspaceId, | |
| 69 | + executionId, | |
| 70 | + }, | |
| 71 | + reactivityKeys: executionReactivityKey(workspaceId, executionId), | |
| 72 | + timeToLive: "15 seconds", | |
| 73 | + }); | |
| 74 | + | |
| 75 | +export const sourcesAtom = (workspaceId: Source["scopeId"]) => | |
| 76 | + getExecutorApiHttpClient().query("sources", "list", { | |
| 77 | + path: { | |
| 78 | + workspaceId, | |
| 79 | + }, | |
| 80 | + reactivityKeys: sourcesReactivityKey(workspaceId), | |
| 81 | + timeToLive: "30 seconds", | |
| 82 | + }); | |
| 83 | + | |
| 84 | +export const sourceAtom = ( | |
| 85 | + workspaceId: Source["scopeId"], | |
| 86 | + sourceId: Source["id"], | |
| 87 | +) => | |
| 88 | + getExecutorApiHttpClient().query("sources", "get", { | |
| 89 | + path: { | |
| 90 | + workspaceId, | |
| 91 | + sourceId, | |
| 92 | + }, | |
| 93 | + reactivityKeys: sourceReactivityKey(workspaceId, sourceId), | |
| 94 | + timeToLive: "30 seconds", | |
| 95 | + }); | |
| 96 | + | |
| 97 | +export const sourceInspectionAtom = ( | |
| 98 | + workspaceId: Source["scopeId"], | |
| 99 | + sourceId: Source["id"], | |
| 100 | +) => | |
| 101 | + getExecutorApiHttpClient().query("sources", "inspection", { | |
| 102 | + path: { | |
| 103 | + workspaceId, | |
| 104 | + sourceId, | |
| 105 | + }, | |
| 106 | + reactivityKeys: sourceInspectionReactivityKey(workspaceId, sourceId), | |
| 107 | + timeToLive: "30 seconds", | |
| 108 | + }); | |
| 109 | + | |
| 110 | +export const sourceInspectionToolAtom = ( | |
| 111 | + workspaceId: Source["scopeId"], | |
| 112 | + sourceId: Source["id"], | |
| 113 | + toolPath: string | null, | |
| 114 | +) => | |
| 115 | + toolPath === null | |
| 116 | + ? disabledAtom<SourceInspectionToolDetail | null>() | |
| 117 | + : getExecutorApiHttpClient().query("sources", "inspectionTool", { | |
| 118 | + path: { | |
| 119 | + workspaceId, | |
| 120 | + sourceId, | |
| 121 | + toolPath, | |
| 122 | + }, | |
| 123 | + reactivityKeys: sourceInspectionToolReactivityKey( | |
| 124 | + workspaceId, | |
| 125 | + sourceId, | |
| 126 | + toolPath, | |
| 127 | + ), | |
| 128 | + timeToLive: "30 seconds", | |
| 129 | + }); | |
| 130 | + | |
| 131 | +const emptyDiscoveryResult: SourceInspectionDiscoverResult = { | |
| 132 | + query: "", | |
| 133 | + queryTokens: [], | |
| 134 | + bestPath: null, | |
| 135 | + total: 0, | |
| 136 | + results: [], | |
| 137 | +}; | |
| 138 | + | |
| 139 | +export const sourceDiscoveryAtom = ( | |
| 140 | + workspaceId: Source["scopeId"], | |
| 141 | + sourceId: Source["id"], | |
| 142 | + query: string, | |
| 143 | + limit: number | null, | |
| 144 | +) => | |
| 145 | + query.trim().length === 0 | |
| 146 | + ? Atom.make( | |
| 147 | + Effect.succeed(emptyDiscoveryResult), | |
| 148 | + ).pipe(Atom.keepAlive) | |
| 149 | + : getExecutorApiHttpClient().query("sources", "inspectionDiscover", { | |
| 150 | + path: { | |
| 151 | + workspaceId, | |
| 152 | + sourceId, | |
| 153 | + }, | |
| 154 | + payload: { | |
| 155 | + query, | |
| 156 | + ...(limit !== null ? { limit } : {}), | |
| 157 | + }, | |
| 158 | + reactivityKeys: sourceDiscoveryReactivityKey( | |
| 159 | + workspaceId, | |
| 160 | + sourceId, | |
| 161 | + query, | |
| 162 | + limit, | |
| 163 | + ), | |
| 164 | + timeToLive: "15 seconds", | |
| 165 | + }); | |
| 166 | + | |
| 167 | +export type ExecutorApiAtoms = { | |
| 168 | + localInstallationAtom: typeof localInstallationAtom; | |
| 169 | + instanceConfigAtom: typeof instanceConfigAtom; | |
| 170 | + secretsAtom: typeof secretsAtom; | |
| 171 | + executionsAtom: typeof executionsAtom; | |
| 172 | + executionAtom: typeof executionAtom; | |
| 173 | + sourcesAtom: typeof sourcesAtom; | |
| 174 | + sourceAtom: typeof sourceAtom; | |
| 175 | + sourceInspectionAtom: typeof sourceInspectionAtom; | |
| 176 | + sourceInspectionToolAtom: typeof sourceInspectionToolAtom; | |
| 177 | + sourceDiscoveryAtom: typeof sourceDiscoveryAtom; | |
| 178 | +}; | |
| 179 | + | |
| 180 | +export type ExecutorApiAtomValues = { | |
| 181 | + localInstallation: LocalInstallation; | |
| 182 | + instanceConfig: InstanceConfig; | |
| 183 | + secrets: ReadonlyArray<SecretListItem>; | |
| 184 | + executions: ReadonlyArray<Execution>; | |
| 185 | + execution: ExecutionEnvelope; | |
| 186 | + sources: ReadonlyArray<Source>; | |
| 187 | + source: Source; | |
| 188 | + sourceInspection: SourceInspection; | |
| 189 | + sourceInspectionTool: SourceInspectionToolDetail | null; | |
| 190 | + sourceDiscovery: SourceInspectionDiscoverResult; | |
| 191 | +}; | |
| @@ -0,0 +1,12 @@ | ||
| 1 | +const DEFAULT_EXECUTOR_API_BASE_URL = "http://127.0.0.1:8788"; | |
| 2 | + | |
| 3 | +let apiBaseUrl = | |
| 4 | + typeof window !== "undefined" && typeof window.location?.origin === "string" | |
| 5 | + ? window.location.origin | |
| 6 | + : DEFAULT_EXECUTOR_API_BASE_URL; | |
| 7 | + | |
| 8 | +export const getExecutorApiBaseUrl = (): string => apiBaseUrl; | |
| 9 | + | |
| 10 | +export const setExecutorApiBaseUrl = (baseUrl: string): void => { | |
| 11 | + apiBaseUrl = baseUrl; | |
| 12 | +}; | |
| @@ -0,0 +1,71 @@ | ||
| 1 | +import { | |
| 2 | + AtomHttpApi, | |
| 3 | +} from "@effect-atom/atom-react"; | |
| 4 | +import { | |
| 5 | + FetchHttpClient, | |
| 6 | +} from "@effect/platform"; | |
| 7 | +import type * as HttpApi from "@effect/platform/HttpApi"; | |
| 8 | +import type * as HttpApiGroup from "@effect/platform/HttpApiGroup"; | |
| 9 | +import { | |
| 10 | + ExecutorApi, | |
| 11 | + createExecutorApi, | |
| 12 | + type ExecutorHttpApiExtension, | |
| 13 | +} from "@executor/platform-api"; | |
| 14 | + | |
| 15 | +import { getExecutorApiBaseUrl } from "./base-url"; | |
| 16 | + | |
| 17 | +export const defineExecutorHttpApiClient = | |
| 18 | + <Self>() => | |
| 19 | + < | |
| 20 | + const Id extends string, | |
| 21 | + ApiId extends string, | |
| 22 | + Groups extends HttpApiGroup.HttpApiGroup.Any, | |
| 23 | + ApiE, | |
| 24 | + R, | |
| 25 | + >( | |
| 26 | + id: Id, | |
| 27 | + api: HttpApi.HttpApi<ApiId, Groups, ApiE, R>, | |
| 28 | + ) => { | |
| 29 | + const build = (baseUrl: string | URL) => | |
| 30 | + AtomHttpApi.Tag<Self>()(id, { | |
| 31 | + api, | |
| 32 | + httpClient: FetchHttpClient.layer as any, | |
| 33 | + baseUrl, | |
| 34 | + }); | |
| 35 | + | |
| 36 | + const cache = new Map<string, ReturnType<typeof build>>(); | |
| 37 | + | |
| 38 | + return (baseUrl: string | URL = getExecutorApiBaseUrl()) => { | |
| 39 | + const key = String(baseUrl); | |
| 40 | + const cached = cache.get(key); | |
| 41 | + if (cached !== undefined) { | |
| 42 | + return cached; | |
| 43 | + } | |
| 44 | + | |
| 45 | + const client = build(baseUrl); | |
| 46 | + cache.set(key, client); | |
| 47 | + return client; | |
| 48 | + }; | |
| 49 | + }; | |
| 50 | + | |
| 51 | +export const defineExecutorPluginHttpApiClient = | |
| 52 | + <Self>() => | |
| 53 | + < | |
| 54 | + const Id extends string, | |
| 55 | + TExtensions extends readonly ExecutorHttpApiExtension[], | |
| 56 | + >( | |
| 57 | + id: Id, | |
| 58 | + extensions: TExtensions, | |
| 59 | + ) => | |
| 60 | + defineExecutorHttpApiClient<Self>()( | |
| 61 | + id, | |
| 62 | + createExecutorApi({ | |
| 63 | + plugins: extensions, | |
| 64 | + }), | |
| 65 | + ); | |
| 66 | + | |
| 67 | +export const getExecutorApiHttpClient = | |
| 68 | + defineExecutorHttpApiClient<"ExecutorReactHttpClient">()( | |
| 69 | + "ExecutorReactHttpClient", | |
| 70 | + ExecutorApi, | |
| 71 | + ); | |
| @@ -0,0 +1,61 @@ | ||
| 1 | +import { | |
| 2 | + Atom, | |
| 3 | + Result, | |
| 4 | + useAtomValue, | |
| 5 | +} from "@effect-atom/atom-react"; | |
| 6 | +import * as Cause from "effect/Cause"; | |
| 7 | +import * as Effect from "effect/Effect"; | |
| 8 | +import * as React from "react"; | |
| 9 | + | |
| 10 | +import type { Loadable } from "./types"; | |
| 11 | + | |
| 12 | +const causeMessage = (cause: Cause.Cause<unknown>): Error => | |
| 13 | + new Error(Cause.pretty(cause)); | |
| 14 | + | |
| 15 | +export const toLoadable = <T>(result: Result.Result<T, unknown>): Loadable<T> => { | |
| 16 | + if (Result.isSuccess(result)) { | |
| 17 | + return { | |
| 18 | + status: "ready", | |
| 19 | + data: result.value, | |
| 20 | + }; | |
| 21 | + } | |
| 22 | + | |
| 23 | + if (Result.isFailure(result)) { | |
| 24 | + return { | |
| 25 | + status: "error", | |
| 26 | + error: causeMessage(result.cause), | |
| 27 | + }; | |
| 28 | + } | |
| 29 | + | |
| 30 | + return { | |
| 31 | + status: "loading", | |
| 32 | + }; | |
| 33 | +}; | |
| 34 | + | |
| 35 | +export const pendingResultAtom = Atom.make( | |
| 36 | + Effect.never as Effect.Effect<never, Error>, | |
| 37 | +).pipe(Atom.keepAlive); | |
| 38 | + | |
| 39 | +export const pendingLoadable = <T>( | |
| 40 | + workspace: Loadable<unknown>, | |
| 41 | +): Loadable<T> => { | |
| 42 | + if (workspace.status === "loading") { | |
| 43 | + return { status: "loading" }; | |
| 44 | + } | |
| 45 | + | |
| 46 | + if (workspace.status === "error") { | |
| 47 | + return { status: "error", error: workspace.error }; | |
| 48 | + } | |
| 49 | + | |
| 50 | + throw new Error("Expected workspace loadable to be pending or errored"); | |
| 51 | +}; | |
| 52 | + | |
| 53 | +export const useLoadableAtom = <T>( | |
| 54 | + atom: Atom.Atom<Result.Result<T, unknown>>, | |
| 55 | +): Loadable<T> => { | |
| 56 | + const result = useAtomValue(atom); | |
| 57 | + return React.useMemo(() => toLoadable(result), [result]); | |
| 58 | +}; | |
| 59 | + | |
| 60 | +export const disabledAtom = <T>() => | |
| 61 | + pendingResultAtom as unknown as Atom.Atom<Result.Result<T, unknown>>; | |
| @@ -0,0 +1,71 @@ | ||
| 1 | +import type { Execution, Source } from "@executor/platform-sdk/schema"; | |
| 2 | + | |
| 3 | +import type { ReactivityKeys } from "./types"; | |
| 4 | + | |
| 5 | +export const localInstallationReactivityKey = (): ReactivityKeys => ({ | |
| 6 | + localInstallation: [], | |
| 7 | +}); | |
| 8 | + | |
| 9 | +export const instanceConfigReactivityKey = (): ReactivityKeys => ({ | |
| 10 | + instanceConfig: [], | |
| 11 | +}); | |
| 12 | + | |
| 13 | +export const secretsReactivityKey = (): ReactivityKeys => ({ | |
| 14 | + secrets: [], | |
| 15 | +}); | |
| 16 | + | |
| 17 | +export const sourcesReactivityKey = ( | |
| 18 | + workspaceId: Source["scopeId"], | |
| 19 | +): ReactivityKeys => ({ | |
| 20 | + sources: [workspaceId], | |
| 21 | +}); | |
| 22 | + | |
| 23 | +export const executionsReactivityKey = ( | |
| 24 | + workspaceId: Execution["scopeId"], | |
| 25 | +): ReactivityKeys => ({ | |
| 26 | + executions: [workspaceId], | |
| 27 | +}); | |
| 28 | + | |
| 29 | +export const executionReactivityKey = ( | |
| 30 | + workspaceId: Execution["scopeId"], | |
| 31 | + executionId: Execution["id"], | |
| 32 | +): ReactivityKeys => ({ | |
| 33 | + execution: [workspaceId, executionId], | |
| 34 | +}); | |
| 35 | + | |
| 36 | +export const sourceReactivityKey = ( | |
| 37 | + workspaceId: Source["scopeId"], | |
| 38 | + sourceId: Source["id"], | |
| 39 | +): ReactivityKeys => ({ | |
| 40 | + source: [workspaceId, sourceId], | |
| 41 | +}); | |
| 42 | + | |
| 43 | +export const sourceInspectionReactivityKey = ( | |
| 44 | + workspaceId: Source["scopeId"], | |
| 45 | + sourceId: Source["id"], | |
| 46 | +): ReactivityKeys => ({ | |
| 47 | + sourceInspection: [workspaceId, sourceId], | |
| 48 | +}); | |
| 49 | + | |
| 50 | +export const sourceInspectionToolReactivityKey = ( | |
| 51 | + workspaceId: Source["scopeId"], | |
| 52 | + sourceId: Source["id"], | |
| 53 | + toolPath?: string | null, | |
| 54 | +): ReactivityKeys => ({ | |
| 55 | + sourceInspectionTool: | |
| 56 | + toolPath === undefined || toolPath === null | |
| 57 | + ? [workspaceId, sourceId] | |
| 58 | + : [workspaceId, sourceId, toolPath], | |
| 59 | +}); | |
| 60 | + | |
| 61 | +export const sourceDiscoveryReactivityKey = ( | |
| 62 | + workspaceId: Source["scopeId"], | |
| 63 | + sourceId: Source["id"], | |
| 64 | + query?: string, | |
| 65 | + limit?: number | null, | |
| 66 | +): ReactivityKeys => ({ | |
| 67 | + sourceDiscovery: | |
| 68 | + query === undefined | |
| 69 | + ? [workspaceId, sourceId] | |
| 70 | + : [workspaceId, sourceId, query, limit ?? null], | |
| 71 | +}); | |
| @@ -0,0 +1,16 @@ | ||
| 1 | +export type SourceRemoveResult = { | |
| 2 | + removed: boolean; | |
| 3 | +}; | |
| 4 | + | |
| 5 | +export type Loadable<T> = | |
| 6 | + | { status: "loading" } | |
| 7 | + | { status: "error"; error: Error } | |
| 8 | + | { status: "ready"; data: T }; | |
| 9 | + | |
| 10 | +export type MutationState<T> = { | |
| 11 | + status: "idle" | "pending" | "success" | "error"; | |
| 12 | + data: T | null; | |
| 13 | + error: Error | null; | |
| 14 | +}; | |
| 15 | + | |
| 16 | +export type ReactivityKeys = Readonly<Record<string, ReadonlyArray<unknown>>>; | |
| @@ -0,0 +1,62 @@ | ||
| 1 | +import type { LocalInstallation } from "@executor/platform-api"; | |
| 2 | +import type { Source } from "@executor/platform-sdk/schema"; | |
| 3 | +import * as React from "react"; | |
| 4 | + | |
| 5 | +import { getExecutorApiBaseUrl } from "./base-url"; | |
| 6 | +import { localInstallationAtom } from "./api-atoms"; | |
| 7 | +import { pendingLoadable, useLoadableAtom } from "./loadable"; | |
| 8 | +import type { Loadable } from "./types"; | |
| 9 | + | |
| 10 | +const PLACEHOLDER_WORKSPACE_ID = "ws_placeholder" as Source["scopeId"]; | |
| 11 | + | |
| 12 | +export type WorkspaceContext = { | |
| 13 | + installation: LocalInstallation; | |
| 14 | + workspaceId: Source["scopeId"]; | |
| 15 | +}; | |
| 16 | + | |
| 17 | +const useWorkspaceContext = (): Loadable<WorkspaceContext> => { | |
| 18 | + const installation = useLoadableAtom( | |
| 19 | + localInstallationAtom(getExecutorApiBaseUrl()), | |
| 20 | + ); | |
| 21 | + | |
| 22 | + return React.useMemo(() => { | |
| 23 | + if (installation.status !== "ready") { | |
| 24 | + return installation; | |
| 25 | + } | |
| 26 | + | |
| 27 | + return { | |
| 28 | + status: "ready", | |
| 29 | + data: { | |
| 30 | + installation: installation.data, | |
| 31 | + workspaceId: installation.data.scopeId, | |
| 32 | + }, | |
| 33 | + } satisfies Loadable<WorkspaceContext>; | |
| 34 | + }, [installation]); | |
| 35 | +}; | |
| 36 | + | |
| 37 | +export const useWorkspaceRequestContext = () => { | |
| 38 | + const workspace = useWorkspaceContext(); | |
| 39 | + const enabled = workspace.status === "ready"; | |
| 40 | + | |
| 41 | + return React.useMemo( | |
| 42 | + () => ({ | |
| 43 | + workspace, | |
| 44 | + enabled, | |
| 45 | + workspaceId: enabled | |
| 46 | + ? workspace.data.workspaceId | |
| 47 | + : PLACEHOLDER_WORKSPACE_ID, | |
| 48 | + }), | |
| 49 | + [enabled, workspace], | |
| 50 | + ); | |
| 51 | +}; | |
| 52 | + | |
| 53 | +export const useWorkspaceId = (): Source["scopeId"] => { | |
| 54 | + const workspace = useWorkspaceRequestContext(); | |
| 55 | + if (!workspace.enabled) { | |
| 56 | + throw new Error("Workspace is still loading."); | |
| 57 | + } | |
| 58 | + | |
| 59 | + return workspace.workspaceId; | |
| 60 | +}; | |
| 61 | + | |
| 62 | +export { pendingLoadable }; | |
| @@ -0,0 +1,46 @@ | ||
| 1 | +import type { | |
| 2 | + Execution, | |
| 3 | + ExecutionEnvelope, | |
| 4 | +} from "@executor/platform-sdk/schema"; | |
| 5 | + | |
| 6 | +import { | |
| 7 | + executionAtom, | |
| 8 | + executionsAtom, | |
| 9 | +} from "../core/api-atoms"; | |
| 10 | +import { | |
| 11 | + disabledAtom, | |
| 12 | + useLoadableAtom, | |
| 13 | +} from "../core/loadable"; | |
| 14 | +import type { | |
| 15 | + Loadable, | |
| 16 | +} from "../core/types"; | |
| 17 | +import { | |
| 18 | + pendingLoadable, | |
| 19 | + useWorkspaceRequestContext, | |
| 20 | +} from "../core/workspace"; | |
| 21 | + | |
| 22 | +export const useExecutions = (): Loadable<ReadonlyArray<Execution>> => { | |
| 23 | + const workspace = useWorkspaceRequestContext(); | |
| 24 | + const atom = workspace.enabled | |
| 25 | + ? executionsAtom(workspace.workspaceId) | |
| 26 | + : disabledAtom<ReadonlyArray<Execution>>(); | |
| 27 | + const executions = useLoadableAtom(atom); | |
| 28 | + | |
| 29 | + return workspace.enabled | |
| 30 | + ? executions | |
| 31 | + : pendingLoadable(workspace.workspace); | |
| 32 | +}; | |
| 33 | + | |
| 34 | +export const useExecution = ( | |
| 35 | + executionId: string, | |
| 36 | +): Loadable<ExecutionEnvelope> => { | |
| 37 | + const workspace = useWorkspaceRequestContext(); | |
| 38 | + const atom = workspace.enabled | |
| 39 | + ? executionAtom(workspace.workspaceId, executionId as Execution["id"]) | |
| 40 | + : disabledAtom<ExecutionEnvelope>(); | |
| 41 | + const execution = useLoadableAtom(atom); | |
| 42 | + | |
| 43 | + return workspace.enabled | |
| 44 | + ? execution | |
| 45 | + : pendingLoadable(workspace.workspace); | |
| 46 | +}; | |
| @@ -0,0 +1,21 @@ | ||
| 1 | +import { | |
| 2 | + useAtomRefresh, | |
| 3 | +} from "@effect-atom/atom-react"; | |
| 4 | +import type { | |
| 5 | + InstanceConfig, | |
| 6 | + LocalInstallation, | |
| 7 | +} from "@executor/platform-api"; | |
| 8 | + | |
| 9 | +import { getExecutorApiBaseUrl } from "../core/base-url"; | |
| 10 | +import { instanceConfigAtom, localInstallationAtom } from "../core/api-atoms"; | |
| 11 | +import { useLoadableAtom } from "../core/loadable"; | |
| 12 | +import type { Loadable } from "../core/types"; | |
| 13 | + | |
| 14 | +export const useLocalInstallation = (): Loadable<LocalInstallation> => | |
| 15 | + useLoadableAtom(localInstallationAtom(getExecutorApiBaseUrl())); | |
| 16 | + | |
| 17 | +export const useInstanceConfig = (): Loadable<InstanceConfig> => | |
| 18 | + useLoadableAtom(instanceConfigAtom(getExecutorApiBaseUrl())); | |
| 19 | + | |
| 20 | +export const useRefreshLocalInstallation = (): (() => void) => | |
| 21 | + useAtomRefresh(localInstallationAtom(getExecutorApiBaseUrl())); | |
| @@ -0,0 +1,44 @@ | ||
| 1 | +import * as React from "react"; | |
| 2 | + | |
| 3 | +import type { MutationState } from "../core/types"; | |
| 4 | + | |
| 5 | +export const useExecutorMutation = <TInput, TOutput>( | |
| 6 | + execute: (input: TInput) => Promise<TOutput>, | |
| 7 | +) => { | |
| 8 | + const [state, setState] = React.useState<MutationState<TOutput>>({ | |
| 9 | + status: "idle", | |
| 10 | + data: null, | |
| 11 | + error: null, | |
| 12 | + }); | |
| 13 | + | |
| 14 | + const mutateAsync = React.useCallback(async (payload: TInput) => { | |
| 15 | + setState((current) => ({ | |
| 16 | + status: "pending", | |
| 17 | + data: current.data, | |
| 18 | + error: null, | |
| 19 | + })); | |
| 20 | + | |
| 21 | + try { | |
| 22 | + const data = await execute(payload); | |
| 23 | + setState({ status: "success", data, error: null }); | |
| 24 | + return data; | |
| 25 | + } catch (cause) { | |
| 26 | + const error = cause instanceof Error ? cause : new Error(String(cause)); | |
| 27 | + setState({ status: "error", data: null, error }); | |
| 28 | + throw error; | |
| 29 | + } | |
| 30 | + }, [execute]); | |
| 31 | + | |
| 32 | + const reset = React.useCallback(() => { | |
| 33 | + setState({ status: "idle", data: null, error: null }); | |
| 34 | + }, []); | |
| 35 | + | |
| 36 | + return React.useMemo( | |
| 37 | + () => ({ | |
| 38 | + ...state, | |
| 39 | + mutateAsync, | |
| 40 | + reset, | |
| 41 | + }), | |
| 42 | + [mutateAsync, reset, state], | |
| 43 | + ); | |
| 44 | +}; | |
| @@ -0,0 +1,85 @@ | ||
| 1 | +import { | |
| 2 | + useAtomRefresh, | |
| 3 | + useAtomSet, | |
| 4 | +} from "@effect-atom/atom-react"; | |
| 5 | +import type { | |
| 6 | + CreateSecretPayload, | |
| 7 | + CreateSecretResult, | |
| 8 | + DeleteSecretResult, | |
| 9 | + SecretListItem, | |
| 10 | + UpdateSecretPayload, | |
| 11 | + UpdateSecretResult, | |
| 12 | +} from "@executor/platform-api"; | |
| 13 | +import * as React from "react"; | |
| 14 | + | |
| 15 | +import { getExecutorApiBaseUrl } from "../core/base-url"; | |
| 16 | +import { getExecutorApiHttpClient } from "../core/http-client"; | |
| 17 | +import { secretsAtom } from "../core/api-atoms"; | |
| 18 | +import { useLoadableAtom } from "../core/loadable"; | |
| 19 | +import { secretsReactivityKey } from "../core/reactivity"; | |
| 20 | +import type { Loadable } from "../core/types"; | |
| 21 | +import { useExecutorMutation } from "./mutations"; | |
| 22 | + | |
| 23 | +export const useSecrets = (): Loadable<ReadonlyArray<SecretListItem>> => | |
| 24 | + useLoadableAtom(secretsAtom(getExecutorApiBaseUrl())); | |
| 25 | + | |
| 26 | +export const useRefreshSecrets = (): (() => void) => | |
| 27 | + useAtomRefresh(secretsAtom(getExecutorApiBaseUrl())); | |
| 28 | + | |
| 29 | +export const useCreateSecret = () => { | |
| 30 | + const mutate = useAtomSet( | |
| 31 | + getExecutorApiHttpClient().mutation("local", "createSecret"), | |
| 32 | + { mode: "promise" }, | |
| 33 | + ); | |
| 34 | + | |
| 35 | + return useExecutorMutation<CreateSecretPayload, CreateSecretResult>( | |
| 36 | + React.useCallback( | |
| 37 | + (payload) => | |
| 38 | + mutate({ | |
| 39 | + payload, | |
| 40 | + reactivityKeys: secretsReactivityKey(), | |
| 41 | + }), | |
| 42 | + [mutate], | |
| 43 | + ), | |
| 44 | + ); | |
| 45 | +}; | |
| 46 | + | |
| 47 | +export const useUpdateSecret = () => { | |
| 48 | + const mutate = useAtomSet( | |
| 49 | + getExecutorApiHttpClient().mutation("local", "updateSecret"), | |
| 50 | + { mode: "promise" }, | |
| 51 | + ); | |
| 52 | + | |
| 53 | + return useExecutorMutation< | |
| 54 | + { secretId: string; payload: UpdateSecretPayload }, | |
| 55 | + UpdateSecretResult | |
| 56 | + >( | |
| 57 | + React.useCallback( | |
| 58 | + (input) => | |
| 59 | + mutate({ | |
| 60 | + path: { secretId: input.secretId }, | |
| 61 | + payload: input.payload, | |
| 62 | + reactivityKeys: secretsReactivityKey(), | |
| 63 | + }), | |
| 64 | + [mutate], | |
| 65 | + ), | |
| 66 | + ); | |
| 67 | +}; | |
| 68 | + | |
| 69 | +export const useDeleteSecret = () => { | |
| 70 | + const mutate = useAtomSet( | |
| 71 | + getExecutorApiHttpClient().mutation("local", "deleteSecret"), | |
| 72 | + { mode: "promise" }, | |
| 73 | + ); | |
| 74 | + | |
| 75 | + return useExecutorMutation<string, DeleteSecretResult>( | |
| 76 | + React.useCallback( | |
| 77 | + (secretId) => | |
| 78 | + mutate({ | |
| 79 | + path: { secretId }, | |
| 80 | + reactivityKeys: secretsReactivityKey(), | |
| 81 | + }), | |
| 82 | + [mutate], | |
| 83 | + ), | |
| 84 | + ); | |
| 85 | +}; | |
| @@ -0,0 +1,156 @@ | ||
| 1 | +import { | |
| 2 | + RegistryContext, | |
| 3 | + useAtomSet, | |
| 4 | +} from "@effect-atom/atom-react"; | |
| 5 | +import type { | |
| 6 | + Source, | |
| 7 | + SourceInspection, | |
| 8 | + SourceInspectionDiscoverResult, | |
| 9 | + SourceInspectionToolDetail, | |
| 10 | +} from "@executor/platform-sdk/schema"; | |
| 11 | +import * as React from "react"; | |
| 12 | + | |
| 13 | +import { | |
| 14 | + sourceAtom, | |
| 15 | + sourceDiscoveryAtom, | |
| 16 | + sourceInspectionAtom, | |
| 17 | + sourceInspectionToolAtom, | |
| 18 | + sourcesAtom, | |
| 19 | +} from "../core/api-atoms"; | |
| 20 | +import { disabledAtom, useLoadableAtom } from "../core/loadable"; | |
| 21 | +import { | |
| 22 | + sourceDiscoveryReactivityKey, | |
| 23 | + sourceInspectionReactivityKey, | |
| 24 | + sourceInspectionToolReactivityKey, | |
| 25 | + sourceReactivityKey, | |
| 26 | + sourcesReactivityKey, | |
| 27 | +} from "../core/reactivity"; | |
| 28 | +import type { Loadable, SourceRemoveResult } from "../core/types"; | |
| 29 | +import { pendingLoadable, useWorkspaceRequestContext } from "../core/workspace"; | |
| 30 | +import { getExecutorApiHttpClient } from "../core/http-client"; | |
| 31 | +import { useExecutorMutation } from "./mutations"; | |
| 32 | + | |
| 33 | +export const useSources = (): Loadable<ReadonlyArray<Source>> => { | |
| 34 | + const workspace = useWorkspaceRequestContext(); | |
| 35 | + const atom = workspace.enabled | |
| 36 | + ? sourcesAtom(workspace.workspaceId) | |
| 37 | + : disabledAtom<ReadonlyArray<Source>>(); | |
| 38 | + const sources = useLoadableAtom(atom); | |
| 39 | + | |
| 40 | + return workspace.enabled ? sources : pendingLoadable(workspace.workspace); | |
| 41 | +}; | |
| 42 | + | |
| 43 | +export const useSource = (sourceId: string): Loadable<Source> => { | |
| 44 | + const workspace = useWorkspaceRequestContext(); | |
| 45 | + const atom = workspace.enabled | |
| 46 | + ? sourceAtom(workspace.workspaceId, sourceId as Source["id"]) | |
| 47 | + : disabledAtom<Source>(); | |
| 48 | + const source = useLoadableAtom(atom); | |
| 49 | + | |
| 50 | + return workspace.enabled ? source : pendingLoadable(workspace.workspace); | |
| 51 | +}; | |
| 52 | + | |
| 53 | +export const useSourceInspection = ( | |
| 54 | + sourceId: string, | |
| 55 | +): Loadable<SourceInspection> => { | |
| 56 | + const workspace = useWorkspaceRequestContext(); | |
| 57 | + const atom = workspace.enabled | |
| 58 | + ? sourceInspectionAtom(workspace.workspaceId, sourceId as Source["id"]) | |
| 59 | + : disabledAtom<SourceInspection>(); | |
| 60 | + const inspection = useLoadableAtom(atom); | |
| 61 | + | |
| 62 | + return workspace.enabled ? inspection : pendingLoadable(workspace.workspace); | |
| 63 | +}; | |
| 64 | + | |
| 65 | +export const useSourceToolDetail = ( | |
| 66 | + sourceId: string, | |
| 67 | + toolPath: string | null, | |
| 68 | +): Loadable<SourceInspectionToolDetail | null> => { | |
| 69 | + const workspace = useWorkspaceRequestContext(); | |
| 70 | + const atom = workspace.enabled | |
| 71 | + ? sourceInspectionToolAtom( | |
| 72 | + workspace.workspaceId, | |
| 73 | + sourceId as Source["id"], | |
| 74 | + toolPath, | |
| 75 | + ) | |
| 76 | + : disabledAtom<SourceInspectionToolDetail | null>(); | |
| 77 | + const detail = useLoadableAtom(atom); | |
| 78 | + | |
| 79 | + return workspace.enabled ? detail : pendingLoadable(workspace.workspace); | |
| 80 | +}; | |
| 81 | + | |
| 82 | +export const useSourceDiscovery = (input: { | |
| 83 | + sourceId: string; | |
| 84 | + query: string; | |
| 85 | + limit?: number; | |
| 86 | +}): Loadable<SourceInspectionDiscoverResult> => { | |
| 87 | + const workspace = useWorkspaceRequestContext(); | |
| 88 | + const atom = workspace.enabled | |
| 89 | + ? sourceDiscoveryAtom( | |
| 90 | + workspace.workspaceId, | |
| 91 | + input.sourceId as Source["id"], | |
| 92 | + input.query, | |
| 93 | + input.limit ?? null, | |
| 94 | + ) | |
| 95 | + : disabledAtom<SourceInspectionDiscoverResult>(); | |
| 96 | + const results = useLoadableAtom(atom); | |
| 97 | + | |
| 98 | + return workspace.enabled ? results : pendingLoadable(workspace.workspace); | |
| 99 | +}; | |
| 100 | + | |
| 101 | +export const usePrefetchToolDetail = () => { | |
| 102 | + const registry = React.useContext(RegistryContext); | |
| 103 | + const workspace = useWorkspaceRequestContext(); | |
| 104 | + | |
| 105 | + return React.useCallback( | |
| 106 | + (sourceId: string, toolPath: string): (() => void) => { | |
| 107 | + if (!workspace.enabled) { | |
| 108 | + return () => {}; | |
| 109 | + } | |
| 110 | + | |
| 111 | + return registry.mount( | |
| 112 | + sourceInspectionToolAtom( | |
| 113 | + workspace.workspaceId, | |
| 114 | + sourceId as Source["id"], | |
| 115 | + toolPath, | |
| 116 | + ), | |
| 117 | + ); | |
| 118 | + }, | |
| 119 | + [registry, workspace.enabled, workspace.workspaceId], | |
| 120 | + ); | |
| 121 | +}; | |
| 122 | + | |
| 123 | +export const useRemoveSource = () => { | |
| 124 | + const workspace = useWorkspaceRequestContext(); | |
| 125 | + const mutate = useAtomSet( | |
| 126 | + getExecutorApiHttpClient().mutation("sources", "remove"), | |
| 127 | + { mode: "promise" }, | |
| 128 | + ); | |
| 129 | + | |
| 130 | + return useExecutorMutation<Source["id"], SourceRemoveResult>( | |
| 131 | + React.useCallback( | |
| 132 | + (sourceId) => { | |
| 133 | + if (!workspace.enabled) { | |
| 134 | + return Promise.reject( | |
| 135 | + new Error("Executor workspace context is not ready"), | |
| 136 | + ); | |
| 137 | + } | |
| 138 | + | |
| 139 | + return mutate({ | |
| 140 | + path: { | |
| 141 | + workspaceId: workspace.workspaceId, | |
| 142 | + sourceId, | |
| 143 | + }, | |
| 144 | + reactivityKeys: { | |
| 145 | + ...sourcesReactivityKey(workspace.workspaceId), | |
| 146 | + ...sourceReactivityKey(workspace.workspaceId, sourceId), | |
| 147 | + ...sourceInspectionReactivityKey(workspace.workspaceId, sourceId), | |
| 148 | + ...sourceInspectionToolReactivityKey(workspace.workspaceId, sourceId), | |
| 149 | + ...sourceDiscoveryReactivityKey(workspace.workspaceId, sourceId), | |
| 150 | + }, | |
| 151 | + }); | |
| 152 | + }, | |
| 153 | + [mutate, workspace.enabled, workspace.workspaceId], | |
| 154 | + ), | |
| 155 | + ); | |
| 156 | +}; | |
| @@ -1,30 +1,17 @@ | ||
| 1 | −import { createServer } from "node:http"; | |
| 2 | − | |
| 3 | 1 | import { |
| 4 | 2 | FileSystem, |
| 5 | 3 | HttpApi, |
| 6 | 4 | HttpApiBuilder, |
| 7 | 5 | HttpApiEndpoint, |
| 8 | 6 | HttpApiGroup, |
| 9 | − HttpServer, | |
| 10 | 7 | OpenApi, |
| 11 | 8 | } from "@effect/platform"; |
| 12 | −import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node"; | |
| 13 | −import { describe, expect, it } from "@effect/vitest"; | |
| 14 | −import { | |
| 15 | − startOpenApiTestServer, | |
| 16 | −} from "@executor/effect-test-utils"; | |
| 17 | −import { | |
| 18 | − createExecutorApiLayer, | |
| 19 | −} from "@executor/platform-api/http"; | |
| 20 | −import { createExecutorEffect } from "@executor/platform-sdk/effect"; | |
| 21 | −import type { ExecutorRuntime } from "@executor/platform-sdk/runtime"; | |
| 22 | −import { createLocalExecutorRuntime as createExecutorRuntime } from "@executor/platform-sdk-file/runtime"; | |
| 9 | +import { NodeFileSystem } from "@effect/platform-node"; | |
| 10 | +import { startOpenApiTestServer } from "@executor/effect-test-utils"; | |
| 23 | 11 | import type { |
| 24 | 12 | LocalInstallation, |
| 25 | 13 | Source, |
| 26 | 14 | } from "@executor/platform-sdk/schema"; |
| 27 | −import * as Context from "effect/Context"; | |
| 28 | 15 | import * as Effect from "effect/Effect"; |
| 29 | 16 | import * as Exit from "effect/Exit"; |
| 30 | 17 | import * as Layer from "effect/Layer"; |
| @@ -33,18 +20,19 @@ import * as Scope from "effect/Scope"; · 2 unmodified lines | ||
| 33 | 20 | import { JSDOM } from "jsdom"; |
| 34 | 21 | import * as React from "react"; |
| 35 | 22 | import { createRoot } from "react-dom/client"; |
| 23 | +import { describe, expect, it } from "vitest"; | |
| 36 | 24 | |
| 37 | 25 | import { |
| 38 | 26 | ExecutorReactProvider, |
| 39 | 27 | setExecutorApiBaseUrl, |
| 40 | 28 | type Loadable, |
| 41 | − useCreateSource, | |
| 29 | + useLocalInstallation, | |
| 42 | 30 | useRemoveSource, |
| 43 | 31 | useSource, |
| 44 | 32 | useSourceInspection, |
| 45 | 33 | useSources, |
| 46 | − useUpdateSource, | |
| 47 | 34 | } from "./index"; |
| 35 | +import { createLocalExecutorServer } from "../../../platform/server/src/index"; | |
| 48 | 36 | |
| 49 | 37 | const dom = new JSDOM("<!doctype html><html><body></body></html>", { |
| 50 | 38 | url: "http://127.0.0.1/", |
| @@ -61,7 +49,6 @@ globalThis.Node = dom.window.Node; · 10 unmodified lines | ||
| 61 | 49 | globalThis.MutationObserver = dom.window.MutationObserver; |
| 62 | 50 | globalThis.Event = dom.window.Event; |
| 63 | 51 | globalThis.EventTarget = dom.window.EventTarget; |
| 64 | − | |
| 65 | 52 | globalThis.requestAnimationFrame = (callback: FrameRequestCallback) => |
| 66 | 53 | setTimeout(() => callback(Date.now()), 0) as unknown as number; |
| 67 | 54 | globalThis.cancelAnimationFrame = (handle: number) => { |
| @@ -70,173 +57,62 @@ globalThis.cancelAnimationFrame = (handle: number) => { · 2 unmodified lines | ||
| 70 | 57 | |
| 71 | 58 | (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 72 | 59 | |
| 73 | −type DelayRule = { | |
| 74 | − method: string; | |
| 75 | − pathname: RegExp; | |
| 76 | − durationMs: number; | |
| 77 | −}; | |
| 78 | − | |
| 79 | 60 | type RunningServer = { |
| 80 | 61 | baseUrl: string; |
| 81 | 62 | close: () => Promise<void>; |
| 82 | 63 | }; |
| 83 | 64 | |
| 84 | −type ApiServer = RunningServer & { | |
| 85 | − runtime: ExecutorRuntime; | |
| 86 | −}; | |
| 87 | − | |
| 88 | 65 | type HookHarness<T> = { |
| 89 | 66 | current: T | null; |
| 90 | 67 | unmount: () => Promise<void>; |
| 91 | 68 | }; |
| 92 | 69 | |
| 93 | −type CreateHarnessState = { | |
| 70 | +type WorkspaceHarnessState = { | |
| 71 | + installation: Loadable<LocalInstallation>; | |
| 94 | 72 | sources: Loadable<ReadonlyArray<Source>>; |
| 95 | − createSource: ReturnType<typeof useCreateSource>; | |
| 96 | 73 | }; |
| 97 | 74 | |
| 98 | 75 | type SourceHarnessState = { |
| 99 | 76 | sources: Loadable<ReadonlyArray<Source>>; |
| 100 | 77 | source: Loadable<Source>; |
| 101 | 78 | inspection: ReturnType<typeof useSourceInspection>; |
| 102 | − updateSource: ReturnType<typeof useUpdateSource>; | |
| 103 | 79 | removeSource: ReturnType<typeof useRemoveSource>; |
| 104 | 80 | }; |
| 105 | 81 | |
| 106 | 82 | const closeScope = (scope: Scope.CloseableScope) => |
| 107 | 83 | Scope.close(scope, Exit.void).pipe(Effect.orDie); |
| 108 | 84 | |
| 109 | −const startControlPlaneServer = async (): Promise<ApiServer> => { | |
| 85 | +const startControlPlaneServer = async (): Promise<RunningServer> => { | |
| 110 | 86 | const workspaceRoot = await Effect.runPromise( |
| 111 | 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 | 90 | Effect.provide(NodeFileSystem.layer), |
| 114 | 91 | ), |
| 115 | 92 | ); |
| 116 | − const runtime = await Effect.runPromise( | |
| 117 | − createExecutorRuntime({ | |
| 118 | − workspaceRoot, | |
| 119 | − }), | |
| 120 | − ); | |
| 121 | 93 | const scope = await Effect.runPromise(Scope.make()); |
| 122 | 94 | |
| 123 | 95 | try { |
| 124 | − const executor = await Effect.runPromise( | |
| 125 | − createExecutorEffect({ | |
| 126 | − backend: { | |
| 127 | − createRuntime: () => Effect.succeed(runtime), | |
| 128 | − }, | |
| 129 | − }), | |
| 130 | − ); | |
| 131 | − const serverLayer = HttpApiBuilder.serve().pipe( | |
| 132 | − Layer.provide(createExecutorApiLayer(executor)), | |
| 133 | − Layer.provideMerge(NodeHttpServer.layerTest), | |
| 96 | + const server = await Effect.runPromise( | |
| 97 | + createLocalExecutorServer({ | |
| 98 | + port: 0, | |
| 99 | + localDataDir: ":memory:", | |
| 100 | + workspaceRoot, | |
| 101 | + }).pipe(Effect.provideService(Scope.Scope, scope)), | |
| 134 | 102 | ); |
| 135 | − const context = await Effect.runPromise(Layer.buildWithScope(serverLayer, scope)); | |
| 136 | − const server = Context.get(context, HttpServer.HttpServer); | |
| 137 | 103 | |
| 138 | 104 | return { |
| 139 | − runtime, | |
| 140 | − baseUrl: HttpServer.formatAddress(server.address), | |
| 105 | + baseUrl: server.baseUrl, | |
| 141 | 106 | close: async () => { |
| 142 | 107 | await Effect.runPromise(closeScope(scope)); |
| 143 | − await runtime.close(); | |
| 144 | 108 | }, |
| 145 | 109 | }; |
| 146 | 110 | } catch (error) { |
| 147 | 111 | await Effect.runPromise(closeScope(scope)); |
| 148 | − await runtime.close(); | |
| 149 | 112 | throw error; |
| 150 | 113 | } |
| 151 | 114 | }; |
| 152 | 115 | |
| 153 | −const startProxyServer = async (input: { | |
| 154 | − targetBaseUrl: string; | |
| 155 | − delays?: ReadonlyArray<DelayRule>; | |
| 156 | −}): Promise<RunningServer> => { | |
| 157 | − const server = createServer(async (req, res) => { | |
| 158 | − try { | |
| 159 | − const method = req.method ?? "GET"; | |
| 160 | − const url = new URL(req.url ?? "/", "http://127.0.0.1"); | |
| 161 | − const rule = input.delays?.find( | |
| 162 | − (candidate) => | |
| 163 | − candidate.method === method && candidate.pathname.test(url.pathname), | |
| 164 | − ); | |
| 165 | − | |
| 166 | − if (rule) { | |
| 167 | − await new Promise((resolve) => setTimeout(resolve, rule.durationMs)); | |
| 168 | − } | |
| 169 | − | |
| 170 | − const headers = new Headers(); | |
| 171 | − for (const [name, value] of Object.entries(req.headers)) { | |
| 172 | − if (value === undefined) { | |
| 173 | − continue; | |
| 174 | − } | |
| 175 | − | |
| 176 | − if (Array.isArray(value)) { | |
| 177 | − value.forEach((item) => headers.append(name, item)); | |
| 178 | − continue; | |
| 179 | − } | |
| 180 | − | |
| 181 | − headers.set(name, value); | |
| 182 | − } | |
| 183 | − headers.delete("host"); | |
| 184 | − | |
| 185 | − const bodyChunks: Array<Uint8Array> = []; | |
| 186 | − for await (const chunk of req) { | |
| 187 | − bodyChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); | |
| 188 | − } | |
| 189 | − | |
| 190 | − const targetUrl = new URL(`${url.pathname}${url.search}`, input.targetBaseUrl); | |
| 191 | − const response = await fetch(targetUrl, { | |
| 192 | − method, | |
| 193 | − headers, | |
| 194 | − body: bodyChunks.length > 0 ? Buffer.concat(bodyChunks) : undefined, | |
| 195 | − }); | |
| 196 | − | |
| 197 | − res.statusCode = response.status; | |
| 198 | − response.headers.forEach((value, name) => { | |
| 199 | − res.setHeader(name, value); | |
| 200 | − }); | |
| 201 | − res.end(Buffer.from(await response.arrayBuffer())); | |
| 202 | − } catch (error) { | |
| 203 | − res.statusCode = 500; | |
| 204 | − res.end(error instanceof Error ? error.message : String(error)); | |
| 205 | − } | |
| 206 | − }); | |
| 207 | − | |
| 208 | − await new Promise<void>((resolve, reject) => { | |
| 209 | − server.once("error", reject); | |
| 210 | − server.listen(0, "127.0.0.1", () => resolve()); | |
| 211 | − }); | |
| 212 | − | |
| 213 | − const address = server.address(); | |
| 214 | − if (!address || typeof address === "string") { | |
| 215 | − throw new Error("Failed to bind proxy server"); | |
| 216 | − } | |
| 217 | − | |
| 218 | − const baseUrl = `http://127.0.0.1:${address.port}`; | |
| 219 | − | |
| 220 | − return { | |
| 221 | − baseUrl, | |
| 222 | − close: () => | |
| 223 | − new Promise<void>((resolve, reject) => { | |
| 224 | − server.close((error) => { | |
| 225 | − if (error) { | |
| 226 | − reject(error); | |
| 227 | − return; | |
| 228 | − } | |
| 229 | − | |
| 230 | − resolve(); | |
| 231 | − }); | |
| 232 | − }), | |
| 233 | − }; | |
| 234 | −}; | |
| 235 | − | |
| 236 | −type OpenApiSpecServer = RunningServer & { | |
| 237 | − specUrl: string; | |
| 238 | −}; | |
| 239 | − | |
| 240 | 116 | class HooksTestPingApi extends HttpApiGroup.make("ping") |
| 241 | 117 | .add( |
| 242 | 118 | HttpApiEndpoint.get("ping")`/ping` |
| @@ -251,30 +127,26 @@ class HooksTestPingApi extends HttpApiGroup.make("ping") · 8 unmodified lines | ||
| 251 | 127 | class HooksTestOpenApi extends HttpApi.make("hooksTest").add(HooksTestPingApi) {} |
| 252 | 128 | |
| 253 | 129 | const hooksTestOpenApiSpec = OpenApi.fromApi(HooksTestOpenApi); |
| 254 | −const hooksTestOpenApiLive = HttpApiBuilder.group( | |
| 255 | − HooksTestOpenApi, | |
| 256 | − "ping", | |
| 257 | − (handlers) => | |
| 258 | − handlers.handle("ping", () => | |
| 259 | − Effect.succeed({ | |
| 260 | − ok: true, | |
| 261 | − }), | |
| 262 | − ), | |
| 263 | −); | |
| 264 | 130 | const hooksTestOpenApiLayer = HttpApiBuilder.api(HooksTestOpenApi).pipe( |
| 265 | − Layer.provide(hooksTestOpenApiLive), | |
| 131 | + Layer.provide( | |
| 132 | + HttpApiBuilder.group(HooksTestOpenApi, "ping", (handlers) => | |
| 133 | + handlers.handle("ping", () => | |
| 134 | + Effect.succeed({ | |
| 135 | + ok: true, | |
| 136 | + })), | |
| 137 | + ), | |
| 138 | + ), | |
| 266 | 139 | ); |
| 267 | 140 | |
| 268 | −const startOpenApiSpecServer = async (): Promise<OpenApiSpecServer> => { | |
| 269 | − return startOpenApiTestServer({ | |
| 141 | +const startOpenApiSpecServer = async (): Promise<OpenApiSpecServer> => | |
| 142 | + startOpenApiTestServer({ | |
| 270 | 143 | apiLayer: hooksTestOpenApiLayer, |
| 271 | 144 | }); |
| 272 | −}; | |
| 273 | 145 | |
| 274 | 146 | async function requestJson<T>(input: { |
| 275 | 147 | baseUrl: string; |
| 276 | 148 | path: string; |
| 277 | − method?: "GET" | "POST" | "PATCH" | "DELETE"; | |
| 149 | + method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; | |
| 278 | 150 | payload?: unknown; |
| 279 | 151 | accountId?: string; |
| 280 | 152 | }): Promise<T> { |
| @@ -305,29 +177,24 @@ const getInstallation = (baseUrl: string) => · 24 unmodified lines | ||
| 305 | 177 | }); |
| 306 | 178 | |
| 307 | 179 | const seedStoredOpenApiSource = async (input: { |
| 308 | − server: ApiServer; | |
| 180 | + server: RunningServer; | |
| 309 | 181 | installation: LocalInstallation; |
| 310 | 182 | name: string; |
| 311 | 183 | specUrl?: string; |
| 312 | 184 | }): Promise<Source> => { |
| 313 | 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 | 189 | return requestJson<Source>({ |
| 317 | 190 | baseUrl: input.server.baseUrl, |
| 318 | − path: `/v1/workspaces/${input.installation.scopeId}/sources`, | |
| 191 | + path: `/v1/workspaces/${input.installation.scopeId}/plugins/openapi/sources`, | |
| 319 | 192 | method: "POST", |
| 320 | 193 | accountId: input.installation.actorScopeId, |
| 321 | 194 | payload: { |
| 322 | 195 | name: input.name, |
| 323 | − kind: "openapi", | |
| 324 | − endpoint: "https://example.com/api", | |
| 325 | − status: "connected", | |
| 326 | − enabled: true, | |
| 327 | − namespace: "hooks-test", | |
| 328 | − binding: { | |
| 329 | − specUrl, | |
| 330 | − }, | |
| 196 | + specUrl, | |
| 197 | + baseUrl: "https://example.com/api", | |
| 331 | 198 | auth: { |
| 332 | 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 | |
| 375 | −const renderCreateHarness = () => | |
| 376 | − renderExecutorHarness<CreateHarnessState>(() => ({ | |
| 242 | +const renderWorkspaceHarness = () => | |
| 243 | + renderExecutorHarness<WorkspaceHarnessState>(() => ({ | |
| 244 | + installation: useLocalInstallation(), | |
| 377 | 245 | sources: useSources(), |
| 378 | − createSource: useCreateSource(), | |
| 379 | 246 | })); |
| 380 | 247 | |
| 381 | 248 | const renderSourceHarness = (sourceId: string) => |
| @@ -383,7 +250,6 @@ const renderSourceHarness = (sourceId: string) => · 1 unmodified line | ||
| 383 | 250 | sources: useSources(), |
| 384 | 251 | source: useSource(sourceId), |
| 385 | 252 | inspection: useSourceInspection(sourceId), |
| 386 | − updateSource: useUpdateSource(), | |
| 387 | 253 | removeSource: useRemoveSource(), |
| 388 | 254 | })); |
| 389 | 255 | |
| @@ -414,190 +280,74 @@ function isReady<T>( · 24 unmodified lines | ||
| 414 | 280 | return loadable.status === "ready"; |
| 415 | 281 | } |
| 416 | 282 | |
| 417 | −function getReadyData<T>(loadable: Loadable<T>): T { | |
| 418 | − if (!isReady(loadable)) { | |
| 419 | − throw new Error("Expected loadable data to be ready"); | |
| 420 | − } | |
| 421 | − | |
| 422 | − return loadable.data; | |
| 423 | −} | |
| 424 | − | |
| 283 | +type OpenApiSpecServer = RunningServer & { | |
| 284 | + specUrl: string; | |
| 285 | +}; | |
| 425 | 286 | |
| 426 | 287 | describe("executor-react source hooks", () => { |
| 427 | − it.effect("rejects invalid source creation before applying optimistic state", () => | |
| 428 | − Effect.promise(async () => { | |
| 429 | − const apiServer = await startControlPlaneServer(); | |
| 430 | − const proxyServer = await startProxyServer({ | |
| 431 | − targetBaseUrl: apiServer.baseUrl, | |
| 432 | − delays: [ | |
| 433 | − { | |
| 434 | − method: "POST", | |
| 435 | − pathname: /^\/v1\/workspaces\/[^/]+\/sources$/, | |
| 436 | − durationMs: 150, | |
| 437 | − }, | |
| 438 | − ], | |
| 439 | − }); | |
| 440 | − | |
| 441 | − setExecutorApiBaseUrl(proxyServer.baseUrl); | |
| 442 | − | |
| 443 | − const harness = await renderCreateHarness(); | |
| 444 | − | |
| 445 | − try { | |
| 446 | − await waitForValue( | |
| 447 | − () => harness.current, | |
| 448 | − (value) => isReady(value.sources) && value.sources.data.length === 0, | |
| 449 | − ); | |
| 450 | − | |
| 451 | − let mutationPromise!: Promise<{ ok: true; value: Source } | { ok: false; error: unknown }>; | |
| 452 | − await React.act(async () => { | |
| 453 | − mutationPromise = harness.current!.createSource.mutateAsync({ | |
| 454 | − name: "" as never, | |
| 455 | − kind: "openapi", | |
| 456 | − endpoint: "https://example.com", | |
| 457 | − binding: { | |
| 458 | − specUrl: "https://example.com/openapi.json", | |
| 459 | − }, | |
| 460 | − }).then( | |
| 461 | − (value) => ({ ok: true as const, value }), | |
| 462 | − (error) => ({ ok: false as const, error }), | |
| 463 | − ); | |
| 464 | − await Promise.resolve(); | |
| 465 | − }); | |
| 466 | − | |
| 467 | − const failed = await waitForValue( | |
| 468 | − () => harness.current, | |
| 469 | − (value) => | |
| 470 | − value.createSource.status === "error" | |
| 471 | − && isReady(value.sources) | |
| 472 | − && value.sources.data.length === 0, | |
| 473 | − ); | |
| 474 | − | |
| 475 | − const result = await mutationPromise; | |
| 476 | − expect(result.ok).toBe(false); | |
| 477 | − if (result.ok) { | |
| 478 | − throw new Error("Expected invalid source creation to fail"); | |
| 479 | − } | |
| 480 | − | |
| 481 | − expect(result.error).toBeInstanceOf(Error); | |
| 482 | − expect(failed.createSource.error).toBeInstanceOf(Error); | |
| 483 | − } finally { | |
| 484 | − await harness.unmount(); | |
| 485 | − await proxyServer.close(); | |
| 486 | − await apiServer.close(); | |
| 487 | − } | |
| 488 | − }), | |
| 489 | − 60_000, | |
| 490 | − ); | |
| 491 | − | |
| 492 | − it.effect("applies optimistic source updates and refreshes live inspection data after success", () => | |
| 493 | − Effect.promise(async () => { | |
| 288 | + it("loads the local installation, sources, and inspection data", async () => { | |
| 494 | 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 | 290 | const specServer = await startOpenApiSpecServer(); |
| 506 | 291 | |
| 507 | − setExecutorApiBaseUrl(proxyServer.baseUrl); | |
| 292 | + setExecutorApiBaseUrl(apiServer.baseUrl); | |
| 508 | 293 | |
| 509 | 294 | try { |
| 510 | 295 | const installation = await getInstallation(apiServer.baseUrl); |
| 511 | 296 | const source = await seedStoredOpenApiSource({ |
| 512 | 297 | server: apiServer, |
| 513 | 298 | installation, |
| 514 | − name: "Original Source", | |
| 299 | + name: "Hooks Test Source", | |
| 515 | 300 | specUrl: specServer.specUrl, |
| 516 | 301 | }); |
| 517 | − const harness = await renderSourceHarness(source.id); | |
| 518 | 302 | |
| 519 | − try { | |
| 520 | − await waitForValue( | |
| 521 | − () => harness.current, | |
| 522 | − (value) => | |
| 523 | − isReady(value.sources) | |
| 524 | − && isReady(value.source) | |
| 525 | − && isReady(value.inspection) | |
| 526 | − && value.source.data.name === "Original Source" | |
| 527 | − && value.inspection.data.source.name === "Original Source", | |
| 528 | − ); | |
| 303 | + const workspaceHarness = await renderWorkspaceHarness(); | |
| 304 | + const sourceHarness = await renderSourceHarness(source.id); | |
| 529 | 305 | |
| 530 | − let mutationPromise!: Promise<Source>; | |
| 531 | − await React.act(async () => { | |
| 532 | − mutationPromise = harness.current!.updateSource.mutateAsync({ | |
| 533 | − sourceId: source.id, | |
| 534 | − payload: { | |
| 535 | − name: "Renamed Source", | |
| 536 | − }, | |
| 537 | − }); | |
| 538 | − await Promise.resolve(); | |
| 539 | − }); | |
| 540 | − | |
| 541 | − const optimistic = await waitForValue( | |
| 542 | − () => harness.current, | |
| 306 | + try { | |
| 307 | + const workspace = await waitForValue( | |
| 308 | + () => workspaceHarness.current, | |
| 543 | 309 | (value) => |
| 544 | − value.updateSource.status === "pending" | |
| 310 | + isReady(value.installation) | |
| 545 | 311 | && isReady(value.sources) |
| 546 | − && isReady(value.source) | |
| 547 | − && isReady(value.inspection) | |
| 548 | − && value.sources.data.some((item) => item.id === source.id && item.name === "Renamed Source") | |
| 549 | − && value.source.data.name === "Renamed Source" | |
| 550 | − && value.inspection.data.source.name === "Original Source", | |
| 312 | + && value.installation.data.scopeId === installation.scopeId | |
| 313 | + && value.sources.data.some((item) => item.id === source.id), | |
| 551 | 314 | ); |
| 552 | 315 | |
| 553 | − expect(getReadyData(optimistic.source).name).toBe("Renamed Source"); | |
| 554 | − expect(getReadyData(optimistic.inspection).source.name).toBe("Original Source"); | |
| 555 | − | |
| 556 | − let updated!: Source; | |
| 557 | − await React.act(async () => { | |
| 558 | − updated = await mutationPromise; | |
| 559 | − }); | |
| 316 | + expect(workspace.installation.status).toBe("ready"); | |
| 317 | + expect(workspace.sources.status).toBe("ready"); | |
| 560 | 318 | |
| 561 | − expect(updated.name).toBe("Renamed Source"); | |
| 562 | − | |
| 563 | − const refreshed = await waitForValue( | |
| 564 | − () => harness.current, | |
| 319 | + const loaded = await waitForValue( | |
| 320 | + () => sourceHarness.current, | |
| 565 | 321 | (value) => |
| 566 | − value.updateSource.status === "success" | |
| 567 | − && isReady(value.source) | |
| 322 | + isReady(value.source) | |
| 568 | 323 | && isReady(value.inspection) |
| 569 | − && value.source.data.name === "Renamed Source" | |
| 570 | − && value.inspection.data.source.name === "Renamed Source", | |
| 324 | + && value.source.data.name === "Hooks Test Source" | |
| 325 | + && value.inspection.data.source.id === source.id | |
| 326 | + && value.inspection.data.toolCount > 0, | |
| 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 | 336 | } finally { |
| 575 | − await harness.unmount(); | |
| 337 | + await sourceHarness.unmount(); | |
| 338 | + await workspaceHarness.unmount(); | |
| 576 | 339 | } |
| 577 | 340 | } finally { |
| 578 | 341 | await specServer.close(); |
| 579 | − await proxyServer.close(); | |
| 580 | 342 | await apiServer.close(); |
| 581 | 343 | } |
| 582 | − }), | |
| 583 | − 60_000, | |
| 584 | − ); | |
| 344 | + }, 60_000); | |
| 585 | 345 | |
| 586 | − it.effect("optimistically removes deleted sources and invalidates mounted source queries", () => | |
| 587 | − Effect.promise(async () => { | |
| 346 | + it("invalidates mounted source and inspection queries after removal", async () => { | |
| 588 | 347 | const apiServer = await startControlPlaneServer(); |
| 589 | − const proxyServer = await startProxyServer({ | |
| 590 | − targetBaseUrl: apiServer.baseUrl, | |
| 591 | − delays: [ | |
| 592 | − { | |
| 593 | − method: "DELETE", | |
| 594 | − pathname: /^\/v1\/workspaces\/[^/]+\/sources\/[^/]+$/, | |
| 595 | − durationMs: 150, | |
| 596 | − }, | |
| 597 | − ], | |
| 598 | − }); | |
| 348 | + const specServer = await startOpenApiSpecServer(); | |
| 599 | 349 | |
| 600 | − setExecutorApiBaseUrl(proxyServer.baseUrl); | |
| 350 | + setExecutorApiBaseUrl(apiServer.baseUrl); | |
| 601 | 351 | |
| 602 | 352 | try { |
| 603 | 353 | const installation = await getInstallation(apiServer.baseUrl); |
| @@ -605,6 +355,7 @@ describe("executor-react source hooks", () => { · 1 unmodified line | ||
| 605 | 355 | server: apiServer, |
| 606 | 356 | installation, |
| 607 | 357 | name: "Disposable Source", |
| 358 | + specUrl: specServer.specUrl, | |
| 608 | 359 | }); |
| 609 | 360 | const harness = await renderSourceHarness(source.id); |
| 610 | 361 | |
| @@ -618,36 +369,14 @@ describe("executor-react source hooks", () => { · 7 unmodified lines | ||
| 618 | 369 | && value.sources.data.some((item) => item.id === source.id), |
| 619 | 370 | ); |
| 620 | 371 | |
| 621 | − let mutationPromise!: Promise<{ removed: boolean }>; | |
| 622 | − await React.act(async () => { | |
| 623 | − mutationPromise = harness.current!.removeSource.mutateAsync(source.id); | |
| 624 | − await Promise.resolve(); | |
| 625 | − }); | |
| 626 | − | |
| 627 | − const optimistic = await waitForValue( | |
| 628 | − () => harness.current, | |
| 629 | − (value) => | |
| 630 | − value.removeSource.status === "pending" | |
| 631 | − && isReady(value.sources) | |
| 632 | − && !value.sources.data.some((item) => item.id === source.id), | |
| 633 | − ); | |
| 634 | − | |
| 635 | − expect( | |
| 636 | − getReadyData(optimistic.sources).some((item: Source) => item.id === source.id), | |
| 637 | − ).toBe(false); | |
| 638 | − | |
| 639 | − let removed!: { removed: boolean }; | |
| 640 | − await React.act(async () => { | |
| 641 | − removed = await mutationPromise; | |
| 642 | − }); | |
| 643 | − | |
| 372 | + const removed = await React.act(async () => | |
| 373 | + harness.current!.removeSource.mutateAsync(source.id)); | |
| 644 | 374 | expect(removed.removed).toBe(true); |
| 645 | 375 | |
| 646 | 376 | const invalidated = await waitForValue( |
| 647 | 377 | () => harness.current, |
| 648 | 378 | (value) => |
| 649 | − value.removeSource.status === "success" | |
| 650 | − && isReady(value.sources) | |
| 379 | + isReady(value.sources) | |
| 651 | 380 | && value.sources.data.length === 0 |
| 652 | 381 | && value.source.status === "error" |
| 653 | 382 | && value.inspection.status === "error", |
| @@ -659,46 +388,41 @@ describe("executor-react source hooks", () => { · 5 unmodified lines | ||
| 659 | 388 | await harness.unmount(); |
| 660 | 389 | } |
| 661 | 390 | } finally { |
| 662 | − await proxyServer.close(); | |
| 391 | + await specServer.close(); | |
| 663 | 392 | await apiServer.close(); |
| 664 | 393 | } |
| 665 | − }), | |
| 666 | − 60_000, | |
| 667 | − ); | |
| 394 | + }, 60_000); | |
| 668 | 395 | |
| 669 | − it.effect("surfaces missing sources as errors instead of staying loading", () => | |
| 670 | − Effect.promise(async () => { | |
| 671 | − const apiServer = await startControlPlaneServer(); | |
| 672 | − setExecutorApiBaseUrl(apiServer.baseUrl); | |
| 396 | + it("surfaces missing sources as errors instead of staying loading", async () => { | |
| 397 | + const apiServer = await startControlPlaneServer(); | |
| 398 | + setExecutorApiBaseUrl(apiServer.baseUrl); | |
| 399 | + | |
| 400 | + try { | |
| 401 | + const harness = await renderSourceHarness("src_missing"); | |
| 673 | 402 | |
| 674 | 403 | try { |
| 675 | − const harness = await renderSourceHarness("src_missing"); | |
| 676 | − | |
| 677 | − try { | |
| 678 | − const missing = await waitForValue( | |
| 679 | − () => harness.current, | |
| 680 | − (value) => | |
| 681 | − isReady(value.sources) | |
| 682 | − && value.sources.data.length === 0 | |
| 683 | − && value.source.status === "error" | |
| 684 | − && value.inspection.status === "error", | |
| 685 | − ); | |
| 686 | − | |
| 687 | − expect(missing.source.status).toBe("error"); | |
| 688 | − expect(missing.inspection.status).toBe("error"); | |
| 689 | − if (missing.source.status === "error") { | |
| 690 | − expect(missing.source.error.message).toContain("Source not found"); | |
| 691 | − } | |
| 692 | − if (missing.inspection.status === "error") { | |
| 693 | − expect(missing.inspection.error.message).toContain("Source not found"); | |
| 694 | − } | |
| 695 | − } finally { | |
| 696 | − await harness.unmount(); | |
| 404 | + const missing = await waitForValue( | |
| 405 | + () => harness.current, | |
| 406 | + (value) => | |
| 407 | + isReady(value.sources) | |
| 408 | + && value.sources.data.length === 0 | |
| 409 | + && value.source.status === "error" | |
| 410 | + && value.inspection.status === "error", | |
| 411 | + ); | |
| 412 | + | |
| 413 | + expect(missing.source.status).toBe("error"); | |
| 414 | + expect(missing.inspection.status).toBe("error"); | |
| 415 | + if (missing.source.status === "error") { | |
| 416 | + expect(missing.source.error.message).toContain("Source not found"); | |
| 417 | + } | |
| 418 | + if (missing.inspection.status === "error") { | |
| 419 | + expect(missing.inspection.error.message).toContain("Source not found"); | |
| 697 | 420 | } |
| 698 | 421 | } finally { |
| 699 | − await apiServer.close(); | |
| 422 | + await harness.unmount(); | |
| 700 | 423 | } |
| 701 | − }), | |
| 702 | − 60_000, | |
| 703 | − ); | |
| 424 | + } finally { | |
| 425 | + await apiServer.close(); | |
| 426 | + } | |
| 427 | + }, 60_000); | |
| 704 | 428 | }); |
| @@ -1,1436 +1,81 @@ | ||
| 1 | −import { Atom, Result } from "@effect-atom/atom"; | |
| 2 | −import type * as Registry from "@effect-atom/atom/Registry"; | |
| 3 | −import { RegistryContext, RegistryProvider, useAtomValue } from "@effect-atom/atom-react"; | |
| 4 | −import { | |
| 5 | − createExecutorApiClient, | |
| 6 | − type ExecutorApiClient, | |
| 7 | −} from "@executor/platform-api/client"; | |
| 8 | −import type { | |
| 9 | − CompleteSourceOAuthResult, | |
| 10 | − ConnectSourceBatchPayload, | |
| 11 | − ConnectSourceBatchResult, | |
| 12 | − ConnectSourcePayload, | |
| 13 | − ConnectSourceResult, | |
| 1 | +export { | |
| 2 | + Atom, | |
| 3 | + AtomHttpApi, | |
| 4 | + RegistryContext, | |
| 5 | + RegistryProvider, | |
| 6 | + Result, | |
| 7 | + useAtomRefresh, | |
| 8 | + useAtomSet, | |
| 9 | + useAtomValue, | |
| 10 | +} from "@effect-atom/atom-react"; | |
| 11 | + | |
| 12 | +export type { | |
| 14 | 13 | CreateSecretPayload, |
| 15 | 14 | CreateSecretResult, |
| 16 | − CreateSourcePayload, | |
| 17 | − CreateWorkspaceOauthClientPayload, | |
| 18 | 15 | DeleteSecretResult, |
| 19 | − DiscoverSourcePayload, | |
| 20 | 16 | InstanceConfig, |
| 21 | 17 | LocalInstallation, |
| 22 | 18 | SecretListItem, |
| 23 | − StartSourceOAuthPayload, | |
| 24 | − StartSourceOAuthResult, | |
| 25 | 19 | UpdateSecretPayload, |
| 26 | 20 | UpdateSecretResult, |
| 27 | − UpdateSourcePayload, | |
| 28 | 21 | } from "@executor/platform-api"; |
| 29 | −import type { | |
| 22 | + | |
| 23 | +export type { | |
| 24 | + Execution, | |
| 25 | + ExecutionEnvelope, | |
| 26 | + ExecutionStatus, | |
| 30 | 27 | Source, |
| 31 | − SourceDiscoveryResult, | |
| 32 | 28 | SourceInspection, |
| 33 | 29 | SourceInspectionDiscoverResult, |
| 34 | 30 | SourceInspectionToolDetail, |
| 35 | − ScopeOauthClient as WorkspaceOauthClient, | |
| 36 | 31 | } from "@executor/platform-sdk/schema"; |
| 37 | −import * as Cause from "effect/Cause"; | |
| 38 | −import * as Effect from "effect/Effect"; | |
| 39 | −import * as Option from "effect/Option"; | |
| 40 | −import * as Runtime from "effect/Runtime"; | |
| 41 | −import * as React from "react"; | |
| 42 | − | |
| 43 | −const DEFAULT_EXECUTOR_API_BASE_URL = "http://127.0.0.1:8788"; | |
| 44 | −const PLACEHOLDER_WORKSPACE_ID = "ws_placeholder" as Source["scopeId"]; | |
| 45 | −const PLACEHOLDER_ACCOUNT_ID = "acc_placeholder"; | |
| 46 | −const PLACEHOLDER_SOURCE_ID = "src_placeholder" as Source["id"]; | |
| 47 | − | |
| 48 | −type SourceMutationState<T> = { | |
| 49 | − status: "idle" | "pending" | "success" | "error"; | |
| 50 | − data: T | null; | |
| 51 | − error: Error | null; | |
| 52 | −}; | |
| 53 | − | |
| 54 | −export type SourceRemoveResult = { | |
| 55 | − removed: boolean; | |
| 56 | −}; | |
| 57 | − | |
| 58 | −type AtomKeyPart = string | number | boolean | null | undefined; | |
| 59 | − | |
| 60 | −type SourcesKeyParts = readonly [boolean, Source["scopeId"], string]; | |
| 61 | −type SourceKeyParts = readonly [boolean, Source["scopeId"], string, Source["id"]]; | |
| 62 | −type SourceToolDetailKeyParts = readonly [ | |
| 63 | − boolean, | |
| 64 | − Source["scopeId"], | |
| 65 | − string, | |
| 66 | − Source["id"], | |
| 67 | − string | null, | |
| 68 | −]; | |
| 69 | −type SourceDiscoveryKeyParts = readonly [ | |
| 70 | − boolean, | |
| 71 | − Source["scopeId"], | |
| 72 | − string, | |
| 73 | − Source["id"], | |
| 74 | − string, | |
| 75 | − number | null, | |
| 76 | −]; | |
| 77 | −type WorkspaceOauthClientsKeyParts = readonly [ | |
| 78 | − boolean, | |
| 79 | − Source["scopeId"], | |
| 80 | − string, | |
| 81 | − string, | |
| 82 | −]; | |
| 83 | − | |
| 84 | −type InvalidationTarget = { | |
| 85 | − workspaceId?: Source["scopeId"]; | |
| 86 | − accountId?: string; | |
| 87 | − sourceId?: Source["id"]; | |
| 88 | −}; | |
| 89 | − | |
| 90 | −type ActiveQueryCollections = { | |
| 91 | − sourceLists: Set<string>; | |
| 92 | − sources: Set<string>; | |
| 93 | − workspaceOauthClients: Set<string>; | |
| 94 | − inspections: Set<string>; | |
| 95 | − toolDetails: Set<string>; | |
| 96 | − discoveries: Set<string>; | |
| 97 | −}; | |
| 98 | − | |
| 99 | −type ExecutorQueryContextValue = { | |
| 100 | − registry: Registry.Registry; | |
| 101 | − activeQueries: ActiveQueryCollections; | |
| 102 | − invalidateQueries: (target?: InvalidationTarget) => void; | |
| 103 | −}; | |
| 104 | − | |
| 105 | −type MutationExecutionContext = { | |
| 106 | − workspaceId: Source["scopeId"]; | |
| 107 | − accountId: string; | |
| 108 | − registry: Registry.Registry; | |
| 109 | − invalidateQueries: (target?: InvalidationTarget) => void; | |
| 110 | −}; | |
| 111 | − | |
| 112 | −type OptimisticMutationResult<T> = | |
| 113 | − | void | |
| 114 | − | (() => void) | |
| 115 | − | { | |
| 116 | − rollback?: () => void; | |
| 117 | − value?: T; | |
| 118 | − }; | |
| 119 | − | |
| 120 | −type MutationOptions<TInput, TOutput, TOptimistic = never> = { | |
| 121 | − optimisticUpdate?: ( | |
| 122 | − context: MutationExecutionContext, | |
| 123 | − payload: TInput, | |
| 124 | − ) => OptimisticMutationResult<TOptimistic>; | |
| 125 | − onSuccess?: ( | |
| 126 | − context: MutationExecutionContext, | |
| 127 | − payload: TInput, | |
| 128 | − data: TOutput, | |
| 129 | − optimisticValue: TOptimistic | undefined, | |
| 130 | − ) => void; | |
| 131 | −}; | |
| 132 | − | |
| 133 | −type InternalNode<A> = { | |
| 134 | − setValue: (value: A) => void; | |
| 135 | − valueOption?: () => Option.Option<A>; | |
| 136 | −}; | |
| 137 | − | |
| 138 | −let apiBaseUrl = | |
| 139 | − typeof window !== "undefined" && typeof window.location?.origin === "string" | |
| 140 | − ? window.location.origin | |
| 141 | − : DEFAULT_EXECUTOR_API_BASE_URL; | |
| 142 | − | |
| 143 | −const ExecutorQueryContext = React.createContext<ExecutorQueryContextValue | null>(null); | |
| 144 | − | |
| 145 | −const encodeAtomKey = (parts: ReadonlyArray<AtomKeyPart>): string => JSON.stringify(parts); | |
| 146 | − | |
| 147 | −const decodeAtomKey = <T extends ReadonlyArray<AtomKeyPart>>(key: string): T => JSON.parse(key) as T; | |
| 148 | − | |
| 149 | −const encodeSourcesKey = ( | |
| 150 | − enabled: boolean, | |
| 151 | − workspaceId: Source["scopeId"], | |
| 152 | − accountId: string, | |
| 153 | −): string => encodeAtomKey([enabled, workspaceId, accountId] satisfies SourcesKeyParts); | |
| 154 | − | |
| 155 | −const encodeSourceKey = ( | |
| 156 | − enabled: boolean, | |
| 157 | − workspaceId: Source["scopeId"], | |
| 158 | − accountId: string, | |
| 159 | − sourceId: Source["id"], | |
| 160 | −): string => encodeAtomKey([enabled, workspaceId, accountId, sourceId] satisfies SourceKeyParts); | |
| 161 | − | |
| 162 | −const encodeToolDetailKey = ( | |
| 163 | − enabled: boolean, | |
| 164 | − workspaceId: Source["scopeId"], | |
| 165 | − accountId: string, | |
| 166 | − sourceId: Source["id"], | |
| 167 | − toolPath: string | null, | |
| 168 | −): string => | |
| 169 | − encodeAtomKey([enabled, workspaceId, accountId, sourceId, toolPath] satisfies SourceToolDetailKeyParts); | |
| 170 | − | |
| 171 | −const encodeDiscoveryKey = ( | |
| 172 | − enabled: boolean, | |
| 173 | − workspaceId: Source["scopeId"], | |
| 174 | − accountId: string, | |
| 175 | − sourceId: Source["id"], | |
| 176 | − query: string, | |
| 177 | − limit: number | null, | |
| 178 | −): string => | |
| 179 | − encodeAtomKey([enabled, workspaceId, accountId, sourceId, query, limit] satisfies SourceDiscoveryKeyParts); | |
| 180 | − | |
| 181 | −const encodeWorkspaceOauthClientsKey = ( | |
| 182 | − enabled: boolean, | |
| 183 | − workspaceId: Source["scopeId"], | |
| 184 | − accountId: string, | |
| 185 | − providerKey: string, | |
| 186 | −): string => | |
| 187 | − encodeAtomKey( | |
| 188 | − [enabled, workspaceId, accountId, providerKey] satisfies WorkspaceOauthClientsKeyParts, | |
| 189 | − ); | |
| 190 | − | |
| 191 | −const toError = (cause: unknown): Error => | |
| 192 | − cause instanceof Error ? cause : new Error(String(cause)); | |
| 193 | − | |
| 194 | −const causeMessage = (cause: Cause.Cause<unknown>): Error => | |
| 195 | − new Error(Cause.pretty(cause)); | |
| 196 | − | |
| 197 | −const shouldLogExecutorDevErrors = (): boolean => { | |
| 198 | − if (typeof window === "undefined") { | |
| 199 | − return false; | |
| 200 | − } | |
| 201 | − | |
| 202 | − const hostname = window.location.hostname; | |
| 203 | − return hostname === "localhost" || hostname === "127.0.0.1"; | |
| 204 | −}; | |
| 205 | − | |
| 206 | −const describeExecutorDevError = (cause: unknown): Record<string, unknown> => { | |
| 207 | − if (Runtime.isFiberFailure(cause)) { | |
| 208 | − const inner = cause[Runtime.FiberFailureCauseId]; | |
| 209 | − return { | |
| 210 | − name: cause.name, | |
| 211 | − message: cause.message, | |
| 212 | − cause: Cause.pretty(inner), | |
| 213 | − }; | |
| 214 | − } | |
| 215 | − | |
| 216 | − if (cause instanceof Error) { | |
| 217 | − return { | |
| 218 | − name: cause.name, | |
| 219 | − message: cause.message, | |
| 220 | − stack: cause.stack, | |
| 221 | − }; | |
| 222 | − } | |
| 223 | − | |
| 224 | − return { | |
| 225 | − message: String(cause), | |
| 226 | − }; | |
| 227 | −}; | |
| 228 | − | |
| 229 | −const logExecutorDevError = (label: string, details: Record<string, unknown>): void => { | |
| 230 | − if (!shouldLogExecutorDevErrors()) { | |
| 231 | − return; | |
| 232 | − } | |
| 233 | − | |
| 234 | − console.error(`[executor react] ${label}`, details); | |
| 235 | −}; | |
| 236 | − | |
| 237 | −const runControlPlane = async <A>(input: { | |
| 238 | − baseUrl?: string; | |
| 239 | − accountId?: string; | |
| 240 | − execute: (client: ExecutorApiClient) => Promise<A>; | |
| 241 | −}): Promise<A> => { | |
| 242 | − const baseUrl = input.baseUrl ?? apiBaseUrl; | |
| 243 | − const accountId = input.accountId; | |
| 244 | − | |
| 245 | − try { | |
| 246 | − const client = await createExecutorApiClient({ | |
| 247 | − baseUrl, | |
| 248 | − ...(accountId !== undefined ? { accountId } : {}), | |
| 249 | − }); | |
| 250 | − return await input.execute(client); | |
| 251 | − } catch (cause) { | |
| 252 | − const error = toError(cause); | |
| 253 | − logExecutorDevError("control-plane request failed", { | |
| 254 | − baseUrl, | |
| 255 | − accountId, | |
| 256 | − error: describeExecutorDevError(error), | |
| 257 | − }); | |
| 258 | − throw error; | |
| 259 | − } | |
| 260 | −}; | |
| 261 | − | |
| 262 | −const controlPlaneRequest = <A>(input: { | |
| 263 | − baseUrl?: string; | |
| 264 | − accountId?: string; | |
| 265 | − execute: (client: ExecutorApiClient) => Promise<A>; | |
| 266 | −}): Effect.Effect<A, Error> => | |
| 267 | − Effect.tryPromise({ | |
| 268 | − try: () => runControlPlane(input), | |
| 269 | − catch: toError, | |
| 270 | − }); | |
| 271 | − | |
| 272 | −const localInstallationAtom = Atom.family((baseUrl: string) => | |
| 273 | − Atom.make( | |
| 274 | − controlPlaneRequest({ | |
| 275 | − baseUrl, | |
| 276 | − execute: (client) => client.local.installation({}), | |
| 277 | − }), | |
| 278 | − ).pipe(Atom.keepAlive), | |
| 279 | −); | |
| 280 | − | |
| 281 | −const instanceConfigAtom = Atom.family((baseUrl: string) => | |
| 282 | − Atom.make( | |
| 283 | − controlPlaneRequest({ | |
| 284 | − baseUrl, | |
| 285 | − execute: (client) => client.local.config({}), | |
| 286 | − }), | |
| 287 | − ).pipe(Atom.keepAlive), | |
| 288 | −); | |
| 289 | − | |
| 290 | −const secretsAtom = Atom.family((baseUrl: string) => | |
| 291 | − Atom.make( | |
| 292 | − controlPlaneRequest({ | |
| 293 | − baseUrl, | |
| 294 | − execute: (client) => client.local.listSecrets({}), | |
| 295 | − }), | |
| 296 | − ).pipe(Atom.keepAlive), | |
| 297 | −); | |
| 298 | − | |
| 299 | −const sourcesAtom = Atom.family((key: string) => { | |
| 300 | − const [enabled, workspaceId, accountId] = decodeAtomKey<SourcesKeyParts>(key); | |
| 301 | − | |
| 302 | − return Atom.make( | |
| 303 | − enabled | |
| 304 | − ? controlPlaneRequest({ | |
| 305 | − accountId, | |
| 306 | − execute: (client) => client.sources.list({ | |
| 307 | − path: { | |
| 308 | − workspaceId, | |
| 309 | − }, | |
| 310 | − }), | |
| 311 | − }) | |
| 312 | − : Effect.never, | |
| 313 | − ).pipe(Atom.keepAlive); | |
| 314 | −}); | |
| 315 | − | |
| 316 | −const sourceAtom = Atom.family((key: string) => { | |
| 317 | − const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceKeyParts>(key); | |
| 318 | − | |
| 319 | − return Atom.make( | |
| 320 | − enabled | |
| 321 | − ? controlPlaneRequest({ | |
| 322 | − accountId, | |
| 323 | − execute: (client) => client.sources.get({ | |
| 324 | − path: { | |
| 325 | − workspaceId, | |
| 326 | − sourceId, | |
| 327 | − }, | |
| 328 | − }), | |
| 329 | − }) | |
| 330 | − : Effect.never, | |
| 331 | − ).pipe(Atom.keepAlive); | |
| 332 | −}); | |
| 333 | − | |
| 334 | −const sourceInspectionAtom = Atom.family((key: string) => { | |
| 335 | − const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceKeyParts>(key); | |
| 336 | − | |
| 337 | − return Atom.make( | |
| 338 | − enabled | |
| 339 | − ? controlPlaneRequest({ | |
| 340 | − accountId, | |
| 341 | − execute: (client) => client.sources.inspection({ | |
| 342 | − path: { | |
| 343 | − workspaceId, | |
| 344 | − sourceId, | |
| 345 | − }, | |
| 346 | − }), | |
| 347 | − }) | |
| 348 | − : Effect.never, | |
| 349 | − ).pipe(Atom.keepAlive); | |
| 350 | −}); | |
| 351 | − | |
| 352 | −const sourceInspectionToolAtom = Atom.family((key: string) => { | |
| 353 | − const [enabled, workspaceId, accountId, sourceId, toolPath] = decodeAtomKey<SourceToolDetailKeyParts>(key); | |
| 354 | − | |
| 355 | − return Atom.make( | |
| 356 | − enabled && toolPath | |
| 357 | − ? controlPlaneRequest({ | |
| 358 | − accountId, | |
| 359 | − execute: (client) => client.sources.inspectionTool({ | |
| 360 | − path: { | |
| 361 | − workspaceId, | |
| 362 | − sourceId, | |
| 363 | − toolPath, | |
| 364 | − }, | |
| 365 | − }), | |
| 366 | − }) | |
| 367 | − : Effect.succeed<SourceInspectionToolDetail | null>(null), | |
| 368 | − ).pipe(Atom.keepAlive); | |
| 369 | −}); | |
| 370 | − | |
| 371 | −const sourceDiscoveryAtom = Atom.family((key: string) => { | |
| 372 | − const [enabled, workspaceId, accountId, sourceId, query, limit] = decodeAtomKey<SourceDiscoveryKeyParts>(key); | |
| 373 | − | |
| 374 | − return Atom.make( | |
| 375 | − !enabled | |
| 376 | − ? Effect.never | |
| 377 | − : query.trim().length === 0 | |
| 378 | − ? Effect.succeed<SourceInspectionDiscoverResult>({ | |
| 379 | − query: "", | |
| 380 | − queryTokens: [], | |
| 381 | − bestPath: null, | |
| 382 | − total: 0, | |
| 383 | − results: [], | |
| 384 | − }) | |
| 385 | − : controlPlaneRequest({ | |
| 386 | − accountId, | |
| 387 | − execute: (client) => client.sources.inspectionDiscover({ | |
| 388 | − path: { | |
| 389 | − workspaceId, | |
| 390 | − sourceId, | |
| 391 | − }, | |
| 392 | − payload: { | |
| 393 | − query, | |
| 394 | − ...(limit !== null ? { limit } : {}), | |
| 395 | − }, | |
| 396 | − }), | |
| 397 | − }), | |
| 398 | − ).pipe(Atom.keepAlive); | |
| 399 | −}); | |
| 400 | − | |
| 401 | −const workspaceOauthClientsAtom = Atom.family((key: string) => { | |
| 402 | − const [enabled, workspaceId, accountId, providerKey] = decodeAtomKey<WorkspaceOauthClientsKeyParts>(key); | |
| 403 | − | |
| 404 | − return Atom.make( | |
| 405 | − enabled | |
| 406 | − ? controlPlaneRequest({ | |
| 407 | − accountId, | |
| 408 | − execute: (client) => client.sources.listWorkspaceOauthClients({ | |
| 409 | − path: { | |
| 410 | − workspaceId, | |
| 411 | − }, | |
| 412 | − urlParams: { | |
| 413 | − providerKey, | |
| 414 | − }, | |
| 415 | − }), | |
| 416 | − }) | |
| 417 | − : Effect.never, | |
| 418 | − ).pipe(Atom.keepAlive); | |
| 419 | −}); | |
| 420 | − | |
| 421 | −export type Loadable<T> = | |
| 422 | − | { status: "loading" } | |
| 423 | − | { status: "error"; error: Error } | |
| 424 | − | { status: "ready"; data: T }; | |
| 425 | − | |
| 426 | −type WorkspaceContext = { | |
| 427 | − installation: LocalInstallation; | |
| 428 | − workspaceId: Source["scopeId"]; | |
| 429 | − accountId: string; | |
| 430 | −}; | |
| 431 | − | |
| 432 | −const toLoadable = <T>(result: Result.Result<T, Error>): Loadable<T> => { | |
| 433 | − if (Result.isSuccess(result)) { | |
| 434 | − return { | |
| 435 | − status: "ready", | |
| 436 | − data: result.value, | |
| 437 | − }; | |
| 438 | − } | |
| 439 | − | |
| 440 | − if (Result.isFailure(result)) { | |
| 441 | − return { | |
| 442 | − status: "error", | |
| 443 | − error: causeMessage(result.cause), | |
| 444 | − }; | |
| 445 | − } | |
| 446 | − | |
| 447 | − return { | |
| 448 | − status: "loading", | |
| 449 | − }; | |
| 450 | −}; | |
| 451 | − | |
| 452 | −const pendingLoadable = <T>(workspace: Loadable<WorkspaceContext>): Loadable<T> => { | |
| 453 | − if (workspace.status === "loading") { | |
| 454 | − return { status: "loading" }; | |
| 455 | − } | |
| 456 | − | |
| 457 | − if (workspace.status === "error") { | |
| 458 | − return { status: "error", error: workspace.error }; | |
| 459 | − } | |
| 460 | − | |
| 461 | − throw new Error("Expected workspace loadable to be pending or errored"); | |
| 462 | −}; | |
| 463 | − | |
| 464 | −const useLoadableAtom = <T>(atom: Atom.Atom<Result.Result<T, Error>>): Loadable<T> => { | |
| 465 | − const result = useAtomValue(atom); | |
| 466 | − return React.useMemo(() => toLoadable(result), [result]); | |
| 467 | −}; | |
| 468 | − | |
| 469 | −const useWorkspaceContext = (): Loadable<WorkspaceContext> => { | |
| 470 | − const installation = useLoadableAtom(localInstallationAtom(apiBaseUrl)); | |
| 471 | − | |
| 472 | − return React.useMemo(() => { | |
| 473 | − if (installation.status !== "ready") { | |
| 474 | − return installation; | |
| 475 | − } | |
| 476 | − | |
| 477 | − return { | |
| 478 | − status: "ready", | |
| 479 | − data: { | |
| 480 | − installation: installation.data, | |
| 481 | − workspaceId: installation.data.scopeId, | |
| 482 | − accountId: installation.data.actorScopeId, | |
| 483 | − }, | |
| 484 | − } satisfies Loadable<WorkspaceContext>; | |
| 485 | − }, [installation]); | |
| 486 | −}; | |
| 487 | − | |
| 488 | −const useWorkspaceRequestContext = () => { | |
| 489 | − const workspace = useWorkspaceContext(); | |
| 490 | − const enabled = workspace.status === "ready"; | |
| 491 | − | |
| 492 | − const workspaceId = enabled | |
| 493 | − ? workspace.data.workspaceId | |
| 494 | − : PLACEHOLDER_WORKSPACE_ID; | |
| 495 | − const accountId = enabled | |
| 496 | − ? workspace.data.accountId | |
| 497 | − : PLACEHOLDER_ACCOUNT_ID; | |
| 498 | − | |
| 499 | − return React.useMemo( | |
| 500 | − () => ({ | |
| 501 | − workspace, | |
| 502 | − enabled, | |
| 503 | − workspaceId, | |
| 504 | − accountId, | |
| 505 | − }), | |
| 506 | − [accountId, enabled, workspace, workspaceId], | |
| 507 | − ); | |
| 508 | −}; | |
| 509 | − | |
| 510 | −const getCachedAtomValue = <A>( | |
| 511 | − registry: Registry.Registry, | |
| 512 | − atom: Atom.Atom<Result.Result<A, Error>>, | |
| 513 | −): A | undefined => { | |
| 514 | − const node = (registry.getNodes().get(atom) ?? null) as InternalNode<Result.Result<A, Error>> | null; | |
| 515 | − if (node === null || typeof node.valueOption !== "function") { | |
| 516 | − return undefined; | |
| 517 | − } | |
| 518 | − | |
| 519 | − const option = node.valueOption(); | |
| 520 | − if (Option.isNone(option)) { | |
| 521 | − return undefined; | |
| 522 | − } | |
| 523 | − | |
| 524 | − if (!Result.isSuccess(option.value)) { | |
| 525 | − return undefined; | |
| 526 | − } | |
| 527 | − | |
| 528 | − return option.value.value; | |
| 529 | −}; | |
| 530 | − | |
| 531 | −const setCachedAtomValue = <A>( | |
| 532 | − registry: Registry.Registry, | |
| 533 | − atom: Atom.Atom<Result.Result<A, Error>>, | |
| 534 | − value: A, | |
| 535 | −): void => { | |
| 536 | − const ensureNode = (registry as { | |
| 537 | − ensureNode?: (atom: Atom.Atom<Result.Result<A, Error>>) => InternalNode<Result.Result<A, Error>>; | |
| 538 | − }).ensureNode; | |
| 539 | − if (typeof ensureNode !== "function") { | |
| 540 | − return; | |
| 541 | − } | |
| 542 | − | |
| 543 | − ensureNode.call(registry, atom).setValue(Result.success(value)); | |
| 544 | −}; | |
| 545 | − | |
| 546 | −const createActiveQueryCollections = (): ActiveQueryCollections => ({ | |
| 547 | − sourceLists: new Set(), | |
| 548 | − sources: new Set(), | |
| 549 | − workspaceOauthClients: new Set(), | |
| 550 | − inspections: new Set(), | |
| 551 | − toolDetails: new Set(), | |
| 552 | − discoveries: new Set(), | |
| 553 | −}); | |
| 554 | − | |
| 555 | −const targetMatches = ( | |
| 556 | − target: InvalidationTarget | undefined, | |
| 557 | − workspaceId: Source["scopeId"], | |
| 558 | − accountId: string, | |
| 559 | − sourceId?: Source["id"], | |
| 560 | −): boolean => { | |
| 561 | − if (target?.workspaceId !== undefined && target.workspaceId !== workspaceId) { | |
| 562 | − return false; | |
| 563 | − } | |
| 564 | − if (target?.accountId !== undefined && target.accountId !== accountId) { | |
| 565 | − return false; | |
| 566 | − } | |
| 567 | − if (target?.sourceId !== undefined && target.sourceId !== sourceId) { | |
| 568 | − return false; | |
| 569 | − } | |
| 570 | − return true; | |
| 571 | −}; | |
| 572 | − | |
| 573 | −const invalidateTrackedQueries = ( | |
| 574 | − registry: Registry.Registry, | |
| 575 | − activeQueries: ActiveQueryCollections, | |
| 576 | − target?: InvalidationTarget, | |
| 577 | −): void => { | |
| 578 | − activeQueries.sourceLists.forEach((key) => { | |
| 579 | − const [enabled, workspaceId, accountId] = decodeAtomKey<SourcesKeyParts>(key); | |
| 580 | − if (enabled && targetMatches(target, workspaceId, accountId)) { | |
| 581 | − registry.refresh(sourcesAtom(key)); | |
| 582 | − } | |
| 583 | − }); | |
| 584 | − | |
| 585 | − activeQueries.sources.forEach((key) => { | |
| 586 | − const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceKeyParts>(key); | |
| 587 | − if (enabled && targetMatches(target, workspaceId, accountId, sourceId)) { | |
| 588 | − registry.refresh(sourceAtom(key)); | |
| 589 | − } | |
| 590 | − }); | |
| 591 | − | |
| 592 | − activeQueries.workspaceOauthClients.forEach((key) => { | |
| 593 | − const [enabled, workspaceId, accountId] = decodeAtomKey<WorkspaceOauthClientsKeyParts>(key); | |
| 594 | − if (enabled && targetMatches(target, workspaceId, accountId)) { | |
| 595 | − registry.refresh(workspaceOauthClientsAtom(key)); | |
| 596 | − } | |
| 597 | − }); | |
| 598 | − | |
| 599 | − activeQueries.inspections.forEach((key) => { | |
| 600 | − const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceKeyParts>(key); | |
| 601 | − if (enabled && targetMatches(target, workspaceId, accountId, sourceId)) { | |
| 602 | − registry.refresh(sourceInspectionAtom(key)); | |
| 603 | − } | |
| 604 | − }); | |
| 605 | − | |
| 606 | − activeQueries.toolDetails.forEach((key) => { | |
| 607 | − const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceToolDetailKeyParts>(key); | |
| 608 | − if (enabled && targetMatches(target, workspaceId, accountId, sourceId)) { | |
| 609 | − registry.refresh(sourceInspectionToolAtom(key)); | |
| 610 | − } | |
| 611 | − }); | |
| 612 | − | |
| 613 | − activeQueries.discoveries.forEach((key) => { | |
| 614 | − const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceDiscoveryKeyParts>(key); | |
| 615 | − if (enabled && targetMatches(target, workspaceId, accountId, sourceId)) { | |
| 616 | − registry.refresh(sourceDiscoveryAtom(key)); | |
| 617 | − } | |
| 618 | − }); | |
| 619 | −}; | |
| 620 | − | |
| 621 | −const useExecutorQueryContext = (): ExecutorQueryContextValue => { | |
| 622 | − const context = React.useContext(ExecutorQueryContext); | |
| 623 | − if (context === null) { | |
| 624 | − throw new Error("ExecutorReactProvider is missing from the React tree"); | |
| 625 | − } | |
| 626 | − return context; | |
| 627 | −}; | |
| 628 | − | |
| 629 | −const useTrackActiveKey = ( | |
| 630 | − collection: keyof ActiveQueryCollections, | |
| 631 | − key: string, | |
| 632 | − enabled: boolean, | |
| 633 | −): void => { | |
| 634 | − const { activeQueries } = useExecutorQueryContext(); | |
| 635 | − | |
| 636 | − React.useEffect(() => { | |
| 637 | − if (!enabled) { | |
| 638 | − return; | |
| 639 | − } | |
| 640 | − | |
| 641 | − const bucket = activeQueries[collection]; | |
| 642 | − bucket.add(key); | |
| 643 | − return () => { | |
| 644 | − bucket.delete(key); | |
| 645 | − }; | |
| 646 | − }, [activeQueries, collection, enabled, key]); | |
| 647 | −}; | |
| 648 | − | |
| 649 | −const upsertSourceInList = ( | |
| 650 | − sources: ReadonlyArray<Source>, | |
| 651 | − nextSource: Source, | |
| 652 | −): ReadonlyArray<Source> => { | |
| 653 | − const index = sources.findIndex((source) => source.id === nextSource.id); | |
| 654 | − if (index === -1) { | |
| 655 | − return [nextSource, ...sources]; | |
| 656 | − } | |
| 657 | − | |
| 658 | − const next = sources.slice(); | |
| 659 | − next[index] = nextSource; | |
| 660 | − return next; | |
| 661 | −}; | |
| 662 | − | |
| 663 | −const removeSourceFromList = ( | |
| 664 | − sources: ReadonlyArray<Source>, | |
| 665 | − sourceId: Source["id"], | |
| 666 | −): ReadonlyArray<Source> => sources.filter((source) => source.id !== sourceId); | |
| 667 | − | |
| 668 | −const createOptimisticSource = (input: { | |
| 669 | − workspaceId: Source["scopeId"]; | |
| 670 | − payload: CreateSourcePayload; | |
| 671 | −}): Source => { | |
| 672 | − const now = Date.now(); | |
| 673 | − | |
| 674 | − return { | |
| 675 | − id: `src_optimistic_${crypto.randomUUID()}` as Source["id"], | |
| 676 | − scopeId: input.workspaceId, | |
| 677 | − name: input.payload.name, | |
| 678 | − kind: input.payload.kind, | |
| 679 | − endpoint: input.payload.endpoint, | |
| 680 | − status: input.payload.status ?? "draft", | |
| 681 | − enabled: input.payload.enabled ?? true, | |
| 682 | − namespace: input.payload.namespace ?? null, | |
| 683 | − bindingVersion: 1, | |
| 684 | − binding: input.payload.binding ?? {}, | |
| 685 | − importAuthPolicy: input.payload.importAuthPolicy ?? "reuse_runtime", | |
| 686 | − importAuth: input.payload.importAuth ?? { kind: "none" }, | |
| 687 | − auth: input.payload.auth ?? { kind: "none" }, | |
| 688 | − sourceHash: input.payload.sourceHash ?? null, | |
| 689 | − lastError: input.payload.lastError ?? null, | |
| 690 | − createdAt: now, | |
| 691 | − updatedAt: now, | |
| 692 | − }; | |
| 693 | −}; | |
| 694 | − | |
| 695 | −const applyUpdatePayloadToSource = (source: Source, payload: UpdateSourcePayload): Source => ({ | |
| 696 | − ...source, | |
| 697 | − name: payload.name ?? source.name, | |
| 698 | − endpoint: payload.endpoint ?? source.endpoint, | |
| 699 | − status: payload.status ?? source.status, | |
| 700 | − enabled: payload.enabled ?? source.enabled, | |
| 701 | − namespace: payload.namespace !== undefined ? payload.namespace : source.namespace, | |
| 702 | − binding: payload.binding !== undefined ? payload.binding : source.binding, | |
| 703 | − auth: payload.auth !== undefined ? payload.auth : source.auth, | |
| 704 | − sourceHash: payload.sourceHash !== undefined ? payload.sourceHash : source.sourceHash, | |
| 705 | − lastError: payload.lastError !== undefined ? payload.lastError : source.lastError, | |
| 706 | − updatedAt: Date.now(), | |
| 707 | −}); | |
| 708 | − | |
| 709 | −const useSourceMutation = <TInput, TOutput, TOptimistic = never>( | |
| 710 | − execute: (input: { | |
| 711 | − workspaceId: Source["scopeId"]; | |
| 712 | − accountId: string; | |
| 713 | − payload: TInput; | |
| 714 | − }) => Promise<TOutput>, | |
| 715 | − options?: MutationOptions<TInput, TOutput, TOptimistic>, | |
| 716 | −) => { | |
| 717 | − const workspace = useWorkspaceRequestContext(); | |
| 718 | − const { registry, invalidateQueries } = useExecutorQueryContext(); | |
| 719 | − const [state, setState] = React.useState<SourceMutationState<TOutput>>({ | |
| 720 | − status: "idle", | |
| 721 | − data: null, | |
| 722 | − error: null, | |
| 723 | − }); | |
| 724 | − | |
| 725 | − const mutateAsync = React.useCallback(async (payload: TInput) => { | |
| 726 | − if (!workspace.enabled) { | |
| 727 | − const error = new Error("Executor workspace context is not ready"); | |
| 728 | − setState({ status: "error", data: null, error }); | |
| 729 | − throw error; | |
| 730 | − } | |
| 731 | − | |
| 732 | − const executionContext: MutationExecutionContext = { | |
| 733 | − workspaceId: workspace.workspaceId, | |
| 734 | − accountId: workspace.accountId, | |
| 735 | − registry, | |
| 736 | − invalidateQueries, | |
| 737 | − }; | |
| 738 | − | |
| 739 | − setState((current) => ({ | |
| 740 | − status: "pending", | |
| 741 | − data: current.data, | |
| 742 | − error: null, | |
| 743 | − })); | |
| 744 | − | |
| 745 | − const optimistic = options?.optimisticUpdate?.(executionContext, payload); | |
| 746 | − const rollback = typeof optimistic === "function" | |
| 747 | − ? optimistic | |
| 748 | − : optimistic?.rollback; | |
| 749 | − const optimisticValue = typeof optimistic === "function" | |
| 750 | − ? undefined | |
| 751 | − : optimistic?.value; | |
| 752 | − | |
| 753 | − try { | |
| 754 | − const data = await execute({ | |
| 755 | − workspaceId: workspace.workspaceId, | |
| 756 | − accountId: workspace.accountId, | |
| 757 | − payload, | |
| 758 | − }); | |
| 759 | − options?.onSuccess?.(executionContext, payload, data, optimisticValue); | |
| 760 | − setState({ status: "success", data, error: null }); | |
| 761 | − return data; | |
| 762 | − } catch (cause) { | |
| 763 | − rollback?.(); | |
| 764 | − logExecutorDevError("source mutation failed", { | |
| 765 | − workspaceId: workspace.workspaceId, | |
| 766 | − accountId: workspace.accountId, | |
| 767 | − payload, | |
| 768 | − error: describeExecutorDevError(cause), | |
| 769 | − cause, | |
| 770 | − }); | |
| 771 | − const error = cause instanceof Error ? cause : new Error(String(cause)); | |
| 772 | − setState({ status: "error", data: null, error }); | |
| 773 | − throw error; | |
| 774 | − } | |
| 775 | − }, [execute, invalidateQueries, options, registry, workspace.accountId, workspace.enabled, workspace.workspaceId]); | |
| 776 | − | |
| 777 | − const reset = React.useCallback(() => { | |
| 778 | − setState({ status: "idle", data: null, error: null }); | |
| 779 | − }, []); | |
| 780 | − | |
| 781 | − return React.useMemo( | |
| 782 | − () => ({ | |
| 783 | − ...state, | |
| 784 | − mutateAsync, | |
| 785 | − reset, | |
| 786 | − }), | |
| 787 | − [mutateAsync, reset, state], | |
| 788 | − ); | |
| 789 | −}; | |
| 790 | − | |
| 791 | −const ExecutorReactProviderInner = (props: React.PropsWithChildren) => { | |
| 792 | − const registry = React.useContext(RegistryContext); | |
| 793 | − const activeQueries = React.useMemo(createActiveQueryCollections, []); | |
| 794 | − const invalidateQueries = React.useCallback((target?: InvalidationTarget) => { | |
| 795 | − invalidateTrackedQueries(registry, activeQueries, target); | |
| 796 | − }, [activeQueries, registry]); | |
| 797 | − | |
| 798 | − const value = React.useMemo<ExecutorQueryContextValue>(() => ({ | |
| 799 | − registry, | |
| 800 | − activeQueries, | |
| 801 | − invalidateQueries, | |
| 802 | − }), [activeQueries, invalidateQueries, registry]); | |
| 803 | − | |
| 804 | − return React.createElement(ExecutorQueryContext.Provider, { value }, props.children); | |
| 805 | −}; | |
| 806 | − | |
| 807 | −export const setExecutorApiBaseUrl = (baseUrl: string): void => { | |
| 808 | − apiBaseUrl = baseUrl; | |
| 809 | −}; | |
| 810 | − | |
| 811 | −export const ExecutorReactProvider = (props: React.PropsWithChildren) => | |
| 812 | − React.createElement( | |
| 813 | − RegistryProvider, | |
| 814 | − null, | |
| 815 | − React.createElement(ExecutorReactProviderInner, null, props.children), | |
| 816 | − ); | |
| 817 | − | |
| 818 | −export const useInvalidateExecutorQueries = (): (() => void) => { | |
| 819 | − const { invalidateQueries } = useExecutorQueryContext(); | |
| 820 | − return React.useCallback(() => { | |
| 821 | − invalidateQueries(); | |
| 822 | − }, [invalidateQueries]); | |
| 823 | −}; | |
| 824 | − | |
| 825 | −export const useLocalInstallation = (): Loadable<LocalInstallation> => | |
| 826 | − useLoadableAtom(localInstallationAtom(apiBaseUrl)); | |
| 827 | − | |
| 828 | −export const useInstanceConfig = (): Loadable<InstanceConfig> => | |
| 829 | − useLoadableAtom(instanceConfigAtom(apiBaseUrl)); | |
| 830 | − | |
| 831 | −export const useSecrets = (): Loadable<ReadonlyArray<SecretListItem>> => | |
| 832 | − useLoadableAtom(secretsAtom(apiBaseUrl)); | |
| 833 | − | |
| 834 | −export const useRefreshSecrets = (): (() => void) => { | |
| 835 | − const registry = React.useContext(RegistryContext); | |
| 836 | − return React.useCallback(() => { | |
| 837 | − registry.refresh(secretsAtom(apiBaseUrl)); | |
| 838 | − }, [registry]); | |
| 839 | −}; | |
| 840 | − | |
| 841 | −type SecretMutationState<T> = { | |
| 842 | − status: "idle" | "pending" | "success" | "error"; | |
| 843 | − data: T | null; | |
| 844 | − error: Error | null; | |
| 845 | −}; | |
| 846 | − | |
| 847 | −const useSecretMutation = <TInput, TOutput>( | |
| 848 | − execute: (input: TInput) => Promise<TOutput>, | |
| 849 | −) => { | |
| 850 | − const registry = React.useContext(RegistryContext); | |
| 851 | − const [state, setState] = React.useState<SecretMutationState<TOutput>>({ | |
| 852 | − status: "idle", | |
| 853 | − data: null, | |
| 854 | − error: null, | |
| 855 | − }); | |
| 856 | − | |
| 857 | − const mutateAsync = React.useCallback(async (payload: TInput) => { | |
| 858 | − setState((current) => ({ | |
| 859 | − status: "pending", | |
| 860 | − data: current.data, | |
| 861 | − error: null, | |
| 862 | − })); | |
| 863 | − | |
| 864 | − try { | |
| 865 | − const data = await execute(payload); | |
| 866 | − registry.refresh(secretsAtom(apiBaseUrl)); | |
| 867 | − setState({ status: "success", data, error: null }); | |
| 868 | − return data; | |
| 869 | − } catch (cause) { | |
| 870 | − const error = cause instanceof Error ? cause : new Error(String(cause)); | |
| 871 | − setState({ status: "error", data: null, error }); | |
| 872 | − throw error; | |
| 873 | − } | |
| 874 | − }, [execute, registry]); | |
| 875 | − | |
| 876 | − const reset = React.useCallback(() => { | |
| 877 | − setState({ status: "idle", data: null, error: null }); | |
| 878 | − }, []); | |
| 879 | − | |
| 880 | − return React.useMemo( | |
| 881 | − () => ({ | |
| 882 | − ...state, | |
| 883 | − mutateAsync, | |
| 884 | − reset, | |
| 885 | − }), | |
| 886 | − [mutateAsync, reset, state], | |
| 887 | − ); | |
| 888 | −}; | |
| 889 | − | |
| 890 | −export const useCreateSecret = () => | |
| 891 | − useSecretMutation<CreateSecretPayload, CreateSecretResult>( | |
| 892 | − React.useCallback( | |
| 893 | − (payload) => | |
| 894 | − runControlPlane({ | |
| 895 | − execute: (client) => client.local.createSecret({ | |
| 896 | − payload, | |
| 897 | − }), | |
| 898 | − }), | |
| 899 | − [], | |
| 900 | − ), | |
| 901 | − ); | |
| 902 | − | |
| 903 | −export const useUpdateSecret = () => | |
| 904 | − useSecretMutation<{ secretId: string; payload: UpdateSecretPayload }, UpdateSecretResult>( | |
| 905 | − React.useCallback( | |
| 906 | − (input) => | |
| 907 | − runControlPlane({ | |
| 908 | − execute: (client) => client.local.updateSecret({ | |
| 909 | − path: { secretId: input.secretId }, | |
| 910 | − payload: input.payload, | |
| 911 | − }), | |
| 912 | − }), | |
| 913 | − [], | |
| 914 | − ), | |
| 915 | − ); | |
| 916 | − | |
| 917 | −export const useDeleteSecret = () => | |
| 918 | − useSecretMutation<string, DeleteSecretResult>( | |
| 919 | − React.useCallback( | |
| 920 | − (secretId) => | |
| 921 | − runControlPlane({ | |
| 922 | − execute: (client) => client.local.deleteSecret({ | |
| 923 | − path: { secretId }, | |
| 924 | − }), | |
| 925 | − }), | |
| 926 | − [], | |
| 927 | − ), | |
| 928 | − ); | |
| 929 | − | |
| 930 | −export const useSources = (): Loadable<ReadonlyArray<Source>> => { | |
| 931 | − const workspace = useWorkspaceRequestContext(); | |
| 932 | − const key = encodeSourcesKey(workspace.enabled, workspace.workspaceId, workspace.accountId); | |
| 933 | − useTrackActiveKey("sourceLists", key, workspace.enabled); | |
| 934 | − const sources = useLoadableAtom(sourcesAtom(key)); | |
| 935 | − | |
| 936 | − return workspace.enabled ? sources : pendingLoadable(workspace.workspace); | |
| 937 | −}; | |
| 938 | − | |
| 939 | −export const useSource = (sourceId: string): Loadable<Source> => { | |
| 940 | − const workspace = useWorkspaceRequestContext(); | |
| 941 | − const requestedSourceId = workspace.enabled | |
| 942 | − ? (sourceId as Source["id"]) | |
| 943 | − : PLACEHOLDER_SOURCE_ID; | |
| 944 | − const key = encodeSourceKey( | |
| 945 | − workspace.enabled, | |
| 946 | − workspace.workspaceId, | |
| 947 | − workspace.accountId, | |
| 948 | − requestedSourceId, | |
| 949 | − ); | |
| 950 | − useTrackActiveKey("sources", key, workspace.enabled); | |
| 951 | − const source = useLoadableAtom(sourceAtom(key)); | |
| 952 | − | |
| 953 | − return workspace.enabled ? source : pendingLoadable(workspace.workspace); | |
| 954 | −}; | |
| 955 | − | |
| 956 | −export const useWorkspaceOauthClients = ( | |
| 957 | − providerKey: string | null, | |
| 958 | −): Loadable<ReadonlyArray<WorkspaceOauthClient>> => { | |
| 959 | − const workspace = useWorkspaceRequestContext(); | |
| 960 | − const key = encodeWorkspaceOauthClientsKey( | |
| 961 | − workspace.enabled && providerKey !== null, | |
| 962 | − workspace.workspaceId, | |
| 963 | − workspace.accountId, | |
| 964 | − providerKey ?? "", | |
| 965 | − ); | |
| 966 | − useTrackActiveKey( | |
| 967 | − "workspaceOauthClients", | |
| 968 | − key, | |
| 969 | − workspace.enabled && providerKey !== null, | |
| 970 | − ); | |
| 971 | − const oauthClients = useLoadableAtom(workspaceOauthClientsAtom(key)); | |
| 972 | − | |
| 973 | − if (!workspace.enabled) { | |
| 974 | − return pendingLoadable(workspace.workspace); | |
| 975 | − } | |
| 976 | − | |
| 977 | − if (providerKey === null) { | |
| 978 | − return { | |
| 979 | − status: "ready", | |
| 980 | − data: [], | |
| 981 | − }; | |
| 982 | − } | |
| 983 | − | |
| 984 | − return oauthClients; | |
| 985 | −}; | |
| 986 | − | |
| 987 | −export const useSourceInspection = (sourceId: string): Loadable<SourceInspection> => { | |
| 988 | − const workspace = useWorkspaceRequestContext(); | |
| 989 | − const requestedSourceId = workspace.enabled | |
| 990 | − ? (sourceId as Source["id"]) | |
| 991 | − : PLACEHOLDER_SOURCE_ID; | |
| 992 | − const key = encodeSourceKey( | |
| 993 | − workspace.enabled, | |
| 994 | − workspace.workspaceId, | |
| 995 | − workspace.accountId, | |
| 996 | − requestedSourceId, | |
| 997 | − ); | |
| 998 | − useTrackActiveKey("inspections", key, workspace.enabled); | |
| 999 | − const inspection = useLoadableAtom(sourceInspectionAtom(key)); | |
| 1000 | − | |
| 1001 | − return workspace.enabled ? inspection : pendingLoadable(workspace.workspace); | |
| 1002 | −}; | |
| 1003 | − | |
| 1004 | −export const useSourceToolDetail = ( | |
| 1005 | − sourceId: string, | |
| 1006 | − toolPath: string | null, | |
| 1007 | −): Loadable<SourceInspectionToolDetail | null> => { | |
| 1008 | − const workspace = useWorkspaceRequestContext(); | |
| 1009 | − const requestedSourceId = workspace.enabled | |
| 1010 | − ? (sourceId as Source["id"]) | |
| 1011 | − : PLACEHOLDER_SOURCE_ID; | |
| 1012 | − const key = encodeToolDetailKey( | |
| 1013 | − workspace.enabled, | |
| 1014 | − workspace.workspaceId, | |
| 1015 | − workspace.accountId, | |
| 1016 | − requestedSourceId, | |
| 1017 | − toolPath, | |
| 1018 | − ); | |
| 1019 | − useTrackActiveKey("toolDetails", key, workspace.enabled && toolPath !== null); | |
| 1020 | − const detail = useLoadableAtom(sourceInspectionToolAtom(key)); | |
| 1021 | − | |
| 1022 | − return workspace.enabled ? detail : pendingLoadable(workspace.workspace); | |
| 1023 | −}; | |
| 1024 | − | |
| 1025 | −export const useSourceDiscovery = (input: { | |
| 1026 | − sourceId: string; | |
| 1027 | − query: string; | |
| 1028 | − limit?: number; | |
| 1029 | −}): Loadable<SourceInspectionDiscoverResult> => { | |
| 1030 | − const workspace = useWorkspaceRequestContext(); | |
| 1031 | − const requestedSourceId = workspace.enabled | |
| 1032 | − ? (input.sourceId as Source["id"]) | |
| 1033 | − : PLACEHOLDER_SOURCE_ID; | |
| 1034 | − const key = encodeDiscoveryKey( | |
| 1035 | − workspace.enabled, | |
| 1036 | − workspace.workspaceId, | |
| 1037 | − workspace.accountId, | |
| 1038 | − requestedSourceId, | |
| 1039 | − input.query, | |
| 1040 | − input.limit ?? null, | |
| 1041 | − ); | |
| 1042 | − useTrackActiveKey("discoveries", key, workspace.enabled); | |
| 1043 | − const results = useLoadableAtom(sourceDiscoveryAtom(key)); | |
| 1044 | − | |
| 1045 | − return workspace.enabled ? results : pendingLoadable(workspace.workspace); | |
| 1046 | −}; | |
| 1047 | − | |
| 1048 | −export const usePrefetchToolDetail = () => { | |
| 1049 | − const registry = React.useContext(RegistryContext); | |
| 1050 | − const workspace = useWorkspaceRequestContext(); | |
| 1051 | − | |
| 1052 | − return React.useCallback( | |
| 1053 | − (sourceId: string, toolPath: string): (() => void) => { | |
| 1054 | − if (!workspace.enabled) return () => {}; | |
| 1055 | − const requestedSourceId = sourceId as Source["id"]; | |
| 1056 | − const atom = sourceInspectionToolAtom( | |
| 1057 | − encodeToolDetailKey( | |
| 1058 | − workspace.enabled, | |
| 1059 | − workspace.workspaceId, | |
| 1060 | − workspace.accountId, | |
| 1061 | − requestedSourceId, | |
| 1062 | − toolPath, | |
| 1063 | − ), | |
| 1064 | − ); | |
| 1065 | − return registry.mount(atom); | |
| 1066 | − }, | |
| 1067 | − [registry, workspace.accountId, workspace.enabled, workspace.workspaceId], | |
| 1068 | − ); | |
| 1069 | −}; | |
| 1070 | − | |
| 1071 | −export const useCreateSource = () => | |
| 1072 | − useSourceMutation<CreateSourcePayload, Source, Source>( | |
| 1073 | − React.useCallback( | |
| 1074 | − ({ workspaceId, accountId, payload }) => | |
| 1075 | − runControlPlane({ | |
| 1076 | − accountId, | |
| 1077 | − execute: (client) => client.sources.create({ | |
| 1078 | − path: { | |
| 1079 | − workspaceId, | |
| 1080 | − }, | |
| 1081 | − payload, | |
| 1082 | − }), | |
| 1083 | − }), | |
| 1084 | − [], | |
| 1085 | − ), | |
| 1086 | − { | |
| 1087 | − optimisticUpdate: (context, payload) => { | |
| 1088 | − const listAtom = sourcesAtom(encodeSourcesKey(true, context.workspaceId, context.accountId)); | |
| 1089 | − const previousList = getCachedAtomValue(context.registry, listAtom); | |
| 1090 | − if (previousList === undefined) { | |
| 1091 | − return; | |
| 1092 | − } | |
| 1093 | − | |
| 1094 | − const optimisticSource = createOptimisticSource({ | |
| 1095 | − workspaceId: context.workspaceId, | |
| 1096 | − payload, | |
| 1097 | − }); | |
| 1098 | − setCachedAtomValue(context.registry, listAtom, [optimisticSource, ...previousList]); | |
| 1099 | − return { | |
| 1100 | − value: optimisticSource, | |
| 1101 | − rollback: () => { | |
| 1102 | − setCachedAtomValue(context.registry, listAtom, previousList); | |
| 1103 | − }, | |
| 1104 | − }; | |
| 1105 | − }, | |
| 1106 | − onSuccess: (context, _payload, source, optimisticSource) => { | |
| 1107 | − const listAtom = sourcesAtom(encodeSourcesKey(true, context.workspaceId, context.accountId)); | |
| 1108 | − const currentList = getCachedAtomValue(context.registry, listAtom); | |
| 1109 | − if (currentList !== undefined) { | |
| 1110 | − const withoutOptimistic = optimisticSource | |
| 1111 | − ? currentList.filter((candidate) => candidate.id !== optimisticSource.id) | |
| 1112 | − : currentList; | |
| 1113 | − setCachedAtomValue(context.registry, listAtom, upsertSourceInList(withoutOptimistic, source)); | |
| 1114 | − } | |
| 1115 | − | |
| 1116 | − setCachedAtomValue( | |
| 1117 | − context.registry, | |
| 1118 | − sourceAtom(encodeSourceKey(true, context.workspaceId, context.accountId, source.id)), | |
| 1119 | − source, | |
| 1120 | − ); | |
| 1121 | − context.invalidateQueries({ | |
| 1122 | − workspaceId: context.workspaceId, | |
| 1123 | − accountId: context.accountId, | |
| 1124 | − }); | |
| 1125 | − }, | |
| 1126 | − }, | |
| 1127 | − ); | |
| 1128 | − | |
| 1129 | −export const useUpdateSource = () => | |
| 1130 | − useSourceMutation<{ sourceId: Source["id"]; payload: UpdateSourcePayload }, Source>( | |
| 1131 | − React.useCallback( | |
| 1132 | − ({ workspaceId, accountId, payload }) => | |
| 1133 | − runControlPlane({ | |
| 1134 | − accountId, | |
| 1135 | − execute: (client) => client.sources.update({ | |
| 1136 | − path: { | |
| 1137 | − workspaceId, | |
| 1138 | − sourceId: payload.sourceId, | |
| 1139 | − }, | |
| 1140 | − payload: payload.payload, | |
| 1141 | − }), | |
| 1142 | − }), | |
| 1143 | − [], | |
| 1144 | − ), | |
| 1145 | − { | |
| 1146 | − optimisticUpdate: (context, input) => { | |
| 1147 | − const listAtom = sourcesAtom(encodeSourcesKey(true, context.workspaceId, context.accountId)); | |
| 1148 | − const detailAtom = sourceAtom(encodeSourceKey(true, context.workspaceId, context.accountId, input.sourceId)); | |
| 1149 | − const previousList = getCachedAtomValue(context.registry, listAtom); | |
| 1150 | − const previousSource = getCachedAtomValue(context.registry, detailAtom) | |
| 1151 | − ?? previousList?.find((source) => source.id === input.sourceId); | |
| 1152 | − if (previousSource === undefined) { | |
| 1153 | − return; | |
| 1154 | − } | |
| 1155 | − | |
| 1156 | − const optimisticSource = applyUpdatePayloadToSource(previousSource, input.payload); | |
| 1157 | − if (previousList !== undefined) { | |
| 1158 | − setCachedAtomValue(context.registry, listAtom, upsertSourceInList(previousList, optimisticSource)); | |
| 1159 | − } | |
| 1160 | − setCachedAtomValue(context.registry, detailAtom, optimisticSource); | |
| 1161 | − | |
| 1162 | − return () => { | |
| 1163 | − if (previousList !== undefined) { | |
| 1164 | − setCachedAtomValue(context.registry, listAtom, previousList); | |
| 1165 | − } | |
| 1166 | − setCachedAtomValue(context.registry, detailAtom, previousSource); | |
| 1167 | − }; | |
| 1168 | − }, | |
| 1169 | − onSuccess: (context, input, source) => { | |
| 1170 | − const listAtom = sourcesAtom(encodeSourcesKey(true, context.workspaceId, context.accountId)); | |
| 1171 | − const detailAtom = sourceAtom(encodeSourceKey(true, context.workspaceId, context.accountId, input.sourceId)); | |
| 1172 | − const currentList = getCachedAtomValue(context.registry, listAtom); | |
| 1173 | − if (currentList !== undefined) { | |
| 1174 | − setCachedAtomValue(context.registry, listAtom, upsertSourceInList(currentList, source)); | |
| 1175 | − } | |
| 1176 | − setCachedAtomValue(context.registry, detailAtom, source); | |
| 1177 | − context.invalidateQueries({ | |
| 1178 | − workspaceId: context.workspaceId, | |
| 1179 | − accountId: context.accountId, | |
| 1180 | − sourceId: input.sourceId, | |
| 1181 | − }); | |
| 1182 | − }, | |
| 1183 | − }, | |
| 1184 | − ); | |
| 1185 | − | |
| 1186 | −export const useStartSourceOAuth = () => | |
| 1187 | − useSourceMutation<StartSourceOAuthPayload, StartSourceOAuthResult>( | |
| 1188 | − React.useCallback( | |
| 1189 | − ({ workspaceId, accountId, payload }) => | |
| 1190 | − runControlPlane({ | |
| 1191 | − accountId, | |
| 1192 | − execute: (client) => client.oauth.startSourceAuth({ | |
| 1193 | − path: { | |
| 1194 | − workspaceId, | |
| 1195 | − }, | |
| 1196 | − payload, | |
| 1197 | − }), | |
| 1198 | − }), | |
| 1199 | − [], | |
| 1200 | − ), | |
| 1201 | − ); | |
| 1202 | − | |
| 1203 | −export const useRemoveSource = () => | |
| 1204 | − useSourceMutation<Source["id"], SourceRemoveResult>( | |
| 1205 | − React.useCallback( | |
| 1206 | − ({ workspaceId, accountId, payload }) => | |
| 1207 | − runControlPlane({ | |
| 1208 | − accountId, | |
| 1209 | − execute: (client) => client.sources.remove({ | |
| 1210 | − path: { | |
| 1211 | − workspaceId, | |
| 1212 | − sourceId: payload, | |
| 1213 | − }, | |
| 1214 | − }), | |
| 1215 | − }), | |
| 1216 | − [], | |
| 1217 | − ), | |
| 1218 | − { | |
| 1219 | − optimisticUpdate: (context, sourceId) => { | |
| 1220 | − const listAtom = sourcesAtom(encodeSourcesKey(true, context.workspaceId, context.accountId)); | |
| 1221 | − const previousList = getCachedAtomValue(context.registry, listAtom); | |
| 1222 | − if (previousList === undefined) { | |
| 1223 | − return; | |
| 1224 | − } | |
| 1225 | − | |
| 1226 | − setCachedAtomValue(context.registry, listAtom, removeSourceFromList(previousList, sourceId)); | |
| 1227 | − return () => { | |
| 1228 | − setCachedAtomValue(context.registry, listAtom, previousList); | |
| 1229 | − }; | |
| 1230 | − }, | |
| 1231 | − onSuccess: (context, sourceId) => { | |
| 1232 | − context.invalidateQueries({ | |
| 1233 | − workspaceId: context.workspaceId, | |
| 1234 | − accountId: context.accountId, | |
| 1235 | − sourceId, | |
| 1236 | − }); | |
| 1237 | − }, | |
| 1238 | − }, | |
| 1239 | − ); | |
| 1240 | − | |
| 1241 | −export const useDiscoverSource = () => | |
| 1242 | − useSourceMutation<DiscoverSourcePayload, SourceDiscoveryResult>( | |
| 1243 | − React.useCallback( | |
| 1244 | − ({ accountId, payload }) => | |
| 1245 | − runControlPlane({ | |
| 1246 | − accountId, | |
| 1247 | − execute: (client) => client.sources.discover({ | |
| 1248 | − payload, | |
| 1249 | − }), | |
| 1250 | − }), | |
| 1251 | − [], | |
| 1252 | − ), | |
| 1253 | − ); | |
| 1254 | − | |
| 1255 | −export const useConnectSource = () => | |
| 1256 | − useSourceMutation<ConnectSourcePayload, ConnectSourceResult, Source>( | |
| 1257 | − React.useCallback( | |
| 1258 | − ({ workspaceId, accountId, payload }) => | |
| 1259 | − runControlPlane({ | |
| 1260 | − accountId, | |
| 1261 | − execute: (client) => client.sources.connect({ | |
| 1262 | − path: { | |
| 1263 | − workspaceId, | |
| 1264 | − }, | |
| 1265 | − payload, | |
| 1266 | − } as any), | |
| 1267 | − }), | |
| 1268 | − [], | |
| 1269 | − ), | |
| 1270 | − { | |
| 1271 | − onSuccess: (context, _payload, result) => { | |
| 1272 | − const listAtom = sourcesAtom(encodeSourcesKey(true, context.workspaceId, context.accountId)); | |
| 1273 | − const currentList = getCachedAtomValue(context.registry, listAtom); | |
| 1274 | − if (currentList !== undefined) { | |
| 1275 | − setCachedAtomValue(context.registry, listAtom, upsertSourceInList(currentList, result.source)); | |
| 1276 | − } | |
| 1277 | − | |
| 1278 | − setCachedAtomValue( | |
| 1279 | − context.registry, | |
| 1280 | − sourceAtom(encodeSourceKey(true, context.workspaceId, context.accountId, result.source.id)), | |
| 1281 | − result.source, | |
| 1282 | − ); | |
| 1283 | − context.invalidateQueries({ | |
| 1284 | − workspaceId: context.workspaceId, | |
| 1285 | − accountId: context.accountId, | |
| 1286 | − }); | |
| 1287 | − }, | |
| 1288 | − }, | |
| 1289 | − ); | |
| 1290 | − | |
| 1291 | −export const useConnectSourceBatch = () => | |
| 1292 | − useSourceMutation<ConnectSourceBatchPayload, ConnectSourceBatchResult>( | |
| 1293 | − React.useCallback( | |
| 1294 | − ({ workspaceId, accountId, payload }) => | |
| 1295 | − runControlPlane({ | |
| 1296 | − accountId, | |
| 1297 | − execute: (client) => client.sources.connectBatch({ | |
| 1298 | − path: { | |
| 1299 | − workspaceId, | |
| 1300 | − }, | |
| 1301 | − payload, | |
| 1302 | − } as any), | |
| 1303 | − }), | |
| 1304 | − [], | |
| 1305 | − ), | |
| 1306 | − { | |
| 1307 | − onSuccess: (context, _payload, result) => { | |
| 1308 | − const listAtom = sourcesAtom(encodeSourcesKey(true, context.workspaceId, context.accountId)); | |
| 1309 | − const currentList = getCachedAtomValue(context.registry, listAtom); | |
| 1310 | − if (currentList !== undefined) { | |
| 1311 | − let nextList = currentList; | |
| 1312 | − for (const entry of result.results) { | |
| 1313 | − nextList = upsertSourceInList(nextList, entry.source); | |
| 1314 | − setCachedAtomValue( | |
| 1315 | − context.registry, | |
| 1316 | − sourceAtom(encodeSourceKey(true, context.workspaceId, context.accountId, entry.source.id)), | |
| 1317 | − entry.source, | |
| 1318 | − ); | |
| 1319 | − } | |
| 1320 | − setCachedAtomValue(context.registry, listAtom, nextList); | |
| 1321 | − } | |
| 1322 | − | |
| 1323 | − context.invalidateQueries({ | |
| 1324 | − workspaceId: context.workspaceId, | |
| 1325 | − accountId: context.accountId, | |
| 1326 | − }); | |
| 1327 | − }, | |
| 1328 | − }, | |
| 1329 | − ); | |
| 1330 | − | |
| 1331 | −export const useCreateWorkspaceOauthClient = () => | |
| 1332 | − useSourceMutation<CreateWorkspaceOauthClientPayload, WorkspaceOauthClient>( | |
| 1333 | − React.useCallback( | |
| 1334 | − ({ workspaceId, accountId, payload }) => | |
| 1335 | − runControlPlane({ | |
| 1336 | − accountId, | |
| 1337 | − execute: (client) => client.sources.createWorkspaceOauthClient({ | |
| 1338 | − path: { | |
| 1339 | − workspaceId, | |
| 1340 | − }, | |
| 1341 | − payload, | |
| 1342 | − }), | |
| 1343 | − }), | |
| 1344 | − [], | |
| 1345 | − ), | |
| 1346 | − { | |
| 1347 | − onSuccess: (context) => { | |
| 1348 | − context.invalidateQueries({ | |
| 1349 | − workspaceId: context.workspaceId, | |
| 1350 | − accountId: context.accountId, | |
| 1351 | − }); | |
| 1352 | − }, | |
| 1353 | − }, | |
| 1354 | − ); | |
| 1355 | − | |
| 1356 | −export const useRemoveWorkspaceOauthClient = () => | |
| 1357 | − useSourceMutation<WorkspaceOauthClient["id"], { removed: boolean }>( | |
| 1358 | − React.useCallback( | |
| 1359 | − ({ workspaceId, accountId, payload }) => | |
| 1360 | − runControlPlane({ | |
| 1361 | − accountId, | |
| 1362 | − execute: (client) => client.sources.removeWorkspaceOauthClient({ | |
| 1363 | − path: { | |
| 1364 | − workspaceId, | |
| 1365 | − oauthClientId: payload, | |
| 1366 | − }, | |
| 1367 | − }), | |
| 1368 | − }), | |
| 1369 | − [], | |
| 1370 | − ), | |
| 1371 | − { | |
| 1372 | − onSuccess: (context) => { | |
| 1373 | − context.invalidateQueries({ | |
| 1374 | − workspaceId: context.workspaceId, | |
| 1375 | − accountId: context.accountId, | |
| 1376 | − }); | |
| 1377 | − }, | |
| 1378 | − }, | |
| 1379 | − ); | |
| 1380 | − | |
| 1381 | −export const useRemoveProviderAuthGrant = () => | |
| 1382 | − useSourceMutation< | |
| 1383 | − Extract<Source["auth"], { kind: "provider_grant_ref" }>["grantId"], | |
| 1384 | − { removed: boolean } | |
| 1385 | − >( | |
| 1386 | − React.useCallback( | |
| 1387 | − ({ workspaceId, accountId, payload }) => | |
| 1388 | − runControlPlane({ | |
| 1389 | − accountId, | |
| 1390 | − execute: (client) => client.sources.removeProviderAuthGrant({ | |
| 1391 | − path: { | |
| 1392 | − workspaceId, | |
| 1393 | − grantId: payload, | |
| 1394 | − }, | |
| 1395 | − }), | |
| 1396 | − }), | |
| 1397 | − [], | |
| 1398 | − ), | |
| 1399 | − { | |
| 1400 | − onSuccess: (context) => { | |
| 1401 | − context.invalidateQueries({ | |
| 1402 | − workspaceId: context.workspaceId, | |
| 1403 | − accountId: context.accountId, | |
| 1404 | − }); | |
| 1405 | − }, | |
| 1406 | − }, | |
| 1407 | − ); | |
| 1408 | 32 | |
| 33 | +export { | |
| 34 | + defineExecutorHttpApiClient, | |
| 35 | + defineExecutorPluginHttpApiClient, | |
| 36 | +} from "./core/http-client"; | |
| 37 | +export { | |
| 38 | + getExecutorApiBaseUrl, | |
| 39 | + setExecutorApiBaseUrl, | |
| 40 | +} from "./core/base-url"; | |
| 1409 | 41 | export type { |
| 1410 | − CompleteSourceOAuthResult, | |
| 1411 | − ConnectSourceBatchPayload, | |
| 1412 | − ConnectSourceBatchResult, | |
| 1413 | − ConnectSourcePayload, | |
| 1414 | − ConnectSourceResult, | |
| 1415 | − CreateSecretPayload, | |
| 1416 | − CreateSecretResult, | |
| 1417 | − CreateSourcePayload, | |
| 1418 | − CreateWorkspaceOauthClientPayload, | |
| 1419 | − DeleteSecretResult, | |
| 1420 | − DiscoverSourcePayload, | |
| 1421 | − InstanceConfig, | |
| 1422 | − LocalInstallation, | |
| 1423 | − | |
| 1424 | − SecretListItem, | |
| 1425 | − Source, | |
| 1426 | − SourceDiscoveryResult, | |
| 1427 | − SourceInspection, | |
| 1428 | − SourceInspectionDiscoverResult, | |
| 1429 | − SourceInspectionToolDetail, | |
| 1430 | − StartSourceOAuthPayload, | |
| 1431 | − StartSourceOAuthResult, | |
| 1432 | − UpdateSecretPayload, | |
| 1433 | − UpdateSecretResult, | |
| 1434 | − UpdateSourcePayload, | |
| 1435 | − WorkspaceOauthClient, | |
| 1436 | −}; | |
| 42 | + Loadable, | |
| 43 | + SourceRemoveResult, | |
| 44 | +} from "./core/types"; | |
| 45 | +export { | |
| 46 | + pendingLoadable, | |
| 47 | + useWorkspaceId, | |
| 48 | + useWorkspaceRequestContext, | |
| 49 | + type WorkspaceContext, | |
| 50 | +} from "./core/workspace"; | |
| 51 | +export { | |
| 52 | + ExecutorReactProvider, | |
| 53 | +} from "./provider"; | |
| 54 | +export { | |
| 55 | + useExecutorMutation, | |
| 56 | +} from "./hooks/mutations"; | |
| 57 | +export { | |
| 58 | + useInstanceConfig, | |
| 59 | + useLocalInstallation, | |
| 60 | + useRefreshLocalInstallation, | |
| 61 | +} from "./hooks/local"; | |
| 62 | +export { | |
| 63 | + useCreateSecret, | |
| 64 | + useDeleteSecret, | |
| 65 | + useRefreshSecrets, | |
| 66 | + useSecrets, | |
| 67 | + useUpdateSecret, | |
| 68 | +} from "./hooks/secrets"; | |
| 69 | +export { | |
| 70 | + useExecution, | |
| 71 | + useExecutions, | |
| 72 | +} from "./hooks/executions"; | |
| 73 | +export { | |
| 74 | + usePrefetchToolDetail, | |
| 75 | + useRemoveSource, | |
| 76 | + useSource, | |
| 77 | + useSourceDiscovery, | |
| 78 | + useSourceInspection, | |
| 79 | + useSourceToolDetail, | |
| 80 | + useSources, | |
| 81 | +} from "./hooks/sources"; | |
| @@ -0,0 +1,44 @@ | ||
| 1 | +import type { HTMLAttributes } from "react"; | |
| 2 | +import { cva, type VariantProps } from "class-variance-authority"; | |
| 3 | + | |
| 4 | +import { cn } from "../lib/cn"; | |
| 5 | + | |
| 6 | +const badgeVariants = cva( | |
| 7 | + "inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium tracking-wide uppercase", | |
| 8 | + { | |
| 9 | + variants: { | |
| 10 | + variant: { | |
| 11 | + default: "border-transparent bg-primary/15 text-primary", | |
| 12 | + secondary: "border-transparent bg-secondary text-secondary-foreground", | |
| 13 | + outline: "border-border text-muted-foreground", | |
| 14 | + muted: "border-transparent bg-muted text-muted-foreground", | |
| 15 | + get: "border-[var(--method-get)]/20 bg-[var(--method-get)]/10 text-[var(--method-get)]", | |
| 16 | + post: "border-[var(--method-post)]/20 bg-[var(--method-post)]/10 text-[var(--method-post)]", | |
| 17 | + put: "border-[var(--method-put)]/20 bg-[var(--method-put)]/10 text-[var(--method-put)]", | |
| 18 | + delete: "border-[var(--method-delete)]/20 bg-[var(--method-delete)]/10 text-[var(--method-delete)]", | |
| 19 | + destructive: "border-destructive/20 bg-destructive/10 text-destructive", | |
| 20 | + }, | |
| 21 | + }, | |
| 22 | + defaultVariants: { | |
| 23 | + variant: "default", | |
| 24 | + }, | |
| 25 | + }, | |
| 26 | +); | |
| 27 | + | |
| 28 | +export type BadgeProps = HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>; | |
| 29 | + | |
| 30 | +export function Badge({ className, variant, ...props }: BadgeProps) { | |
| 31 | + return <span className={cn(badgeVariants({ variant }), className)} {...props} />; | |
| 32 | +} | |
| 33 | + | |
| 34 | +export function MethodBadge({ method }: { method: string }) { | |
| 35 | + const variant = ({ | |
| 36 | + GET: "get", | |
| 37 | + POST: "post", | |
| 38 | + PUT: "put", | |
| 39 | + PATCH: "put", | |
| 40 | + DELETE: "delete", | |
| 41 | + } as const)[method.toUpperCase()] ?? "outline"; | |
| 42 | + | |
| 43 | + return <Badge variant={variant}>{method}</Badge>; | |
| 44 | +} | |
| @@ -0,0 +1,104 @@ | ||
| 1 | +import { useCallback, useEffect, useRef, useState } from "react"; | |
| 2 | + | |
| 3 | +import { codeToHtml, resolveLang } from "../lib/shiki"; | |
| 4 | +import { cn } from "../lib/cn"; | |
| 5 | +import { IconCheck, IconCopy } from "./icons"; | |
| 6 | + | |
| 7 | +const highlightCache = new Map<string, string>(); | |
| 8 | + | |
| 9 | +function cacheKey(code: string, lang: string) { | |
| 10 | + return `${lang}::${code.length}::${code.slice(0, 64)}`; | |
| 11 | +} | |
| 12 | + | |
| 13 | +function detectLanguage(code: string, hint?: string): string { | |
| 14 | + if (hint) { | |
| 15 | + return resolveLang(hint) ?? "json"; | |
| 16 | + } | |
| 17 | + | |
| 18 | + const trimmed = code.trimStart(); | |
| 19 | + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { | |
| 20 | + return "json"; | |
| 21 | + } | |
| 22 | + if (trimmed.startsWith("<")) { | |
| 23 | + return "xml"; | |
| 24 | + } | |
| 25 | + if (trimmed.startsWith("---")) { | |
| 26 | + return "yaml"; | |
| 27 | + } | |
| 28 | + return "json"; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export function CodeBlock(props: { | |
| 32 | + code: string; | |
| 33 | + lang?: string; | |
| 34 | + className?: string; | |
| 35 | +}) { | |
| 36 | + const { code, lang: langHint, className } = props; | |
| 37 | + const [html, setHtml] = useState<string | null>(null); | |
| 38 | + const [copied, setCopied] = useState(false); | |
| 39 | + const mountedRef = useRef(true); | |
| 40 | + | |
| 41 | + const language = detectLanguage(code, langHint); | |
| 42 | + const key = cacheKey(code, language); | |
| 43 | + | |
| 44 | + useEffect(() => { | |
| 45 | + mountedRef.current = true; | |
| 46 | + return () => { | |
| 47 | + mountedRef.current = false; | |
| 48 | + }; | |
| 49 | + }, []); | |
| 50 | + | |
| 51 | + useEffect(() => { | |
| 52 | + const cached = highlightCache.get(key); | |
| 53 | + if (cached) { | |
| 54 | + setHtml(cached); | |
| 55 | + return; | |
| 56 | + } | |
| 57 | + | |
| 58 | + let cancelled = false; | |
| 59 | + void codeToHtml(code, { lang: language }).then((result) => { | |
| 60 | + if (cancelled) { | |
| 61 | + return; | |
| 62 | + } | |
| 63 | + highlightCache.set(key, result); | |
| 64 | + if (mountedRef.current) { | |
| 65 | + setHtml(result); | |
| 66 | + } | |
| 67 | + }); | |
| 68 | + | |
| 69 | + return () => { | |
| 70 | + cancelled = true; | |
| 71 | + }; | |
| 72 | + }, [code, key, language]); | |
| 73 | + | |
| 74 | + const handleCopy = useCallback(() => { | |
| 75 | + void navigator.clipboard.writeText(code).then(() => { | |
| 76 | + setCopied(true); | |
| 77 | + setTimeout(() => setCopied(false), 1500); | |
| 78 | + }); | |
| 79 | + }, [code]); | |
| 80 | + | |
| 81 | + return ( | |
| 82 | + <div className={cn("group relative overflow-auto", className)}> | |
| 83 | + <button | |
| 84 | + type="button" | |
| 85 | + onClick={handleCopy} | |
| 86 | + className="absolute right-2 top-2 z-10 rounded-md border border-border bg-card/90 p-1.5 text-muted-foreground/40 opacity-0 backdrop-blur-sm transition-all hover:text-foreground group-hover:opacity-100" | |
| 87 | + title="Copy to clipboard" | |
| 88 | + > | |
| 89 | + {copied ? <IconCheck className="size-3" /> : <IconCopy className="size-3" />} | |
| 90 | + </button> | |
| 91 | + | |
| 92 | + {html ? ( | |
| 93 | + <div | |
| 94 | + className="shiki-container text-[12px] leading-relaxed [&_pre]:!bg-transparent [&_pre]:p-3 [&_code]:font-mono" | |
| 95 | + dangerouslySetInnerHTML={{ __html: html }} | |
| 96 | + /> | |
| 97 | + ) : ( | |
| 98 | + <pre className="p-3 font-mono text-[12px] leading-relaxed text-foreground/60"> | |
| 99 | + {code} | |
| 100 | + </pre> | |
| 101 | + )} | |
| 102 | + </div> | |
| 103 | + ); | |
| 104 | +} | |
| @@ -0,0 +1,30 @@ | ||
| 1 | +import { cn } from "../lib/cn"; | |
| 2 | +import { CodeBlock } from "./code-block"; | |
| 3 | + | |
| 4 | +export function DocumentPanel(props: { | |
| 5 | + title: string; | |
| 6 | + body: string | null; | |
| 7 | + lang?: string; | |
| 8 | + empty: string; | |
| 9 | + compact?: boolean; | |
| 10 | +}) { | |
| 11 | + return ( | |
| 12 | + <section | |
| 13 | + className={cn( | |
| 14 | + "overflow-hidden rounded-lg border border-border bg-card/60", | |
| 15 | + props.compact && "min-h-48", | |
| 16 | + )} | |
| 17 | + > | |
| 18 | + <div className="border-b border-border px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70"> | |
| 19 | + {props.title} | |
| 20 | + </div> | |
| 21 | + {props.body ? ( | |
| 22 | + <CodeBlock code={props.body} lang={props.lang} className="max-h-[32rem]" /> | |
| 23 | + ) : ( | |
| 24 | + <div className="flex items-center justify-center p-6 text-[13px] text-muted-foreground/40"> | |
| 25 | + {props.empty} | |
| 26 | + </div> | |
| 27 | + )} | |
| 28 | + </section> | |
| 29 | + ); | |
| 30 | +} | |
| @@ -0,0 +1,90 @@ | ||
| 1 | +import type { SVGProps } from "react"; | |
| 2 | + | |
| 3 | +import { cn } from "../lib/cn"; | |
| 4 | + | |
| 5 | +type IconProps = SVGProps<SVGSVGElement> & { className?: string }; | |
| 6 | + | |
| 7 | +export function IconTool({ className, ...props }: IconProps) { | |
| 8 | + return ( | |
| 9 | + <svg viewBox="0 0 16 16" fill="none" className={cn("size-4", className)} {...props}> | |
| 10 | + <path d="M4 8h8M8 4v8" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" /> | |
| 11 | + </svg> | |
| 12 | + ); | |
| 13 | +} | |
| 14 | + | |
| 15 | +export function IconSearch({ className, ...props }: IconProps) { | |
| 16 | + return ( | |
| 17 | + <svg viewBox="0 0 16 16" fill="none" className={cn("size-4", className)} {...props}> | |
| 18 | + <circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.3" /> | |
| 19 | + <path d="M10.5 10.5L14 14" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" /> | |
| 20 | + </svg> | |
| 21 | + ); | |
| 22 | +} | |
| 23 | + | |
| 24 | +export function IconChevron({ className, ...props }: IconProps) { | |
| 25 | + return ( | |
| 26 | + <svg viewBox="0 0 16 16" fill="none" className={cn("size-3", className)} {...props}> | |
| 27 | + <path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> | |
| 28 | + </svg> | |
| 29 | + ); | |
| 30 | +} | |
| 31 | + | |
| 32 | +export function IconFolder({ className, ...props }: IconProps) { | |
| 33 | + return ( | |
| 34 | + <svg viewBox="0 0 16 16" fill="none" className={cn("size-3", className)} {...props}> | |
| 35 | + <path d="M2 4h5l2 2h5v7H2V4z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" /> | |
| 36 | + </svg> | |
| 37 | + ); | |
| 38 | +} | |
| 39 | + | |
| 40 | +export function IconCopy({ className, ...props }: IconProps) { | |
| 41 | + return ( | |
| 42 | + <svg viewBox="0 0 16 16" fill="none" className={cn("size-3.5", className)} {...props}> | |
| 43 | + <rect x="5" y="5" width="8" height="8" rx="1" stroke="currentColor" strokeWidth="1.2" /> | |
| 44 | + <path d="M11 3H4a1 1 0 00-1 1v7" stroke="currentColor" strokeWidth="1.2" /> | |
| 45 | + </svg> | |
| 46 | + ); | |
| 47 | +} | |
| 48 | + | |
| 49 | +export function IconCheck({ className, ...props }: IconProps) { | |
| 50 | + return ( | |
| 51 | + <svg viewBox="0 0 16 16" fill="none" className={cn("size-3.5", className)} {...props}> | |
| 52 | + <path d="M4 8.5l3 3 5-6" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" /> | |
| 53 | + </svg> | |
| 54 | + ); | |
| 55 | +} | |
| 56 | + | |
| 57 | +export function IconClose({ className, ...props }: IconProps) { | |
| 58 | + return ( | |
| 59 | + <svg viewBox="0 0 16 16" fill="none" className={cn("size-3", className)} {...props}> | |
| 60 | + <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> | |
| 61 | + </svg> | |
| 62 | + ); | |
| 63 | +} | |
| 64 | + | |
| 65 | +export function IconEmpty({ className, ...props }: IconProps) { | |
| 66 | + return ( | |
| 67 | + <svg viewBox="0 0 48 48" fill="none" className={cn("size-12", className)} {...props}> | |
| 68 | + <rect x="6" y="6" width="36" height="36" rx="4" stroke="currentColor" strokeWidth="1.5" /> | |
| 69 | + <path d="M16 18h16M16 24h12M16 30h8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> | |
| 70 | + </svg> | |
| 71 | + ); | |
| 72 | +} | |
| 73 | + | |
| 74 | +export function IconSpinner({ className, ...props }: IconProps) { | |
| 75 | + return ( | |
| 76 | + <svg viewBox="0 0 16 16" fill="none" className={cn("size-4 animate-spin", className)} {...props}> | |
| 77 | + <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.5" opacity="0.2" /> | |
| 78 | + <path d="M14 8a6 6 0 00-6-6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> | |
| 79 | + </svg> | |
| 80 | + ); | |
| 81 | +} | |
| 82 | + | |
| 83 | +export function IconPencil({ className, ...props }: IconProps) { | |
| 84 | + return ( | |
| 85 | + <svg viewBox="0 0 16 16" fill="none" className={cn("size-4", className)} {...props}> | |
| 86 | + <path d="M3 11.75L11.6 3.15a1.2 1.2 0 011.7 0l.55.55a1.2 1.2 0 010 1.7L5.25 14H3v-2.25z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" /> | |
| 87 | + <path d="M9.75 5l1.25 1.25" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /> | |
| 88 | + </svg> | |
| 89 | + ); | |
| 90 | +} | |
| @@ -0,0 +1,51 @@ | ||
| 1 | +import type { ReactNode } from "react"; | |
| 2 | + | |
| 3 | +import type { Loadable } from "../../core/types"; | |
| 4 | +import { IconEmpty, IconSpinner } from "./icons"; | |
| 5 | + | |
| 6 | +export function LoadableBlock<T>(props: { | |
| 7 | + loadable: Loadable<T>; | |
| 8 | + loading?: string; | |
| 9 | + children: (data: T) => ReactNode; | |
| 10 | +}) { | |
| 11 | + if (props.loadable.status === "loading") { | |
| 12 | + return ( | |
| 13 | + <div className="flex h-full min-h-48 items-center justify-center"> | |
| 14 | + <div className="flex items-center gap-3 text-sm text-muted-foreground"> | |
| 15 | + <IconSpinner /> | |
| 16 | + <span>{props.loading ?? "Loading..."}</span> | |
| 17 | + </div> | |
| 18 | + </div> | |
| 19 | + ); | |
| 20 | + } | |
| 21 | + | |
| 22 | + if (props.loadable.status === "error") { | |
| 23 | + return ( | |
| 24 | + <div className="flex h-full min-h-48 items-center justify-center"> | |
| 25 | + <div className="rounded-lg border border-destructive/25 bg-destructive/5 px-4 py-3 text-sm text-destructive"> | |
| 26 | + {props.loadable.error.message} | |
| 27 | + </div> | |
| 28 | + </div> | |
| 29 | + ); | |
| 30 | + } | |
| 31 | + | |
| 32 | + return <>{props.children(props.loadable.data)}</>; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export function EmptyState(props: { | |
| 36 | + title: string; | |
| 37 | + description?: string; | |
| 38 | + className?: string; | |
| 39 | +}) { | |
| 40 | + return ( | |
| 41 | + <div className={`flex h-full items-center justify-center ${props.className ?? ""}`}> | |
| 42 | + <div className="text-center"> | |
| 43 | + <IconEmpty className="mx-auto mb-3 text-muted-foreground/20" /> | |
| 44 | + <p className="text-[13px] text-muted-foreground/60">{props.title}</p> | |
| 45 | + {props.description && ( | |
| 46 | + <p className="mt-1 text-[11px] text-muted-foreground/40">{props.description}</p> | |
| 47 | + )} | |
| 48 | + </div> | |
| 49 | + </div> | |
| 50 | + ); | |
| 51 | +} | |
| @@ -0,0 +1,41 @@ | ||
| 1 | +import { Streamdown } from "streamdown"; | |
| 2 | + | |
| 3 | +import { createLimitedCodePlugin } from "../lib/shiki"; | |
| 4 | + | |
| 5 | +const codePlugin = createLimitedCodePlugin(); | |
| 6 | + | |
| 7 | +const PROSE_CLASSES = [ | |
| 8 | + "text-[13px] leading-relaxed text-muted-foreground", | |
| 9 | + "[&_p]:mb-[0.4em] [&_p:last-child]:mb-0", | |
| 10 | + "[&_strong]:text-foreground [&_strong]:font-semibold", | |
| 11 | + "[&_b]:text-foreground [&_b]:font-semibold", | |
| 12 | + "[&_code]:font-mono [&_code]:text-xs [&_code]:bg-muted [&_code]:border [&_code]:border-border", | |
| 13 | + "[&_code]:rounded-sm [&_code]:px-1.5 [&_code]:py-px [&_code]:text-primary", | |
| 14 | + "[&_pre]:bg-muted [&_pre]:border [&_pre]:border-border [&_pre]:rounded-md", | |
| 15 | + "[&_pre]:px-3 [&_pre]:py-2 [&_pre]:overflow-x-auto [&_pre]:my-2 [&_pre]:text-xs [&_pre]:leading-relaxed", | |
| 16 | + "[&_pre_code]:bg-transparent [&_pre_code]:border-0 [&_pre_code]:p-0 [&_pre_code]:text-inherit", | |
| 17 | + "[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2", | |
| 18 | + "[&_a]:decoration-primary/30 hover:[&_a]:decoration-primary/80", | |
| 19 | + "[&_ul]:pl-5 [&_ul]:my-1.5 [&_ol]:pl-5 [&_ol]:my-1.5", | |
| 20 | + "[&_li]:mb-0.5 [&_li_::marker]:text-muted-foreground", | |
| 21 | + "[&_table]:w-full [&_table]:border-collapse [&_table]:text-xs [&_table]:my-2", | |
| 22 | + "[&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:bg-muted [&_th]:font-semibold [&_th]:text-foreground", | |
| 23 | + "[&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1 [&_td]:text-left [&_td]:bg-background", | |
| 24 | + "[&_h1]:font-semibold [&_h1]:text-foreground [&_h1]:mt-2 [&_h1]:mb-1 [&_h1]:text-[15px]", | |
| 25 | + "[&_h2]:font-semibold [&_h2]:text-foreground [&_h2]:mt-2 [&_h2]:mb-1 [&_h2]:text-sm", | |
| 26 | + "[&_h3]:font-semibold [&_h3]:text-foreground [&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:text-[13px]", | |
| 27 | + "[&_h4]:font-semibold [&_h4]:text-foreground [&_h4]:mt-2 [&_h4]:mb-1 [&_h4]:text-[13px]", | |
| 28 | + "[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:my-1.5 [&_blockquote]:text-muted-foreground", | |
| 29 | + "[&_hr]:border-0 [&_hr]:border-t [&_hr]:border-border [&_hr]:my-2", | |
| 30 | + "[&_img]:max-w-full [&_img]:rounded", | |
| 31 | +].join(" "); | |
| 32 | + | |
| 33 | +export function Markdown(props: { children: string; className?: string }) { | |
| 34 | + return ( | |
| 35 | + <div className={props.className ? `${PROSE_CLASSES} ${props.className}` : PROSE_CLASSES}> | |
| 36 | + <Streamdown plugins={{ code: codePlugin }} controls={{ code: true }} linkSafety={{ enabled: false }}> | |
| 37 | + {props.children} | |
| 38 | + </Streamdown> | |
| 39 | + </div> | |
| 40 | + ); | |
| 41 | +} | |
| @@ -0,0 +1,24 @@ | ||
| 1 | +export type SourceToolExplorerSearch = { | |
| 2 | + tab: "model" | "discover"; | |
| 3 | + tool?: string; | |
| 4 | + query?: string; | |
| 5 | +}; | |
| 6 | + | |
| 7 | +const sourceToolExplorerTabs = ["model", "discover"] as const; | |
| 8 | + | |
| 9 | +export const parseSourceToolExplorerSearch = ( | |
| 10 | + search: Record<string, unknown>, | |
| 11 | +): SourceToolExplorerSearch => ({ | |
| 12 | + tab: | |
| 13 | + typeof search.tab === "string" | |
| 14 | + && sourceToolExplorerTabs.includes( | |
| 15 | + search.tab as SourceToolExplorerSearch["tab"], | |
| 16 | + ) | |
| 17 | + ? (search.tab as SourceToolExplorerSearch["tab"]) | |
| 18 | + : "model", | |
| 19 | + tool: | |
| 20 | + typeof search.tool === "string" && search.tool.length > 0 | |
| 21 | + ? search.tool | |
| 22 | + : undefined, | |
| 23 | + query: typeof search.query === "string" ? search.query : undefined, | |
| 24 | +}); | |
| @@ -0,0 +1,122 @@ | ||
| 1 | +import { startTransition, useEffect, type ReactNode } from "react"; | |
| 2 | +import type { | |
| 3 | + SourceInspectionToolDetail, | |
| 4 | +} from "../../index"; | |
| 5 | + | |
| 6 | +import { | |
| 7 | + useSourceInspection, | |
| 8 | + useSourceToolDetail, | |
| 9 | +} from "../../hooks/sources"; | |
| 10 | +import { Badge } from "./badge"; | |
| 11 | +import { LoadableBlock } from "./loadable"; | |
| 12 | +import { | |
| 13 | + SourceToolModelWorkbench, | |
| 14 | +} from "./source-tool-workbench"; | |
| 15 | +import type { SourceToolExplorerSearch } from "./source-tool-explorer-search"; | |
| 16 | + | |
| 17 | +export const SourceToolExplorer = (props: { | |
| 18 | + sourceId: string; | |
| 19 | + title: string; | |
| 20 | + kind: string; | |
| 21 | + search: SourceToolExplorerSearch; | |
| 22 | + selectedToolPath?: string | null; | |
| 23 | + navigate?: (search: { | |
| 24 | + tab: "model" | "discover"; | |
| 25 | + tool?: string; | |
| 26 | + query?: string; | |
| 27 | + }) => void | Promise<void>; | |
| 28 | + actions?: ReactNode; | |
| 29 | + summary?: ReactNode; | |
| 30 | + renderDetail?: (detail: SourceInspectionToolDetail) => ReactNode; | |
| 31 | +}) => { | |
| 32 | + const inspection = useSourceInspection(props.sourceId); | |
| 33 | + const selectedToolPath = props.selectedToolPath ?? props.search.tool ?? null; | |
| 34 | + const detail = useSourceToolDetail( | |
| 35 | + props.sourceId, | |
| 36 | + selectedToolPath, | |
| 37 | + ); | |
| 38 | + const navigate = props.navigate; | |
| 39 | + | |
| 40 | + useEffect(() => { | |
| 41 | + if (inspection.status !== "ready" || !navigate) { | |
| 42 | + return; | |
| 43 | + } | |
| 44 | + | |
| 45 | + const firstTool = inspection.data.tools[0]?.path; | |
| 46 | + const nextToolPath = selectedToolPath ?? firstTool; | |
| 47 | + if (!nextToolPath) { | |
| 48 | + return; | |
| 49 | + } | |
| 50 | + | |
| 51 | + if (props.search.tab !== "discover" && selectedToolPath) { | |
| 52 | + return; | |
| 53 | + } | |
| 54 | + | |
| 55 | + startTransition(() => { | |
| 56 | + void navigate({ | |
| 57 | + tab: "model", | |
| 58 | + tool: nextToolPath, | |
| 59 | + query: "", | |
| 60 | + }); | |
| 61 | + }); | |
| 62 | + }, [inspection, navigate, props.search.tab, selectedToolPath]); | |
| 63 | + | |
| 64 | + return ( | |
| 65 | + <LoadableBlock loadable={inspection} loading="Loading source..."> | |
| 66 | + {(loadedInspection) => { | |
| 67 | + const selectedTool = | |
| 68 | + loadedInspection.tools.find((candidate) => candidate.path === selectedToolPath) | |
| 69 | + ?? loadedInspection.tools[0] | |
| 70 | + ?? null; | |
| 71 | + | |
| 72 | + return ( | |
| 73 | + <div className="flex h-full flex-col overflow-hidden"> | |
| 74 | + <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur-sm"> | |
| 75 | + <div className="flex min-w-0 items-center gap-3"> | |
| 76 | + <h2 className="truncate text-sm font-semibold text-foreground"> | |
| 77 | + {props.title} | |
| 78 | + </h2> | |
| 79 | + <Badge variant="outline">{props.kind}</Badge> | |
| 80 | + <span className="hidden text-[11px] tabular-nums text-muted-foreground/50 sm:block"> | |
| 81 | + {loadedInspection.toolCount} {loadedInspection.toolCount === 1 ? "tool" : "tools"} | |
| 82 | + </span> | |
| 83 | + </div> | |
| 84 | + <div className="flex shrink-0 items-center gap-2"> | |
| 85 | + {props.actions} | |
| 86 | + </div> | |
| 87 | + </div> | |
| 88 | + | |
| 89 | + {props.summary && ( | |
| 90 | + <div className="shrink-0 border-b border-border bg-card/30 px-4 py-2"> | |
| 91 | + {props.summary} | |
| 92 | + </div> | |
| 93 | + )} | |
| 94 | + | |
| 95 | + <div className="flex min-h-0 flex-1 overflow-hidden"> | |
| 96 | + <SourceToolModelWorkbench | |
| 97 | + bundle={loadedInspection} | |
| 98 | + detail={detail} | |
| 99 | + selectedToolPath={selectedTool?.path ?? null} | |
| 100 | + onSelectTool={(nextToolPath) => { | |
| 101 | + if (!navigate) { | |
| 102 | + return; | |
| 103 | + } | |
| 104 | + | |
| 105 | + startTransition(() => { | |
| 106 | + void navigate({ | |
| 107 | + tab: "model", | |
| 108 | + tool: nextToolPath, | |
| 109 | + query: "", | |
| 110 | + }); | |
| 111 | + }); | |
| 112 | + }} | |
| 113 | + sourceId={props.sourceId} | |
| 114 | + renderDetail={props.renderDetail} | |
| 115 | + /> | |
| 116 | + </div> | |
| 117 | + </div> | |
| 118 | + ); | |
| 119 | + }} | |
| 120 | + </LoadableBlock> | |
| 121 | + ); | |
| 122 | +}; | |
| @@ -0,0 +1,755 @@ | ||
| 1 | +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | |
| 2 | + | |
| 3 | +import type { Loadable } from "../../core/types"; | |
| 4 | +import { | |
| 5 | + usePrefetchToolDetail, | |
| 6 | +} from "../../hooks/sources"; | |
| 7 | +import type { | |
| 8 | + SourceInspection, | |
| 9 | + SourceInspectionToolDetail, | |
| 10 | +} from "@executor/platform-sdk/schema"; | |
| 11 | +import { MethodBadge } from "./badge"; | |
| 12 | +import { CodeBlock } from "./code-block"; | |
| 13 | +import { DocumentPanel } from "./document-panel"; | |
| 14 | +import { EmptyState, LoadableBlock } from "./loadable"; | |
| 15 | +import { Markdown } from "./markdown"; | |
| 16 | +import { | |
| 17 | + IconCheck, | |
| 18 | + IconChevron, | |
| 19 | + IconClose, | |
| 20 | + IconCopy, | |
| 21 | + IconFolder, | |
| 22 | + IconSearch, | |
| 23 | + IconTool, | |
| 24 | +} from "./icons"; | |
| 25 | +import { cn } from "../lib/cn"; | |
| 26 | + | |
| 27 | +type ToolTreeNode = { | |
| 28 | + segment: string; | |
| 29 | + tool?: SourceInspectionToolDetail["summary"] | SourceInspection["tools"][number]; | |
| 30 | + children: Map<string, ToolTreeNode>; | |
| 31 | +}; | |
| 32 | + | |
| 33 | +export type SourceToolDiscoveryResult = { | |
| 34 | + query: string; | |
| 35 | + results: ReadonlyArray<{ | |
| 36 | + path: string; | |
| 37 | + score: number; | |
| 38 | + description?: string | null; | |
| 39 | + }>; | |
| 40 | +}; | |
| 41 | + | |
| 42 | +export type SourceToolDetailPanelProps = { | |
| 43 | + detail: SourceInspectionToolDetail; | |
| 44 | + renderHeaderMeta?: (detail: SourceInspectionToolDetail) => ReactNode; | |
| 45 | + renderSchemaExtras?: (detail: SourceInspectionToolDetail) => ReactNode; | |
| 46 | +}; | |
| 47 | + | |
| 48 | +export type SourceToolModelWorkbenchProps = { | |
| 49 | + bundle: SourceInspection; | |
| 50 | + detail: Loadable<SourceInspectionToolDetail | null>; | |
| 51 | + selectedToolPath: string | null; | |
| 52 | + onSelectTool: (toolPath: string) => void; | |
| 53 | + sourceId: string; | |
| 54 | + renderDetail?: (detail: SourceInspectionToolDetail) => ReactNode; | |
| 55 | +}; | |
| 56 | + | |
| 57 | +export type SourceToolDiscoveryPanelProps< | |
| 58 | + TResult extends SourceToolDiscoveryResult = SourceToolDiscoveryResult, | |
| 59 | +> = { | |
| 60 | + discovery: Loadable<TResult>; | |
| 61 | + initialQuery: string; | |
| 62 | + onSubmitQuery: (query: string) => void; | |
| 63 | + onOpenTool: (toolPath: string) => void; | |
| 64 | +}; | |
| 65 | + | |
| 66 | +const buildToolTree = (tools: SourceInspection["tools"]): ToolTreeNode => { | |
| 67 | + const root: ToolTreeNode = { | |
| 68 | + segment: "", | |
| 69 | + children: new Map(), | |
| 70 | + }; | |
| 71 | + | |
| 72 | + for (const tool of tools) { | |
| 73 | + const parts = tool.path.split("."); | |
| 74 | + let node = root; | |
| 75 | + | |
| 76 | + for (const part of parts) { | |
| 77 | + const existing = node.children.get(part); | |
| 78 | + if (existing) { | |
| 79 | + node = existing; | |
| 80 | + continue; | |
| 81 | + } | |
| 82 | + | |
| 83 | + const next: ToolTreeNode = { | |
| 84 | + segment: part, | |
| 85 | + children: new Map(), | |
| 86 | + }; | |
| 87 | + node.children.set(part, next); | |
| 88 | + node = next; | |
| 89 | + } | |
| 90 | + | |
| 91 | + node.tool = tool; | |
| 92 | + } | |
| 93 | + | |
| 94 | + return root; | |
| 95 | +}; | |
| 96 | + | |
| 97 | +const countToolLeaves = (node: ToolTreeNode): number => { | |
| 98 | + let count = node.tool ? 1 : 0; | |
| 99 | + for (const child of node.children.values()) { | |
| 100 | + count += countToolLeaves(child); | |
| 101 | + } | |
| 102 | + return count; | |
| 103 | +}; | |
| 104 | + | |
| 105 | +const highlightMatch = (text: string, search: string) => { | |
| 106 | + if (!search.trim()) { | |
| 107 | + return text; | |
| 108 | + } | |
| 109 | + | |
| 110 | + const terms = search.trim().toLowerCase().split(/\s+/); | |
| 111 | + const lowerText = text.toLowerCase(); | |
| 112 | + const ranges: Array<[number, number]> = []; | |
| 113 | + | |
| 114 | + for (const term of terms) { | |
| 115 | + let index = 0; | |
| 116 | + while (index < lowerText.length) { | |
| 117 | + const found = lowerText.indexOf(term, index); | |
| 118 | + if (found === -1) { | |
| 119 | + break; | |
| 120 | + } | |
| 121 | + ranges.push([found, found + term.length]); | |
| 122 | + index = found + 1; | |
| 123 | + } | |
| 124 | + } | |
| 125 | + | |
| 126 | + if (ranges.length === 0) { | |
| 127 | + return text; | |
| 128 | + } | |
| 129 | + | |
| 130 | + ranges.sort((left, right) => left[0] - right[0]); | |
| 131 | + const merged: Array<[number, number]> = [ranges[0]!]; | |
| 132 | + for (let index = 1; index < ranges.length; index++) { | |
| 133 | + const last = merged[merged.length - 1]!; | |
| 134 | + const current = ranges[index]!; | |
| 135 | + if (current[0] <= last[1]) { | |
| 136 | + last[1] = Math.max(last[1], current[1]); | |
| 137 | + } else { | |
| 138 | + merged.push(current); | |
| 139 | + } | |
| 140 | + } | |
| 141 | + | |
| 142 | + const parts: Array<{ text: string; highlighted: boolean }> = []; | |
| 143 | + let cursor = 0; | |
| 144 | + for (const [start, end] of merged) { | |
| 145 | + if (cursor < start) { | |
| 146 | + parts.push({ text: text.slice(cursor, start), highlighted: false }); | |
| 147 | + } | |
| 148 | + parts.push({ text: text.slice(start, end), highlighted: true }); | |
| 149 | + cursor = end; | |
| 150 | + } | |
| 151 | + if (cursor < text.length) { | |
| 152 | + parts.push({ text: text.slice(cursor), highlighted: false }); | |
| 153 | + } | |
| 154 | + | |
| 155 | + return ( | |
| 156 | + <> | |
| 157 | + {parts.map((part, index) => | |
| 158 | + part.highlighted ? ( | |
| 159 | + <mark key={index} className="rounded-sm bg-primary/20 px-px text-foreground"> | |
| 160 | + {part.text} | |
| 161 | + </mark> | |
| 162 | + ) : ( | |
| 163 | + <span key={index}>{part.text}</span> | |
| 164 | + ), | |
| 165 | + )} | |
| 166 | + </> | |
| 167 | + ); | |
| 168 | +}; | |
| 169 | + | |
| 170 | +const CopyButton = (props: { | |
| 171 | + text: string; | |
| 172 | + field: string; | |
| 173 | + copiedField: string | null; | |
| 174 | + onCopy: (text: string, field: string) => void | Promise<void>; | |
| 175 | +}) => ( | |
| 176 | + <button | |
| 177 | + type="button" | |
| 178 | + onClick={() => { | |
| 179 | + void props.onCopy(props.text, props.field); | |
| 180 | + }} | |
| 181 | + className="shrink-0 rounded p-1 text-muted-foreground/30 transition-colors hover:text-muted-foreground" | |
| 182 | + title={`Copy ${props.field}`} | |
| 183 | + > | |
| 184 | + {props.copiedField === props.field ? <IconCheck /> : <IconCopy />} | |
| 185 | + </button> | |
| 186 | +); | |
| 187 | + | |
| 188 | +const SourceToolTree = (props: { | |
| 189 | + tools: SourceInspection["tools"]; | |
| 190 | + selectedToolPath: string | null; | |
| 191 | + onSelectTool: (path: string) => void; | |
| 192 | + search: string; | |
| 193 | + isFiltered: boolean; | |
| 194 | + sourceId: string; | |
| 195 | +}) => { | |
| 196 | + const tree = useMemo(() => buildToolTree(props.tools), [props.tools]); | |
| 197 | + const prefetch = usePrefetchToolDetail(); | |
| 198 | + const entries = [...tree.children.values()].sort((left, right) => | |
| 199 | + left.segment.localeCompare(right.segment) | |
| 200 | + ); | |
| 201 | + | |
| 202 | + return ( | |
| 203 | + <div className="flex flex-col gap-px"> | |
| 204 | + {entries.map((node) => ( | |
| 205 | + <SourceToolTreeNodeView | |
| 206 | + key={node.segment} | |
| 207 | + node={node} | |
| 208 | + depth={0} | |
| 209 | + selectedToolPath={props.selectedToolPath} | |
| 210 | + onSelectTool={props.onSelectTool} | |
| 211 | + search={props.search} | |
| 212 | + defaultOpen={props.isFiltered} | |
| 213 | + sourceId={props.sourceId} | |
| 214 | + prefetch={prefetch} | |
| 215 | + /> | |
| 216 | + ))} | |
| 217 | + </div> | |
| 218 | + ); | |
| 219 | +}; | |
| 220 | + | |
| 221 | +const SourceToolTreeNodeView = (props: { | |
| 222 | + node: ToolTreeNode; | |
| 223 | + depth: number; | |
| 224 | + selectedToolPath: string | null; | |
| 225 | + onSelectTool: (path: string) => void; | |
| 226 | + search: string; | |
| 227 | + defaultOpen: boolean; | |
| 228 | + sourceId: string; | |
| 229 | + prefetch: ReturnType<typeof usePrefetchToolDetail>; | |
| 230 | +}) => { | |
| 231 | + const { | |
| 232 | + node, | |
| 233 | + depth, | |
| 234 | + selectedToolPath, | |
| 235 | + onSelectTool, | |
| 236 | + search, | |
| 237 | + defaultOpen, | |
| 238 | + sourceId, | |
| 239 | + prefetch, | |
| 240 | + } = props; | |
| 241 | + const hasChildren = node.children.size > 0; | |
| 242 | + const isLeaf = !!node.tool && !hasChildren; | |
| 243 | + | |
| 244 | + const hasSelectedDescendant = useMemo(() => { | |
| 245 | + if (!selectedToolPath) { | |
| 246 | + return false; | |
| 247 | + } | |
| 248 | + | |
| 249 | + const check = (candidate: ToolTreeNode): boolean => { | |
| 250 | + if (candidate.tool?.path === selectedToolPath) { | |
| 251 | + return true; | |
| 252 | + } | |
| 253 | + | |
| 254 | + for (const child of candidate.children.values()) { | |
| 255 | + if (check(child)) { | |
| 256 | + return true; | |
| 257 | + } | |
| 258 | + } | |
| 259 | + | |
| 260 | + return false; | |
| 261 | + }; | |
| 262 | + | |
| 263 | + return check(node); | |
| 264 | + }, [node, selectedToolPath]); | |
| 265 | + | |
| 266 | + const [open, setOpen] = useState(defaultOpen || hasSelectedDescendant); | |
| 267 | + | |
| 268 | + useEffect(() => { | |
| 269 | + if (defaultOpen || hasSelectedDescendant) { | |
| 270 | + setOpen(true); | |
| 271 | + } | |
| 272 | + }, [defaultOpen, hasSelectedDescendant]); | |
| 273 | + | |
| 274 | + if (isLeaf) { | |
| 275 | + return ( | |
| 276 | + <SourceToolListItem | |
| 277 | + tool={node.tool as SourceInspection["tools"][number]} | |
| 278 | + active={node.tool?.path === selectedToolPath} | |
| 279 | + onSelect={() => onSelectTool(node.tool!.path)} | |
| 280 | + search={search} | |
| 281 | + depth={depth} | |
| 282 | + sourceId={sourceId} | |
| 283 | + prefetch={prefetch} | |
| 284 | + /> | |
| 285 | + ); | |
| 286 | + } | |
| 287 | + | |
| 288 | + const paddingLeft = 8 + depth * 16; | |
| 289 | + const sortedChildren = [...node.children.values()].sort((left, right) => | |
| 290 | + left.segment.localeCompare(right.segment) | |
| 291 | + ); | |
| 292 | + const leafCount = countToolLeaves(node); | |
| 293 | + | |
| 294 | + return ( | |
| 295 | + <div> | |
| 296 | + {node.tool ? ( | |
| 297 | + <div className="flex items-center"> | |
| 298 | + <button | |
| 299 | + type="button" | |
| 300 | + onClick={() => setOpen((value) => !value)} | |
| 301 | + className="shrink-0 rounded p-0.5 text-muted-foreground/30 hover:text-muted-foreground" | |
| 302 | + style={{ marginLeft: paddingLeft }} | |
| 303 | + > | |
| 304 | + <IconChevron | |
| 305 | + className={cn("shrink-0 transition-transform duration-150", open && "rotate-90")} | |
| 306 | + style={{ width: 8, height: 8 }} | |
| 307 | + /> | |
| 308 | + </button> | |
| 309 | + <SourceToolListItem | |
| 310 | + tool={node.tool as SourceInspection["tools"][number]} | |
| 311 | + active={node.tool?.path === selectedToolPath} | |
| 312 | + onSelect={() => onSelectTool(node.tool!.path)} | |
| 313 | + search={search} | |
| 314 | + depth={-1} | |
| 315 | + className="flex-1 pl-1" | |
| 316 | + sourceId={sourceId} | |
| 317 | + prefetch={prefetch} | |
| 318 | + /> | |
| 319 | + </div> | |
| 320 | + ) : ( | |
| 321 | + <button | |
| 322 | + type="button" | |
| 323 | + onClick={() => setOpen((value) => !value)} | |
| 324 | + className={cn( | |
| 325 | + "group flex w-full items-center gap-1.5 rounded-md py-1 pr-2.5 text-[12px] transition-colors hover:bg-accent/40", | |
| 326 | + open ? "text-foreground/80" : "text-muted-foreground/60", | |
| 327 | + )} | |
| 328 | + style={{ paddingLeft }} | |
| 329 | + > | |
| 330 | + <IconChevron | |
| 331 | + className={cn( | |
| 332 | + "shrink-0 text-muted-foreground/30 transition-transform duration-150", | |
| 333 | + open && "rotate-90", | |
| 334 | + )} | |
| 335 | + style={{ width: 8, height: 8 }} | |
| 336 | + /> | |
| 337 | + <IconFolder | |
| 338 | + className={cn("shrink-0", open ? "text-primary/60" : "text-muted-foreground/30")} | |
| 339 | + style={{ width: 12, height: 12 }} | |
| 340 | + /> | |
| 341 | + <span className="flex-1 truncate text-left font-mono"> | |
| 342 | + {highlightMatch(node.segment, search)} | |
| 343 | + </span> | |
| 344 | + <span className="shrink-0 tabular-nums text-[10px] text-muted-foreground/25"> | |
| 345 | + {leafCount} | |
| 346 | + </span> | |
| 347 | + </button> | |
| 348 | + )} | |
| 349 | + | |
| 350 | + {open && hasChildren && ( | |
| 351 | + <div className="relative flex flex-col gap-px"> | |
| 352 | + <span | |
| 353 | + className="absolute bottom-1 top-0 w-px bg-border/40" | |
| 354 | + style={{ left: paddingLeft + 5 }} | |
| 355 | + aria-hidden | |
| 356 | + /> | |
| 357 | + {sortedChildren.map((child) => ( | |
| 358 | + <SourceToolTreeNodeView | |
| 359 | + key={child.segment} | |
| 360 | + node={child} | |
| 361 | + depth={depth + 1} | |
| 362 | + selectedToolPath={selectedToolPath} | |
| 363 | + onSelectTool={onSelectTool} | |
| 364 | + search={search} | |
| 365 | + defaultOpen={defaultOpen} | |
| 366 | + sourceId={sourceId} | |
| 367 | + prefetch={prefetch} | |
| 368 | + /> | |
| 369 | + ))} | |
| 370 | + </div> | |
| 371 | + )} | |
| 372 | + </div> | |
| 373 | + ); | |
| 374 | +}; | |
| 375 | + | |
| 376 | +const SourceToolListItem = (props: { | |
| 377 | + tool: SourceInspection["tools"][number]; | |
| 378 | + active: boolean; | |
| 379 | + onSelect: () => void; | |
| 380 | + search: string; | |
| 381 | + depth: number; | |
| 382 | + className?: string; | |
| 383 | + sourceId: string; | |
| 384 | + prefetch: ReturnType<typeof usePrefetchToolDetail>; | |
| 385 | +}) => { | |
| 386 | + const ref = useRef<HTMLButtonElement>(null); | |
| 387 | + const paddingLeft = props.depth >= 0 ? 8 + props.depth * 16 + 8 : undefined; | |
| 388 | + | |
| 389 | + useEffect(() => { | |
| 390 | + if (props.active && ref.current) { | |
| 391 | + ref.current.scrollIntoView({ block: "nearest" }); | |
| 392 | + } | |
| 393 | + }, [props.active]); | |
| 394 | + | |
| 395 | + const label = props.depth >= 0 | |
| 396 | + ? props.tool.path.split(".").pop() ?? props.tool.path | |
| 397 | + : props.tool.path; | |
| 398 | + | |
| 399 | + return ( | |
| 400 | + <button | |
| 401 | + ref={ref} | |
| 402 | + type="button" | |
| 403 | + onMouseEnter={() => { | |
| 404 | + props.prefetch(props.sourceId, props.tool.path); | |
| 405 | + }} | |
| 406 | + onClick={props.onSelect} | |
| 407 | + className={cn( | |
| 408 | + "group flex w-full items-center gap-2 rounded-md py-1.5 pr-2.5 text-left transition-colors", | |
| 409 | + props.active | |
| 410 | + ? "border-l-2 border-l-primary bg-primary/10 text-foreground" | |
| 411 | + : "text-foreground/70 hover:bg-accent/50 hover:text-foreground", | |
| 412 | + props.className, | |
| 413 | + )} | |
| 414 | + style={paddingLeft != null ? { paddingLeft } : undefined} | |
| 415 | + > | |
| 416 | + <IconTool className="size-3 shrink-0 text-muted-foreground/40" /> | |
| 417 | + <span className="flex-1 truncate font-mono text-[12px]"> | |
| 418 | + {highlightMatch(label, props.search)} | |
| 419 | + </span> | |
| 420 | + {props.tool.method && <MethodBadge method={props.tool.method} />} | |
| 421 | + </button> | |
| 422 | + ); | |
| 423 | +}; | |
| 424 | + | |
| 425 | +export const SourceToolDetailPanel = (props: SourceToolDetailPanelProps) => { | |
| 426 | + const [copiedField, setCopiedField] = useState<string | null>(null); | |
| 427 | + const inputType = props.detail.contract.input.typeDeclaration | |
| 428 | + ?? props.detail.contract.input.typePreview | |
| 429 | + ?? null; | |
| 430 | + const outputType = props.detail.contract.output.typeDeclaration | |
| 431 | + ?? props.detail.contract.output.typePreview | |
| 432 | + ?? null; | |
| 433 | + | |
| 434 | + return ( | |
| 435 | + <div className="flex h-full flex-col overflow-hidden"> | |
| 436 | + <div className="shrink-0 border-b border-border bg-background/95 backdrop-blur-sm"> | |
| 437 | + <div className="flex items-start gap-3 px-5 py-3.5"> | |
| 438 | + <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"> | |
| 439 | + <IconTool className="size-4" /> | |
| 440 | + </div> | |
| 441 | + <div className="min-w-0 flex-1"> | |
| 442 | + <div className="flex items-center gap-2"> | |
| 443 | + <h3 className="truncate text-sm font-semibold text-foreground"> | |
| 444 | + {props.detail.summary.path} | |
| 445 | + </h3> | |
| 446 | + <CopyButton | |
| 447 | + text={props.detail.summary.path} | |
| 448 | + field="path" | |
| 449 | + copiedField={copiedField} | |
| 450 | + onCopy={async (text, field) => { | |
| 451 | + await navigator.clipboard.writeText(text); | |
| 452 | + setCopiedField(field); | |
| 453 | + window.setTimeout(() => setCopiedField(null), 1500); | |
| 454 | + }} | |
| 455 | + /> | |
| 456 | + </div> | |
| 457 | + <div className="mt-1 flex flex-wrap items-center gap-1.5"> | |
| 458 | + {props.detail.summary.method && ( | |
| 459 | + <MethodBadge method={props.detail.summary.method} /> | |
| 460 | + )} | |
| 461 | + {props.renderHeaderMeta?.(props.detail)} | |
| 462 | + </div> | |
| 463 | + </div> | |
| 464 | + </div> | |
| 465 | + </div> | |
| 466 | + | |
| 467 | + <div className="flex-1 overflow-y-auto"> | |
| 468 | + <div className="space-y-4 px-5 py-4"> | |
| 469 | + {props.detail.summary.description && ( | |
| 470 | + <Markdown>{props.detail.summary.description}</Markdown> | |
| 471 | + )} | |
| 472 | + | |
| 473 | + <div className="grid grid-cols-1 gap-3 xl:grid-cols-2"> | |
| 474 | + <DocumentPanel title="Input" body={inputType} lang="typescript" empty="No input." /> | |
| 475 | + <DocumentPanel title="Output" body={outputType} lang="typescript" empty="No output." /> | |
| 476 | + </div> | |
| 477 | + | |
| 478 | + <DocumentPanel | |
| 479 | + title="Call Signature" | |
| 480 | + body={props.detail.contract.callSignature} | |
| 481 | + lang="typescript" | |
| 482 | + empty="No call signature." | |
| 483 | + /> | |
| 484 | + | |
| 485 | + <div className="grid grid-cols-1 gap-2 xl:grid-cols-2"> | |
| 486 | + <DocumentPanel | |
| 487 | + title="Input schema" | |
| 488 | + body={props.detail.contract.input.schemaJson} | |
| 489 | + empty="No input schema." | |
| 490 | + compact | |
| 491 | + /> | |
| 492 | + <DocumentPanel | |
| 493 | + title="Output schema" | |
| 494 | + body={props.detail.contract.output.schemaJson} | |
| 495 | + empty="No output schema." | |
| 496 | + compact | |
| 497 | + /> | |
| 498 | + {props.renderSchemaExtras?.(props.detail)} | |
| 499 | + </div> | |
| 500 | + | |
| 501 | + {props.detail.sections.map((section, index) => ( | |
| 502 | + <section | |
| 503 | + key={`${section.title}-${String(index)}`} | |
| 504 | + className="overflow-hidden rounded-lg border border-border bg-card/60" | |
| 505 | + > | |
| 506 | + <div className="border-b border-border px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70"> | |
| 507 | + {section.title} | |
| 508 | + </div> | |
| 509 | + {section.kind === "facts" ? ( | |
| 510 | + <div className="grid gap-2 p-4"> | |
| 511 | + {section.items.map((item) => ( | |
| 512 | + <div key={`${section.title}-${item.label}`} className="text-sm"> | |
| 513 | + <span className="text-muted-foreground">{item.label}:</span>{" "} | |
| 514 | + <span className={item.mono ? "font-mono text-xs text-foreground" : "text-foreground"}> | |
| 515 | + {item.value} | |
| 516 | + </span> | |
| 517 | + </div> | |
| 518 | + ))} | |
| 519 | + </div> | |
| 520 | + ) : section.kind === "markdown" ? ( | |
| 521 | + <div className="p-4"> | |
| 522 | + <Markdown>{section.body}</Markdown> | |
| 523 | + </div> | |
| 524 | + ) : ( | |
| 525 | + section.body ? ( | |
| 526 | + <CodeBlock | |
| 527 | + code={section.body} | |
| 528 | + lang={section.language} | |
| 529 | + className="max-h-[32rem]" | |
| 530 | + /> | |
| 531 | + ) : ( | |
| 532 | + <div className="flex items-center justify-center p-6 text-[13px] text-muted-foreground/40"> | |
| 533 | + No content. | |
| 534 | + </div> | |
| 535 | + ) | |
| 536 | + )} | |
| 537 | + </section> | |
| 538 | + ))} | |
| 539 | + </div> | |
| 540 | + </div> | |
| 541 | + </div> | |
| 542 | + ); | |
| 543 | +}; | |
| 544 | + | |
| 545 | +export const SourceToolModelWorkbench = ( | |
| 546 | + props: SourceToolModelWorkbenchProps, | |
| 547 | +) => { | |
| 548 | + const [search, setSearch] = useState(""); | |
| 549 | + const searchRef = useRef<HTMLInputElement>(null); | |
| 550 | + const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); | |
| 551 | + | |
| 552 | + const filteredTools = useMemo( | |
| 553 | + () => | |
| 554 | + props.bundle.tools.filter((tool) => { | |
| 555 | + if (terms.length === 0) { | |
| 556 | + return true; | |
| 557 | + } | |
| 558 | + | |
| 559 | + const corpus = [ | |
| 560 | + tool.path, | |
| 561 | + tool.method ?? "", | |
| 562 | + tool.inputTypePreview ?? "", | |
| 563 | + tool.outputTypePreview ?? "", | |
| 564 | + ] | |
| 565 | + .join(" ") | |
| 566 | + .toLowerCase(); | |
| 567 | + | |
| 568 | + return terms.every((term) => corpus.includes(term)); | |
| 569 | + }), | |
| 570 | + [props.bundle.tools, terms], | |
| 571 | + ); | |
| 572 | + | |
| 573 | + useEffect(() => { | |
| 574 | + const handler = (event: KeyboardEvent) => { | |
| 575 | + if (event.key === "/" && document.activeElement?.tagName !== "INPUT") { | |
| 576 | + event.preventDefault(); | |
| 577 | + searchRef.current?.focus(); | |
| 578 | + } | |
| 579 | + | |
| 580 | + if (event.key === "Escape") { | |
| 581 | + searchRef.current?.blur(); | |
| 582 | + if (search.length > 0) { | |
| 583 | + setSearch(""); | |
| 584 | + } | |
| 585 | + } | |
| 586 | + }; | |
| 587 | + | |
| 588 | + document.addEventListener("keydown", handler); | |
| 589 | + return () => document.removeEventListener("keydown", handler); | |
| 590 | + }, [search]); | |
| 591 | + | |
| 592 | + const renderDetail = props.renderDetail ?? ((detail: SourceInspectionToolDetail) => ( | |
| 593 | + <SourceToolDetailPanel detail={detail} /> | |
| 594 | + )); | |
| 595 | + | |
| 596 | + return ( | |
| 597 | + <> | |
| 598 | + <div className="flex w-72 shrink-0 flex-col border-r border-border bg-card/30 lg:w-80 xl:w-[22rem]"> | |
| 599 | + <div className="shrink-0 border-b border-border px-3 py-2"> | |
| 600 | + <div className="flex h-8 items-center gap-2 rounded-md border border-input bg-background px-2.5"> | |
| 601 | + <IconSearch className="size-3.5 shrink-0 text-muted-foreground/40" /> | |
| 602 | + <input | |
| 603 | + ref={searchRef} | |
| 604 | + value={search} | |
| 605 | + onChange={(event) => setSearch(event.target.value)} | |
| 606 | + placeholder={`Filter ${props.bundle.toolCount} tools...`} | |
| 607 | + className="min-w-0 flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground/40" | |
| 608 | + /> | |
| 609 | + {search.length > 0 ? ( | |
| 610 | + <button | |
| 611 | + type="button" | |
| 612 | + onClick={() => setSearch("")} | |
| 613 | + className="shrink-0 rounded p-0.5 text-muted-foreground/40 hover:text-foreground" | |
| 614 | + > | |
| 615 | + <IconClose /> | |
| 616 | + </button> | |
| 617 | + ) : ( | |
| 618 | + <kbd className="shrink-0 rounded border border-border bg-muted px-1 py-px text-[10px] leading-none text-muted-foreground/50"> | |
| 619 | + / | |
| 620 | + </kbd> | |
| 621 | + )} | |
| 622 | + </div> | |
| 623 | + </div> | |
| 624 | + | |
| 625 | + <div className="flex-1 overflow-y-auto max-w-[300px]"> | |
| 626 | + {filteredTools.length === 0 ? ( | |
| 627 | + <div className="p-4 text-center text-[13px] text-muted-foreground/50"> | |
| 628 | + {terms.length > 0 ? "No tools match your filter" : "No tools available"} | |
| 629 | + </div> | |
| 630 | + ) : ( | |
| 631 | + <div className="p-1.5"> | |
| 632 | + <SourceToolTree | |
| 633 | + tools={filteredTools} | |
| 634 | + selectedToolPath={props.selectedToolPath} | |
| 635 | + onSelectTool={props.onSelectTool} | |
| 636 | + search={search} | |
| 637 | + isFiltered={terms.length > 0} | |
| 638 | + sourceId={props.sourceId} | |
| 639 | + /> | |
| 640 | + </div> | |
| 641 | + )} | |
| 642 | + </div> | |
| 643 | + </div> | |
| 644 | + | |
| 645 | + <div className="flex min-w-0 flex-1 flex-col overflow-hidden"> | |
| 646 | + <LoadableBlock loadable={props.detail} loading="Loading tool..."> | |
| 647 | + {(detail) => | |
| 648 | + detail ? ( | |
| 649 | + renderDetail(detail) | |
| 650 | + ) : ( | |
| 651 | + <EmptyState | |
| 652 | + title={props.bundle.toolCount > 0 ? "Select a tool" : "No tools available"} | |
| 653 | + description={ | |
| 654 | + props.bundle.toolCount > 0 | |
| 655 | + ? "Choose from the list or press / to search." | |
| 656 | + : undefined | |
| 657 | + } | |
| 658 | + /> | |
| 659 | + ) | |
| 660 | + } | |
| 661 | + </LoadableBlock> | |
| 662 | + </div> | |
| 663 | + </> | |
| 664 | + ); | |
| 665 | +}; | |
| 666 | + | |
| 667 | +export const SourceToolDiscoveryPanel = < | |
| 668 | + TResult extends SourceToolDiscoveryResult = SourceToolDiscoveryResult, | |
| 669 | +>( | |
| 670 | + props: SourceToolDiscoveryPanelProps<TResult>, | |
| 671 | +) => { | |
| 672 | + const [draftQuery, setDraftQuery] = useState(props.initialQuery); | |
| 673 | + | |
| 674 | + useEffect(() => { | |
| 675 | + setDraftQuery(props.initialQuery); | |
| 676 | + }, [props.initialQuery]); | |
| 677 | + | |
| 678 | + return ( | |
| 679 | + <div className="flex flex-1 flex-col overflow-hidden"> | |
| 680 | + <div className="shrink-0 border-b border-border px-4 py-3"> | |
| 681 | + <form | |
| 682 | + className="flex max-w-2xl items-center gap-2" | |
| 683 | + onSubmit={(event) => { | |
| 684 | + event.preventDefault(); | |
| 685 | + props.onSubmitQuery(draftQuery.trim()); | |
| 686 | + }} | |
| 687 | + > | |
| 688 | + <div className="relative flex-1"> | |
| 689 | + <IconSearch className="absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/40" /> | |
| 690 | + <input | |
| 691 | + value={draftQuery} | |
| 692 | + onChange={(event) => setDraftQuery(event.target.value)} | |
| 693 | + placeholder="Search tools..." | |
| 694 | + className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/30" | |
| 695 | + /> | |
| 696 | + </div> | |
| 697 | + <button | |
| 698 | + type="submit" | |
| 699 | + className="inline-flex h-9 items-center rounded-md border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50" | |
| 700 | + > | |
| 701 | + Search | |
| 702 | + </button> | |
| 703 | + </form> | |
| 704 | + </div> | |
| 705 | + | |
| 706 | + <div className="flex-1 overflow-y-auto p-4"> | |
| 707 | + <LoadableBlock loadable={props.discovery} loading="Searching..."> | |
| 708 | + {(result) => | |
| 709 | + result.query.length === 0 ? ( | |
| 710 | + <EmptyState | |
| 711 | + title="Search your tools" | |
| 712 | + description="Type a query to find matching tools across this source." | |
| 713 | + /> | |
| 714 | + ) : result.results.length === 0 ? ( | |
| 715 | + <EmptyState | |
| 716 | + title="No results" | |
| 717 | + description="Try different search terms." | |
| 718 | + /> | |
| 719 | + ) : ( | |
| 720 | + <div className="max-w-3xl space-y-2"> | |
| 721 | + {result.results.map((item, index) => ( | |
| 722 | + <button | |
| 723 | + key={item.path} | |
| 724 | + type="button" | |
| 725 | + onClick={() => props.onOpenTool(item.path)} | |
| 726 | + className="group w-full rounded-lg border border-border bg-card/60 p-3.5 text-left transition-all hover:border-primary/30 hover:shadow-sm" | |
| 727 | + > | |
| 728 | + <div className="mb-1.5 flex items-center justify-between gap-3"> | |
| 729 | + <div className="flex min-w-0 items-center gap-2.5"> | |
| 730 | + <span className="flex size-5 shrink-0 items-center justify-center rounded-md bg-muted text-[10px] font-mono tabular-nums text-muted-foreground/60"> | |
| 731 | + {index + 1} | |
| 732 | + </span> | |
| 733 | + <h4 className="truncate font-mono text-[13px] font-medium text-foreground transition-colors group-hover:text-primary"> | |
| 734 | + {item.path} | |
| 735 | + </h4> | |
| 736 | + </div> | |
| 737 | + <span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground/50"> | |
| 738 | + {item.score.toFixed(2)} | |
| 739 | + </span> | |
| 740 | + </div> | |
| 741 | + {item.description && ( | |
| 742 | + <p className="line-clamp-2 text-[12px] leading-relaxed text-muted-foreground"> | |
| 743 | + {item.description} | |
| 744 | + </p> | |
| 745 | + )} | |
| 746 | + </button> | |
| 747 | + ))} | |
| 748 | + </div> | |
| 749 | + ) | |
| 750 | + } | |
| 751 | + </LoadableBlock> | |
| 752 | + </div> | |
| 753 | + </div> | |
| 754 | + ); | |
| 755 | +}; | |
| @@ -0,0 +1,106 @@ | ||
| 1 | +export type { | |
| 2 | + ExecutorFrontendPlugin, | |
| 3 | + ExecutorPluginNavigation, | |
| 4 | + ExecutorPluginRouteContextValue, | |
| 5 | + FrontendPluginRouteDefinition, | |
| 6 | + FrontendPluginRouteParams, | |
| 7 | + FrontendPluginRouteSearch, | |
| 8 | + SourcePluginNavigation, | |
| 9 | + SourcePluginRouteParams, | |
| 10 | + SourcePluginRouteSearch, | |
| 11 | +} from "./types"; | |
| 12 | + | |
| 13 | +export { | |
| 14 | + createExecutorPluginPaths, | |
| 15 | + createSourcePluginPaths, | |
| 16 | + executorPluginBasePath, | |
| 17 | + executorPluginRoutePath, | |
| 18 | + executorPluginRoutePattern, | |
| 19 | + normalizeExecutorPluginPath, | |
| 20 | + normalizeSourcePluginPath, | |
| 21 | + sourcePluginAddPath, | |
| 22 | + sourcePluginChildPath, | |
| 23 | + sourcePluginChildPattern, | |
| 24 | + sourcePluginDetailPath, | |
| 25 | + sourcePluginDetailPattern, | |
| 26 | + sourcePluginEditPath, | |
| 27 | + sourcePluginEditPattern, | |
| 28 | + sourcePluginsIndexPath, | |
| 29 | + type ExecutorPluginPaths, | |
| 30 | + type SourcePluginPaths, | |
| 31 | +} from "./paths"; | |
| 32 | + | |
| 33 | +export { | |
| 34 | + defineExecutorFrontendPlugin, | |
| 35 | + defineFrontendPluginRoute, | |
| 36 | + registerExecutorFrontendPlugins, | |
| 37 | + type RegisteredFrontendPluginRoute, | |
| 38 | +} from "./registry"; | |
| 39 | + | |
| 40 | +export { | |
| 41 | + ExecutorPluginRouteProvider, | |
| 42 | + useExecutorPlugin, | |
| 43 | + useExecutorPluginNavigation, | |
| 44 | + useExecutorPluginPaths, | |
| 45 | + useExecutorPluginRoute, | |
| 46 | + useExecutorPluginRouteDefinition, | |
| 47 | + useExecutorPluginRouteParams, | |
| 48 | + useExecutorPluginSearch, | |
| 49 | +} from "./plugin-route-context"; | |
| 50 | + | |
| 51 | +export { | |
| 52 | + useSourcePlugin, | |
| 53 | + useSourcePluginNavigation, | |
| 54 | + useSourcePluginPaths, | |
| 55 | + useSourcePluginRoute, | |
| 56 | + useSourcePluginRouteParams, | |
| 57 | + useSourcePluginSearch, | |
| 58 | +} from "./route-context"; | |
| 59 | + | |
| 60 | +export { | |
| 61 | + cn, | |
| 62 | +} from "./lib/cn"; | |
| 63 | + | |
| 64 | +export { | |
| 65 | + Badge, | |
| 66 | + MethodBadge, | |
| 67 | +} from "./components/badge"; | |
| 68 | +export { | |
| 69 | + CodeBlock, | |
| 70 | +} from "./components/code-block"; | |
| 71 | +export { | |
| 72 | + DocumentPanel, | |
| 73 | +} from "./components/document-panel"; | |
| 74 | +export { | |
| 75 | + IconCheck, | |
| 76 | + IconChevron, | |
| 77 | + IconClose, | |
| 78 | + IconCopy, | |
| 79 | + IconEmpty, | |
| 80 | + IconFolder, | |
| 81 | + IconPencil, | |
| 82 | + IconSearch, | |
| 83 | + IconSpinner, | |
| 84 | + IconTool, | |
| 85 | +} from "./components/icons"; | |
| 86 | +export { | |
| 87 | + EmptyState, | |
| 88 | + LoadableBlock, | |
| 89 | +} from "./components/loadable"; | |
| 90 | +export { | |
| 91 | + Markdown, | |
| 92 | +} from "./components/markdown"; | |
| 93 | +export { | |
| 94 | + SourceToolExplorer, | |
| 95 | +} from "./components/source-tool-explorer"; | |
| 96 | +export { | |
| 97 | + type SourceToolExplorerSearch, | |
| 98 | + parseSourceToolExplorerSearch, | |
| 99 | +} from "./components/source-tool-explorer-search"; | |
| 100 | +export { | |
| 101 | + SourceToolDetailPanel, | |
| 102 | + SourceToolDiscoveryPanel, | |
| 103 | + SourceToolModelWorkbench, | |
| 104 | + type SourceToolDetailPanelProps, | |
| 105 | + type SourceToolDiscoveryResult, | |
| 106 | +} from "./components/source-tool-workbench"; | |
| @@ -0,0 +1,6 @@ | ||
| 1 | +import { clsx, type ClassValue } from "clsx"; | |
| 2 | +import { twMerge } from "tailwind-merge"; | |
| 3 | + | |
| 4 | +export function cn(...inputs: ReadonlyArray<ClassValue>): string { | |
| 5 | + return twMerge(clsx(inputs)); | |
| 6 | +} | |
| @@ -0,0 +1,211 @@ | ||
| 1 | +import { | |
| 2 | + createHighlighterCore, | |
| 3 | + type HighlighterCore, | |
| 4 | + type LanguageInput, | |
| 5 | +} from "shiki/core"; | |
| 6 | +import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; | |
| 7 | +import type { CodeHighlighterPlugin, ThemeInput } from "streamdown"; | |
| 8 | + | |
| 9 | +const SUPPORTED_LANGS = [ | |
| 10 | + "json", | |
| 11 | + "xml", | |
| 12 | + "yaml", | |
| 13 | + "shellscript", | |
| 14 | + "typescript", | |
| 15 | + "javascript", | |
| 16 | + "python", | |
| 17 | + "html", | |
| 18 | + "css", | |
| 19 | + "markdown", | |
| 20 | + "sql", | |
| 21 | + "graphql", | |
| 22 | + "go", | |
| 23 | + "rust", | |
| 24 | + "java", | |
| 25 | + "ruby", | |
| 26 | + "php", | |
| 27 | + "swift", | |
| 28 | + "kotlin", | |
| 29 | + "c", | |
| 30 | + "cpp", | |
| 31 | + "csharp", | |
| 32 | + "tsx", | |
| 33 | + "jsx", | |
| 34 | + "toml", | |
| 35 | + "dockerfile", | |
| 36 | + "diff", | |
| 37 | + "http", | |
| 38 | + "jsonc", | |
| 39 | + "log", | |
| 40 | + "proto", | |
| 41 | +] as const; | |
| 42 | + | |
| 43 | +type SupportedLang = (typeof SUPPORTED_LANGS)[number]; | |
| 44 | + | |
| 45 | +const LANG_ALIASES: Record<string, SupportedLang> = { | |
| 46 | + sh: "shellscript", | |
| 47 | + shell: "shellscript", | |
| 48 | + bash: "shellscript", | |
| 49 | + zsh: "shellscript", | |
| 50 | + ts: "typescript", | |
| 51 | + js: "javascript", | |
| 52 | + py: "python", | |
| 53 | + rb: "ruby", | |
| 54 | + rs: "rust", | |
| 55 | + "c++": "cpp", | |
| 56 | + "c#": "csharp", | |
| 57 | + cs: "csharp", | |
| 58 | + kt: "kotlin", | |
| 59 | + md: "markdown", | |
| 60 | + gql: "graphql", | |
| 61 | + yml: "yaml", | |
| 62 | +}; | |
| 63 | + | |
| 64 | +const LANG_LOADERS: Record<SupportedLang, () => LanguageInput> = { | |
| 65 | + json: () => import("@shikijs/langs/json"), | |
| 66 | + xml: () => import("@shikijs/langs/xml"), | |
| 67 | + yaml: () => import("@shikijs/langs/yaml"), | |
| 68 | + shellscript: () => import("@shikijs/langs/shellscript"), | |
| 69 | + typescript: () => import("@shikijs/langs/typescript"), | |
| 70 | + javascript: () => import("@shikijs/langs/javascript"), | |
| 71 | + python: () => import("@shikijs/langs/python"), | |
| 72 | + html: () => import("@shikijs/langs/html"), | |
| 73 | + css: () => import("@shikijs/langs/css"), | |
| 74 | + markdown: () => import("@shikijs/langs/markdown"), | |
| 75 | + sql: () => import("@shikijs/langs/sql"), | |
| 76 | + graphql: () => import("@shikijs/langs/graphql"), | |
| 77 | + go: () => import("@shikijs/langs/go"), | |
| 78 | + rust: () => import("@shikijs/langs/rust"), | |
| 79 | + java: () => import("@shikijs/langs/java"), | |
| 80 | + ruby: () => import("@shikijs/langs/ruby"), | |
| 81 | + php: () => import("@shikijs/langs/php"), | |
| 82 | + swift: () => import("@shikijs/langs/swift"), | |
| 83 | + kotlin: () => import("@shikijs/langs/kotlin"), | |
| 84 | + c: () => import("@shikijs/langs/c"), | |
| 85 | + cpp: () => import("@shikijs/langs/cpp"), | |
| 86 | + csharp: () => import("@shikijs/langs/csharp"), | |
| 87 | + tsx: () => import("@shikijs/langs/tsx"), | |
| 88 | + jsx: () => import("@shikijs/langs/jsx"), | |
| 89 | + toml: () => import("@shikijs/langs/toml"), | |
| 90 | + dockerfile: () => import("@shikijs/langs/dockerfile"), | |
| 91 | + diff: () => import("@shikijs/langs/diff"), | |
| 92 | + http: () => import("@shikijs/langs/http"), | |
| 93 | + jsonc: () => import("@shikijs/langs/jsonc"), | |
| 94 | + log: () => import("@shikijs/langs/log"), | |
| 95 | + proto: () => import("@shikijs/langs/proto"), | |
| 96 | +}; | |
| 97 | + | |
| 98 | +const supportedSet = new Set<string>([ | |
| 99 | + ...SUPPORTED_LANGS, | |
| 100 | + ...Object.keys(LANG_ALIASES), | |
| 101 | +]); | |
| 102 | + | |
| 103 | +export function resolveLang(lang: string): SupportedLang | null { | |
| 104 | + const value = lang.trim().toLowerCase(); | |
| 105 | + if (value in LANG_LOADERS) { | |
| 106 | + return value as SupportedLang; | |
| 107 | + } | |
| 108 | + if (value in LANG_ALIASES) { | |
| 109 | + return LANG_ALIASES[value]!; | |
| 110 | + } | |
| 111 | + return null; | |
| 112 | +} | |
| 113 | + | |
| 114 | +export function isSupportedLang(lang: string): boolean { | |
| 115 | + return supportedSet.has(lang.trim().toLowerCase()); | |
| 116 | +} | |
| 117 | + | |
| 118 | +const jsEngine = createJavaScriptRegexEngine({ forgiving: true }); | |
| 119 | + | |
| 120 | +let highlighter: HighlighterCore | null = null; | |
| 121 | +let highlighterPromise: Promise<HighlighterCore> | null = null; | |
| 122 | + | |
| 123 | +async function getHighlighter(): Promise<HighlighterCore> { | |
| 124 | + if (highlighter) { | |
| 125 | + return highlighter; | |
| 126 | + } | |
| 127 | + if (highlighterPromise) { | |
| 128 | + return highlighterPromise; | |
| 129 | + } | |
| 130 | + | |
| 131 | + highlighterPromise = createHighlighterCore({ | |
| 132 | + themes: [import("@shikijs/themes/vitesse-dark")], | |
| 133 | + langs: Object.values(LANG_LOADERS).map((loader) => loader()), | |
| 134 | + engine: jsEngine, | |
| 135 | + }).then((instance: HighlighterCore) => { | |
| 136 | + highlighter = instance; | |
| 137 | + return instance; | |
| 138 | + }); | |
| 139 | + | |
| 140 | + return highlighterPromise; | |
| 141 | +} | |
| 142 | + | |
| 143 | +void getHighlighter(); | |
| 144 | + | |
| 145 | +export async function codeToHtml( | |
| 146 | + code: string, | |
| 147 | + options: { lang: string }, | |
| 148 | +): Promise<string> { | |
| 149 | + const highlighter = await getHighlighter(); | |
| 150 | + const resolved = resolveLang(options.lang) ?? "json"; | |
| 151 | + return highlighter.codeToHtml(code, { | |
| 152 | + lang: resolved, | |
| 153 | + theme: "vitesse-dark", | |
| 154 | + }); | |
| 155 | +} | |
| 156 | + | |
| 157 | +const THEME = "vitesse-dark" as ThemeInput; | |
| 158 | +const tokensCache = new Map<string, unknown>(); | |
| 159 | +const pendingCallbacks = new Map<string, Set<(result: unknown) => void>>(); | |
| 160 | +type CodePluginHighlight = NonNullable<CodeHighlighterPlugin["highlight"]>; | |
| 161 | +type CodeHighlightOptions = Parameters<CodePluginHighlight>[0]; | |
| 162 | +type CodeHighlightCallback = Parameters<CodePluginHighlight>[1]; | |
| 163 | + | |
| 164 | +export function createLimitedCodePlugin(): CodeHighlighterPlugin { | |
| 165 | + return { | |
| 166 | + name: "shiki" as const, | |
| 167 | + type: "code-highlighter" as const, | |
| 168 | + getSupportedLanguages: () => [...SUPPORTED_LANGS] as string[] as never, | |
| 169 | + getThemes: () => [THEME, THEME], | |
| 170 | + supportsLanguage: (language: string) => isSupportedLang(language), | |
| 171 | + highlight( | |
| 172 | + options: CodeHighlightOptions, | |
| 173 | + callback: CodeHighlightCallback, | |
| 174 | + ) { | |
| 175 | + const resolved = resolveLang(options.language); | |
| 176 | + const lang = resolved ?? "json"; | |
| 177 | + const key = `${lang}:${options.code.length}:${options.code.slice(0, 128)}`; | |
| 178 | + | |
| 179 | + const cached = tokensCache.get(key); | |
| 180 | + if (cached) { | |
| 181 | + return cached as never; | |
| 182 | + } | |
| 183 | + | |
| 184 | + if (callback) { | |
| 185 | + if (!pendingCallbacks.has(key)) { | |
| 186 | + pendingCallbacks.set(key, new Set()); | |
| 187 | + } | |
| 188 | + pendingCallbacks.get(key)!.add(callback as (result: unknown) => void); | |
| 189 | + } | |
| 190 | + | |
| 191 | + void getHighlighter().then((instance) => { | |
| 192 | + if (tokensCache.has(key)) { | |
| 193 | + const result = tokensCache.get(key); | |
| 194 | + pendingCallbacks.get(key)?.forEach((cb) => cb(result)); | |
| 195 | + pendingCallbacks.delete(key); | |
| 196 | + return; | |
| 197 | + } | |
| 198 | + | |
| 199 | + const result = instance.codeToTokens(options.code, { | |
| 200 | + lang, | |
| 201 | + themes: { light: "vitesse-dark", dark: "vitesse-dark" }, | |
| 202 | + }); | |
| 203 | + tokensCache.set(key, result); | |
| 204 | + pendingCallbacks.get(key)?.forEach((cb) => cb(result)); | |
| 205 | + pendingCallbacks.delete(key); | |
| 206 | + }); | |
| 207 | + | |
| 208 | + return null; | |
| 209 | + }, | |
| 210 | + }; | |
| 211 | +} | |
| @@ -0,0 +1,105 @@ | ||
| 1 | +const trimSlashes = (value: string): string => | |
| 2 | + value.replace(/^\/+/, "").replace(/\/+$/, ""); | |
| 3 | + | |
| 4 | +const joinPath = (...parts: ReadonlyArray<string>): string => | |
| 5 | + `/${parts.map(trimSlashes).filter(Boolean).join("/")}`; | |
| 6 | + | |
| 7 | +export type ExecutorPluginPaths = { | |
| 8 | + base: string; | |
| 9 | + routePattern: (path?: string) => string; | |
| 10 | + route: (path?: string) => string; | |
| 11 | +}; | |
| 12 | + | |
| 13 | +export type SourcePluginPaths = { | |
| 14 | + add: string; | |
| 15 | + detailPattern: string; | |
| 16 | + editPattern: string; | |
| 17 | + detail: (sourceId: string) => string; | |
| 18 | + edit: (sourceId: string) => string; | |
| 19 | + childPattern: (path: string) => string; | |
| 20 | + child: (sourceId: string, path: string) => string; | |
| 21 | +}; | |
| 22 | + | |
| 23 | +export const sourcePluginsIndexPath = "/sources/add"; | |
| 24 | + | |
| 25 | +export const normalizeExecutorPluginPath = (value: string): string => | |
| 26 | + trimSlashes(value); | |
| 27 | + | |
| 28 | +export const normalizeSourcePluginPath = normalizeExecutorPluginPath; | |
| 29 | + | |
| 30 | +export const executorPluginBasePath = (key: string): string => | |
| 31 | + joinPath("plugins", key); | |
| 32 | + | |
| 33 | +export const executorPluginRoutePattern = ( | |
| 34 | + key: string, | |
| 35 | + path = "", | |
| 36 | +): string => { | |
| 37 | + const relativePath = normalizeExecutorPluginPath(path); | |
| 38 | + return relativePath.length === 0 | |
| 39 | + ? executorPluginBasePath(key) | |
| 40 | + : joinPath("plugins", key, relativePath); | |
| 41 | +}; | |
| 42 | + | |
| 43 | +export const executorPluginRoutePath = ( | |
| 44 | + key: string, | |
| 45 | + path = "", | |
| 46 | +): string => | |
| 47 | + executorPluginRoutePattern(key, path); | |
| 48 | + | |
| 49 | +export const sourcePluginAddPath = (key: string): string => | |
| 50 | + executorPluginRoutePath(key, "add"); | |
| 51 | + | |
| 52 | +export const sourcePluginDetailPattern = (key: string): string => | |
| 53 | + joinPath("plugins", key, "sources", "$sourceId"); | |
| 54 | + | |
| 55 | +export const sourcePluginEditPattern = (key: string): string => | |
| 56 | + joinPath("plugins", key, "sources", "$sourceId", "edit"); | |
| 57 | + | |
| 58 | +export const sourcePluginDetailPath = ( | |
| 59 | + key: string, | |
| 60 | + sourceId: string, | |
| 61 | +): string => | |
| 62 | + joinPath("plugins", key, "sources", sourceId); | |
| 63 | + | |
| 64 | +export const sourcePluginEditPath = ( | |
| 65 | + key: string, | |
| 66 | + sourceId: string, | |
| 67 | +): string => | |
| 68 | + joinPath("plugins", key, "sources", sourceId, "edit"); | |
| 69 | + | |
| 70 | +export const sourcePluginChildPattern = ( | |
| 71 | + key: string, | |
| 72 | + path: string, | |
| 73 | +): string => { | |
| 74 | + const relativePath = normalizeSourcePluginPath(path); | |
| 75 | + return relativePath.length === 0 | |
| 76 | + ? sourcePluginDetailPattern(key) | |
| 77 | + : joinPath("plugins", key, "sources", "$sourceId", relativePath); | |
| 78 | +}; | |
| 79 | + | |
| 80 | +export const sourcePluginChildPath = ( | |
| 81 | + key: string, | |
| 82 | + sourceId: string, | |
| 83 | + path: string, | |
| 84 | +): string => { | |
| 85 | + const relativePath = normalizeSourcePluginPath(path); | |
| 86 | + return relativePath.length === 0 | |
| 87 | + ? sourcePluginDetailPath(key, sourceId) | |
| 88 | + : joinPath("plugins", key, "sources", sourceId, relativePath); | |
| 89 | +}; | |
| 90 | + | |
| 91 | +export const createExecutorPluginPaths = (key: string): ExecutorPluginPaths => ({ | |
| 92 | + base: executorPluginBasePath(key), | |
| 93 | + routePattern: (path = "") => executorPluginRoutePattern(key, path), | |
| 94 | + route: (path = "") => executorPluginRoutePath(key, path), | |
| 95 | +}); | |
| 96 | + | |
| 97 | +export const createSourcePluginPaths = (key: string): SourcePluginPaths => ({ | |
| 98 | + add: sourcePluginAddPath(key), | |
| 99 | + detailPattern: sourcePluginDetailPattern(key), | |
| 100 | + editPattern: sourcePluginEditPattern(key), | |
| 101 | + detail: (sourceId) => sourcePluginDetailPath(key, sourceId), | |
| 102 | + edit: (sourceId) => sourcePluginEditPath(key, sourceId), | |
| 103 | + childPattern: (path) => sourcePluginChildPattern(key, path), | |
| 104 | + child: (sourceId, path) => sourcePluginChildPath(key, sourceId, path), | |
| 105 | +}); | |
| @@ -0,0 +1,55 @@ | ||
| 1 | +import * as React from "react"; | |
| 2 | + | |
| 3 | +import type { | |
| 4 | + ExecutorPluginRouteContextValue, | |
| 5 | + FrontendPluginRouteParams, | |
| 6 | + FrontendPluginRouteSearch, | |
| 7 | +} from "./types"; | |
| 8 | + | |
| 9 | +const ExecutorPluginRouteContext = | |
| 10 | + React.createContext<ExecutorPluginRouteContextValue | null>(null); | |
| 11 | + | |
| 12 | +export function ExecutorPluginRouteProvider(props: { | |
| 13 | + value: ExecutorPluginRouteContextValue; | |
| 14 | + children: React.ReactNode; | |
| 15 | +}) { | |
| 16 | + return ( | |
| 17 | + <ExecutorPluginRouteContext.Provider value={props.value}> | |
| 18 | + {props.children} | |
| 19 | + </ExecutorPluginRouteContext.Provider> | |
| 20 | + ); | |
| 21 | +} | |
| 22 | + | |
| 23 | +const useExecutorPluginRouteContext = (): ExecutorPluginRouteContextValue => { | |
| 24 | + const value = React.useContext(ExecutorPluginRouteContext); | |
| 25 | + if (value === null) { | |
| 26 | + throw new Error("Executor plugin route context is unavailable."); | |
| 27 | + } | |
| 28 | + | |
| 29 | + return value; | |
| 30 | +}; | |
| 31 | + | |
| 32 | +export const useExecutorPluginRoute = (): ExecutorPluginRouteContextValue => | |
| 33 | + useExecutorPluginRouteContext(); | |
| 34 | + | |
| 35 | +export const useExecutorPlugin = () => | |
| 36 | + useExecutorPluginRouteContext().plugin; | |
| 37 | + | |
| 38 | +export const useExecutorPluginRouteDefinition = () => | |
| 39 | + useExecutorPluginRouteContext().route; | |
| 40 | + | |
| 41 | +export const useExecutorPluginNavigation = () => | |
| 42 | + useExecutorPluginRouteContext().navigation; | |
| 43 | + | |
| 44 | +export const useExecutorPluginSearch = < | |
| 45 | + TSearch extends FrontendPluginRouteSearch = FrontendPluginRouteSearch, | |
| 46 | +>(): TSearch => | |
| 47 | + useExecutorPluginRouteContext().search as TSearch; | |
| 48 | + | |
| 49 | +export const useExecutorPluginRouteParams = < | |
| 50 | + TParams extends FrontendPluginRouteParams = FrontendPluginRouteParams, | |
| 51 | +>(): TParams => | |
| 52 | + useExecutorPluginRouteContext().params as TParams; | |
| 53 | + | |
| 54 | +export const useExecutorPluginPaths = () => | |
| 55 | + useExecutorPluginRouteContext().navigation.paths; | |
| @@ -0,0 +1,75 @@ | ||
| 1 | +import type { | |
| 2 | + ExecutorFrontendPlugin, | |
| 3 | + FrontendPluginRouteDefinition, | |
| 4 | +} from "./types"; | |
| 5 | +import { | |
| 6 | + normalizeExecutorPluginPath, | |
| 7 | +} from "./paths"; | |
| 8 | + | |
| 9 | +export type RegisteredFrontendPluginRoute = { | |
| 10 | + plugin: ExecutorFrontendPlugin; | |
| 11 | + route: FrontendPluginRouteDefinition; | |
| 12 | +}; | |
| 13 | + | |
| 14 | +export const defineFrontendPluginRoute = < | |
| 15 | + TDefinition extends FrontendPluginRouteDefinition, | |
| 16 | +>( | |
| 17 | + definition: TDefinition, | |
| 18 | +): TDefinition => definition; | |
| 19 | + | |
| 20 | +export const defineExecutorFrontendPlugin = < | |
| 21 | + TPlugin extends ExecutorFrontendPlugin, | |
| 22 | +>( | |
| 23 | + input: TPlugin, | |
| 24 | +): TPlugin => input; | |
| 25 | + | |
| 26 | +export const registerExecutorFrontendPlugins = ( | |
| 27 | + plugins: readonly ExecutorFrontendPlugin[], | |
| 28 | +) => { | |
| 29 | + const pluginsByKey = new Map<string, ExecutorFrontendPlugin>(); | |
| 30 | + const routesByPluginAndKey = new Map<string, RegisteredFrontendPluginRoute>(); | |
| 31 | + | |
| 32 | + for (const plugin of plugins) { | |
| 33 | + if (pluginsByKey.has(plugin.key)) { | |
| 34 | + throw new Error(`Duplicate frontend plugin registration: ${plugin.key}`); | |
| 35 | + } | |
| 36 | + | |
| 37 | + pluginsByKey.set(plugin.key, plugin); | |
| 38 | + | |
| 39 | + const pluginRouteKeys = new Set<string>(); | |
| 40 | + const pluginRoutePaths = new Set<string>(); | |
| 41 | + | |
| 42 | + for (const route of plugin.routes ?? []) { | |
| 43 | + if (pluginRouteKeys.has(route.key)) { | |
| 44 | + throw new Error( | |
| 45 | + `Duplicate frontend plugin route key for ${plugin.key}: ${route.key}`, | |
| 46 | + ); | |
| 47 | + } | |
| 48 | + | |
| 49 | + pluginRouteKeys.add(route.key); | |
| 50 | + | |
| 51 | + const normalizedPath = normalizeExecutorPluginPath(route.path ?? ""); | |
| 52 | + if (pluginRoutePaths.has(normalizedPath)) { | |
| 53 | + throw new Error( | |
| 54 | + `Duplicate frontend plugin route path for ${plugin.key}: ${normalizedPath || "<root>"}`, | |
| 55 | + ); | |
| 56 | + } | |
| 57 | + | |
| 58 | + pluginRoutePaths.add(normalizedPath); | |
| 59 | + routesByPluginAndKey.set(`${plugin.key}:${route.key}`, { | |
| 60 | + plugin, | |
| 61 | + route, | |
| 62 | + }); | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + const routes = [...routesByPluginAndKey.values()]; | |
| 67 | + | |
| 68 | + return { | |
| 69 | + plugins: [...pluginsByKey.values()], | |
| 70 | + routes, | |
| 71 | + getPlugin: (key: string) => pluginsByKey.get(key) ?? null, | |
| 72 | + getRoute: (pluginKey: string, routeKey: string) => | |
| 73 | + routesByPluginAndKey.get(`${pluginKey}:${routeKey}`) ?? null, | |
| 74 | + }; | |
| 75 | +}; | |
| @@ -0,0 +1,64 @@ | ||
| 1 | +import { | |
| 2 | + useExecutorPlugin, | |
| 3 | + useExecutorPluginNavigation, | |
| 4 | + useExecutorPluginRouteParams, | |
| 5 | + useExecutorPluginSearch, | |
| 6 | +} from "./plugin-route-context"; | |
| 7 | +import { | |
| 8 | + createSourcePluginPaths, | |
| 9 | + normalizeSourcePluginPath, | |
| 10 | +} from "./paths"; | |
| 11 | +import type { | |
| 12 | + SourcePluginNavigation, | |
| 13 | + SourcePluginRouteParams, | |
| 14 | + SourcePluginRouteSearch, | |
| 15 | +} from "./types"; | |
| 16 | + | |
| 17 | +export const useSourcePluginRoute = () => ({ | |
| 18 | + plugin: useExecutorPlugin(), | |
| 19 | + params: useExecutorPluginRouteParams(), | |
| 20 | + search: useExecutorPluginSearch(), | |
| 21 | + navigation: useSourcePluginNavigation(), | |
| 22 | +}); | |
| 23 | + | |
| 24 | +export const useSourcePlugin = () => | |
| 25 | + useExecutorPlugin(); | |
| 26 | + | |
| 27 | +export const useSourcePluginNavigation = (): SourcePluginNavigation => { | |
| 28 | + const plugin = useExecutorPlugin(); | |
| 29 | + const navigation = useExecutorPluginNavigation(); | |
| 30 | + const paths = createSourcePluginPaths(plugin.key); | |
| 31 | + | |
| 32 | + return { | |
| 33 | + paths, | |
| 34 | + home: () => navigation.home(), | |
| 35 | + add: () => navigation.route("add"), | |
| 36 | + detail: (sourceId, search) => | |
| 37 | + navigation.route(`sources/${sourceId}`, search), | |
| 38 | + edit: (sourceId, search) => | |
| 39 | + navigation.route(`sources/${sourceId}/edit`, search), | |
| 40 | + child: ({ sourceId, path, search }) => { | |
| 41 | + const normalizedPath = normalizeSourcePluginPath(path); | |
| 42 | + return navigation.route( | |
| 43 | + normalizedPath.length === 0 | |
| 44 | + ? `sources/${sourceId}` | |
| 45 | + : `sources/${sourceId}/${normalizedPath}`, | |
| 46 | + search, | |
| 47 | + ); | |
| 48 | + }, | |
| 49 | + updateSearch: (search) => navigation.updateSearch(search), | |
| 50 | + }; | |
| 51 | +}; | |
| 52 | + | |
| 53 | +export const useSourcePluginSearch = < | |
| 54 | + TSearch extends SourcePluginRouteSearch = SourcePluginRouteSearch, | |
| 55 | +>(): TSearch => | |
| 56 | + useExecutorPluginSearch<TSearch>(); | |
| 57 | + | |
| 58 | +export const useSourcePluginRouteParams = < | |
| 59 | + TParams extends SourcePluginRouteParams = SourcePluginRouteParams, | |
| 60 | +>(): TParams => | |
| 61 | + useExecutorPluginRouteParams<TParams>(); | |
| 62 | + | |
| 63 | +export const useSourcePluginPaths = () => | |
| 64 | + createSourcePluginPaths(useExecutorPlugin().key); | |
| @@ -0,0 +1,58 @@ | ||
| 1 | +import type { ComponentType } from "react"; | |
| 2 | + | |
| 3 | +import type { | |
| 4 | + ExecutorPluginPaths, | |
| 5 | + SourcePluginPaths, | |
| 6 | +} from "./paths"; | |
| 7 | + | |
| 8 | +export type FrontendPluginRouteSearch = Record<string, unknown>; | |
| 9 | +export type FrontendPluginRouteParams = Readonly<Record<string, string | undefined>>; | |
| 10 | + | |
| 11 | +export type ExecutorPluginNavigation = { | |
| 12 | + paths: ExecutorPluginPaths; | |
| 13 | + home: () => void | Promise<void>; | |
| 14 | + route: (path?: string, search?: FrontendPluginRouteSearch) => void | Promise<void>; | |
| 15 | + updateSearch: (search: FrontendPluginRouteSearch) => void | Promise<void>; | |
| 16 | +}; | |
| 17 | + | |
| 18 | +export type FrontendPluginRouteDefinition = { | |
| 19 | + key: string; | |
| 20 | + path?: string; | |
| 21 | + component: ComponentType; | |
| 22 | + nav?: { | |
| 23 | + label: string; | |
| 24 | + section?: string; | |
| 25 | + }; | |
| 26 | +}; | |
| 27 | + | |
| 28 | +export type ExecutorPluginRouteContextValue = { | |
| 29 | + plugin: ExecutorFrontendPlugin; | |
| 30 | + route: FrontendPluginRouteDefinition; | |
| 31 | + params: FrontendPluginRouteParams; | |
| 32 | + search: FrontendPluginRouteSearch; | |
| 33 | + navigation: ExecutorPluginNavigation; | |
| 34 | +}; | |
| 35 | + | |
| 36 | +export type SourcePluginRouteSearch = FrontendPluginRouteSearch; | |
| 37 | +export type SourcePluginRouteParams = FrontendPluginRouteParams; | |
| 38 | + | |
| 39 | +export type SourcePluginNavigation = { | |
| 40 | + paths: SourcePluginPaths; | |
| 41 | + home: () => void | Promise<void>; | |
| 42 | + add: () => void | Promise<void>; | |
| 43 | + detail: (sourceId: string, search?: SourcePluginRouteSearch) => void | Promise<void>; | |
| 44 | + edit: (sourceId: string, search?: SourcePluginRouteSearch) => void | Promise<void>; | |
| 45 | + child: (input: { | |
| 46 | + sourceId: string; | |
| 47 | + path: string; | |
| 48 | + search?: SourcePluginRouteSearch; | |
| 49 | + }) => void | Promise<void>; | |
| 50 | + updateSearch: (search: SourcePluginRouteSearch) => void | Promise<void>; | |
| 51 | +}; | |
| 52 | + | |
| 53 | +export type ExecutorFrontendPlugin = { | |
| 54 | + key: string; | |
| 55 | + displayName?: string; | |
| 56 | + description?: string; | |
| 57 | + routes?: readonly FrontendPluginRouteDefinition[]; | |
| 58 | +}; | |
| @@ -0,0 +1,6 @@ | ||
| 1 | +import { | |
| 2 | + RegistryProvider, | |
| 3 | +} from "@effect-atom/atom-react"; | |
| 4 | +import * as React from "react"; | |
| 5 | + | |
| 6 | +export const ExecutorReactProvider = (props: React.PropsWithChildren) => <RegistryProvider>{props.children}</RegistryProvider>; | |
| @@ -0,0 +1 @@ | ||
| 1 | +export * from "../plugins/index"; | |
| @@ -14,7 +14,6 @@ · 13 unmodified lines | ||
| 14 | 14 | "@executor/codemode-core": "workspace:*", |
| 15 | 15 | "@executor/platform-sdk": "workspace:*", |
| 16 | 16 | "@executor/runtime-ses": "workspace:*", |
| 17 | − "@executor/source-openapi": "workspace:*", | |
| 18 | 17 | "effect": "catalog:" |
| 19 | 18 | }, |
| 20 | 19 | "devDependencies": { |
| @@ -1,901 +0,0 @@ | ||
| 1 | −import { createServer } from "node:http"; | |
| 2 | − | |
| 3 | −import { | |
| 4 | − HttpApi, | |
| 5 | − HttpApiEndpoint, | |
| 6 | − HttpApiGroup, | |
| 7 | − HttpApiSchema, | |
| 8 | − OpenApi, | |
| 9 | −} from "@effect/platform"; | |
| 10 | −import { describe, expect, it } from "@effect/vitest"; | |
| 11 | −import * as Effect from "effect/Effect"; | |
| 12 | −import * as Schema from "effect/Schema"; | |
| 13 | −import { Schema as EffectSchema } from "effect"; | |
| 14 | − | |
| 15 | −import { | |
| 16 | − type SecretRef, | |
| 17 | − type Source, | |
| 18 | − type SourceId, | |
| 19 | − type WorkspaceId, | |
| 20 | −} from "@executor/platform-sdk/schema"; | |
| 21 | −import { | |
| 22 | − createToolCatalogDiscovery, | |
| 23 | − createToolCatalogFromTools, | |
| 24 | − createSystemToolMap, | |
| 25 | − makeToolInvokerFromTools, | |
| 26 | − mergeToolMaps, | |
| 27 | − type ToolCatalog, | |
| 28 | − type ToolMap, | |
| 29 | − type ToolPath, | |
| 30 | − type ToolInvocationContext, | |
| 31 | − type ToolInvoker, | |
| 32 | −} from "@executor/codemode-core"; | |
| 33 | −import { createOpenApiToolsFromSpec } from "@executor/source-openapi"; | |
| 34 | −import { makeSesExecutor } from "@executor/runtime-ses"; | |
| 35 | − | |
| 36 | −const asToolPath = (value: string): ToolPath => value as ToolPath; | |
| 37 | −const asSourceId = (value: string): SourceId => value as SourceId; | |
| 38 | −const asWorkspaceId = (value: string): WorkspaceId => value as WorkspaceId; | |
| 39 | − | |
| 40 | −const tokenize = (value: string): string[] => | |
| 41 | − value | |
| 42 | − .trim() | |
| 43 | − .toLowerCase() | |
| 44 | − .split(/\s+/) | |
| 45 | − .filter(Boolean); | |
| 46 | − | |
| 47 | −const normalizeSearchText = (...parts: ReadonlyArray<string | null | undefined>): string => | |
| 48 | − parts | |
| 49 | − .flatMap((part) => { | |
| 50 | − const normalized = part?.trim(); | |
| 51 | − return normalized ? [normalized] : []; | |
| 52 | − }) | |
| 53 | − .join(" ") | |
| 54 | − .toLowerCase(); | |
| 55 | − | |
| 56 | −const catalogNamespaceFromPath = (path: string): string => { | |
| 57 | − const [first, second] = path.split("."); | |
| 58 | − return second ? `${first}.${second}` : first; | |
| 59 | −}; | |
| 60 | − | |
| 61 | −type StoredToolArtifactRecord = { | |
| 62 | − workspaceId: WorkspaceId; | |
| 63 | − path: string; | |
| 64 | − toolId: string; | |
| 65 | − sourceId: SourceId; | |
| 66 | − title: string | null; | |
| 67 | − description: string | null; | |
| 68 | − searchNamespace: string; | |
| 69 | − searchText: string; | |
| 70 | − inputSchema: unknown | null; | |
| 71 | − outputSchema: unknown | null; | |
| 72 | − providerKind: "openapi"; | |
| 73 | − openApiMethod: "get" | "put" | "post" | "delete" | "patch" | "head" | "options" | "trace"; | |
| 74 | − openApiPathTemplate: string | null; | |
| 75 | − openApiOperationHash: string | null; | |
| 76 | − openApiRawToolId: string | null; | |
| 77 | − openApiOperationId: string | null; | |
| 78 | − openApiTagsJson: string | null; | |
| 79 | − openApiRequestBodyRequired: boolean | null; | |
| 80 | − createdAt: number; | |
| 81 | − updatedAt: number; | |
| 82 | −}; | |
| 83 | − | |
| 84 | −const toDescriptor = ( | |
| 85 | − artifact: StoredToolArtifactRecord, | |
| 86 | − includeSchemas: boolean, | |
| 87 | −) => ({ | |
| 88 | − path: asToolPath(artifact.path), | |
| 89 | − sourceKey: artifact.sourceId, | |
| 90 | − description: artifact.description ?? artifact.title ?? undefined, | |
| 91 | − interaction: "auto" as const, | |
| 92 | − inputTypePreview: includeSchemas && artifact.inputSchema ? "object" : undefined, | |
| 93 | − outputTypePreview: includeSchemas && artifact.outputSchema ? "output" : undefined, | |
| 94 | − inputSchema: includeSchemas ? artifact.inputSchema ?? undefined : undefined, | |
| 95 | − outputSchema: includeSchemas ? artifact.outputSchema ?? undefined : undefined, | |
| 96 | −}); | |
| 97 | − | |
| 98 | −type SourceCallContext = { | |
| 99 | − auth: | |
| 100 | − | { kind: "none" } | |
| 101 | − | { | |
| 102 | − kind: "headers"; | |
| 103 | − headers: Record<string, string>; | |
| 104 | − }; | |
| 105 | −}; | |
| 106 | − | |
| 107 | −interface SecretMaterialProvider { | |
| 108 | − providerId: string; | |
| 109 | − get(input: { | |
| 110 | − handle: string; | |
| 111 | − }): Promise<string>; | |
| 112 | −} | |
| 113 | − | |
| 114 | −interface SecretMaterialRegistry { | |
| 115 | − get(input: { | |
| 116 | − ref: SecretRef; | |
| 117 | − }): Promise<string>; | |
| 118 | −} | |
| 119 | − | |
| 120 | −interface ProviderInvoker { | |
| 121 | − invoke(input: { | |
| 122 | − source: Source; | |
| 123 | − artifact: StoredToolArtifactRecord; | |
| 124 | − args: unknown; | |
| 125 | − runtime: SourceCallContext; | |
| 126 | − context?: ToolInvocationContext; | |
| 127 | − }): Promise<unknown>; | |
| 128 | −} | |
| 129 | − | |
| 130 | −type WorkspaceScopedSourceStore = { | |
| 131 | − registerSource(input: { | |
| 132 | − workspaceId: WorkspaceId; | |
| 133 | − source: Source; | |
| 134 | − }): Promise<void>; | |
| 135 | − getById(input: { | |
| 136 | − sourceId: SourceId; | |
| 137 | − }): Promise<Source | null>; | |
| 138 | −}; | |
| 139 | − | |
| 140 | −type WorkspaceScopedToolStore = { | |
| 141 | − indexArtifacts(input: { | |
| 142 | − workspaceId: WorkspaceId; | |
| 143 | − artifacts: readonly StoredToolArtifactRecord[]; | |
| 144 | − }): Promise<void>; | |
| 145 | − listNamespaces(input: { | |
| 146 | − workspaceId: WorkspaceId; | |
| 147 | − limit?: number; | |
| 148 | − }): Promise< | |
| 149 | − readonly { | |
| 150 | − namespace: string; | |
| 151 | − toolCount: number; | |
| 152 | − }[] | |
| 153 | − >; | |
| 154 | − list(input: { | |
| 155 | − workspaceId: WorkspaceId; | |
| 156 | − query?: string; | |
| 157 | − namespace?: string; | |
| 158 | − limit?: number; | |
| 159 | − }): Promise<readonly StoredToolArtifactRecord[]>; | |
| 160 | − getByPath(input: { | |
| 161 | − workspaceId: WorkspaceId; | |
| 162 | − path: ToolPath; | |
| 163 | − }): Promise<StoredToolArtifactRecord | null>; | |
| 164 | −}; | |
| 165 | − | |
| 166 | −const createInMemorySourceStore = (): WorkspaceScopedSourceStore => { | |
| 167 | − const byWorkspace = new Map<string, Map<string, Source>>(); | |
| 168 | − | |
| 169 | − const getWorkspaceMap = (workspaceId: WorkspaceId) => { | |
| 170 | − const existing = byWorkspace.get(workspaceId); | |
| 171 | − if (existing) { | |
| 172 | − return existing; | |
| 173 | − } | |
| 174 | − | |
| 175 | − const created = new Map<string, Source>(); | |
| 176 | − byWorkspace.set(workspaceId, created); | |
| 177 | − return created; | |
| 178 | − }; | |
| 179 | − | |
| 180 | − return { | |
| 181 | − async registerSource({ workspaceId, source }) { | |
| 182 | − getWorkspaceMap(workspaceId).set(source.id, source); | |
| 183 | − }, | |
| 184 | − async getById({ sourceId }) { | |
| 185 | − for (const workspace of byWorkspace.values()) { | |
| 186 | − const source = workspace.get(sourceId); | |
| 187 | − if (source) { | |
| 188 | − return source; | |
| 189 | − } | |
| 190 | − } | |
| 191 | − return null; | |
| 192 | − }, | |
| 193 | − }; | |
| 194 | −}; | |
| 195 | − | |
| 196 | −const createInMemoryToolStore = (): WorkspaceScopedToolStore => { | |
| 197 | − const byWorkspace = new Map<string, Map<string, StoredToolArtifactRecord>>(); | |
| 198 | − | |
| 199 | − const getWorkspaceMap = (workspaceId: WorkspaceId) => { | |
| 200 | − const existing = byWorkspace.get(workspaceId); | |
| 201 | − if (existing) { | |
| 202 | − return existing; | |
| 203 | − } | |
| 204 | − | |
| 205 | − const created = new Map<string, StoredToolArtifactRecord>(); | |
| 206 | − byWorkspace.set(workspaceId, created); | |
| 207 | − return created; | |
| 208 | − }; | |
| 209 | − | |
| 210 | − return { | |
| 211 | − async indexArtifacts({ workspaceId, artifacts }) { | |
| 212 | − const workspace = getWorkspaceMap(workspaceId); | |
| 213 | − for (const artifact of artifacts) { | |
| 214 | − workspace.set(artifact.path, artifact); | |
| 215 | − } | |
| 216 | − }, | |
| 217 | − async listNamespaces({ workspaceId, limit = 200 }) { | |
| 218 | − const counts = new Map<string, number>(); | |
| 219 | − for (const artifact of getWorkspaceMap(workspaceId).values()) { | |
| 220 | − counts.set( | |
| 221 | − artifact.searchNamespace, | |
| 222 | − (counts.get(artifact.searchNamespace) ?? 0) + 1, | |
| 223 | − ); | |
| 224 | − } | |
| 225 | − | |
| 226 | − return [...counts.entries()] | |
| 227 | − .map(([namespace, toolCount]) => ({ | |
| 228 | − namespace, | |
| 229 | − toolCount, | |
| 230 | − })) | |
| 231 | − .sort((left, right) => left.namespace.localeCompare(right.namespace)) | |
| 232 | − .slice(0, limit); | |
| 233 | − }, | |
| 234 | − async list({ workspaceId, namespace, query, limit = 200 }) { | |
| 235 | − return [...getWorkspaceMap(workspaceId).values()] | |
| 236 | − .filter((artifact) => !namespace || artifact.searchNamespace === namespace) | |
| 237 | − .filter((artifact) => | |
| 238 | − !query || tokenize(query).every((token) => artifact.searchText.includes(token)) | |
| 239 | − ) | |
| 240 | − .slice(0, limit); | |
| 241 | − }, | |
| 242 | − async getByPath({ workspaceId, path }) { | |
| 243 | − return getWorkspaceMap(workspaceId).get(path) ?? null; | |
| 244 | − }, | |
| 245 | − }; | |
| 246 | −}; | |
| 247 | − | |
| 248 | −const createStaticSecretProvider = ( | |
| 249 | − providerId: string, | |
| 250 | − values: Record<string, string>, | |
| 251 | −): SecretMaterialProvider => { | |
| 252 | − const handles = new Map(Object.entries(values)); | |
| 253 | − | |
| 254 | − return { | |
| 255 | − providerId, | |
| 256 | − async get({ handle }) { | |
| 257 | − const value = handles.get(handle); | |
| 258 | − if (!value) { | |
| 259 | − throw new Error(`Unknown secret handle ${providerId}:${handle}`); | |
| 260 | − } | |
| 261 | − return value; | |
| 262 | − }, | |
| 263 | − }; | |
| 264 | −}; | |
| 265 | − | |
| 266 | −const createSecretRegistry = ( | |
| 267 | − providers: readonly SecretMaterialProvider[], | |
| 268 | −): SecretMaterialRegistry => { | |
| 269 | − const byId = new Map(providers.map((provider) => [provider.providerId, provider])); | |
| 270 | − | |
| 271 | − return { | |
| 272 | − async get({ ref }) { | |
| 273 | − const provider = byId.get(ref.providerId); | |
| 274 | − if (!provider) { | |
| 275 | − throw new Error(`Unknown secret provider ${ref.providerId}`); | |
| 276 | − } | |
| 277 | − return provider.get({ handle: ref.handle }); | |
| 278 | − }, | |
| 279 | − }; | |
| 280 | −}; | |
| 281 | − | |
| 282 | −const resolveSourceCallContext = (input: { | |
| 283 | − secretRegistry: SecretMaterialRegistry; | |
| 284 | −}) => | |
| 285 | − async (source: Source): Promise<SourceCallContext> => { | |
| 286 | − switch (source.auth.kind) { | |
| 287 | − case "none": | |
| 288 | − return { auth: { kind: "none" } }; | |
| 289 | − case "bearer": { | |
| 290 | − const token = await input.secretRegistry.get({ ref: source.auth.token }); | |
| 291 | − return { | |
| 292 | − auth: { | |
| 293 | − kind: "headers", | |
| 294 | − headers: { | |
| 295 | − [source.auth.headerName]: `${source.auth.prefix}${token}`, | |
| 296 | − }, | |
| 297 | − }, | |
| 298 | − }; | |
| 299 | − } | |
| 300 | − case "oauth2": { | |
| 301 | − const token = await input.secretRegistry.get({ ref: source.auth.accessToken }); | |
| 302 | − return { | |
| 303 | − auth: { | |
| 304 | − kind: "headers", | |
| 305 | − headers: { | |
| 306 | − [source.auth.headerName]: `${source.auth.prefix}${token}`, | |
| 307 | − }, | |
| 308 | − }, | |
| 309 | − }; | |
| 310 | − } | |
| 311 | − case "oauth2_authorized_user": | |
| 312 | − throw new Error("oauth2_authorized_user auth requires persistence-backed runtime resolution"); | |
| 313 | − case "provider_grant_ref": | |
| 314 | − throw new Error("provider_grant_ref auth requires persistence-backed runtime resolution"); | |
| 315 | − case "mcp_oauth": | |
| 316 | − throw new Error("mcp_oauth auth requires persistence-backed runtime resolution"); | |
| 317 | − } | |
| 318 | − }; | |
| 319 | − | |
| 320 | −const createProviderInvoker = (): ProviderInvoker => ({ | |
| 321 | − async invoke({ source, artifact, args, runtime, context }) { | |
| 322 | − const invocation = { | |
| 323 | − method: artifact.openApiMethod, | |
| 324 | − pathTemplate: artifact.openApiPathTemplate, | |
| 325 | − operationHash: artifact.openApiOperationHash, | |
| 326 | − }; | |
| 327 | − | |
| 328 | − return { | |
| 329 | − sourceId: source.id, | |
| 330 | − path: artifact.path, | |
| 331 | − provider: artifact.providerKind, | |
| 332 | − invocation, | |
| 333 | − args, | |
| 334 | − auth: runtime.auth, | |
| 335 | − workspaceId: context?.workspaceId ?? null, | |
| 336 | − runId: context?.runId ?? null, | |
| 337 | − }; | |
| 338 | − }, | |
| 339 | −}); | |
| 340 | − | |
| 341 | −const createWorkspaceToolCatalog = (input: { | |
| 342 | − workspaceId: WorkspaceId; | |
| 343 | − toolStore: WorkspaceScopedToolStore; | |
| 344 | −}): ToolCatalog => ({ | |
| 345 | − listNamespaces: ({ limit }) => | |
| 346 | − Effect.promise(() => | |
| 347 | − input.toolStore.listNamespaces({ | |
| 348 | − workspaceId: input.workspaceId, | |
| 349 | − limit, | |
| 350 | − }).then((namespaces) => | |
| 351 | − namespaces.map((namespace) => ({ | |
| 352 | − namespace: namespace.namespace, | |
| 353 | − toolCount: namespace.toolCount, | |
| 354 | − })) | |
| 355 | − ) | |
| 356 | − ), | |
| 357 | − listTools: ({ namespace, query, limit, includeSchemas = false }) => | |
| 358 | − Effect.promise(() => | |
| 359 | − input.toolStore.list({ | |
| 360 | − workspaceId: input.workspaceId, | |
| 361 | − ...(namespace !== undefined ? { namespace } : {}), | |
| 362 | − ...(query !== undefined ? { query } : {}), | |
| 363 | − limit, | |
| 364 | − }).then((artifacts) => artifacts.map((artifact) => toDescriptor(artifact, includeSchemas))) | |
| 365 | − ), | |
| 366 | − getToolByPath: ({ path, includeSchemas }) => | |
| 367 | − Effect.promise(() => | |
| 368 | − input.toolStore.getByPath({ | |
| 369 | − workspaceId: input.workspaceId, | |
| 370 | − path, | |
| 371 | − }).then((artifact) => (artifact ? toDescriptor(artifact, includeSchemas) : null)) | |
| 372 | − ), | |
| 373 | − searchTools: ({ query, namespace, limit }) => | |
| 374 | − Effect.promise(() => | |
| 375 | − input.toolStore.list({ | |
| 376 | − workspaceId: input.workspaceId, | |
| 377 | − ...(namespace !== undefined ? { namespace } : {}), | |
| 378 | − query, | |
| 379 | − limit: 500, | |
| 380 | − }).then((artifacts) => { | |
| 381 | − const queryTokens = tokenize(query); | |
| 382 | − | |
| 383 | − return artifacts | |
| 384 | − .map((artifact) => { | |
| 385 | − const score = queryTokens.reduce( | |
| 386 | − (total, token) => total + (artifact.searchText.includes(token) ? 1 : 0), | |
| 387 | − 0, | |
| 388 | − ); | |
| 389 | − | |
| 390 | − return { | |
| 391 | − path: asToolPath(artifact.path), | |
| 392 | − score, | |
| 393 | − }; | |
| 394 | − }) | |
| 395 | − .filter((hit) => hit.score > 0) | |
| 396 | − .sort((left, right) => right.score - left.score) | |
| 397 | − .slice(0, limit); | |
| 398 | − }) | |
| 399 | − ), | |
| 400 | −}); | |
| 401 | − | |
| 402 | −const createWorkspaceToolInvoker = (input: { | |
| 403 | − workspaceId: WorkspaceId; | |
| 404 | − sourceStore: WorkspaceScopedSourceStore; | |
| 405 | − toolStore: WorkspaceScopedToolStore; | |
| 406 | − resolveSourceCallContext: (source: Source) => Promise<SourceCallContext>; | |
| 407 | − providerInvoker: ProviderInvoker; | |
| 408 | −}): ToolInvoker => ({ | |
| 409 | − invoke: (() => { | |
| 410 | − const catalog = createWorkspaceToolCatalog({ | |
| 411 | − workspaceId: input.workspaceId, | |
| 412 | − toolStore: input.toolStore, | |
| 413 | − }); | |
| 414 | − const systemTools = createSystemToolMap({ | |
| 415 | − catalog, | |
| 416 | − sourceKey: "system", | |
| 417 | − }); | |
| 418 | − const systemToolPaths = new Set(Object.keys(systemTools)); | |
| 419 | − const systemToolInvoker = makeToolInvokerFromTools({ | |
| 420 | − tools: systemTools, | |
| 421 | − sourceKey: "system", | |
| 422 | − }); | |
| 423 | − | |
| 424 | − return ({ path, args, context }) => | |
| 425 | − systemToolPaths.has(path) | |
| 426 | − ? systemToolInvoker.invoke({ path, args, context }) | |
| 427 | − : Effect.tryPromise({ | |
| 428 | − try: async () => { | |
| 429 | − const mergedContext: ToolInvocationContext = { | |
| 430 | − ...context, | |
| 431 | − workspaceId: input.workspaceId, | |
| 432 | − }; | |
| 433 | − | |
| 434 | − const artifact = await input.toolStore.getByPath({ | |
| 435 | − workspaceId: input.workspaceId, | |
| 436 | − path: asToolPath(path), | |
| 437 | − }); | |
| 438 | − if (!artifact) { | |
| 439 | − throw new Error(`Unknown tool path: ${path}`); | |
| 440 | − } | |
| 441 | − | |
| 442 | − const source = await input.sourceStore.getById({ | |
| 443 | − sourceId: artifact.sourceId, | |
| 444 | − }); | |
| 445 | − if (!source) { | |
| 446 | − throw new Error(`Unknown source for tool path: ${path}`); | |
| 447 | − } | |
| 448 | − | |
| 449 | − const runtime = await input.resolveSourceCallContext(source); | |
| 450 | − | |
| 451 | − return input.providerInvoker.invoke({ | |
| 452 | − source, | |
| 453 | − artifact, | |
| 454 | − args, | |
| 455 | − runtime, | |
| 456 | − context: mergedContext, | |
| 457 | − }); | |
| 458 | − }, | |
| 459 | − catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), | |
| 460 | − }); | |
| 461 | − })(), | |
| 462 | −}); | |
| 463 | − | |
| 464 | −const toolInvokerFromWorkspace = (input: { | |
| 465 | − workspaceId: WorkspaceId; | |
| 466 | − sourceStore: WorkspaceScopedSourceStore; | |
| 467 | − toolStore: WorkspaceScopedToolStore; | |
| 468 | − secretRegistry: SecretMaterialRegistry; | |
| 469 | − providerInvoker?: ProviderInvoker; | |
| 470 | −}): ToolInvoker => | |
| 471 | − createWorkspaceToolInvoker({ | |
| 472 | − workspaceId: input.workspaceId, | |
| 473 | − sourceStore: input.sourceStore, | |
| 474 | − toolStore: input.toolStore, | |
| 475 | − resolveSourceCallContext: resolveSourceCallContext({ | |
| 476 | − secretRegistry: input.secretRegistry, | |
| 477 | − }), | |
| 478 | − providerInvoker: input.providerInvoker ?? createProviderInvoker(), | |
| 479 | − }); | |
| 480 | − | |
| 481 | −const bearerSourceAuth = (input: { | |
| 482 | − providerId: string; | |
| 483 | − handle: string; | |
| 484 | −}): Source["auth"] => ({ | |
| 485 | − kind: "bearer", | |
| 486 | − headerName: "Authorization", | |
| 487 | − prefix: "Bearer ", | |
| 488 | − token: { | |
| 489 | − providerId: input.providerId, | |
| 490 | − handle: input.handle, | |
| 491 | − }, | |
| 492 | −}); | |
| 493 | − | |
| 494 | −const openApiSource = (input: { | |
| 495 | − workspaceId: WorkspaceId; | |
| 496 | − sourceId: SourceId; | |
| 497 | − displayName: string; | |
| 498 | − baseUrl: string; | |
| 499 | − specUrl?: string; | |
| 500 | − namespace?: string; | |
| 501 | − auth: Source["auth"]; | |
| 502 | −}): Source => ({ | |
| 503 | − id: input.sourceId, | |
| 504 | − workspaceId: input.workspaceId, | |
| 505 | − name: input.displayName, | |
| 506 | − kind: "openapi", | |
| 507 | − endpoint: input.baseUrl, | |
| 508 | − status: "connected", | |
| 509 | − enabled: true, | |
| 510 | − namespace: input.namespace ?? null, | |
| 511 | − bindingVersion: 1, | |
| 512 | − binding: { | |
| 513 | − specUrl: input.specUrl ?? null, | |
| 514 | − defaultHeaders: null, | |
| 515 | − }, | |
| 516 | − importAuthPolicy: "reuse_runtime", | |
| 517 | − importAuth: { kind: "none" }, | |
| 518 | − auth: input.auth, | |
| 519 | − sourceHash: null, | |
| 520 | − lastError: null, | |
| 521 | − createdAt: 0, | |
| 522 | − updatedAt: 0, | |
| 523 | −}); | |
| 524 | − | |
| 525 | −const openApiArtifact = (input: { | |
| 526 | − workspaceId: WorkspaceId; | |
| 527 | − sourceId: SourceId; | |
| 528 | − path: ToolPath; | |
| 529 | − toolId: string; | |
| 530 | − title: string; | |
| 531 | − description?: string; | |
| 532 | − method: NonNullable<StoredToolArtifactRecord["openApiMethod"]>; | |
| 533 | − pathTemplate: string; | |
| 534 | − operationHash?: string; | |
| 535 | − inputSchema?: unknown | null; | |
| 536 | − outputSchema?: unknown | null; | |
| 537 | −}): StoredToolArtifactRecord => { | |
| 538 | − const path = input.path as string; | |
| 539 | − const description = | |
| 540 | − input.description ?? `${input.method.toUpperCase()} ${input.pathTemplate}`; | |
| 541 | − | |
| 542 | − return { | |
| 543 | − workspaceId: input.workspaceId, | |
| 544 | − path, | |
| 545 | − toolId: input.toolId, | |
| 546 | − sourceId: input.sourceId, | |
| 547 | − title: input.title, | |
| 548 | − description, | |
| 549 | − searchNamespace: catalogNamespaceFromPath(path), | |
| 550 | − searchText: normalizeSearchText( | |
| 551 | − path, | |
| 552 | − catalogNamespaceFromPath(path), | |
| 553 | − input.title, | |
| 554 | − description, | |
| 555 | − input.method.toUpperCase(), | |
| 556 | − input.pathTemplate, | |
| 557 | − ), | |
| 558 | − inputSchema: input.inputSchema ?? null, | |
| 559 | − outputSchema: input.outputSchema ?? null, | |
| 560 | − providerKind: "openapi", | |
| 561 | − openApiMethod: input.method, | |
| 562 | − openApiPathTemplate: input.pathTemplate, | |
| 563 | − openApiOperationHash: input.operationHash ?? input.toolId, | |
| 564 | − openApiRawToolId: input.toolId, | |
| 565 | − openApiOperationId: input.toolId, | |
| 566 | − openApiTagsJson: null, | |
| 567 | − openApiRequestBodyRequired: null, | |
| 568 | − createdAt: 0, | |
| 569 | − updatedAt: 0, | |
| 570 | − }; | |
| 571 | −}; | |
| 572 | − | |
| 573 | −const numberPairInputSchema = Schema.standardSchemaV1( | |
| 574 | − Schema.Struct({ | |
| 575 | − a: Schema.Number, | |
| 576 | − b: Schema.Number, | |
| 577 | − }), | |
| 578 | −); | |
| 579 | − | |
| 580 | −const createDiscoveryBackedToolMap = (input: { | |
| 581 | − tools: ToolMap; | |
| 582 | − namespace: string; | |
| 583 | − sourceKey?: string; | |
| 584 | −}) => { | |
| 585 | − const sourceKey = input.sourceKey ?? "in_memory.tools"; | |
| 586 | − const catalog = createToolCatalogFromTools({ | |
| 587 | − tools: input.tools, | |
| 588 | − defaultNamespace: input.namespace, | |
| 589 | − }); | |
| 590 | − const discovery = createToolCatalogDiscovery({ catalog }); | |
| 591 | − | |
| 592 | − return { | |
| 593 | − executeDescription: discovery.executeDescription, | |
| 594 | − tools: mergeToolMaps([ | |
| 595 | − input.tools, | |
| 596 | − createSystemToolMap({ | |
| 597 | − catalog, | |
| 598 | − sourceKey, | |
| 599 | − }), | |
| 600 | − ]), | |
| 601 | − }; | |
| 602 | −}; | |
| 603 | − | |
| 604 | −const ownerParam = HttpApiSchema.param("owner", EffectSchema.String); | |
| 605 | −const repoParam = HttpApiSchema.param("repo", EffectSchema.String); | |
| 606 | − | |
| 607 | −class GeneratedReposApi extends HttpApiGroup.make("repos") | |
| 608 | − .add( | |
| 609 | − HttpApiEndpoint.get("getRepo")`/repos/${ownerParam}/${repoParam}` | |
| 610 | − .addSuccess(EffectSchema.Unknown), | |
| 611 | − ) {} | |
| 612 | − | |
| 613 | −class GeneratedApi extends HttpApi.make("generated").add(GeneratedReposApi) {} | |
| 614 | − | |
| 615 | −const generatedOpenApiSpec = OpenApi.fromApi(GeneratedApi); | |
| 616 | − | |
| 617 | −type OpenApiTestServer = { | |
| 618 | − baseUrl: string; | |
| 619 | − requests: Array<{ | |
| 620 | − method: string; | |
| 621 | − path: string; | |
| 622 | − authorization: string | null; | |
| 623 | − }>; | |
| 624 | − close: () => Promise<void>; | |
| 625 | −}; | |
| 626 | − | |
| 627 | −const makeOpenApiTestServer = Effect.acquireRelease( | |
| 628 | − Effect.promise<OpenApiTestServer>( | |
| 629 | − () => | |
| 630 | − new Promise<OpenApiTestServer>((resolve, reject) => { | |
| 631 | − const requests: OpenApiTestServer["requests"] = []; | |
| 632 | − | |
| 633 | − const server = createServer((req, res) => { | |
| 634 | − requests.push({ | |
| 635 | − method: req.method ?? "GET", | |
| 636 | − path: req.url ?? "/", | |
| 637 | − authorization: | |
| 638 | − typeof req.headers.authorization === "string" | |
| 639 | − ? req.headers.authorization | |
| 640 | − : null, | |
| 641 | − }); | |
| 642 | − | |
| 643 | − res.statusCode = 200; | |
| 644 | − res.setHeader("content-type", "application/json"); | |
| 645 | − res.end( | |
| 646 | − JSON.stringify({ | |
| 647 | − ok: true, | |
| 648 | − path: req.url ?? "/", | |
| 649 | − authorization: | |
| 650 | − typeof req.headers.authorization === "string" | |
| 651 | − ? req.headers.authorization | |
| 652 | − : null, | |
| 653 | − }), | |
| 654 | − ); | |
| 655 | − }); | |
| 656 | − | |
| 657 | − server.once("error", reject); | |
| 658 | − server.listen(0, "127.0.0.1", () => { | |
| 659 | − const address = server.address(); | |
| 660 | − if (!address || typeof address === "string") { | |
| 661 | − reject(new Error("failed to resolve test server address")); | |
| 662 | − return; | |
| 663 | − } | |
| 664 | − | |
| 665 | − resolve({ | |
| 666 | − baseUrl: `http://127.0.0.1:${address.port}`, | |
| 667 | − requests, | |
| 668 | − close: async () => { | |
| 669 | − await new Promise<void>((closeResolve, closeReject) => { | |
| 670 | − server.close((error) => { | |
| 671 | − if (error) { | |
| 672 | − closeReject(error); | |
| 673 | − return; | |
| 674 | − } | |
| 675 | − closeResolve(); | |
| 676 | − }); | |
| 677 | − }); | |
| 678 | − }, | |
| 679 | − }); | |
| 680 | − }); | |
| 681 | − }), | |
| 682 | − ), | |
| 683 | − (server) => | |
| 684 | − Effect.tryPromise({ | |
| 685 | − try: () => server.close(), | |
| 686 | − catch: (error: unknown) => | |
| 687 | − error instanceof Error ? error : new Error(String(error)), | |
| 688 | − }).pipe(Effect.orDie), | |
| 689 | −); | |
| 690 | − | |
| 691 | −describe("source runtime", () => { | |
| 692 | − it.effect("searches serialized workspace tools and calls one", () => | |
| 693 | − Effect.gen(function* () { | |
| 694 | − const workspaceId = asWorkspaceId("workspace_123"); | |
| 695 | − const sourceStore = createInMemorySourceStore(); | |
| 696 | − const toolStore = createInMemoryToolStore(); | |
| 697 | − const secretRegistry = createSecretRegistry([ | |
| 698 | − createStaticSecretProvider("local", { | |
| 699 | − "github-db-token": "ghp_from_db", | |
| 700 | − }), | |
| 701 | − ]); | |
| 702 | − | |
| 703 | − const githubSource = openApiSource({ | |
| 704 | − workspaceId, | |
| 705 | − sourceId: asSourceId("github"), | |
| 706 | − displayName: "GitHub API", | |
| 707 | − baseUrl: "https://api.github.com", | |
| 708 | − specUrl: "https://api.github.com/openapi.json", | |
| 709 | − namespace: "github", | |
| 710 | − auth: bearerSourceAuth({ | |
| 711 | − providerId: "local", | |
| 712 | − handle: "github-db-token", | |
| 713 | − }), | |
| 714 | − }); | |
| 715 | − | |
| 716 | − yield* Effect.promise(() => | |
| 717 | − sourceStore.registerSource({ | |
| 718 | − workspaceId, | |
| 719 | − source: githubSource, | |
| 720 | − }) | |
| 721 | − ); | |
| 722 | − yield* Effect.promise(() => | |
| 723 | − toolStore.indexArtifacts({ | |
| 724 | − workspaceId, | |
| 725 | − artifacts: [ | |
| 726 | − openApiArtifact({ | |
| 727 | − workspaceId, | |
| 728 | − sourceId: githubSource.id, | |
| 729 | − path: asToolPath("github.issues.list"), | |
| 730 | − toolId: "issues.list", | |
| 731 | − title: "List issues", | |
| 732 | − description: "Serialized artifact loaded from a database row", | |
| 733 | − method: "get", | |
| 734 | − pathTemplate: "/repos/{owner}/{repo}/issues", | |
| 735 | − }), | |
| 736 | − ], | |
| 737 | − }) | |
| 738 | − ); | |
| 739 | − | |
| 740 | − const toolInvoker = toolInvokerFromWorkspace({ | |
| 741 | − workspaceId, | |
| 742 | − sourceStore, | |
| 743 | − toolStore, | |
| 744 | − secretRegistry, | |
| 745 | − }); | |
| 746 | − const workspaceCatalog = createWorkspaceToolCatalog({ | |
| 747 | − workspaceId, | |
| 748 | − toolStore, | |
| 749 | − }); | |
| 750 | − const executeDescription = yield* createToolCatalogDiscovery({ | |
| 751 | − catalog: workspaceCatalog, | |
| 752 | − }).executeDescription; | |
| 753 | − | |
| 754 | − const output = yield* makeSesExecutor().execute( | |
| 755 | − [ | |
| 756 | − 'const matches = await tools.discover({ query: "github issues", limit: 3 });', | |
| 757 | − "const result = await tools.github.issues.list({ owner: 'vercel', repo: 'next.js' });", | |
| 758 | − "return { matches, result };", | |
| 759 | − ].join("\n"), | |
| 760 | − toolInvoker, | |
| 761 | − ); | |
| 762 | − | |
| 763 | − expect(output.result).toMatchObject({ | |
| 764 | − matches: { | |
| 765 | − bestPath: "github.issues.list", | |
| 766 | − results: [ | |
| 767 | − { | |
| 768 | − path: "github.issues.list", | |
| 769 | − score: expect.any(Number), | |
| 770 | − description: "Serialized artifact loaded from a database row", | |
| 771 | − interaction: "auto", | |
| 772 | − }, | |
| 773 | − ], | |
| 774 | − total: 1, | |
| 775 | − }, | |
| 776 | − result: { | |
| 777 | − provider: "openapi", | |
| 778 | − path: "github.issues.list", | |
| 779 | − auth: { | |
| 780 | − kind: "headers", | |
| 781 | − headers: { | |
| 782 | − Authorization: "Bearer ghp_from_db", | |
| 783 | − }, | |
| 784 | − }, | |
| 785 | − workspaceId, | |
| 786 | − }, | |
| 787 | − }); | |
| 788 | − expect(executeDescription).toBe( | |
| 789 | − [ | |
| 790 | − "Execute TypeScript in sandbox; call tools via discovery workflow.", | |
| 791 | − "Available namespaces:", | |
| 792 | − "- github.issues", | |
| 793 | − "Workflow:", | |
| 794 | − '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', | |
| 795 | − "2) const details = await tools.describe.tool({ path, includeSchemas: true });", | |
| 796 | − "3) Read details.inputSchema/details.outputSchema when you need the projected shape.", | |
| 797 | − "4) Call selected tools.<path>(input).", | |
| 798 | − "Do not use fetch; use tools.* only.", | |
| 799 | − ].join("\n"), | |
| 800 | − ); | |
| 801 | − }), | |
| 802 | − ); | |
| 803 | − | |
| 804 | − it.scoped("loads an api from inline sources and calls it", () => | |
| 805 | − Effect.gen(function* () { | |
| 806 | − const server = yield* makeOpenApiTestServer; | |
| 807 | − | |
| 808 | − const extracted = yield* createOpenApiToolsFromSpec({ | |
| 809 | − sourceName: "github", | |
| 810 | − openApiSpec: generatedOpenApiSpec, | |
| 811 | − baseUrl: server.baseUrl, | |
| 812 | − namespace: "github", | |
| 813 | − credentialHeaders: { | |
| 814 | − Authorization: "Bearer ghp_from_keychain", | |
| 815 | − }, | |
| 816 | − }); | |
| 817 | − | |
| 818 | − const discoveryBacked = createDiscoveryBackedToolMap({ | |
| 819 | − tools: extracted.tools, | |
| 820 | − namespace: "github", | |
| 821 | − sourceKey: "github.openapi", | |
| 822 | − }); | |
| 823 | − | |
| 824 | − const toolInvoker = makeToolInvokerFromTools({ | |
| 825 | − tools: discoveryBacked.tools, | |
| 826 | − }); | |
| 827 | − | |
| 828 | − const output = yield* makeSesExecutor().execute( | |
| 829 | − [ | |
| 830 | − 'const matches = await tools.discover({ query: "github repo", limit: 3 });', | |
| 831 | − "const result = await tools.github.repos.getRepo({ owner: 'vercel', repo: 'ai' });", | |
| 832 | − "return { matches, result };", | |
| 833 | − ].join("\n"), | |
| 834 | − toolInvoker, | |
| 835 | − ); | |
| 836 | − | |
| 837 | − expect(output.result).toEqual({ | |
| 838 | − matches: { | |
| 839 | − bestPath: "github.repos.getRepo", | |
| 840 | − results: [ | |
| 841 | − { | |
| 842 | − path: "github.repos.getRepo", | |
| 843 | − score: expect.any(Number), | |
| 844 | − description: "GET /repos/{owner}/{repo}", | |
| 845 | − interaction: "auto", | |
| 846 | − inputTypePreview: "{ owner: string, repo: string }", | |
| 847 | − outputTypePreview: "unknown", | |
| 848 | − }, | |
| 849 | − ], | |
| 850 | − total: 1, | |
| 851 | − }, | |
| 852 | − result: { | |
| 853 | − ok: true, | |
| 854 | − path: "/repos/vercel/ai", | |
| 855 | − authorization: "Bearer ghp_from_keychain", | |
| 856 | − }, | |
| 857 | − }); | |
| 858 | − expect(yield* discoveryBacked.executeDescription).toBe( | |
| 859 | − [ | |
| 860 | − "Execute TypeScript in sandbox; call tools via discovery workflow.", | |
| 861 | − "Available namespaces:", | |
| 862 | − "- github", | |
| 863 | − "Workflow:", | |
| 864 | − '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', | |
| 865 | − "2) const details = await tools.describe.tool({ path, includeSchemas: true });", | |
| 866 | − "3) Read details.inputSchema/details.outputSchema when you need the projected shape.", | |
| 867 | − "4) Call selected tools.<path>(input).", | |
| 868 | − "Do not use fetch; use tools.* only.", | |
| 869 | − ].join("\n"), | |
| 870 | − ); | |
| 871 | − expect(server.requests).toEqual([ | |
| 872 | − { | |
| 873 | − method: "GET", | |
| 874 | − path: "/repos/vercel/ai", | |
| 875 | − authorization: "Bearer ghp_from_keychain", | |
| 876 | − }, | |
| 877 | − ]); | |
| 878 | − }), | |
| 879 | − ); | |
| 880 | − | |
| 881 | − it.effect("basic calling of tools via codemode", () => | |
| 882 | − Effect.gen(function* () { | |
| 883 | − const toolInvoker = makeToolInvokerFromTools({ | |
| 884 | − tools: { | |
| 885 | − "math.add": { | |
| 886 | − description: "Add two numbers", | |
| 887 | − inputSchema: numberPairInputSchema, | |
| 888 | − execute: ({ a, b }: { a: number; b: number }) => ({ sum: a + b }), | |
| 889 | − }, | |
| 890 | − }, | |
| 891 | − }); | |
| 892 | − | |
| 893 | − const output = yield* makeSesExecutor().execute( | |
| 894 | − "return await tools.math.add({ a: 20, b: 22 });", | |
| 895 | − toolInvoker, | |
| 896 | − ); | |
| 897 | − | |
| 898 | − expect(output.result).toEqual({ sum: 42 }); | |
| 899 | − }), | |
| 900 | − ); | |
| 901 | −}); | |
| @@ -7,7 +7,7 @@ · 6 unmodified lines | ||
| 7 | 7 | }, |
| 8 | 8 | "scripts": { |
| 9 | 9 | "typecheck": "bunx tsc --noEmit -p tsconfig.json", |
| 10 | − "test": "vitest run --passWithNoTests", | |
| 10 | + "test": "vitest run", | |
| 11 | 11 | "test:watch": "vitest" |
| 12 | 12 | }, |
| 13 | 13 | "dependencies": { |
| @@ -2,7 +2,7 @@ import type { · 1 unmodified line | ||
| 2 | 2 | ExecutionEnvelope, |
| 3 | 3 | } from "@executor/platform-sdk/schema"; |
| 4 | 4 | import { |
| 5 | − EXECUTOR_SOURCES_ADD_HELP_LINES, | |
| 5 | + getExecutorSourcesAddHelpLines, | |
| 6 | 6 | RuntimeSourceCatalogStoreService, |
| 7 | 7 | createExecution, |
| 8 | 8 | getExecution, |
| @@ -172,14 +172,12 @@ const buildExecuteWorkflowText = ( · 163 unmodified lines | ||
| 172 | 172 | '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', |
| 173 | 173 | "2) const details = await tools.describe.tool({ path, includeSchemas: true });", |
| 174 | 174 | "3) Call selected tools.<path>(input).", |
| 175 | − '4) To connect a source, call tools.executor.sources.add(...) for MCP, OpenAPI, or GraphQL APIs.', | |
| 176 | − ...EXECUTOR_SOURCES_ADD_HELP_LINES, | |
| 175 | + "4) Use source plugins to inspect or add API sources.", | |
| 176 | + ...getExecutorSourcesAddHelpLines(), | |
| 177 | 177 | "5) If execution pauses for interaction, resume it with the returned resumePayload or the available resume flow.", |
| 178 | 178 | "Do not use fetch; use tools.* only.", |
| 179 | 179 | ].join("\n"); |
| 180 | 180 | |
| 181 | −const defaultExecuteDescription = buildExecuteWorkflowText(); | |
| 182 | − | |
| 183 | 181 | const loadExecuteDescription = (runtime: ExecutorRuntime): Promise<string> => |
| 184 | 182 | runControlPlane( |
| 185 | 183 | runtime, |
| @@ -203,11 +201,11 @@ const loadExecuteDescription = (runtime: ExecutorRuntime): Promise<string> => · 17 unmodified lines | ||
| 203 | 201 | .filter((catalog) => catalog.toolPaths.length > 0); |
| 204 | 202 | |
| 205 | 203 | if (sourceToolExamples.length === 0) { |
| 206 | − return defaultExecuteDescription; | |
| 204 | + return buildExecuteWorkflowText(); | |
| 207 | 205 | } |
| 208 | 206 | |
| 209 | 207 | return buildExecuteWorkflowText(sourceToolExamples); |
| 210 | − }).pipe(Effect.catchAll(() => Effect.succeed(defaultExecuteDescription))), | |
| 208 | + }).pipe(Effect.catchAll(() => Effect.succeed(buildExecuteWorkflowText()))), | |
| 211 | 209 | ); |
| 212 | 210 | |
| 213 | 211 | const summarizeExecution = (execution: ExecutionEnvelope["execution"]): string => { |
| @@ -448,9 +448,9 @@ export function toolDescriptorsFromTools(input: { · 447 unmodified lines | ||
| 448 | 448 | interaction: metadata?.interaction, |
| 449 | 449 | elicitation: metadata?.elicitation, |
| 450 | 450 | contract, |
| 451 | − ...(metadata?.providerKind ? { providerKind: metadata.providerKind } : {}), | |
| 452 | − ...(metadata?.providerData !== undefined | |
| 453 | − ? { providerData: metadata.providerData } | |
| 451 | + ...(metadata?.pluginKind ? { pluginKind: metadata.pluginKind } : {}), | |
| 452 | + ...(metadata?.pluginData !== undefined | |
| 453 | + ? { pluginData: metadata.pluginData } | |
| 454 | 454 | : {}), |
| 455 | 455 | } satisfies ToolDescriptor; |
| 456 | 456 | }); |
| @@ -98,8 +98,8 @@ export type ToolMetadata = { · 97 unmodified lines | ||
| 98 | 98 | elicitation?: ElicitationRequest; |
| 99 | 99 | contract?: ToolContract; |
| 100 | 100 | sourceKey?: string; |
| 101 | − providerKind?: string; | |
| 102 | − providerData?: unknown; | |
| 101 | + pluginKind?: string; | |
| 102 | + pluginData?: unknown; | |
| 103 | 103 | }; |
| 104 | 104 | |
| 105 | 105 | export type ToolExecutionContext = { |
| @@ -145,8 +145,8 @@ export type ToolDescriptor = { · 39 unmodified lines | ||
| 145 | 145 | interaction?: "auto" | "required"; |
| 146 | 146 | elicitation?: ElicitationRequest; |
| 147 | 147 | contract?: ToolContract; |
| 148 | − providerKind?: string; | |
| 149 | − providerData?: unknown; | |
| 148 | + pluginKind?: string; | |
| 149 | + pluginData?: unknown; | |
| 150 | 150 | }; |
| 151 | 151 | |
| 152 | 152 | export const ToolDescriptorSchema = Schema.Struct({ |
| @@ -158,8 +158,8 @@ export const ToolDescriptorSchema = Schema.Struct({ · 5 unmodified lines | ||
| 158 | 158 | ), |
| 159 | 159 | elicitation: Schema.optional(Schema.Unknown), |
| 160 | 160 | contract: Schema.optional(ToolContractSchema), |
| 161 | − providerKind: Schema.optional(Schema.String), | |
| 162 | − providerData: Schema.optional(Schema.Unknown), | |
| 161 | + pluginKind: Schema.optional(Schema.String), | |
| 162 | + pluginData: Schema.optional(Schema.Unknown), | |
| 163 | 163 | }); |
| 164 | 164 | |
| 165 | 165 | export type ToolNamespace = { |
| @@ -54,6 +54,7 @@ const baseProvenance = (pointer: string): ProvenanceRef[] => [{ · 53 unmodified lines | ||
| 54 | 54 | const createHttpCatalog = (input: { |
| 55 | 55 | collideIds?: boolean; |
| 56 | 56 | method?: string; |
| 57 | + noContentSuccess?: boolean; | |
| 57 | 58 | } = {}): CatalogV1 => { |
| 58 | 59 | const catalog = createEmptyCatalogV1(); |
| 59 | 60 | |
| @@ -327,16 +328,22 @@ const createHttpCatalog = (input: { · 267 unmodified lines | ||
| 327 | 328 | id: responseId, |
| 328 | 329 | kind: "response", |
| 329 | 330 | docs: { |
| 330 | − summary: "Updated event", | |
| 331 | + summary: input.noContentSuccess ? "No content" : "Updated event", | |
| 331 | 332 | }, |
| 332 | − contents: [ | |
| 333 | − { | |
| 334 | − mediaType: "application/json", | |
| 335 | − shapeId: resultShapeId, | |
| 336 | − }, | |
| 337 | − ], | |
| 333 | + ...(input.noContentSuccess | |
| 334 | + ? {} | |
| 335 | + : { | |
| 336 | + contents: [ | |
| 337 | + { | |
| 338 | + mediaType: "application/json", | |
| 339 | + shapeId: resultShapeId, | |
| 340 | + }, | |
| 341 | + ], | |
| 342 | + }), | |
| 338 | 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 | 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 | 353 | match: { |
| 347 | 354 | kind: "exact", |
| 348 | − status: 200, | |
| 355 | + status: input.noContentSuccess ? 204 : 200, | |
| 349 | 356 | }, |
| 350 | 357 | responseId, |
| 351 | 358 | traits: ["success"], |
| @@ -369,7 +376,7 @@ const createHttpCatalog = (input: { · 17 unmodified lines | ||
| 369 | 376 | id: executableId, |
| 370 | 377 | capabilityId, |
| 371 | 378 | scopeId, |
| 372 | − adapterKey: "openapi", | |
| 379 | + pluginKey: "openapi", | |
| 373 | 380 | bindingVersion: 1, |
| 374 | 381 | binding: {}, |
| 375 | 382 | projection: { |
| @@ -606,7 +613,7 @@ const createGraphqlCatalog = (): CatalogV1 => { · 230 unmodified lines | ||
| 606 | 613 | id: executableId, |
| 607 | 614 | capabilityId, |
| 608 | 615 | scopeId, |
| 609 | − adapterKey: "graphql", | |
| 616 | + pluginKey: "graphql", | |
| 610 | 617 | bindingVersion: 1, |
| 611 | 618 | binding: {}, |
| 612 | 619 | projection: { |
| @@ -670,7 +677,7 @@ describe("IR catalog", () => { · 57 unmodified lines | ||
| 670 | 677 | const snapshot = createCatalogSnapshotV1({ |
| 671 | 678 | import: { |
| 672 | 679 | sourceKind: "openapi", |
| 673 | − adapterKey: "openapi", | |
| 680 | + pluginKey: "openapi", | |
| 674 | 681 | importerVersion: "1.0.0", |
| 675 | 682 | importedAt: "2026-03-14T00:00:00.000Z", |
| 676 | 683 | sourceConfigHash: "hash_source", |
| @@ -713,7 +720,11 @@ describe("IR catalog", () => { · 36 unmodified lines | ||
| 713 | 720 | |
| 714 | 721 | const merged = mergeCatalogFragments([fragmentA, fragmentB]); |
| 715 | 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 | 730 | it("reports invariant violations for unresolved shapes without diagnostics", () => { |
| @@ -801,6 +812,27 @@ describe("IR catalog", () => { · 81 unmodified lines | ||
| 801 | 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 | 836 | it("groups colliding HTTP parameter names into path/query containers", () => { |
| 805 | 837 | const projected = projectCatalogForAgentSdk({ |
| 806 | 838 | catalog: createHttpCatalog({ collideIds: true }), |
| @@ -815,7 +847,11 @@ describe("IR catalog", () => { · 8 unmodified lines | ||
| 815 | 847 | expect(Object.keys(callShape.node.fields)).toContain("path"); |
| 816 | 848 | expect(Object.keys(callShape.node.fields)).toContain("query"); |
| 817 | 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 | 857 | it("keeps GraphQL selection explicit for caller-driven outputs", () => { |
| @@ -538,15 +538,30 @@ const projectResultShapeFromResponses = ( · 537 unmodified lines | ||
| 538 | 538 | catalog: CatalogV1, |
| 539 | 539 | capability: Capability, |
| 540 | 540 | responseSet: ResponseSet, |
| 541 | −): ShapeSymbolId | undefined => | |
| 542 | − contentShapeIdFromResponseEntries( | |
| 541 | +): ShapeSymbolId | undefined => { | |
| 542 | + const successEntries = responseVariantEntries(catalog, responseSet) | |
| 543 | + .filter(({ variant }) => isSuccessStatusMatch(variant.match)); | |
| 544 | + const contentShapeId = contentShapeIdFromResponseEntries( | |
| 543 | 545 | catalog, |
| 544 | 546 | capability, |
| 545 | 547 | `responseSet:${responseSet.id}`, |
| 546 | 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 | + | |
| 550 | 565 | const projectErrorShapeFromResponses = ( |
| 551 | 566 | catalog: CatalogV1, |
| 552 | 567 | capability: Capability, |
| @@ -622,6 +637,15 @@ const nullableShape = ( · 69 unmodified lines | ||
| 622 | 637 | baseShapeId: ShapeSymbolId; |
| 623 | 638 | }, |
| 624 | 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 | 649 | const nullShapeId = constNullShape(catalog, capability, `${input.label}:null`); |
| 626 | 650 | if (input.baseShapeId === nullShapeId) { |
| 627 | 651 | return nullShapeId; |
| @@ -845,7 +869,7 @@ const searchDoc = ( · 217 unmodified lines | ||
| 845 | 869 | capability.executableIds |
| 846 | 870 | .map((executableId: ExecutableId) => |
| 847 | 871 | catalog.executables[executableId]?.display?.protocol |
| 848 | − ?? catalog.executables[executableId]?.adapterKey | |
| 872 | + ?? catalog.executables[executableId]?.pluginKey | |
| 849 | 873 | ) |
| 850 | 874 | .filter((protocol): protocol is string => protocol !== undefined), |
| 851 | 875 | ), |
| @@ -158,7 +158,7 @@ export const EntityBaseSchema = Schema.Struct({ · 157 unmodified lines | ||
| 158 | 158 | |
| 159 | 159 | export const ImportMetadataSchema = Schema.Struct({ |
| 160 | 160 | sourceKind: SourceKindSchema, |
| 161 | − adapterKey: Schema.String, | |
| 161 | + pluginKey: Schema.String, | |
| 162 | 162 | importerVersion: Schema.String, |
| 163 | 163 | importedAt: Schema.String, |
| 164 | 164 | sourceConfigHash: Schema.String, |
| @@ -696,7 +696,7 @@ export const ExecutableSchema = Schema.extend( · 531 unmodified lines | ||
| 696 | 696 | id: ExecutableIdSchema, |
| 697 | 697 | capabilityId: CapabilityIdSchema, |
| 698 | 698 | scopeId: ScopeIdSchema, |
| 699 | − adapterKey: Schema.String, | |
| 699 | + pluginKey: Schema.String, | |
| 700 | 700 | bindingVersion: Schema.Number, |
| 701 | 701 | binding: Schema.Unknown, |
| 702 | 702 | projection: ExecutableProjectionSchema, |
| @@ -5,15 +5,13 @@ · 4 unmodified lines | ||
| 5 | 5 | "exports": { |
| 6 | 6 | ".": "./src/index.ts", |
| 7 | 7 | "./api": "./src/api.ts", |
| 8 | − "./client": "./src/client.ts", | |
| 9 | 8 | "./effect": "./src/effect.ts", |
| 10 | 9 | "./http": "./src/http.ts", |
| 10 | + "./plugins": "./src/plugins.ts", | |
| 11 | 11 | "./errors": "./src/errors.ts", |
| 12 | 12 | "./local/api": "./src/local/api.ts", |
| 13 | 13 | "./local/http": "./src/local/http.ts", |
| 14 | 14 | "./local-context": "./src/local-context.ts", |
| 15 | − "./oauth/api": "./src/oauth/api.ts", | |
| 16 | − "./oauth/http": "./src/oauth/http.ts", | |
| 17 | 15 | "./policies/api": "./src/policies/api.ts", |
| 18 | 16 | "./policies/http": "./src/policies/http.ts", |
| 19 | 17 | "./sources/api": "./src/sources/api.ts", |
| @@ -22,7 +20,7 @@ · 2 unmodified lines | ||
| 22 | 20 | "./executions/http": "./src/executions/http.ts" |
| 23 | 21 | }, |
| 24 | 22 | "scripts": { |
| 25 | − "test": "vitest run", | |
| 23 | + "test": "vitest run --passWithNoTests", | |
| 26 | 24 | "test:watch": "vitest", |
| 27 | 25 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| 28 | 26 | }, |
| @@ -1,14 +1,18 @@ | ||
| 1 | 1 | import { HttpApi, OpenApi } from "@effect/platform"; |
| 2 | +import type { HttpApiGroup } from "@effect/platform"; | |
| 2 | 3 | |
| 3 | 4 | import { ExecutionsApi } from "./executions/api"; |
| 4 | 5 | import { LocalApi } from "./local/api"; |
| 5 | −import { OAuthApi } from "./oauth/api"; | |
| 6 | 6 | import { PoliciesApi } from "./policies/api"; |
| 7 | 7 | import { SourcesApi } from "./sources/api"; |
| 8 | +import type { | |
| 9 | + ExecutorHttpApiExtension, | |
| 10 | + ExecutorHttpPlugin, | |
| 11 | + ExecutorHttpPluginGroups, | |
| 12 | +} from "./plugins"; | |
| 8 | 13 | |
| 9 | −export class ExecutorApi extends HttpApi.make("executor") | |
| 14 | +export const CoreExecutorApi = HttpApi.make("executor") | |
| 10 | 15 | .add(LocalApi) |
| 11 | − .add(OAuthApi) | |
| 12 | 16 | .add(SourcesApi) |
| 13 | 17 | .add(PoliciesApi) |
| 14 | 18 | .add(ExecutionsApi) |
| @@ -17,6 +21,54 @@ export class ExecutorApi extends HttpApi.make("executor") · 2 unmodified lines | ||
| 17 | 21 | title: "Executor API", |
| 18 | 22 | description: "Local-first API for workspace sources, policies, auth, and execution", |
| 19 | 23 | }), |
| 20 | − ) {} | |
| 24 | + ); | |
| 21 | 25 | |
| 22 | −export const executorOpenApiSpec = OpenApi.fromApi(ExecutorApi); | |
| 26 | +type HttpApiGroupsOf<TApi> = TApi extends HttpApi.HttpApi<any, infer TGroups, any, any> | |
| 27 | + ? TGroups | |
| 28 | + : never; | |
| 29 | +type HttpApiErrorOf<TApi> = TApi extends HttpApi.HttpApi<any, any, infer TError, any> | |
| 30 | + ? TError | |
| 31 | + : never; | |
| 32 | +type HttpApiContextOf<TApi> = TApi extends HttpApi.HttpApi<any, any, any, infer TContext> | |
| 33 | + ? TContext | |
| 34 | + : never; | |
| 35 | + | |
| 36 | +export type CoreExecutorApiGroups = HttpApiGroupsOf<typeof CoreExecutorApi>; | |
| 37 | +export type ExecutorApiWithPlugins< | |
| 38 | + TPlugins extends readonly ExecutorHttpApiExtension[] = [], | |
| 39 | +> = HttpApi.HttpApi< | |
| 40 | + "executor", | |
| 41 | + CoreExecutorApiGroups | ExecutorHttpPluginGroups<TPlugins>, | |
| 42 | + HttpApiErrorOf<typeof CoreExecutorApi>, | |
| 43 | + HttpApiContextOf<typeof CoreExecutorApi> | |
| 44 | +>; | |
| 45 | + | |
| 46 | +export const createExecutorApi = < | |
| 47 | + const TPlugins extends readonly ExecutorHttpApiExtension[] = [], | |
| 48 | +>( | |
| 49 | + options: { | |
| 50 | + plugins?: TPlugins; | |
| 51 | + } = {}, | |
| 52 | +): ExecutorApiWithPlugins<TPlugins> => | |
| 53 | + (options.plugins ?? []).reduce( | |
| 54 | + (api, plugin) => | |
| 55 | + api.add(plugin.group as HttpApiGroup.HttpApiGroup.Any), | |
| 56 | + CoreExecutorApi as HttpApi.HttpApi< | |
| 57 | + "executor", | |
| 58 | + CoreExecutorApiGroups | ExecutorHttpPluginGroups<TPlugins>, | |
| 59 | + HttpApiErrorOf<typeof CoreExecutorApi>, | |
| 60 | + HttpApiContextOf<typeof CoreExecutorApi> | |
| 61 | + >, | |
| 62 | + ); | |
| 63 | + | |
| 64 | +export const ExecutorApi = createExecutorApi(); | |
| 65 | + | |
| 66 | +export const createExecutorOpenApiSpec = < | |
| 67 | + const TPlugins extends readonly ExecutorHttpPlugin[] = [], | |
| 68 | +>( | |
| 69 | + options: { | |
| 70 | + plugins?: TPlugins; | |
| 71 | + } = {}, | |
| 72 | +) => OpenApi.fromApi(createExecutorApi(options)); | |
| 73 | + | |
| 74 | +export const executorOpenApiSpec = createExecutorOpenApiSpec(); | |
| @@ -1,196 +0,0 @@ | ||
| 1 | −import * as Effect from "effect/Effect"; | |
| 2 | − | |
| 3 | −import { | |
| 4 | − createExecutorApiEffectClient, | |
| 5 | − type ExecutorApiEffectClient, | |
| 6 | −} from "./effect"; | |
| 7 | − | |
| 8 | −type StripResponse<T> = T extends readonly [infer Value, unknown] ? Value : T; | |
| 9 | −type RemoveWithResponse<T> = T extends object ? Omit<T, "withResponse"> : T; | |
| 10 | −type MethodInput<F extends (input: any) => Effect.Effect<any, any, any>> = RemoveWithResponse< | |
| 11 | − Parameters<F>[0] | |
| 12 | −>; | |
| 13 | −type MethodOutput<F extends (input: any) => Effect.Effect<any, any, any>> = StripResponse< | |
| 14 | − Effect.Effect.Success<ReturnType<F>> | |
| 15 | −>; | |
| 16 | −type PromiseMethod<F extends (input: any) => Effect.Effect<any, any, any>> = ( | |
| 17 | − input: MethodInput<F>, | |
| 18 | −) => Promise<MethodOutput<F>>; | |
| 19 | − | |
| 20 | −export type ExecutorApiClient = { | |
| 21 | − local: { | |
| 22 | − installation: PromiseMethod<ExecutorApiEffectClient["local"]["installation"]>; | |
| 23 | − config: PromiseMethod<ExecutorApiEffectClient["local"]["config"]>; | |
| 24 | − listSecrets: PromiseMethod<ExecutorApiEffectClient["local"]["listSecrets"]>; | |
| 25 | − createSecret: PromiseMethod<ExecutorApiEffectClient["local"]["createSecret"]>; | |
| 26 | − updateSecret: PromiseMethod<ExecutorApiEffectClient["local"]["updateSecret"]>; | |
| 27 | − deleteSecret: PromiseMethod<ExecutorApiEffectClient["local"]["deleteSecret"]>; | |
| 28 | − }; | |
| 29 | − policies: { | |
| 30 | − list: PromiseMethod<ExecutorApiEffectClient["policies"]["list"]>; | |
| 31 | − create: PromiseMethod<ExecutorApiEffectClient["policies"]["create"]>; | |
| 32 | − get: PromiseMethod<ExecutorApiEffectClient["policies"]["get"]>; | |
| 33 | − update: PromiseMethod<ExecutorApiEffectClient["policies"]["update"]>; | |
| 34 | − remove: PromiseMethod<ExecutorApiEffectClient["policies"]["remove"]>; | |
| 35 | − }; | |
| 36 | − sources: { | |
| 37 | − discover: PromiseMethod<ExecutorApiEffectClient["sources"]["discover"]>; | |
| 38 | − connect: PromiseMethod<ExecutorApiEffectClient["sources"]["connect"]>; | |
| 39 | − connectBatch: PromiseMethod<ExecutorApiEffectClient["sources"]["connectBatch"]>; | |
| 40 | − listWorkspaceOauthClients: PromiseMethod< | |
| 41 | − ExecutorApiEffectClient["sources"]["listWorkspaceOauthClients"] | |
| 42 | − >; | |
| 43 | − createWorkspaceOauthClient: PromiseMethod< | |
| 44 | − ExecutorApiEffectClient["sources"]["createWorkspaceOauthClient"] | |
| 45 | − >; | |
| 46 | − removeWorkspaceOauthClient: PromiseMethod< | |
| 47 | − ExecutorApiEffectClient["sources"]["removeWorkspaceOauthClient"] | |
| 48 | − >; | |
| 49 | − removeProviderAuthGrant: PromiseMethod< | |
| 50 | − ExecutorApiEffectClient["sources"]["removeProviderAuthGrant"] | |
| 51 | − >; | |
| 52 | − list: PromiseMethod<ExecutorApiEffectClient["sources"]["list"]>; | |
| 53 | − create: PromiseMethod<ExecutorApiEffectClient["sources"]["create"]>; | |
| 54 | − get: PromiseMethod<ExecutorApiEffectClient["sources"]["get"]>; | |
| 55 | − update: PromiseMethod<ExecutorApiEffectClient["sources"]["update"]>; | |
| 56 | − remove: PromiseMethod<ExecutorApiEffectClient["sources"]["remove"]>; | |
| 57 | − inspection: PromiseMethod<ExecutorApiEffectClient["sources"]["inspection"]>; | |
| 58 | − inspectionTool: PromiseMethod<ExecutorApiEffectClient["sources"]["inspectionTool"]>; | |
| 59 | − inspectionDiscover: PromiseMethod< | |
| 60 | − ExecutorApiEffectClient["sources"]["inspectionDiscover"] | |
| 61 | − >; | |
| 62 | − credentialPage: PromiseMethod<ExecutorApiEffectClient["sources"]["credentialPage"]>; | |
| 63 | − credentialSubmit: PromiseMethod<ExecutorApiEffectClient["sources"]["credentialSubmit"]>; | |
| 64 | − credentialComplete: PromiseMethod< | |
| 65 | − ExecutorApiEffectClient["sources"]["credentialComplete"] | |
| 66 | − >; | |
| 67 | − providerOauthComplete: PromiseMethod< | |
| 68 | − ExecutorApiEffectClient["sources"]["providerOauthComplete"] | |
| 69 | − >; | |
| 70 | − }; | |
| 71 | − oauth: { | |
| 72 | − startSourceAuth: PromiseMethod<ExecutorApiEffectClient["oauth"]["startSourceAuth"]>; | |
| 73 | − sourceAuthCallback: PromiseMethod<ExecutorApiEffectClient["oauth"]["sourceAuthCallback"]>; | |
| 74 | − }; | |
| 75 | − executions: { | |
| 76 | − create: PromiseMethod<ExecutorApiEffectClient["executions"]["create"]>; | |
| 77 | − get: PromiseMethod<ExecutorApiEffectClient["executions"]["get"]>; | |
| 78 | − resume: PromiseMethod<ExecutorApiEffectClient["executions"]["resume"]>; | |
| 79 | − }; | |
| 80 | −}; | |
| 81 | − | |
| 82 | −const isResponseTuple = <Value>( | |
| 83 | − value: Value | readonly [Value, unknown], | |
| 84 | −): value is readonly [Value, unknown] => | |
| 85 | − Array.isArray(value) && value.length === 2; | |
| 86 | − | |
| 87 | −function stripResponse<Value>(value: Value | readonly [Value, unknown]): Value { | |
| 88 | − return isResponseTuple(value) ? value[0] : value; | |
| 89 | −} | |
| 90 | − | |
| 91 | −const wrapMethod = < | |
| 92 | − F extends (input: any) => Effect.Effect<any, unknown, never>, | |
| 93 | −>( | |
| 94 | − effectClientPromise: Promise<ExecutorApiEffectClient>, | |
| 95 | − select: (client: ExecutorApiEffectClient) => F, | |
| 96 | −): PromiseMethod<F> => { | |
| 97 | − const wrapped = (input: MethodInput<F>): Promise<MethodOutput<F>> => | |
| 98 | − effectClientPromise | |
| 99 | − .then((client) => Effect.runPromise(select(client)(input))) | |
| 100 | − .then(stripResponse); | |
| 101 | − | |
| 102 | − return wrapped; | |
| 103 | −}; | |
| 104 | − | |
| 105 | −export const createExecutorApiClient = async (input: { | |
| 106 | − baseUrl: string; | |
| 107 | − accountId?: string; | |
| 108 | −}): Promise<ExecutorApiClient> => { | |
| 109 | − const effectClientPromise = Effect.runPromise( | |
| 110 | − createExecutorApiEffectClient(input), | |
| 111 | − ); | |
| 112 | − | |
| 113 | − return { | |
| 114 | − local: { | |
| 115 | − installation: wrapMethod(effectClientPromise, (client) => client.local.installation), | |
| 116 | − config: wrapMethod(effectClientPromise, (client) => client.local.config), | |
| 117 | − listSecrets: wrapMethod(effectClientPromise, (client) => client.local.listSecrets), | |
| 118 | − createSecret: wrapMethod(effectClientPromise, (client) => client.local.createSecret), | |
| 119 | − updateSecret: wrapMethod(effectClientPromise, (client) => client.local.updateSecret), | |
| 120 | − deleteSecret: wrapMethod(effectClientPromise, (client) => client.local.deleteSecret), | |
| 121 | − }, | |
| 122 | − policies: { | |
| 123 | − list: wrapMethod(effectClientPromise, (client) => client.policies.list), | |
| 124 | − create: wrapMethod(effectClientPromise, (client) => client.policies.create), | |
| 125 | − get: wrapMethod(effectClientPromise, (client) => client.policies.get), | |
| 126 | − update: wrapMethod(effectClientPromise, (client) => client.policies.update), | |
| 127 | − remove: wrapMethod(effectClientPromise, (client) => client.policies.remove), | |
| 128 | − }, | |
| 129 | − sources: { | |
| 130 | − discover: wrapMethod(effectClientPromise, (client) => client.sources.discover), | |
| 131 | − connect: wrapMethod(effectClientPromise, (client) => client.sources.connect), | |
| 132 | − connectBatch: wrapMethod(effectClientPromise, (client) => client.sources.connectBatch), | |
| 133 | − listWorkspaceOauthClients: wrapMethod( | |
| 134 | − effectClientPromise, | |
| 135 | − (client) => client.sources.listWorkspaceOauthClients, | |
| 136 | − ), | |
| 137 | − createWorkspaceOauthClient: wrapMethod( | |
| 138 | − effectClientPromise, | |
| 139 | − (client) => client.sources.createWorkspaceOauthClient, | |
| 140 | − ), | |
| 141 | − removeWorkspaceOauthClient: wrapMethod( | |
| 142 | − effectClientPromise, | |
| 143 | − (client) => client.sources.removeWorkspaceOauthClient, | |
| 144 | − ), | |
| 145 | − removeProviderAuthGrant: wrapMethod( | |
| 146 | − effectClientPromise, | |
| 147 | − (client) => client.sources.removeProviderAuthGrant, | |
| 148 | − ), | |
| 149 | − list: wrapMethod(effectClientPromise, (client) => client.sources.list), | |
| 150 | − create: wrapMethod(effectClientPromise, (client) => client.sources.create), | |
| 151 | − get: wrapMethod(effectClientPromise, (client) => client.sources.get), | |
| 152 | − update: wrapMethod(effectClientPromise, (client) => client.sources.update), | |
| 153 | − remove: wrapMethod(effectClientPromise, (client) => client.sources.remove), | |
| 154 | − inspection: wrapMethod(effectClientPromise, (client) => client.sources.inspection), | |
| 155 | − inspectionTool: wrapMethod( | |
| 156 | − effectClientPromise, | |
| 157 | − (client) => client.sources.inspectionTool, | |
| 158 | − ), | |
| 159 | − inspectionDiscover: wrapMethod( | |
| 160 | − effectClientPromise, | |
| 161 | − (client) => client.sources.inspectionDiscover, | |
| 162 | − ), | |
| 163 | − credentialPage: wrapMethod( | |
| 164 | − effectClientPromise, | |
| 165 | − (client) => client.sources.credentialPage, | |
| 166 | − ), | |
| 167 | − credentialSubmit: wrapMethod( | |
| 168 | − effectClientPromise, | |
| 169 | − (client) => client.sources.credentialSubmit, | |
| 170 | − ), | |
| 171 | − credentialComplete: wrapMethod( | |
| 172 | − effectClientPromise, | |
| 173 | − (client) => client.sources.credentialComplete, | |
| 174 | − ), | |
| 175 | − providerOauthComplete: wrapMethod( | |
| 176 | − effectClientPromise, | |
| 177 | − (client) => client.sources.providerOauthComplete, | |
| 178 | − ), | |
| 179 | − }, | |
| 180 | − oauth: { | |
| 181 | − startSourceAuth: wrapMethod( | |
| 182 | − effectClientPromise, | |
| 183 | − (client) => client.oauth.startSourceAuth, | |
| 184 | − ), | |
| 185 | − sourceAuthCallback: wrapMethod( | |
| 186 | − effectClientPromise, | |
| 187 | − (client) => client.oauth.sourceAuthCallback, | |
| 188 | − ), | |
| 189 | − }, | |
| 190 | − executions: { | |
| 191 | − create: wrapMethod(effectClientPromise, (client) => client.executions.create), | |
| 192 | − get: wrapMethod(effectClientPromise, (client) => client.executions.get), | |
| 193 | − resume: wrapMethod(effectClientPromise, (client) => client.executions.resume), | |
| 194 | − }, | |
| 195 | − }; | |
| 196 | −}; | |
| @@ -4,13 +4,16 @@ import { · 3 unmodified lines | ||
| 4 | 4 | } from "@effect/platform"; |
| 5 | 5 | import * as Effect from "effect/Effect"; |
| 6 | 6 | |
| 7 | −import { ExecutorApi } from "./api"; | |
| 7 | +import { createExecutorApi } from "./api"; | |
| 8 | +import type { ExecutorHttpPlugin } from "./plugins"; | |
| 8 | 9 | |
| 9 | −export const createExecutorApiEffectClient = (input: { | |
| 10 | +export const createExecutorApiEffectClient = < | |
| 11 | + const TPlugins extends readonly ExecutorHttpPlugin[] = [], | |
| 12 | +>(input: { | |
| 10 | 13 | baseUrl: string; |
| 11 | − accountId?: string; | |
| 14 | + plugins?: TPlugins; | |
| 12 | 15 | }) => |
| 13 | − HttpApiClient.make(ExecutorApi, { | |
| 16 | + HttpApiClient.make(createExecutorApi({ plugins: input.plugins }), { | |
| 14 | 17 | baseUrl: input.baseUrl, |
| 15 | 18 | }).pipe(Effect.provide(FetchHttpClient.layer)); |
| 16 | 19 | |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | +import * as Schema from "effect/Schema"; | |
| 2 | 3 | import { |
| 3 | 4 | CreateExecutionPayloadSchema, |
| 4 | 5 | ResumeExecutionPayloadSchema, |
| @@ -8,6 +9,7 @@ export type { · 3 unmodified lines | ||
| 8 | 9 | ResumeExecutionPayload, |
| 9 | 10 | } from "@executor/platform-sdk/contracts"; |
| 10 | 11 | import { |
| 12 | + ExecutionSchema, | |
| 11 | 13 | ExecutionIdSchema, |
| 12 | 14 | ExecutionEnvelopeSchema, |
| 13 | 15 | ScopeIdSchema as WorkspaceIdSchema, |
| @@ -30,6 +32,15 @@ const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); · 16 unmodified lines | ||
| 30 | 32 | const executionIdParam = HttpApiSchema.param("executionId", ExecutionIdSchema); |
| 31 | 33 | |
| 32 | 34 | export class ExecutionsApi extends HttpApiGroup.make("executions") |
| 35 | + .add( | |
| 36 | + HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/executions` | |
| 37 | + .addSuccess(Schema.Array(ExecutionSchema)) | |
| 38 | + .addError(ControlPlaneBadRequestError) | |
| 39 | + .addError(ControlPlaneUnauthorizedError) | |
| 40 | + .addError(ControlPlaneForbiddenError) | |
| 41 | + .addError(ControlPlaneNotFoundError) | |
| 42 | + .addError(ControlPlaneStorageError), | |
| 43 | + ) | |
| 33 | 44 | .add( |
| 34 | 45 | HttpApiEndpoint.post("create")`/workspaces/${workspaceIdParam}/executions` |
| 35 | 46 | .setPayload(CreateExecutionPayloadSchema) |
| @@ -9,6 +9,11 @@ export const ExecutorExecutionsLive = HttpApiBuilder.group( · 8 unmodified lines | ||
| 9 | 9 | "executions", |
| 10 | 10 | (handlers) => |
| 11 | 11 | handlers |
| 12 | + .handle("list", ({ path }) => | |
| 13 | + resolveRequestedLocalWorkspace("executions.list", path.workspaceId).pipe( | |
| 14 | + Effect.flatMap((executor) => executor.executions.list()), | |
| 15 | + ), | |
| 16 | + ) | |
| 12 | 17 | .handle("create", ({ path, payload }) => |
| 13 | 18 | resolveRequestedLocalWorkspace("executions.create", path.workspaceId).pipe( |
| 14 | 19 | Effect.flatMap((executor) => executor.executions.create(payload)), |
| @@ -1,9 +1,7 @@ | ||
| 1 | 1 | import type { ExecutorEffect } from "@executor/platform-sdk/effect"; |
| 2 | 2 | import type { ScopeId as WorkspaceId } from "@executor/platform-sdk/schema"; |
| 3 | 3 | import { |
| 4 | − ControlPlaneBadRequestError, | |
| 5 | 4 | ControlPlaneForbiddenError, |
| 6 | − ControlPlaneUnauthorizedError, | |
| 7 | 5 | } from "@executor/platform-sdk/errors"; |
| 8 | 6 | import * as Context from "effect/Context"; |
| 9 | 7 | import * as Effect from "effect/Effect"; |
| @@ -2,26 +2,46 @@ import { HttpApiBuilder } from "@effect/platform"; · 1 unmodified line | ||
| 2 | 2 | import * as Layer from "effect/Layer"; |
| 3 | 3 | import type { ExecutorEffect } from "@executor/platform-sdk/effect"; |
| 4 | 4 | |
| 5 | −import { ExecutorApi } from "./api"; | |
| 5 | +import { createExecutorApi } from "./api"; | |
| 6 | 6 | import { createControlPlaneExecutorLayer } from "./executor-context"; |
| 7 | 7 | import { ExecutorExecutionsLive } from "./executions/http"; |
| 8 | 8 | import { ExecutorLocalLive } from "./local/http"; |
| 9 | −import { ExecutorOAuthLive } from "./oauth/http"; | |
| 10 | 9 | import { ExecutorPoliciesLive } from "./policies/http"; |
| 10 | +import type { ExecutorHttpPlugin } from "./plugins"; | |
| 11 | 11 | import { ExecutorSourcesLive } from "./sources/http"; |
| 12 | 12 | |
| 13 | −export const ExecutorApiLive = HttpApiBuilder.api(ExecutorApi).pipe( | |
| 14 | − Layer.provide(ExecutorLocalLive), | |
| 15 | − Layer.provide(ExecutorOAuthLive), | |
| 16 | − Layer.provide(ExecutorSourcesLive), | |
| 17 | − Layer.provide(ExecutorPoliciesLive), | |
| 18 | − Layer.provide(ExecutorExecutionsLive), | |
| 19 | −); | |
| 13 | +const createExecutorApiLive = ( | |
| 14 | + executor: ExecutorEffect, | |
| 15 | + plugins: readonly ExecutorHttpPlugin[] = [], | |
| 16 | +) => { | |
| 17 | + const api = createExecutorApi({ plugins }); | |
| 18 | + let live: Layer.Layer<any, any, any> = HttpApiBuilder.api(api as any).pipe( | |
| 19 | + Layer.provide(ExecutorLocalLive), | |
| 20 | + Layer.provide(ExecutorSourcesLive), | |
| 21 | + Layer.provide(ExecutorPoliciesLive), | |
| 22 | + Layer.provide(ExecutorExecutionsLive), | |
| 23 | + ); | |
| 24 | + | |
| 25 | + for (const plugin of plugins) { | |
| 26 | + live = live.pipe( | |
| 27 | + Layer.provide( | |
| 28 | + plugin.build({ | |
| 29 | + executor, | |
| 30 | + }), | |
| 31 | + ), | |
| 32 | + ); | |
| 33 | + } | |
| 20 | 34 | |
| 21 | −export type ExecutorApiRuntimeContext = Layer.Layer.Context<typeof ExecutorApiLive>; | |
| 35 | + return live; | |
| 36 | +}; | |
| 22 | 37 | |
| 23 | −export const createExecutorApiLayer = (executor: ExecutorEffect) => | |
| 24 | − ExecutorApiLive.pipe( | |
| 38 | +export const createExecutorApiLayer = ( | |
| 39 | + executor: ExecutorEffect, | |
| 40 | + options: { | |
| 41 | + plugins?: readonly ExecutorHttpPlugin[]; | |
| 42 | + } = {}, | |
| 43 | +) => | |
| 44 | + createExecutorApiLive(executor, options.plugins).pipe( | |
| 25 | 45 | Layer.provide(createControlPlaneExecutorLayer(executor)), |
| 26 | 46 | ); |
| 27 | 47 | |
| @@ -1,11 +1,15 @@ | ||
| 1 | 1 | export { |
| 2 | + CoreExecutorApi, | |
| 3 | + createExecutorApi, | |
| 4 | + createExecutorOpenApiSpec, | |
| 2 | 5 | ExecutorApi, |
| 3 | 6 | executorOpenApiSpec, |
| 4 | 7 | } from "./api"; |
| 5 | −export { | |
| 6 | − createExecutorApiClient, | |
| 7 | − type ExecutorApiClient, | |
| 8 | −} from "./client"; | |
| 8 | +export type { | |
| 9 | + ExecutorHttpApiExtension, | |
| 10 | + ExecutorHttpPlugin, | |
| 11 | + ExecutorHttpPluginGroups, | |
| 12 | +} from "./plugins"; | |
| 9 | 13 | |
| 10 | 14 | export type { LocalInstallation } from "@executor/platform-sdk/schema"; |
| 11 | 15 | |
| @@ -38,37 +42,7 @@ export { · 26 unmodified lines | ||
| 38 | 42 | } from "./local/api"; |
| 39 | 43 | |
| 40 | 44 | export { |
| 41 | − OAuthApi, | |
| 42 | − StartSourceOAuthPayloadSchema, | |
| 43 | − StartSourceOAuthResultSchema, | |
| 44 | − CompleteSourceOAuthResultSchema, | |
| 45 | − SourceOAuthPopupFailureResultSchema, | |
| 46 | − SourceOAuthPopupResultSchema, | |
| 47 | − SourceOAuthPopupSuccessResultSchema, | |
| 48 | − type StartSourceOAuthPayload, | |
| 49 | − type StartSourceOAuthResult, | |
| 50 | − type CompleteSourceOAuthResult, | |
| 51 | − type SourceOAuthPopupResult, | |
| 52 | −} from "./oauth/api"; | |
| 53 | − | |
| 54 | −export { | |
| 55 | − ConnectSourceBatchPayloadSchema, | |
| 56 | − ConnectSourceBatchResultSchema, | |
| 57 | − ConnectSourcePayloadSchema, | |
| 58 | − ConnectSourceResultSchema, | |
| 59 | − CreateWorkspaceOauthClientPayloadSchema, | |
| 60 | − CreateSourcePayloadSchema, | |
| 61 | − DiscoverSourcePayloadSchema, | |
| 62 | 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 | 46 | } from "./sources/api"; |
| 73 | 47 | |
| 74 | 48 | export { |
| @@ -1,103 +0,0 @@ | ||
| 1 | −import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | |
| 2 | −import { | |
| 3 | − SecretRefSchema, | |
| 4 | − SourceAuthSessionIdSchema, | |
| 5 | − SourceTransportSchema, | |
| 6 | − StringMapSchema, | |
| 7 | − ScopeIdSchema as WorkspaceIdSchema, | |
| 8 | −} from "@executor/platform-sdk/schema"; | |
| 9 | −import * as Schema from "effect/Schema"; | |
| 10 | − | |
| 11 | −import { | |
| 12 | − ControlPlaneBadRequestError, | |
| 13 | − ControlPlaneForbiddenError, | |
| 14 | − ControlPlaneStorageError, | |
| 15 | − ControlPlaneUnauthorizedError, | |
| 16 | −} from "@executor/platform-sdk/errors"; | |
| 17 | −import { TrimmedNonEmptyStringSchema } from "@executor/platform-sdk/string-schemas"; | |
| 18 | − | |
| 19 | −const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); | |
| 20 | − | |
| 21 | −const HtmlSchema = HttpApiSchema.Text({ | |
| 22 | − contentType: "text/html", | |
| 23 | −}); | |
| 24 | − | |
| 25 | −export const StartSourceOAuthPayloadSchema = Schema.Struct({ | |
| 26 | − provider: Schema.Literal("mcp"), | |
| 27 | − name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 28 | − endpoint: TrimmedNonEmptyStringSchema, | |
| 29 | − transport: Schema.optional(SourceTransportSchema), | |
| 30 | − queryParams: Schema.optional(Schema.NullOr(StringMapSchema)), | |
| 31 | − headers: Schema.optional(Schema.NullOr(StringMapSchema)), | |
| 32 | −}); | |
| 33 | − | |
| 34 | −export type StartSourceOAuthPayload = typeof StartSourceOAuthPayloadSchema.Type; | |
| 35 | − | |
| 36 | −export const StartSourceOAuthResultSchema = Schema.Struct({ | |
| 37 | − sessionId: SourceAuthSessionIdSchema, | |
| 38 | − authorizationUrl: Schema.String, | |
| 39 | −}); | |
| 40 | − | |
| 41 | −export type StartSourceOAuthResult = typeof StartSourceOAuthResultSchema.Type; | |
| 42 | − | |
| 43 | −export const SourceOAuthAuthSchema = Schema.Struct({ | |
| 44 | − kind: Schema.Literal("oauth2"), | |
| 45 | − headerName: Schema.String, | |
| 46 | − prefix: Schema.String, | |
| 47 | − accessToken: SecretRefSchema, | |
| 48 | − refreshToken: Schema.NullOr(SecretRefSchema), | |
| 49 | −}); | |
| 50 | − | |
| 51 | −export const CompleteSourceOAuthResultSchema = Schema.Struct({ | |
| 52 | − sessionId: SourceAuthSessionIdSchema, | |
| 53 | − auth: SourceOAuthAuthSchema, | |
| 54 | −}); | |
| 55 | − | |
| 56 | −export type CompleteSourceOAuthResult = typeof CompleteSourceOAuthResultSchema.Type; | |
| 57 | − | |
| 58 | −export const SourceOAuthPopupSuccessResultSchema = Schema.Struct({ | |
| 59 | − type: Schema.Literal("executor:oauth-result"), | |
| 60 | − ok: Schema.Literal(true), | |
| 61 | − sessionId: SourceAuthSessionIdSchema, | |
| 62 | − auth: SourceOAuthAuthSchema, | |
| 63 | −}); | |
| 64 | − | |
| 65 | −export const SourceOAuthPopupFailureResultSchema = Schema.Struct({ | |
| 66 | − type: Schema.Literal("executor:oauth-result"), | |
| 67 | − ok: Schema.Literal(false), | |
| 68 | − sessionId: Schema.Null, | |
| 69 | − error: Schema.String, | |
| 70 | −}); | |
| 71 | − | |
| 72 | −export const SourceOAuthPopupResultSchema = Schema.Union( | |
| 73 | − SourceOAuthPopupSuccessResultSchema, | |
| 74 | − SourceOAuthPopupFailureResultSchema, | |
| 75 | −); | |
| 76 | − | |
| 77 | −export type SourceOAuthPopupResult = typeof SourceOAuthPopupResultSchema.Type; | |
| 78 | − | |
| 79 | −const OAuthCallbackUrlParamsSchema = Schema.Struct({ | |
| 80 | − state: Schema.String, | |
| 81 | − code: Schema.optional(Schema.String), | |
| 82 | − error: Schema.optional(Schema.String), | |
| 83 | − error_description: Schema.optional(Schema.String), | |
| 84 | −}); | |
| 85 | − | |
| 86 | −export class OAuthApi extends HttpApiGroup.make("oauth") | |
| 87 | − .add( | |
| 88 | − HttpApiEndpoint.post("startSourceAuth")`/workspaces/${workspaceIdParam}/oauth/source-auth/start` | |
| 89 | − .setPayload(StartSourceOAuthPayloadSchema) | |
| 90 | − .addSuccess(StartSourceOAuthResultSchema) | |
| 91 | − .addError(ControlPlaneBadRequestError) | |
| 92 | − .addError(ControlPlaneUnauthorizedError) | |
| 93 | − .addError(ControlPlaneForbiddenError) | |
| 94 | − .addError(ControlPlaneStorageError), | |
| 95 | − ) | |
| 96 | − .add( | |
| 97 | − HttpApiEndpoint.get("sourceAuthCallback")`/oauth/source-auth/callback` | |
| 98 | − .setUrlParams(OAuthCallbackUrlParamsSchema) | |
| 99 | − .addSuccess(HtmlSchema) | |
| 100 | − .addError(ControlPlaneBadRequestError) | |
| 101 | − .addError(ControlPlaneStorageError), | |
| 102 | − ) | |
| 103 | − .prefix("/v1") {} | |
| @@ -1,321 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − HttpApiBuilder, | |
| 3 | − HttpServerRequest, | |
| 4 | − HttpServerResponse, | |
| 5 | −} from "@effect/platform"; | |
| 6 | −import type { ScopeId as WorkspaceId } from "@executor/platform-sdk/schema"; | |
| 7 | −import * as Cause from "effect/Cause"; | |
| 8 | −import * as Either from "effect/Either"; | |
| 9 | −import * as Effect from "effect/Effect"; | |
| 10 | −import * as Schema from "effect/Schema"; | |
| 11 | − | |
| 12 | −import { ExecutorApi } from "../api"; | |
| 13 | −import { | |
| 14 | − ControlPlaneBadRequestError, | |
| 15 | − ControlPlaneStorageError, | |
| 16 | −} from "../errors"; | |
| 17 | −import { | |
| 18 | − getControlPlaneExecutor, | |
| 19 | − resolveRequestedLocalWorkspace, | |
| 20 | −} from "../local-context"; | |
| 21 | −import { | |
| 22 | − SourceOAuthPopupResultSchema, | |
| 23 | − type SourceOAuthPopupResult, | |
| 24 | −} from "./api"; | |
| 25 | − | |
| 26 | −const OAUTH_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:"; | |
| 27 | − | |
| 28 | −const escapeHtml = (value: string): string => | |
| 29 | − value | |
| 30 | − .replaceAll("&", "&") | |
| 31 | − .replaceAll("<", "<") | |
| 32 | − .replaceAll(">", ">") | |
| 33 | − .replaceAll('"', """) | |
| 34 | − .replaceAll("'", "'"); | |
| 35 | − | |
| 36 | −const serializeValueForScript = (value: unknown): string => | |
| 37 | − JSON.stringify(value) | |
| 38 | − .replaceAll("<", "\\u003c") | |
| 39 | − .replaceAll(">", "\\u003e") | |
| 40 | − .replaceAll("&", "\\u0026"); | |
| 41 | − | |
| 42 | −const encodeSourceOAuthPopupResultJson = Schema.encodeSync( | |
| 43 | − Schema.parseJson(SourceOAuthPopupResultSchema), | |
| 44 | −); | |
| 45 | − | |
| 46 | −const serializeJsonLiteralForScript = (value: string): string => | |
| 47 | − value | |
| 48 | − .replaceAll("<", "\\u003c") | |
| 49 | − .replaceAll(">", "\\u003e") | |
| 50 | − .replaceAll("&", "\\u0026"); | |
| 51 | − | |
| 52 | −const htmlResponse = (html: string, status = 200) => | |
| 53 | − HttpServerResponse.html(html).pipe( | |
| 54 | − HttpServerResponse.setStatus(status), | |
| 55 | − ); | |
| 56 | − | |
| 57 | −const readHeader = (headers: unknown, name: string): string | null => { | |
| 58 | − if (headers instanceof Headers) { | |
| 59 | − return headers.get(name); | |
| 60 | − } | |
| 61 | − | |
| 62 | − if (headers && typeof headers === "object") { | |
| 63 | − const record = headers as Record<string, unknown>; | |
| 64 | − const direct = record[name]; | |
| 65 | − if (typeof direct === "string" && direct.length > 0) { | |
| 66 | − return direct; | |
| 67 | − } | |
| 68 | − | |
| 69 | − const lower = record[name.toLowerCase()]; | |
| 70 | − if (typeof lower === "string" && lower.length > 0) { | |
| 71 | − return lower; | |
| 72 | − } | |
| 73 | − } | |
| 74 | − | |
| 75 | − return null; | |
| 76 | −}; | |
| 77 | − | |
| 78 | −const resolveRequestOrigin = (request: { url: string; headers: unknown }): string | null => { | |
| 79 | − try { | |
| 80 | − return new URL(request.url).origin; | |
| 81 | − } catch { | |
| 82 | − const forwardedHost = readHeader(request.headers, "x-forwarded-host"); | |
| 83 | − const host = forwardedHost ?? readHeader(request.headers, "host"); | |
| 84 | − if (!host) { | |
| 85 | − return null; | |
| 86 | − } | |
| 87 | − | |
| 88 | − const forwardedProto = readHeader(request.headers, "x-forwarded-proto"); | |
| 89 | − const protocol = forwardedProto && forwardedProto.length > 0 ? forwardedProto : "http"; | |
| 90 | − return `${protocol}://${host}`; | |
| 91 | − } | |
| 92 | −}; | |
| 93 | − | |
| 94 | −const toStartSourceOAuthError = (input: { | |
| 95 | − workspaceId: WorkspaceId; | |
| 96 | − provider: string; | |
| 97 | − endpoint: string; | |
| 98 | − cause: Cause.Cause<unknown>; | |
| 99 | −}) => { | |
| 100 | − const pretty = Cause.pretty(input.cause); | |
| 101 | − console.error("oauth.start_source_auth failed", { | |
| 102 | − workspaceId: input.workspaceId, | |
| 103 | − provider: input.provider, | |
| 104 | − endpoint: input.endpoint, | |
| 105 | − pretty, | |
| 106 | − }); | |
| 107 | − | |
| 108 | − if (pretty.includes("Invalid URL") || pretty.includes("required")) { | |
| 109 | − return new ControlPlaneBadRequestError({ | |
| 110 | − operation: "oauth.start_source_auth", | |
| 111 | − message: pretty, | |
| 112 | − details: pretty, | |
| 113 | − }); | |
| 114 | − } | |
| 115 | − | |
| 116 | − return new ControlPlaneStorageError({ | |
| 117 | − operation: "oauth.start_source_auth", | |
| 118 | − message: pretty, | |
| 119 | − details: pretty, | |
| 120 | − }); | |
| 121 | −}; | |
| 122 | − | |
| 123 | −const sourceOAuthPopupResultDocument = (input: { | |
| 124 | − title: string; | |
| 125 | − message: string; | |
| 126 | − status: "connected" | "failed"; | |
| 127 | − sessionId: string; | |
| 128 | − payload: SourceOAuthPopupResult; | |
| 129 | −}) => `<!doctype html> | |
| 130 | −<html lang="en"> | |
| 131 | − <head> | |
| 132 | − <meta charset="utf-8" /> | |
| 133 | − <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| 134 | − <title>${escapeHtml(input.title)}</title> | |
| 135 | − <style> | |
| 136 | − :root { | |
| 137 | − --bg: #fafaf9; | |
| 138 | − --surface: #ffffff; | |
| 139 | − --text: #1c1917; | |
| 140 | − --text-secondary: #78716c; | |
| 141 | − --border: #e7e5e4; | |
| 142 | − --green: #16a34a; | |
| 143 | − --green-light: #f0fdf4; | |
| 144 | − --green-border: #bbf7d0; | |
| 145 | − --red: #dc2626; | |
| 146 | − --red-light: #fef2f2; | |
| 147 | − --red-border: #fecaca; | |
| 148 | − } | |
| 149 | − | |
| 150 | − * { box-sizing: border-box; margin: 0; padding: 0; } | |
| 151 | − | |
| 152 | − body { | |
| 153 | − min-height: 100vh; | |
| 154 | − display: flex; | |
| 155 | − align-items: center; | |
| 156 | − justify-content: center; | |
| 157 | − background: var(--bg); | |
| 158 | − color: var(--text); | |
| 159 | − font-family: system-ui, -apple-system, sans-serif; | |
| 160 | − padding: 24px; | |
| 161 | − } | |
| 162 | − | |
| 163 | − .card { | |
| 164 | − width: 100%; | |
| 165 | − max-width: 420px; | |
| 166 | − background: var(--surface); | |
| 167 | − border: 1px solid var(--border); | |
| 168 | − border-radius: 16px; | |
| 169 | − padding: 28px; | |
| 170 | − text-align: center; | |
| 171 | − } | |
| 172 | − | |
| 173 | − .status { | |
| 174 | − display: inline-flex; | |
| 175 | − align-items: center; | |
| 176 | − justify-content: center; | |
| 177 | − padding: 6px 12px; | |
| 178 | − border-radius: 999px; | |
| 179 | − font-size: 12px; | |
| 180 | − font-weight: 600; | |
| 181 | − margin-bottom: 14px; | |
| 182 | − } | |
| 183 | − | |
| 184 | − .status[data-status='connected'] { | |
| 185 | − background: var(--green-light); | |
| 186 | − border: 1px solid var(--green-border); | |
| 187 | − color: var(--green); | |
| 188 | − } | |
| 189 | − | |
| 190 | − .status[data-status='failed'] { | |
| 191 | − background: var(--red-light); | |
| 192 | − border: 1px solid var(--red-border); | |
| 193 | − color: var(--red); | |
| 194 | − } | |
| 195 | − | |
| 196 | − h1 { | |
| 197 | − font-size: 22px; | |
| 198 | − line-height: 1.2; | |
| 199 | − margin-bottom: 10px; | |
| 200 | − } | |
| 201 | − | |
| 202 | − p { | |
| 203 | − color: var(--text-secondary); | |
| 204 | − font-size: 14px; | |
| 205 | − line-height: 1.6; | |
| 206 | − } | |
| 207 | − </style> | |
| 208 | − </head> | |
| 209 | − <body> | |
| 210 | − <main class="card"> | |
| 211 | − <div class="status" data-status="${escapeHtml(input.status)}">${escapeHtml(input.status)}</div> | |
| 212 | − <h1>${escapeHtml(input.title)}</h1> | |
| 213 | − <p>${escapeHtml(input.message)}</p> | |
| 214 | − </main> | |
| 215 | − <script> | |
| 216 | − (() => { | |
| 217 | − const payload = ${serializeJsonLiteralForScript(encodeSourceOAuthPopupResultJson(input.payload))}; | |
| 218 | − const storageKey = ${serializeValueForScript(`${OAUTH_RESULT_STORAGE_KEY_PREFIX}${input.sessionId}`)}; | |
| 219 | − try { | |
| 220 | − window.localStorage.setItem(storageKey, JSON.stringify(payload)); | |
| 221 | − } catch {} | |
| 222 | − try { | |
| 223 | − if (window.opener) { | |
| 224 | − window.opener.postMessage(payload, window.location.origin); | |
| 225 | − } | |
| 226 | − } finally { | |
| 227 | − window.setTimeout(() => window.close(), 120); | |
| 228 | − } | |
| 229 | − })(); | |
| 230 | − </script> | |
| 231 | − </body> | |
| 232 | −</html>`; | |
| 233 | − | |
| 234 | −export const ExecutorOAuthLive = HttpApiBuilder.group( | |
| 235 | − ExecutorApi, | |
| 236 | − "oauth", | |
| 237 | − (handlers) => | |
| 238 | − handlers | |
| 239 | − .handle("startSourceAuth", ({ path, payload }) => | |
| 240 | − resolveRequestedLocalWorkspace("oauth.start_source_auth", path.workspaceId).pipe( | |
| 241 | − Effect.flatMap((executor) => | |
| 242 | − Effect.gen(function* () { | |
| 243 | − const request = yield* HttpServerRequest.HttpServerRequest; | |
| 244 | − return yield* executor.oauth.startSourceAuth({ | |
| 245 | − baseUrl: resolveRequestOrigin(request), | |
| 246 | − displayName: payload.name, | |
| 247 | − provider: { | |
| 248 | − kind: payload.provider, | |
| 249 | − endpoint: payload.endpoint, | |
| 250 | − transport: payload.transport, | |
| 251 | − queryParams: payload.queryParams, | |
| 252 | − headers: payload.headers, | |
| 253 | − }, | |
| 254 | − }); | |
| 255 | − }).pipe( | |
| 256 | − Effect.catchAllCause((cause) => | |
| 257 | − Effect.fail( | |
| 258 | − toStartSourceOAuthError({ | |
| 259 | − workspaceId: path.workspaceId, | |
| 260 | − provider: payload.provider, | |
| 261 | − endpoint: payload.endpoint, | |
| 262 | − cause, | |
| 263 | − }), | |
| 264 | − ), | |
| 265 | − ), | |
| 266 | − ) | |
| 267 | − ), | |
| 268 | − ), | |
| 269 | − ) | |
| 270 | − .handle("sourceAuthCallback", ({ urlParams }) => | |
| 271 | − Effect.flatMap(getControlPlaneExecutor(), (executor) => | |
| 272 | − Effect.gen(function* () { | |
| 273 | − const completed = yield* Effect.either( | |
| 274 | − executor.oauth.completeSourceAuth({ | |
| 275 | − state: urlParams.state, | |
| 276 | − code: urlParams.code, | |
| 277 | − error: urlParams.error, | |
| 278 | − errorDescription: urlParams.error_description, | |
| 279 | − }), | |
| 280 | − ); | |
| 281 | − | |
| 282 | − if (Either.isRight(completed)) { | |
| 283 | − return htmlResponse( | |
| 284 | − sourceOAuthPopupResultDocument({ | |
| 285 | − title: "OAuth connected", | |
| 286 | − message: "OAuth credentials are ready. Return to the source form to finish saving.", | |
| 287 | − status: "connected", | |
| 288 | − sessionId: completed.right.sessionId, | |
| 289 | − payload: { | |
| 290 | − type: "executor:oauth-result", | |
| 291 | − ok: true, | |
| 292 | − sessionId: completed.right.sessionId, | |
| 293 | − auth: completed.right.auth, | |
| 294 | − }, | |
| 295 | − }), | |
| 296 | − ); | |
| 297 | − } | |
| 298 | − | |
| 299 | − const message = completed.left instanceof Error | |
| 300 | − ? completed.left.message | |
| 301 | − : "Failed completing OAuth"; | |
| 302 | − | |
| 303 | − return htmlResponse( | |
| 304 | − sourceOAuthPopupResultDocument({ | |
| 305 | − title: "OAuth failed", | |
| 306 | − message, | |
| 307 | − status: "failed", | |
| 308 | − sessionId: "failed", | |
| 309 | − payload: { | |
| 310 | − type: "executor:oauth-result", | |
| 311 | − ok: false, | |
| 312 | − sessionId: null, | |
| 313 | − error: message, | |
| 314 | − }, | |
| 315 | − }), | |
| 316 | − 500, | |
| 317 | − ); | |
| 318 | − }), | |
| 319 | − ), | |
| 320 | − ), | |
| 321 | −); | |
| @@ -1,100 +0,0 @@ | ||
| 1 | −import { describe, expect, it } from "@effect/vitest"; | |
| 2 | −import { throws } from "@effect/vitest/utils"; | |
| 3 | −import * as Schema from "effect/Schema"; | |
| 4 | − | |
| 5 | −import { | |
| 6 | − CreateExecutionPayloadSchema, | |
| 7 | −} from "./executions/api"; | |
| 8 | −import { CreatePolicyPayloadSchema } from "./policies/api"; | |
| 9 | −import { | |
| 10 | − ConnectSourcePayloadSchema, | |
| 11 | − CreateSourcePayloadSchema, | |
| 12 | − UpdateSourcePayloadSchema, | |
| 13 | −} from "./sources/api"; | |
| 14 | − | |
| 15 | −describe("control-plane payload schemas", () => { | |
| 16 | − it("normalizes trimmed strings at decode time", () => { | |
| 17 | − expect( | |
| 18 | − Schema.decodeUnknownSync(CreateSourcePayloadSchema)({ | |
| 19 | − name: " Github ", | |
| 20 | − kind: "openapi", | |
| 21 | − endpoint: " https://api.github.com ", | |
| 22 | − }), | |
| 23 | − ).toEqual({ | |
| 24 | − name: "Github", | |
| 25 | − kind: "openapi", | |
| 26 | − endpoint: "https://api.github.com", | |
| 27 | − }); | |
| 28 | − | |
| 29 | − expect( | |
| 30 | − Schema.decodeUnknownSync(CreateExecutionPayloadSchema)({ | |
| 31 | − code: " console.log('ok') ", | |
| 32 | − }), | |
| 33 | − ).toEqual({ | |
| 34 | − code: "console.log('ok')", | |
| 35 | − }); | |
| 36 | − | |
| 37 | − expect( | |
| 38 | − Schema.decodeUnknownSync(ConnectSourcePayloadSchema)({ | |
| 39 | − kind: "openapi", | |
| 40 | − endpoint: " https://api.github.com ", | |
| 41 | − specUrl: " https://example.com/openapi.json ", | |
| 42 | − }), | |
| 43 | − ).toEqual({ | |
| 44 | − kind: "openapi", | |
| 45 | − endpoint: "https://api.github.com", | |
| 46 | − specUrl: "https://example.com/openapi.json", | |
| 47 | − }); | |
| 48 | − | |
| 49 | − expect( | |
| 50 | − Schema.decodeUnknownSync(ConnectSourcePayloadSchema)({ | |
| 51 | − kind: "google_discovery", | |
| 52 | − service: " sheets ", | |
| 53 | − version: " v4 ", | |
| 54 | − discoveryUrl: " https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest ", | |
| 55 | − oauthClient: { | |
| 56 | − clientId: " google-client ", | |
| 57 | − clientSecret: " google-secret ", | |
| 58 | − }, | |
| 59 | − }), | |
| 60 | − ).toEqual({ | |
| 61 | − kind: "google_discovery", | |
| 62 | − service: "sheets", | |
| 63 | − version: "v4", | |
| 64 | − discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest", | |
| 65 | − oauthClient: { | |
| 66 | − clientId: "google-client", | |
| 67 | − clientSecret: "google-secret", | |
| 68 | − }, | |
| 69 | − }); | |
| 70 | − | |
| 71 | − expect( | |
| 72 | − Schema.decodeUnknownSync(CreatePolicyPayloadSchema)({ | |
| 73 | − resourcePattern: " source.github.* ", | |
| 74 | − }), | |
| 75 | − ).toEqual({ | |
| 76 | − resourcePattern: "source.github.*", | |
| 77 | − }); | |
| 78 | − }); | |
| 79 | − | |
| 80 | − it("rejects blank strings for normalized string fields", () => { | |
| 81 | − throws(() => | |
| 82 | − Schema.decodeUnknownSync(UpdateSourcePayloadSchema)({ | |
| 83 | − endpoint: " ", | |
| 84 | − }) | |
| 85 | − ); | |
| 86 | − | |
| 87 | − throws(() => | |
| 88 | − Schema.decodeUnknownSync(ConnectSourcePayloadSchema)({ | |
| 89 | − kind: "graphql", | |
| 90 | − endpoint: " ", | |
| 91 | − }) | |
| 92 | − ); | |
| 93 | − | |
| 94 | − throws(() => | |
| 95 | − Schema.decodeUnknownSync(CreatePolicyPayloadSchema)({ | |
| 96 | − resourcePattern: " ", | |
| 97 | − }) | |
| 98 | − ); | |
| 99 | − }); | |
| 100 | −}); | |
| @@ -0,0 +1,23 @@ | ||
| 1 | +import type { HttpApiGroup } from "@effect/platform"; | |
| 2 | +import type * as Layer from "effect/Layer"; | |
| 3 | +import type { ExecutorEffect } from "@executor/platform-sdk/effect"; | |
| 4 | + | |
| 5 | +export type ExecutorHttpApiExtension< | |
| 6 | + TGroup extends HttpApiGroup.HttpApiGroup.Any = HttpApiGroup.HttpApiGroup.Any, | |
| 7 | +> = { | |
| 8 | + key: string; | |
| 9 | + group: TGroup; | |
| 10 | +}; | |
| 11 | + | |
| 12 | +export type ExecutorHttpPlugin< | |
| 13 | + TGroup extends HttpApiGroup.HttpApiGroup.Any = HttpApiGroup.HttpApiGroup.Any, | |
| 14 | + TExecutorExtension extends object = {}, | |
| 15 | +> = ExecutorHttpApiExtension<TGroup> & { | |
| 16 | + build(input: { | |
| 17 | + executor: ExecutorEffect & TExecutorExtension; | |
| 18 | + }): Layer.Layer<any, any, any>; | |
| 19 | +}; | |
| 20 | + | |
| 21 | +export type ExecutorHttpPluginGroups< | |
| 22 | + TPlugins extends readonly ExecutorHttpApiExtension[], | |
| 23 | +> = TPlugins[number]["group"]; | |
| @@ -1,18 +1,4 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | −import { | |
| 3 | − ConnectSourceBatchPayloadSchema, | |
| 4 | − ConnectSourceBatchResultSchema, | |
| 5 | − ConnectSourcePayloadSchema, | |
| 6 | − ConnectSourceResultSchema, | |
| 7 | − CreateSourcePayloadSchema, | |
| 8 | − CreateScopeOauthClientPayloadSchema as CreateWorkspaceOauthClientPayloadSchema, | |
| 9 | − CredentialOauthCompleteUrlParamsSchema, | |
| 10 | − CredentialPageUrlParamsSchema, | |
| 11 | − CredentialSubmitPayloadSchema, | |
| 12 | − DiscoverSourcePayloadSchema, | |
| 13 | − UpdateSourcePayloadSchema, | |
| 14 | − ScopeOauthClientQuerySchema as WorkspaceOauthClientQuerySchema, | |
| 15 | −} from "@executor/platform-sdk/contracts"; | |
| 16 | 2 | import { |
| 17 | 3 | ControlPlaneBadRequestError, |
| 18 | 4 | ControlPlaneForbiddenError, |
| @@ -21,116 +7,21 @@ import { · 2 unmodified lines | ||
| 21 | 7 | ControlPlaneUnauthorizedError, |
| 22 | 8 | } from "@executor/platform-sdk/errors"; |
| 23 | 9 | import { |
| 24 | − ProviderAuthGrantIdSchema, | |
| 25 | − SourceDiscoveryResultSchema, | |
| 26 | 10 | SourceIdSchema, |
| 27 | 11 | SourceInspectionDiscoverPayloadSchema, |
| 28 | 12 | SourceInspectionDiscoverResultSchema, |
| 29 | 13 | SourceInspectionSchema, |
| 30 | 14 | SourceInspectionToolDetailSchema, |
| 31 | 15 | SourceSchema, |
| 32 | − ScopeOauthClientIdSchema as WorkspaceOauthClientIdSchema, | |
| 33 | − ScopeOauthClientSchema as WorkspaceOauthClientSchema, | |
| 34 | 16 | ScopeIdSchema as WorkspaceIdSchema, |
| 35 | 17 | } from "@executor/platform-sdk/schema"; |
| 36 | 18 | import * as Schema from "effect/Schema"; |
| 37 | 19 | |
| 38 | −export type { | |
| 39 | − ConnectSourceBatchPayload, | |
| 40 | − ConnectSourceBatchResult, | |
| 41 | − ConnectSourcePayload, | |
| 42 | − ConnectSourceResult, | |
| 43 | − CreateSourcePayload, | |
| 44 | − CreateScopeOauthClientPayload as CreateWorkspaceOauthClientPayload, | |
| 45 | − DiscoverSourcePayload, | |
| 46 | − UpdateSourcePayload, | |
| 47 | −} from "@executor/platform-sdk/contracts"; | |
| 48 | − | |
| 49 | −export { | |
| 50 | − ConnectSourceBatchPayloadSchema, | |
| 51 | − ConnectSourceBatchResultSchema, | |
| 52 | − ConnectSourcePayloadSchema, | |
| 53 | − ConnectSourceResultSchema, | |
| 54 | − CreateSourcePayloadSchema, | |
| 55 | − CreateWorkspaceOauthClientPayloadSchema, | |
| 56 | − DiscoverSourcePayloadSchema, | |
| 57 | − UpdateSourcePayloadSchema, | |
| 58 | −}; | |
| 59 | − | |
| 60 | 20 | const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); |
| 61 | 21 | const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema); |
| 62 | 22 | const toolPathParam = HttpApiSchema.param("toolPath", Schema.String); |
| 63 | −const oauthClientIdParam = HttpApiSchema.param( | |
| 64 | − "oauthClientId", | |
| 65 | − WorkspaceOauthClientIdSchema, | |
| 66 | −); | |
| 67 | −const grantIdParam = HttpApiSchema.param("grantId", ProviderAuthGrantIdSchema); | |
| 68 | − | |
| 69 | −const HtmlSchema = HttpApiSchema.Text({ | |
| 70 | − contentType: "text/html", | |
| 71 | −}); | |
| 72 | 23 | |
| 73 | 24 | export class SourcesApi extends HttpApiGroup.make("sources") |
| 74 | − .add( | |
| 75 | − HttpApiEndpoint.post("discover")`/sources/discover` | |
| 76 | − .setPayload(DiscoverSourcePayloadSchema) | |
| 77 | − .addSuccess(SourceDiscoveryResultSchema) | |
| 78 | − .addError(ControlPlaneBadRequestError) | |
| 79 | − .addError(ControlPlaneUnauthorizedError) | |
| 80 | − .addError(ControlPlaneForbiddenError), | |
| 81 | − ) | |
| 82 | − .add( | |
| 83 | − HttpApiEndpoint.post("connect")`/workspaces/${workspaceIdParam}/sources/connect` | |
| 84 | − .setPayload(ConnectSourcePayloadSchema) | |
| 85 | − .addSuccess(ConnectSourceResultSchema) | |
| 86 | − .addError(ControlPlaneBadRequestError) | |
| 87 | − .addError(ControlPlaneUnauthorizedError) | |
| 88 | − .addError(ControlPlaneForbiddenError) | |
| 89 | − .addError(ControlPlaneStorageError), | |
| 90 | − ) | |
| 91 | − .add( | |
| 92 | − HttpApiEndpoint.post("connectBatch")`/workspaces/${workspaceIdParam}/sources/connect-batch` | |
| 93 | − .setPayload(ConnectSourceBatchPayloadSchema) | |
| 94 | − .addSuccess(ConnectSourceBatchResultSchema) | |
| 95 | − .addError(ControlPlaneBadRequestError) | |
| 96 | − .addError(ControlPlaneUnauthorizedError) | |
| 97 | − .addError(ControlPlaneForbiddenError) | |
| 98 | − .addError(ControlPlaneStorageError), | |
| 99 | − ) | |
| 100 | − .add( | |
| 101 | − HttpApiEndpoint.get("listWorkspaceOauthClients")`/workspaces/${workspaceIdParam}/oauth-clients` | |
| 102 | − .setUrlParams(WorkspaceOauthClientQuerySchema) | |
| 103 | − .addSuccess(Schema.Array(WorkspaceOauthClientSchema)) | |
| 104 | − .addError(ControlPlaneBadRequestError) | |
| 105 | − .addError(ControlPlaneUnauthorizedError) | |
| 106 | − .addError(ControlPlaneForbiddenError) | |
| 107 | − .addError(ControlPlaneStorageError), | |
| 108 | − ) | |
| 109 | − .add( | |
| 110 | − HttpApiEndpoint.post("createWorkspaceOauthClient")`/workspaces/${workspaceIdParam}/oauth-clients` | |
| 111 | − .setPayload(CreateWorkspaceOauthClientPayloadSchema) | |
| 112 | − .addSuccess(WorkspaceOauthClientSchema) | |
| 113 | − .addError(ControlPlaneBadRequestError) | |
| 114 | − .addError(ControlPlaneUnauthorizedError) | |
| 115 | − .addError(ControlPlaneForbiddenError) | |
| 116 | − .addError(ControlPlaneStorageError), | |
| 117 | − ) | |
| 118 | − .add( | |
| 119 | − HttpApiEndpoint.del("removeWorkspaceOauthClient")`/workspaces/${workspaceIdParam}/oauth-clients/${oauthClientIdParam}` | |
| 120 | − .addSuccess(Schema.Struct({ removed: Schema.Boolean })) | |
| 121 | − .addError(ControlPlaneBadRequestError) | |
| 122 | − .addError(ControlPlaneUnauthorizedError) | |
| 123 | − .addError(ControlPlaneForbiddenError) | |
| 124 | − .addError(ControlPlaneStorageError), | |
| 125 | − ) | |
| 126 | − .add( | |
| 127 | − HttpApiEndpoint.del("removeProviderAuthGrant")`/workspaces/${workspaceIdParam}/provider-grants/${grantIdParam}` | |
| 128 | − .addSuccess(Schema.Struct({ removed: Schema.Boolean })) | |
| 129 | − .addError(ControlPlaneBadRequestError) | |
| 130 | − .addError(ControlPlaneUnauthorizedError) | |
| 131 | − .addError(ControlPlaneForbiddenError) | |
| 132 | − .addError(ControlPlaneStorageError), | |
| 133 | − ) | |
| 134 | 25 | .add( |
| 135 | 26 | HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/sources` |
| 136 | 27 | .addSuccess(Schema.Array(SourceSchema)) |
| @@ -139,15 +30,6 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 2 unmodified lines | ||
| 139 | 30 | .addError(ControlPlaneForbiddenError) |
| 140 | 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 | 33 | .add( |
| 152 | 34 | HttpApiEndpoint.get("get")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}` |
| 153 | 35 | .addSuccess(SourceSchema) |
| @@ -157,16 +39,6 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 3 unmodified lines | ||
| 157 | 39 | .addError(ControlPlaneNotFoundError) |
| 158 | 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 | 42 | .add( |
| 171 | 43 | HttpApiEndpoint.del("remove")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}` |
| 172 | 44 | .addSuccess(Schema.Struct({ removed: Schema.Boolean })) |
| @@ -175,39 +47,6 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 2 unmodified lines | ||
| 175 | 47 | .addError(ControlPlaneForbiddenError) |
| 176 | 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 | 50 | .add( |
| 212 | 51 | HttpApiEndpoint.get("inspection")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/inspection` |
| 213 | 52 | .addSuccess(SourceInspectionSchema) |
| @@ -1,906 +1,21 @@ | ||
| 1 | −import { | |
| 2 | − HttpApiBuilder, | |
| 3 | − HttpServerRequest, | |
| 4 | − HttpServerResponse, | |
| 5 | −} from "@effect/platform"; | |
| 6 | −import type { | |
| 7 | − ExecutionInteraction, | |
| 8 | − Source, | |
| 9 | − ScopeId as WorkspaceId, | |
| 10 | − ScopeOauthClient as WorkspaceOauthClient, | |
| 11 | −} from "@executor/platform-sdk/schema"; | |
| 12 | −import type { | |
| 13 | − ExecutorMcpSourceInput, | |
| 14 | − ExecutorSourceInput, | |
| 15 | −} from "@executor/platform-sdk"; | |
| 1 | +import { HttpApiBuilder } from "@effect/platform"; | |
| 16 | 2 | import * as Effect from "effect/Effect"; |
| 17 | 3 | |
| 18 | −import { | |
| 19 | − sourceAdapterRequiresInteractiveConnect, | |
| 20 | −} from "@executor/platform-sdk/runtime"; | |
| 21 | −import { ScopeOauthClientIdSchema as WorkspaceOauthClientIdSchema } from "@executor/platform-sdk/schema"; | |
| 22 | − | |
| 23 | −import { | |
| 24 | − ControlPlaneBadRequestError, | |
| 25 | − ControlPlaneNotFoundError, | |
| 26 | − ControlPlaneStorageError, | |
| 27 | −} from "../errors"; | |
| 28 | 4 | import { ExecutorApi } from "../api"; |
| 29 | −import type { ConnectSourcePayload } from "./api"; | |
| 30 | 5 | import { |
| 31 | − getControlPlaneExecutor, | |
| 32 | 6 | resolveRequestedLocalWorkspace, |
| 33 | 7 | } from "../local-context"; |
| 34 | 8 | |
| 35 | −const readHeader = (headers: unknown, name: string): string | null => { | |
| 36 | − if (headers == null || typeof headers !== "object") { | |
| 37 | − return null; | |
| 38 | − } | |
| 39 | − | |
| 40 | − const record = headers as Record<string, unknown>; | |
| 41 | − const exact = record[name]; | |
| 42 | − if (typeof exact === "string" && exact.length > 0) { | |
| 43 | − return exact; | |
| 44 | − } | |
| 45 | − | |
| 46 | − const lower = record[name.toLowerCase()]; | |
| 47 | − if (typeof lower === "string" && lower.length > 0) { | |
| 48 | − return lower; | |
| 49 | − } | |
| 50 | − | |
| 51 | − return null; | |
| 52 | −}; | |
| 53 | − | |
| 54 | −const resolveRequestOrigin = (request: { | |
| 55 | − url: string; | |
| 56 | − headers: unknown; | |
| 57 | −}): string | null => { | |
| 58 | − try { | |
| 59 | − return new URL(request.url).origin; | |
| 60 | − } catch { | |
| 61 | − const forwardedHost = readHeader(request.headers, "x-forwarded-host"); | |
| 62 | − const host = forwardedHost ?? readHeader(request.headers, "host"); | |
| 63 | − if (!host) { | |
| 64 | − return null; | |
| 65 | − } | |
| 66 | − | |
| 67 | − const forwardedProto = readHeader(request.headers, "x-forwarded-proto"); | |
| 68 | − const protocol = | |
| 69 | − forwardedProto && forwardedProto.length > 0 ? forwardedProto : "http"; | |
| 70 | − return `${protocol}://${host}`; | |
| 71 | − } | |
| 72 | −}; | |
| 73 | − | |
| 74 | −const toBadRequestError = (operation: string, cause: unknown) => { | |
| 75 | − const message = cause instanceof Error ? cause.message : String(cause); | |
| 76 | − return new ControlPlaneBadRequestError({ | |
| 77 | − operation, | |
| 78 | − message, | |
| 79 | − details: message, | |
| 80 | − }); | |
| 81 | −}; | |
| 82 | − | |
| 83 | −const isInteractiveConnectPayload = ( | |
| 84 | − payload: ConnectSourcePayload, | |
| 85 | −): payload is Extract<ConnectSourcePayload, { kind?: "mcp" }> => | |
| 86 | − payload.kind === undefined || | |
| 87 | − sourceAdapterRequiresInteractiveConnect(payload.kind); | |
| 88 | − | |
| 89 | −const nullToUndefined = <T>(value: T | null | undefined): T | undefined => | |
| 90 | − value ?? undefined; | |
| 91 | − | |
| 92 | −const toWorkspaceOauthClientId = ( | |
| 93 | − value: string | null | undefined, | |
| 94 | −): WorkspaceOauthClient["id"] | null | undefined => | |
| 95 | − value == null ? value : WorkspaceOauthClientIdSchema.make(value); | |
| 96 | − | |
| 97 | −const toExecutorSourceInput = ( | |
| 98 | − payload: ConnectSourcePayload, | |
| 99 | −): ExecutorSourceInput | null => { | |
| 100 | − if (isInteractiveConnectPayload(payload)) { | |
| 101 | − return null; | |
| 102 | − } | |
| 103 | − | |
| 104 | − switch (payload.kind) { | |
| 105 | − case "openapi": | |
| 106 | − return { | |
| 107 | − kind: "openapi", | |
| 108 | − endpoint: payload.endpoint, | |
| 109 | − specUrl: payload.specUrl, | |
| 110 | − name: payload.name, | |
| 111 | − namespace: payload.namespace, | |
| 112 | − importAuthPolicy: nullToUndefined(payload.importAuthPolicy), | |
| 113 | − importAuth: payload.importAuth, | |
| 114 | − auth: payload.auth, | |
| 115 | − }; | |
| 116 | − case "graphql": | |
| 117 | − return { | |
| 118 | − kind: "graphql", | |
| 119 | − endpoint: payload.endpoint, | |
| 120 | − name: payload.name, | |
| 121 | − namespace: payload.namespace, | |
| 122 | − importAuthPolicy: nullToUndefined(payload.importAuthPolicy), | |
| 123 | − importAuth: payload.importAuth, | |
| 124 | − auth: payload.auth, | |
| 125 | − }; | |
| 126 | − case "google_discovery": | |
| 127 | − return { | |
| 128 | − kind: "google_discovery", | |
| 129 | − service: payload.service, | |
| 130 | − version: payload.version, | |
| 131 | − discoveryUrl: payload.discoveryUrl, | |
| 132 | − scopes: payload.scopes, | |
| 133 | − scopeOauthClientId: toWorkspaceOauthClientId( | |
| 134 | − payload.scopeOauthClientId, | |
| 135 | − ), | |
| 136 | − oauthClient: payload.oauthClient, | |
| 137 | − name: payload.name, | |
| 138 | − namespace: payload.namespace, | |
| 139 | − importAuthPolicy: nullToUndefined(payload.importAuthPolicy), | |
| 140 | − importAuth: payload.importAuth, | |
| 141 | − auth: payload.auth, | |
| 142 | − }; | |
| 143 | − } | |
| 144 | −}; | |
| 145 | − | |
| 146 | −const toExecutorMcpSourceInput = ( | |
| 147 | − payload: Extract<ConnectSourcePayload, { kind?: "mcp" }>, | |
| 148 | − baseUrl: string | null, | |
| 149 | −): ExecutorMcpSourceInput => ({ | |
| 150 | − endpoint: payload.endpoint, | |
| 151 | − name: payload.name, | |
| 152 | − namespace: payload.namespace, | |
| 153 | − transport: payload.transport, | |
| 154 | − queryParams: payload.queryParams, | |
| 155 | − headers: payload.headers, | |
| 156 | − command: payload.command, | |
| 157 | − args: payload.args, | |
| 158 | − env: payload.env, | |
| 159 | − cwd: payload.cwd, | |
| 160 | − baseUrl, | |
| 161 | −}); | |
| 162 | − | |
| 163 | −const escapeHtml = (value: string): string => | |
| 164 | − value | |
| 165 | − .replaceAll("&", "&") | |
| 166 | − .replaceAll("<", "<") | |
| 167 | − .replaceAll(">", ">") | |
| 168 | − .replaceAll('"', """) | |
| 169 | − .replaceAll("'", "'"); | |
| 170 | − | |
| 171 | −const serializeJsonForScript = (value: unknown): string => | |
| 172 | − JSON.stringify(value) | |
| 173 | − .replaceAll("<", "\\u003c") | |
| 174 | − .replaceAll(">", "\\u003e") | |
| 175 | − .replaceAll("&", "\\u0026"); | |
| 176 | − | |
| 177 | −const SOURCE_OAUTH_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:"; | |
| 178 | − | |
| 179 | −const credentialPageDocument = (input: { | |
| 180 | − title: string; | |
| 181 | − eyebrow: string; | |
| 182 | − message: string; | |
| 183 | − sourceLabel: string; | |
| 184 | − endpoint: string; | |
| 185 | − interactionId: string; | |
| 186 | − error?: string; | |
| 187 | − state?: "pending" | "stored" | "continued" | "cancelled" | "inactive"; | |
| 188 | −}) => `<!doctype html> | |
| 189 | −<html lang="en"> | |
| 190 | − <head> | |
| 191 | − <meta charset="utf-8" /> | |
| 192 | − <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| 193 | − <title>${escapeHtml(input.title)}</title> | |
| 194 | − <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| 195 | − <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| 196 | − <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet" /> | |
| 197 | − <style> | |
| 198 | − :root { | |
| 199 | − --bg: #fafaf9; | |
| 200 | − --surface: #ffffff; | |
| 201 | − --text: #1c1917; | |
| 202 | − --text-secondary: #78716c; | |
| 203 | − --text-tertiary: #a8a29e; | |
| 204 | − --border: #e7e5e4; | |
| 205 | − --accent: #1d4ed8; | |
| 206 | − --accent-light: #eff6ff; | |
| 207 | − --green: #16a34a; | |
| 208 | − --green-light: #f0fdf4; | |
| 209 | − --green-border: #bbf7d0; | |
| 210 | − --red: #dc2626; | |
| 211 | − --red-light: #fef2f2; | |
| 212 | − --red-border: #fecaca; | |
| 213 | − --amber: #d97706; | |
| 214 | − --amber-light: #fffbeb; | |
| 215 | − --amber-border: #fde68a; | |
| 216 | − } | |
| 217 | − | |
| 218 | − * { box-sizing: border-box; margin: 0; padding: 0; } | |
| 219 | − | |
| 220 | − body { | |
| 221 | − min-height: 100vh; | |
| 222 | − background: var(--bg); | |
| 223 | − color: var(--text); | |
| 224 | − font-family: 'DM Sans', system-ui, -apple-system, sans-serif; | |
| 225 | − display: flex; | |
| 226 | − justify-content: center; | |
| 227 | − padding: 80px 24px 48px; | |
| 228 | − -webkit-font-smoothing: antialiased; | |
| 229 | − } | |
| 230 | − | |
| 231 | − .page { | |
| 232 | − width: 100%; | |
| 233 | − max-width: 540px; | |
| 234 | − } | |
| 235 | − | |
| 236 | − /* Status pill at the very top */ | |
| 237 | − .status-pill { | |
| 238 | − display: inline-flex; | |
| 239 | − align-items: center; | |
| 240 | − gap: 7px; | |
| 241 | − padding: 5px 14px 5px 10px; | |
| 242 | − border-radius: 999px; | |
| 243 | − font-family: 'DM Mono', monospace; | |
| 244 | − font-size: 12px; | |
| 245 | − font-weight: 500; | |
| 246 | − letter-spacing: 0.01em; | |
| 247 | − margin-bottom: 28px; | |
| 248 | − } | |
| 249 | − | |
| 250 | − .status-pill::before { | |
| 251 | − content: ''; | |
| 252 | − width: 7px; | |
| 253 | − height: 7px; | |
| 254 | − border-radius: 50%; | |
| 255 | − flex-shrink: 0; | |
| 256 | − } | |
| 257 | − | |
| 258 | − .status-pill[data-state='pending'] { | |
| 259 | − background: var(--amber-light); | |
| 260 | − color: var(--amber); | |
| 261 | − border: 1px solid var(--amber-border); | |
| 262 | − } | |
| 263 | − .status-pill[data-state='pending']::before { background: var(--amber); } | |
| 264 | − | |
| 265 | − .status-pill[data-state='stored'] { | |
| 266 | − background: var(--green-light); | |
| 267 | − color: var(--green); | |
| 268 | − border: 1px solid var(--green-border); | |
| 269 | − } | |
| 270 | − .status-pill[data-state='stored']::before { background: var(--green); } | |
| 271 | − | |
| 272 | − .status-pill[data-state='continued'] { | |
| 273 | − background: var(--green-light); | |
| 274 | − color: var(--green); | |
| 275 | − border: 1px solid var(--green-border); | |
| 276 | − } | |
| 277 | − .status-pill[data-state='continued']::before { background: var(--green); } | |
| 278 | − | |
| 279 | − .status-pill[data-state='cancelled'], | |
| 280 | − .status-pill[data-state='inactive'] { | |
| 281 | − background: #f5f5f4; | |
| 282 | − color: var(--text-secondary); | |
| 283 | − border: 1px solid var(--border); | |
| 284 | − } | |
| 285 | − .status-pill[data-state='cancelled']::before, | |
| 286 | − .status-pill[data-state='inactive']::before { background: var(--text-tertiary); } | |
| 287 | − | |
| 288 | − /* Title area */ | |
| 289 | − h1 { | |
| 290 | − font-size: 28px; | |
| 291 | − font-weight: 600; | |
| 292 | − letter-spacing: -0.025em; | |
| 293 | − line-height: 1.2; | |
| 294 | − margin-bottom: 10px; | |
| 295 | − } | |
| 296 | − | |
| 297 | − .subtitle { | |
| 298 | − font-size: 15px; | |
| 299 | − line-height: 1.6; | |
| 300 | − color: var(--text-secondary); | |
| 301 | − } | |
| 302 | − | |
| 303 | − /* Divider */ | |
| 304 | − hr { | |
| 305 | − border: none; | |
| 306 | − height: 1px; | |
| 307 | − background: var(--border); | |
| 308 | − margin: 36px 0; | |
| 309 | − } | |
| 310 | − | |
| 311 | − /* Source detail rows */ | |
| 312 | − .detail { | |
| 313 | − display: flex; | |
| 314 | − flex-direction: column; | |
| 315 | − gap: 6px; | |
| 316 | − margin-bottom: 24px; | |
| 317 | − } | |
| 318 | − | |
| 319 | − .detail-label { | |
| 320 | − font-size: 13px; | |
| 321 | − font-weight: 500; | |
| 322 | − color: var(--text-secondary); | |
| 323 | − } | |
| 324 | − | |
| 325 | − .detail-value { | |
| 326 | − font-size: 15px; | |
| 327 | − line-height: 1.5; | |
| 328 | − word-break: break-word; | |
| 329 | − } | |
| 330 | − | |
| 331 | − .detail-mono { | |
| 332 | − font-family: 'DM Mono', monospace; | |
| 333 | − font-size: 13px; | |
| 334 | − line-height: 1.5; | |
| 335 | − padding: 10px 14px; | |
| 336 | − background: #f5f5f4; | |
| 337 | − border: 1px solid var(--border); | |
| 338 | − border-radius: 8px; | |
| 339 | − word-break: break-all; | |
| 340 | − color: var(--text); | |
| 341 | − } | |
| 342 | − | |
| 343 | − .detail-id { | |
| 344 | − font-family: 'DM Mono', monospace; | |
| 345 | − font-size: 12px; | |
| 346 | − color: var(--text-tertiary); | |
| 347 | − } | |
| 348 | − | |
| 349 | − /* Result card for stored/cancelled/inactive */ | |
| 350 | − .result-card { | |
| 351 | − padding: 32px; | |
| 352 | − border-radius: 12px; | |
| 353 | − text-align: center; | |
| 354 | − } | |
| 355 | − | |
| 356 | − .result-card.success { | |
| 357 | − background: var(--green-light); | |
| 358 | − border: 1px solid var(--green-border); | |
| 359 | − } | |
| 360 | − | |
| 361 | − .result-card.neutral { | |
| 362 | − background: #f5f5f4; | |
| 363 | − border: 1px solid var(--border); | |
| 364 | − } | |
| 365 | − | |
| 366 | − .result-card p { | |
| 367 | − font-size: 15px; | |
| 368 | − line-height: 1.6; | |
| 369 | − color: var(--text-secondary); | |
| 370 | − } | |
| 371 | − | |
| 372 | − .result-card .result-heading { | |
| 373 | − font-weight: 600; | |
| 374 | − font-size: 15px; | |
| 375 | − color: var(--text); | |
| 376 | − margin-bottom: 6px; | |
| 377 | − } | |
| 378 | − | |
| 379 | − /* Form */ | |
| 380 | − .alert { | |
| 381 | − padding: 12px 14px; | |
| 382 | − border-radius: 8px; | |
| 383 | − background: var(--red-light); | |
| 384 | − border: 1px solid var(--red-border); | |
| 385 | − color: var(--red); | |
| 386 | − font-size: 14px; | |
| 387 | − line-height: 1.5; | |
| 388 | − margin-bottom: 20px; | |
| 389 | − } | |
| 390 | − | |
| 391 | − .field { margin-bottom: 20px; } | |
| 392 | − | |
| 393 | − label { | |
| 394 | − display: block; | |
| 395 | − font-size: 14px; | |
| 396 | − font-weight: 500; | |
| 397 | − margin-bottom: 6px; | |
| 398 | − } | |
| 399 | − | |
| 400 | − input[type='password'] { | |
| 401 | − width: 100%; | |
| 402 | − padding: 10px 14px; | |
| 403 | − background: var(--surface); | |
| 404 | − border: 1px solid var(--border); | |
| 405 | − border-radius: 8px; | |
| 406 | − font-family: 'DM Mono', monospace; | |
| 407 | − font-size: 14px; | |
| 408 | − color: var(--text); | |
| 409 | − transition: border-color 0.15s, box-shadow 0.15s; | |
| 410 | − } | |
| 411 | − | |
| 412 | − input[type='password']::placeholder { color: var(--text-tertiary); } | |
| 413 | − | |
| 414 | − input[type='password']:focus { | |
| 415 | − outline: none; | |
| 416 | − border-color: var(--accent); | |
| 417 | − box-shadow: 0 0 0 3px rgba(29, 78, 216, 0.1); | |
| 418 | − } | |
| 419 | − | |
| 420 | − .hint { | |
| 421 | − margin-top: 8px; | |
| 422 | − font-size: 13px; | |
| 423 | − line-height: 1.5; | |
| 424 | − color: var(--text-tertiary); | |
| 425 | − } | |
| 426 | − | |
| 427 | − .actions { | |
| 428 | − display: flex; | |
| 429 | − gap: 10px; | |
| 430 | − margin-top: 24px; | |
| 431 | − } | |
| 432 | − | |
| 433 | − button { | |
| 434 | − appearance: none; | |
| 435 | − border: none; | |
| 436 | − border-radius: 8px; | |
| 437 | − padding: 10px 20px; | |
| 438 | − font-family: inherit; | |
| 439 | − font-size: 14px; | |
| 440 | − font-weight: 500; | |
| 441 | − cursor: pointer; | |
| 442 | − transition: background 0.15s; | |
| 443 | − } | |
| 444 | − | |
| 445 | − .primary { | |
| 446 | − background: var(--text); | |
| 447 | − color: var(--surface); | |
| 448 | − } | |
| 449 | − .primary:hover { background: #292524; } | |
| 450 | − | |
| 451 | − .secondary { | |
| 452 | − background: transparent; | |
| 453 | − color: var(--text-secondary); | |
| 454 | − border: 1px solid var(--border); | |
| 455 | − } | |
| 456 | − .secondary:hover { background: #f5f5f4; } | |
| 457 | − | |
| 458 | − @media (max-width: 540px) { | |
| 459 | − body { padding: 48px 20px 32px; } | |
| 460 | − h1 { font-size: 24px; } | |
| 461 | − } | |
| 462 | − </style> | |
| 463 | − </head> | |
| 464 | − <body> | |
| 465 | − <main class="page"> | |
| 466 | − <div class="status-pill" data-state="${escapeHtml(input.state ?? "pending")}">${escapeHtml(input.state ?? "pending")}</div> | |
| 467 | − | |
| 468 | − <h1>${escapeHtml(input.title)}</h1> | |
| 469 | − <p class="subtitle">${escapeHtml(input.message)}</p> | |
| 470 | − | |
| 471 | − <hr /> | |
| 472 | − | |
| 473 | − <div class="detail"> | |
| 474 | − <span class="detail-label">Source</span> | |
| 475 | − <span class="detail-value">${escapeHtml(input.sourceLabel)}</span> | |
| 476 | − </div> | |
| 477 | − | |
| 478 | − <div class="detail"> | |
| 479 | − <span class="detail-label">API endpoint</span> | |
| 480 | − <div class="detail-mono">${escapeHtml(input.endpoint)}</div> | |
| 481 | − </div> | |
| 482 | − | |
| 483 | − <div class="detail"> | |
| 484 | − <span class="detail-label">Interaction</span> | |
| 485 | − <span class="detail-id">${escapeHtml(input.interactionId)}</span> | |
| 486 | − </div> | |
| 487 | − | |
| 488 | − <hr /> | |
| 489 | − | |
| 490 | − ${input.error ? `<p class="alert">${escapeHtml(input.error)}</p>` : ""} | |
| 491 | − ${ | |
| 492 | − input.state === "stored" | |
| 493 | − ? `<div class="result-card success"> | |
| 494 | − <p class="result-heading">Credential stored</p> | |
| 495 | − <p>Executor resumed the source connection request. You can close this window.</p> | |
| 496 | − </div>` | |
| 497 | − : input.state === "continued" | |
| 498 | − ? `<div class="result-card success"> | |
| 499 | − <p class="result-heading">Continuing without auth</p> | |
| 500 | − <p>Executor resumed the source connection without stored credentials. You can close this window.</p> | |
| 501 | − </div>` | |
| 502 | − : input.state === "cancelled" | |
| 503 | − ? `<div class="result-card neutral"> | |
| 504 | − <p class="result-heading">Request cancelled</p> | |
| 505 | − <p>Executor was notified. You can close this window.</p> | |
| 506 | − </div>` | |
| 507 | − : input.state === "inactive" | |
| 508 | − ? `<div class="result-card neutral"> | |
| 509 | − <p class="result-heading">No longer active</p> | |
| 510 | − <p>This credential request has expired. Start a new source connection flow if needed.</p> | |
| 511 | − </div>` | |
| 512 | − : `<form method="post"> | |
| 513 | − <div class="field"> | |
| 514 | − <label for="token">Personal access token</label> | |
| 515 | − <input | |
| 516 | − id="token" | |
| 517 | − name="token" | |
| 518 | − type="password" | |
| 519 | − autocomplete="off" | |
| 520 | − spellcheck="false" | |
| 521 | − placeholder="ghp_..." | |
| 522 | − /> | |
| 523 | − <p class="hint"> | |
| 524 | − Add a token to enable authenticated requests, or continue without auth if this API supports public access. | |
| 525 | − </p> | |
| 526 | − </div> | |
| 527 | − <div class="actions"> | |
| 528 | − <button class="primary" type="submit" name="action" value="submit">Connect token</button> | |
| 529 | − <button class="secondary" type="submit" name="action" value="continue" formnovalidate>Continue without auth</button> | |
| 530 | − <button class="secondary" type="submit" name="action" value="cancel" formnovalidate>Cancel</button> | |
| 531 | − </div> | |
647 more lines aren’t shown here.
This diff is large: its 2 lines aren’t shown here.
This diff is large: its 12 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 74 lines aren’t shown here.
This diff is large: its 31 lines aren’t shown here.
This diff is large: its 21 lines aren’t shown here.
This diff is large: its 167 lines aren’t shown here.
This diff is large: its 706 lines aren’t shown here.
This diff is large: its 94 lines aren’t shown here.
This diff is large: its 68 lines aren’t shown here.
This diff is large: its 32 lines aren’t shown here.
This diff is large: its 359 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 957 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 20 lines aren’t shown here.
This diff is large: its 19 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 114 lines aren’t shown here.
This diff is large: its 5 lines aren’t shown here.
This diff is large: its 602 lines aren’t shown here.
This diff is large: its 447 lines aren’t shown here.
This diff is large: its 223 lines aren’t shown here.
This diff is large: its 4 lines aren’t shown here.
This diff is large: its 403 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 544 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 515 lines aren’t shown here.
This diff is large: its 508 lines aren’t shown here.
This diff is large: its 263 lines aren’t shown here.
This diff is large: its 2 lines aren’t shown here.
This diff is large: its 111 lines aren’t shown here.
This diff is large: its 107 lines aren’t shown here.
This diff is large: its 2 lines aren’t shown here.
This diff is large: its 95 lines aren’t shown here.
This diff is large: its 269 lines aren’t shown here.
This diff is large: its 177 lines aren’t shown here.
This diff is large: its 29 lines aren’t shown here.
This diff is large: its 1161 lines aren’t shown here.
This diff is large: its 64 lines aren’t shown here.
This diff is large: its 1372 lines aren’t shown here.
This diff is large: its 274 lines aren’t shown here.
This diff is large: its 55 lines aren’t shown here.
This diff is large: its 48 lines aren’t shown here.
This diff is large: its 52 lines aren’t shown here.
This diff is large: its 412 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 21 lines aren’t shown here.
This diff is large: its 156 lines aren’t shown here.
This diff is large: its 43 lines aren’t shown here.
This diff is large: its 233 lines aren’t shown here.
This diff is large: its 22 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 2391 lines aren’t shown here.
This diff is large: its 148 lines aren’t shown here.
This diff is large: its 242 lines aren’t shown here.
This diff is large: its 24 lines aren’t shown here.
This diff is large: its 737 lines aren’t shown here.
This diff is large: its 2 lines aren’t shown here.
This diff is large: its 294 lines aren’t shown here.
This diff is large: its 1470 lines aren’t shown here.
This diff is large: its 140 lines aren’t shown here.
This diff is large: its 2 lines aren’t shown here.
This diff is large: its 158 lines aren’t shown here.
This diff is large: its 2 lines aren’t shown here.
This diff is large: its 53 lines aren’t shown here.
This diff is large: its 132 lines aren’t shown here.
This diff is large: its 1083 lines aren’t shown here.
This diff is large: its 2 lines aren’t shown here.
This diff is large: its 3816 lines aren’t shown here.
This diff is large: its 35 lines aren’t shown here.
This diff is large: its 633 lines aren’t shown here.
This diff is large: its 545 lines aren’t shown here.
This diff is large: its 279 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 30 lines aren’t shown here.
This diff is large: its 97 lines aren’t shown here.
This diff is large: its 66 lines aren’t shown here.
This diff is large: its 142 lines aren’t shown here.
This diff is large: its 173 lines aren’t shown here.
This diff is large: its 224 lines aren’t shown here.
This diff is large: its 242 lines aren’t shown here.
This diff is large: its 62 lines aren’t shown here.
This diff is large: its 40 lines aren’t shown here.
This diff is large: its 19 lines aren’t shown here.
This diff is large: its 376 lines aren’t shown here.
This diff is large: its 55 lines aren’t shown here.
This diff is large: its 100 lines aren’t shown here.
This diff is large: its 38 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 160 lines aren’t shown here.
This diff is large: its 34 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 49 lines aren’t shown here.
This diff is large: its 187 lines aren’t shown here.
This diff is large: its 237 lines aren’t shown here.
This diff is large: its 57 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 158 lines aren’t shown here.
This diff is large: its 374 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 49 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 41 lines aren’t shown here.
This diff is large: its 44 lines aren’t shown here.
This diff is large: its 44 lines aren’t shown here.
This diff is large: its 1393 lines aren’t shown here.
This diff is large: its 126 lines aren’t shown here.
This diff is large: its 80 lines aren’t shown here.
This diff is large: its 41 lines aren’t shown here.
This diff is large: its 44 lines aren’t shown here.
This diff is large: its 93 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 51 lines aren’t shown here.
This diff is large: its 89 lines aren’t shown here.
This diff is large: its 12 lines aren’t shown here.
This diff is large: its 19 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 45 lines aren’t shown here.
This diff is large: its 262 lines aren’t shown here.
This diff is large: its 143 lines aren’t shown here.
This diff is large: its 183 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 203 lines aren’t shown here.
This diff is large: its 709 lines aren’t shown here.
This diff is large: its 35 lines aren’t shown here.
This diff is large: its 12 lines aren’t shown here.
This diff is large: its 540 lines aren’t shown here.
This diff is large: its 14 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 628 lines aren’t shown here.
This diff is large: its 40 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 2 lines aren’t shown here.
This diff is large: its 446 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 21 lines aren’t shown here.
This diff is large: its 1017 lines aren’t shown here.
This diff is large: its 59 lines aren’t shown here.
This diff is large: its 685 lines aren’t shown here.
This diff is large: its 488 lines aren’t shown here.
This diff is large: its 53 lines aren’t shown here.
This diff is large: its 50 lines aren’t shown here.
This diff is large: its 127 lines aren’t shown here.
This diff is large: its 894 lines aren’t shown here.
This diff is large: its 70 lines aren’t shown here.
This diff is large: its 101 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 40 lines aren’t shown here.
This diff is large: its 231 lines aren’t shown here.
Binary file not shown.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 352 lines aren’t shown here.
This diff is large: its 132 lines aren’t shown here.
This diff is large: its 361 lines aren’t shown here.
This diff is large: its 334 lines aren’t shown here.
This diff is large: its 1317 lines aren’t shown here.
This diff is large: its 626 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 302 lines aren’t shown here.
This diff is large: its 30 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 357 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 1462 lines aren’t shown here.
This diff is large: its 45 lines aren’t shown here.
This diff is large: its 36 lines aren’t shown here.
This diff is large: its 29 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
File renamed without changes.
This diff is large: its 1012 lines aren’t shown here.
This diff is large: its 88 lines aren’t shown here.
This diff is large: its 32 lines aren’t shown here.
This diff is large: its 15 lines aren’t shown here.
This diff is large: its 50 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 15 lines aren’t shown here.
This diff is large: its 224 lines aren’t shown here.
This diff is large: its 30 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 181 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 841 lines aren’t shown here.
This diff is large: its 37 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 42 lines aren’t shown here.
File renamed without changes.
This diff is large: its 22 lines aren’t shown here.
This diff is large: its 543 lines aren’t shown here.
This diff is large: its 33 lines aren’t shown here.
File renamed without changes.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 67 lines aren’t shown here.
This diff is large: its 23 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 334 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 1286 lines aren’t shown here.
This diff is large: its 31 lines aren’t shown here.
This diff is large: its 57 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
File renamed without changes.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
File renamed without changes.
File renamed without changes.
This diff is large: its 31 lines aren’t shown here.
File renamed without changes.
File renamed without changes.
This diff is large: its 33 lines aren’t shown here.
This diff is large: its 992 lines aren’t shown here.
File renamed without changes.
File renamed without changes.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 38 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
File renamed without changes.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 175 lines aren’t shown here.
This diff is large: its 24 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 224 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 997 lines aren’t shown here.
This diff is large: its 30 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 705 lines aren’t shown here.
This diff is large: its 201 lines aren’t shown here.
This diff is large: its 33 lines aren’t shown here.
This diff is large: its 33 lines aren’t shown here.
This diff is large: its 1526 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 15 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 19 lines aren’t shown here.
This diff is large: its 3716 lines aren’t shown here.
This diff is large: its 23501 lines aren’t shown here.
This diff is large: its 621 lines aren’t shown here.
This diff is large: its 742 lines aren’t shown here.
This diff is large: its 41 lines aren’t shown here.
This diff is large: its 217 lines aren’t shown here.
This diff is large: its 120 lines aren’t shown here.
This diff is large: its 165 lines aren’t shown here.
This diff is large: its 14 lines aren’t shown here.
This diff is large: its 270 lines aren’t shown here.
This diff is large: its 277 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 23 lines aren’t shown here.