| author | |
| committer | |
| log | 29150b57367062e4382de7fb3ff0c952b7c38c4e |
| tree | 8c1e83dc0eca4bf6cf5b0a3044fa8fd7aea706bf |
| parent | 07c6d70b91d2d2a81e9ccee0a9b1742b60d59eb2 |
46 files changed, 3973 insertions(+), 49 deletions(-)
.plans/shared-http-mcp-server-with-preview-automation-revised.md created+520| ... | @@ -0,0 +1,520 @@ | ||
| 1 | # Shared HTTP MCP Server with Preview Automation | ||
| 2 | |||
| 3 | ## Summary | ||
| 4 | |||
| 5 | Embed one reusable HTTP MCP server in each T3 environment server. Every agent session connects to that shared MCP endpoint using a session-scoped bearer token. | ||
| 6 | |||
| 7 | Preview browser automation is the first MCP toolkit, not the purpose or boundary of the MCP server. Future T3 toolkits register with the same server. | ||
| 8 | |||
| 9 | Architecture: | ||
| 10 | |||
| 11 | `agent session` -> `shared T3 HTTP MCP server` -> `tool dispatcher` -> `preview broker` -> `focused desktop client` -> `Electron webview via CDP` | ||
| 12 | |||
| 13 | No per-thread MCP process, stdio transport, headless browser, or automatic remote port forwarding. | ||
| 14 | |||
| 15 | ## Process Model | ||
| 16 | |||
| 17 | Each `apps/server` process owns exactly one MCP server instance. | ||
| 18 | |||
| 19 | - MCP transport: HTTP. | ||
| 20 | - MCP endpoint: `/mcp`. | ||
| 21 | - MCP lifetime: environment server lifetime. | ||
| 22 | - Agent sessions create MCP protocol sessions/connections, not OS processes. | ||
| 23 | - Toolkit registration happens once during server startup. | ||
| 24 | - Provider session termination revokes only its scoped credential. | ||
| 25 | |||
| 26 | Implement the endpoint with: | ||
| 27 | |||
| 28 | ```ts | ||
| 29 | McpServer.layerHttp({ | ||
| 30 | name: "T3 Code", | ||
| 31 | version, | ||
| 32 | path: "/mcp", | ||
| 33 | }); | ||
| 34 | ``` | ||
| 35 | |||
| 36 | Use the API from: | ||
| 37 | |||
| 38 | `effect/unstable/ai/McpServer` | ||
| 39 | |||
| 40 | Reference source: | ||
| 41 | |||
| 42 | `.repos/effect-smol/packages/effect/src/unstable/ai/McpServer.ts` | ||
| 43 | |||
| 44 | Do not implement MCP framing, initialization, session management, or JSON-RPC manually. | ||
| 45 | |||
| 46 | ## Invocation Identity | ||
| 47 | |||
| 48 | MCP `tools/call` does not include T3 thread identity. Bind identity to the MCP connection through authentication. | ||
| 49 | |||
| 50 | When starting or resuming a provider session, issue an opaque bearer token associated internally with: | ||
| 51 | |||
| 52 | ```ts | ||
| 53 | interface McpInvocationScope { | ||
| 54 | environmentId: EnvironmentId; | ||
| 55 | threadId: ThreadId; | ||
| 56 | providerSessionId: string; | ||
| 57 | providerInstanceId: ProviderInstanceId; | ||
| 58 | allowedCapabilities: ReadonlySet<string>; | ||
| 59 | issuedAt: string; | ||
| 60 | expiresAt: string; | ||
| 61 | } | ||
| 62 | ``` | ||
| 63 | |||
| 64 | Provider MCP configuration: | ||
| 65 | |||
| 66 | ```ts | ||
| 67 | { | ||
| 68 | type: "http", | ||
| 69 | name: "t3", | ||
| 70 | url: `${environmentHttpBaseUrl}/mcp`, | ||
| 71 | headers: [ | ||
| 72 | { | ||
| 73 | name: "Authorization", | ||
| 74 | value: `Bearer ${token}`, | ||
| 75 | }, | ||
| 76 | ], | ||
| 77 | } | ||
| 78 | ``` | ||
| 79 | |||
| 80 | Requirements: | ||
| 81 | |||
| 82 | - Agents never receive or pass `threadId` as a tool argument. | ||
| 83 | - Tool handlers obtain `McpInvocationScope` from request authentication middleware. | ||
| 84 | - Tokens are cryptographically random opaque values. | ||
| 85 | - Store only a hash of each token server-side. | ||
| 86 | - Revoke tokens when the provider session stops. | ||
| 87 | - Expire tokens after inactivity and at a fixed maximum lifetime. | ||
| 88 | - Server restart invalidates all tokens. | ||
| 89 | - Resuming a provider session issues a fresh token. | ||
| 90 | |||
| 91 | ## General MCP Architecture | ||
| 92 | |||
| 93 | Add server modules such as: | ||
| 94 | |||
| 95 | ```text | ||
| 96 | apps/server/src/mcp/ | ||
| 97 | Services/ | ||
| 98 | McpSessionRegistry.ts | ||
| 99 | McpInvocationContext.ts | ||
| 100 | Layers/ | ||
| 101 | McpSessionRegistry.ts | ||
| 102 | McpHttpServer.ts | ||
| 103 | toolkits/ | ||
| 104 | preview/ | ||
| 105 | tools.ts | ||
| 106 | handlers.ts | ||
| 107 | layer.ts | ||
| 108 | ``` | ||
| 109 | |||
| 110 | ### Toolkit Registration | ||
| 111 | |||
| 112 | Define capabilities with Effect AI: | ||
| 113 | |||
| 114 | ```ts | ||
| 115 | import { McpServer, Tool, Toolkit } from "effect/unstable/ai"; | ||
| 116 | ``` | ||
| 117 | |||
| 118 | Each capability family owns: | ||
| 119 | |||
| 120 | - `Tool.make` definitions | ||
| 121 | - a `Toolkit.make` collection | ||
| 122 | - handler services/layers | ||
| 123 | - an MCP registration layer | ||
| 124 | |||
| 125 | Server startup merges all registration layers: | ||
| 126 | |||
| 127 | ```ts | ||
| 128 | const T3McpToolkits = Layer.mergeAll( | ||
| 129 | PreviewToolkitRegistration, | ||
| 130 | // Future toolkit registrations | ||
| 131 | ); | ||
| 132 | ``` | ||
| 133 | |||
| 134 | Future filesystem, terminal, source-control, or environment tools must not require changes to MCP transport or authentication. | ||
| 135 | |||
| 136 | ### Naming | ||
| 137 | |||
| 138 | Use stable capability-prefixed names: | ||
| 139 | |||
| 140 | - `preview_status` | ||
| 141 | - `preview_open` | ||
| 142 | - `preview_navigate` | ||
| 143 | - `preview_snapshot` | ||
| 144 | - `preview_click` | ||
| 145 | - `preview_type` | ||
| 146 | - `preview_press` | ||
| 147 | - `preview_scroll` | ||
| 148 | - `preview_evaluate` | ||
| 149 | - `preview_wait_for` | ||
| 150 | |||
| 151 | ## MCP Authentication | ||
| 152 | |||
| 153 | Integrate bearer authentication into the HTTP MCP route before MCP request handling. | ||
| 154 | |||
| 155 | Authentication flow: | ||
| 156 | |||
| 157 | 1. Read `Authorization: Bearer <token>`. | ||
| 158 | 2. Hash token and resolve it through `McpSessionRegistry`. | ||
| 159 | 3. Verify expiration and provider-session liveness. | ||
| 160 | 4. Provide `McpInvocationContext` to MCP toolkit handlers. | ||
| 161 | 5. Reject invalid credentials without invoking MCP tools. | ||
| 162 | 6. Update token activity timestamp after authenticated requests. | ||
| 163 | |||
| 164 | Capability authorization occurs inside handlers or common middleware: | ||
| 165 | |||
| 166 | ```ts | ||
| 167 | yield* McpInvocationContext.requireCapability("preview"); | ||
| 168 | ``` | ||
| 169 | |||
| 170 | ## Preview Automation Contracts | ||
| 171 | |||
| 172 | Add `packages/contracts/src/previewAutomation.ts`. | ||
| 173 | |||
| 174 | Define schemas for: | ||
| 175 | |||
| 176 | - preview status | ||
| 177 | - opening/showing preview | ||
| 178 | - navigation | ||
| 179 | - page snapshot | ||
| 180 | - selector or coordinate click | ||
| 181 | - text entry | ||
| 182 | - key press | ||
| 183 | - scrolling | ||
| 184 | - JavaScript evaluation | ||
| 185 | - waiting for selector, text, or URL | ||
| 186 | |||
| 187 | Define tagged errors: | ||
| 188 | |||
| 189 | - `PreviewAutomationUnavailableError` | ||
| 190 | - `PreviewAutomationNoFocusedOwnerError` | ||
| 191 | - `PreviewAutomationUnsupportedClientError` | ||
| 192 | - `PreviewAutomationTabNotFoundError` | ||
| 193 | - `PreviewAutomationTimeoutError` | ||
| 194 | - `PreviewAutomationExecutionError` | ||
| 195 | - `PreviewAutomationInvalidSelectorError` | ||
| 196 | - `PreviewAutomationResultTooLargeError` | ||
| 197 | |||
| 198 | ## Preview Toolkit | ||
| 199 | |||
| 200 | Implement preview tools as an independent Effect AI toolkit. | ||
| 201 | |||
| 202 | Apply annotations: | ||
| 203 | |||
| 204 | - `preview_status` and `preview_snapshot`: read-only | ||
| 205 | - `preview_status`: idempotent | ||
| 206 | - navigation and page interaction tools: open-world | ||
| 207 | - browser operations: non-destructive | ||
| 208 | - all tools: human-readable title and precise description | ||
| 209 | |||
| 210 | ### `preview_open` | ||
| 211 | |||
| 212 | Input: | ||
| 213 | |||
| 214 | ```ts | ||
| 215 | { | ||
| 216 | url?: string; | ||
| 217 | show?: boolean; | ||
| 218 | reuseExistingTab?: boolean; | ||
| 219 | } | ||
| 220 | ``` | ||
| 221 | |||
| 222 | Defaults: | ||
| 223 | |||
| 224 | - `show: true` | ||
| 225 | - `reuseExistingTab: true` | ||
| 226 | |||
| 227 | Behavior: | ||
| 228 | |||
| 229 | - Show the preview panel for the scoped thread. | ||
| 230 | - Reuse its active preview tab when available. | ||
| 231 | - Create and mount a tab otherwise. | ||
| 232 | - Navigate when `url` is supplied. | ||
| 233 | - Wait until the webview has registered before returning. | ||
| 234 | |||
| 235 | ### `preview_snapshot` | ||
| 236 | |||
| 237 | Return: | ||
| 238 | |||
| 239 | - current URL, title, loading state | ||
| 240 | - bounded visible text | ||
| 241 | - up to 200 interactive elements | ||
| 242 | - accessibility tree | ||
| 243 | - PNG screenshot scaled to a maximum width of 1280 pixels | ||
| 244 | |||
| 245 | Expose the screenshot as MCP image content and metadata as structured content. | ||
| 246 | |||
| 247 | ### Browser Controls | ||
| 248 | |||
| 249 | - `preview_click`: selector or viewport coordinates | ||
| 250 | - `preview_type`: optionally focus selector and clear existing value | ||
| 251 | - `preview_press`: common keys and modifiers | ||
| 252 | - `preview_scroll`: viewport or selector target | ||
| 253 | - `preview_evaluate`: execute bounded JavaScript | ||
| 254 | - `preview_wait_for`: selector, visible text, or URL substring | ||
| 255 | - `preview_navigate`: navigate and wait for selected readiness condition | ||
| 256 | |||
| 257 | Default operation timeout: 15 seconds. | ||
| 258 | |||
| 259 | Maximum serialized evaluation result: 64 KB. | ||
| 260 | |||
| 261 | Maximum visible text: 20 KB. | ||
| 262 | |||
| 263 | ## Preview Broker | ||
| 264 | |||
| 265 | Add `PreviewAutomationBroker` to `apps/server`. | ||
| 266 | |||
| 267 | Responsibilities: | ||
| 268 | |||
| 269 | - Track automation-capable desktop clients. | ||
| 270 | - Track preview ownership by environment and thread. | ||
| 271 | - Route operations to the correct desktop client. | ||
| 272 | - Correlate requests and responses. | ||
| 273 | - Enforce timeouts. | ||
| 274 | - Fail pending calls when clients disconnect. | ||
| 275 | |||
| 276 | Owner state: | ||
| 277 | |||
| 278 | ```ts | ||
| 279 | interface PreviewAutomationOwner { | ||
| 280 | clientId: string; | ||
| 281 | environmentId: EnvironmentId; | ||
| 282 | threadId: ThreadId; | ||
| 283 | tabId: PreviewTabId | null; | ||
| 284 | visible: boolean; | ||
| 285 | supportsAutomation: boolean; | ||
| 286 | focusedAt: string; | ||
| 287 | } | ||
| 288 | ``` | ||
| 289 | |||
| 290 | Routing policy: | ||
| 291 | |||
| 292 | - Use the most recently focused Electron window displaying the scoped thread. | ||
| 293 | - Never accept environment or thread overrides from tool arguments. | ||
| 294 | - Return `PreviewAutomationNoFocusedOwnerError` when no valid owner exists. | ||
| 295 | - Do not switch the UI to a different thread automatically. | ||
| 296 | |||
| 297 | ## Server-to-Desktop Protocol | ||
| 298 | |||
| 299 | Add WS RPCs: | ||
| 300 | |||
| 301 | - `previewAutomation.connect` | ||
| 302 | - `previewAutomation.respond` | ||
| 303 | - `previewAutomation.reportOwner` | ||
| 304 | - `previewAutomation.clearOwner` | ||
| 305 | |||
| 306 | `connect` is a long-lived stream from the environment server to the desktop client. | ||
| 307 | |||
| 308 | Request: | ||
| 309 | |||
| 310 | ```ts | ||
| 311 | { | ||
| 312 | requestId: string; | ||
| 313 | threadId: ThreadId; | ||
| 314 | tabId?: PreviewTabId; | ||
| 315 | operation: PreviewAutomationOperation; | ||
| 316 | input: unknown; | ||
| 317 | timeoutMs: number; | ||
| 318 | } | ||
| 319 | ``` | ||
| 320 | |||
| 321 | Response: | ||
| 322 | |||
| 323 | ```ts | ||
| 324 | { | ||
| 325 | requestId: string; | ||
| 326 | ok: boolean; | ||
| 327 | result?: unknown; | ||
| 328 | error?: { | ||
| 329 | _tag: string; | ||
| 330 | message: string; | ||
| 331 | detail?: unknown; | ||
| 332 | }; | ||
| 333 | } | ||
| 334 | ``` | ||
| 335 | |||
| 336 | ## Desktop Automation | ||
| 337 | |||
| 338 | Extend `PreviewViewManager` using Electron `webContents.debugger`. | ||
| 339 | |||
| 340 | Use CDP domains: | ||
| 341 | |||
| 342 | - `Runtime` | ||
| 343 | - `DOM` | ||
| 344 | - `Page` | ||
| 345 | - `Accessibility` | ||
| 346 | - `Input` | ||
| 347 | |||
| 348 | Use `webContents.capturePage()` for screenshots. | ||
| 349 | |||
| 350 | Create a scoped CDP helper that: | ||
| 351 | |||
| 352 | 1. Resolves the tab’s webContents. | ||
| 353 | 2. Attaches lazily. | ||
| 354 | 3. Enables required domains. | ||
| 355 | 4. Executes one bounded operation. | ||
| 356 | 5. Detaches in finalization. | ||
| 357 | 6. Maps protocol failures to typed errors. | ||
| 358 | |||
| 359 | If DevTools or another debugger owns the target, return a typed automation error rather than disrupting it. | ||
| 360 | |||
| 361 | Place pure logic in separate modules: | ||
| 362 | |||
| 363 | - DOM summary extraction | ||
| 364 | - selector generation | ||
| 365 | - result clamping | ||
| 366 | - key mapping | ||
| 367 | - CDP response parsing | ||
| 368 | |||
| 369 | ## Desktop IPC | ||
| 370 | |||
| 371 | Extend `DesktopPreviewBridge` with: | ||
| 372 | |||
| 373 | ```ts | ||
| 374 | automation: { | ||
| 375 | status(...): Promise<...>; | ||
| 376 | snapshot(...): Promise<...>; | ||
| 377 | click(...): Promise<...>; | ||
| 378 | type(...): Promise<...>; | ||
| 379 | press(...): Promise<...>; | ||
| 380 | scroll(...): Promise<...>; | ||
| 381 | evaluate(...): Promise<...>; | ||
| 382 | waitFor(...): Promise<...>; | ||
| 383 | } | ||
| 384 | ``` | ||
| 385 | |||
| 386 | Add schema-validated IPC channels and handlers. | ||
| 387 | |||
| 388 | ## Web Client | ||
| 389 | |||
| 390 | Add a preview ownership hook mounted with `PreviewView`. | ||
| 391 | |||
| 392 | Report changes to: | ||
| 393 | |||
| 394 | - active environment/thread | ||
| 395 | - tab id | ||
| 396 | - panel visibility | ||
| 397 | - window focus | ||
| 398 | - Electron automation availability | ||
| 399 | |||
| 400 | Handle broker requests: | ||
| 401 | |||
| 402 | - `preview_open` opens the right panel for the active scoped thread. | ||
| 403 | - Create a preview session and tab if needed. | ||
| 404 | - Wait for webview registration. | ||
| 405 | - Other operations invoke desktop automation IPC. | ||
| 406 | - Always send a correlated success or failure response. | ||
| 407 | |||
| 408 | Clear ownership on unmount, thread change, panel close, or desktop disconnect. | ||
| 409 | |||
| 410 | ## Provider Integration | ||
| 411 | |||
| 412 | Add `McpSessionRegistry` integration to provider lifecycle. | ||
| 413 | |||
| 414 | For each provider session: | ||
| 415 | |||
| 416 | 1. Issue a scoped MCP bearer token. | ||
| 417 | 2. Add the shared HTTP MCP configuration to session startup. | ||
| 418 | 3. Start or resume the agent session. | ||
| 419 | 4. Revoke the token during provider-session finalization. | ||
| 420 | |||
| 421 | ACP providers use their existing HTTP MCP configuration fields. | ||
| 422 | |||
| 423 | Codex uses its supported MCP/config override mechanism to register the same shared HTTP endpoint. | ||
| 424 | |||
| 425 | Assume every supported provider can use HTTP MCP. Do not implement stdio fallback. | ||
| 426 | |||
| 427 | ## Remote Environment Behavior | ||
| 428 | |||
| 429 | For a Mac mini environment viewed from a MacBook: | ||
| 430 | |||
| 431 | 1. Mac mini runs the T3 environment server and shared MCP endpoint. | ||
| 432 | 2. Agent session connects to that endpoint locally/remotely using its scoped token. | ||
| 433 | 3. Preview tool calls enter the Mac mini preview broker. | ||
| 434 | 4. Broker routes them over the existing T3 connection to the focused MacBook desktop. | ||
| 435 | 5. MacBook controls its visible Electron webview. | ||
| 436 | |||
| 437 | URLs must already be reachable from the MacBook, such as: | ||
| 438 | |||
| 439 | - `http://mac-mini.local:5173` | ||
| 440 | - `http://192.168.1.42:5173` | ||
| 441 | |||
| 442 | Warn when a remote environment opens `localhost`, `127.0.0.1`, or `::1`, because loopback resolves on the preview client. | ||
| 443 | |||
| 444 | Preserve URL-resolution metadata: | ||
| 445 | |||
| 446 | ```ts | ||
| 447 | { | ||
| 448 | requestedUrl: string; | ||
| 449 | resolvedUrl: string; | ||
| 450 | resolutionKind: "direct"; | ||
| 451 | environmentId: EnvironmentId; | ||
| 452 | } | ||
| 453 | ``` | ||
| 454 | |||
| 455 | This leaves room for future SSH, relay, or Tailscale resolution. | ||
| 456 | |||
| 457 | ## Tests | ||
| 458 | |||
| 459 | ### MCP Server | ||
| 460 | |||
| 461 | - one MCP server layer starts per environment server | ||
| 462 | - multiple authenticated MCP clients share the same server instance | ||
| 463 | - each client receives its own invocation scope | ||
| 464 | - toolkit registration is independent of transport | ||
| 465 | - a mock future toolkit can register without changing server runtime | ||
| 466 | - malformed parameters are rejected by Effect schemas | ||
| 467 | - tool annotations appear in `tools/list` | ||
| 468 | |||
| 469 | ### Authentication | ||
| 470 | |||
| 471 | - valid token resolves correct thread and provider session | ||
| 472 | - concurrent tokens remain isolated | ||
| 473 | - revoked and expired tokens fail | ||
| 474 | - token cannot call unauthorized capability family | ||
| 475 | - thread identity cannot be overridden in arguments | ||
| 476 | - server restart invalidates tokens | ||
| 477 | |||
| 478 | ### Preview Broker | ||
| 479 | |||
| 480 | - focused owner receives operation | ||
| 481 | - most recently focused client wins | ||
| 482 | - wrong-thread client is never selected | ||
| 483 | - no owner returns typed error | ||
| 484 | - disconnect fails pending calls | ||
| 485 | - stale responses are ignored | ||
| 486 | |||
| 487 | ### Desktop and Web | ||
| 488 | |||
| 489 | - agent can show and open preview | ||
| 490 | - webview registration is awaited | ||
| 491 | - CDP click, typing, key press, scroll, evaluation, and wait work | ||
| 492 | - snapshot bounds screenshot, text, and interactive elements | ||
| 493 | - ownership updates on focus and visibility changes | ||
| 494 | - background threads are not automatically activated | ||
| 495 | |||
| 496 | ### End-to-End Integration | ||
| 497 | |||
| 498 | Run two mocked agent sessions against one HTTP MCP server: | ||
| 499 | |||
| 500 | 1. Bind each bearer token to a different thread. | ||
| 501 | 2. Call `preview_status` concurrently. | ||
| 502 | 3. Verify each request routes to its own focused preview owner. | ||
| 503 | 4. Verify no MCP child process is spawned. | ||
| 504 | 5. Revoke one provider session and confirm only its MCP access fails. | ||
| 505 | |||
| 506 | ## Validation | ||
| 507 | |||
| 508 | - `vp check` | ||
| 509 | - `vp run typecheck` | ||
| 510 | - `vp test` | ||
| 511 | |||
| 512 | ## Assumptions | ||
| 513 | |||
| 514 | - HTTP MCP is supported by every target provider. | ||
| 515 | - One MCP server is embedded in each T3 environment server. | ||
| 516 | - MCP connection authentication supplies invocation identity. | ||
| 517 | - Agents never know or pass T3 thread IDs. | ||
| 518 | - Preview automation is the first of multiple future MCP toolkits. | ||
| 519 | - Only Electron desktop preview clients support browser automation in v1. | ||
| 520 | - No headless browser, stdio fallback, or automatic tunnel management is included. | ||
.plans/visible-preview-browser-automation-via-cdp-mcp.md created+670| ... | @@ -0,0 +1,670 @@ | ||
| 1 | # Visible Preview Browser Automation via CDP + MCP | ||
| 2 | |||
| 3 | ## Summary | ||
| 4 | |||
| 5 | Implement agent control of the user-visible T3 preview browser only. Do not add headless browser support. Do not add SSH/relay/private forwarding in v1; preview URLs must already be reachable from the desktop client, such as a Mac mini private IP URL opened on a MacBook. | ||
| 6 | |||
| 7 | Architecture: | ||
| 8 | |||
| 9 | `agent` -> `stdio MCP server in environment` -> `private T3 server bridge` -> `focused desktop client/window` -> `Electron preview webview via CDP` | ||
| 10 | |||
| 11 | The stdio MCP server is the agent-facing integration because all target agents can speak MCP. The MCP server is intentionally thin: it does not automate Chromium directly. It calls the T3 environment server, which routes commands to the focused desktop client that owns the visible preview. | ||
| 12 | |||
| 13 | ## Explicit Non-Goals | ||
| 14 | |||
| 15 | - No headless browser runner. | ||
| 16 | - No Playwright-managed browser. | ||
| 17 | - No arbitrary SSH dev-port forwarding in v1. | ||
| 18 | - No Cloudflare/Tailscale/relay URL rewriting in v1. | ||
| 19 | - No automation of browser/web clients that do not have Electron preview support. | ||
| 20 | - No per-action user approval prompts in v1. | ||
| 21 | |||
| 22 | ## Key Decisions | ||
| 23 | |||
| 24 | - **Transport to agents:** stdio MCP. | ||
| 25 | - **Browser being controlled:** the actual integrated Electron preview webview. | ||
| 26 | - **Remote URL handling:** manual reachable URLs only. Agents may open `http://mac-mini.local:5173` or `http://192.168.x.y:5173`; T3 will not tunnel `127.0.0.1:5173` from remote to local yet. | ||
| 27 | - **Client routing:** route tool requests to the most recently focused desktop window/client for the agent’s thread. Return a typed error if no focused desktop preview owner is available. | ||
| 28 | - **Scope:** full control of preview browser, including opening/showing the preview panel. | ||
| 29 | - **Primary automation engine:** Chrome DevTools Protocol through Electron `webContents.debugger`, with `webContents.capturePage()` where it is simpler and more reliable. | ||
| 30 | |||
| 31 | ## New Contracts | ||
| 32 | |||
| 33 | Add `packages/contracts/src/previewAutomation.ts`. | ||
| 34 | |||
| 35 | ### Branded IDs | ||
| 36 | |||
| 37 | - `PreviewAutomationRequestId` | ||
| 38 | - `PreviewAutomationClientId` | ||
| 39 | - `PreviewAutomationOwnerId` | ||
| 40 | |||
| 41 | ### Tool Input/Output Schemas | ||
| 42 | |||
| 43 | Add Effect schemas for these operations: | ||
| 44 | |||
| 45 | - `PreviewAutomationOpenInput` | ||
| 46 | - `url?: string` | ||
| 47 | - `show?: boolean` default `true` | ||
| 48 | - `reuseExistingTab?: boolean` default `true` | ||
| 49 | - `PreviewAutomationNavigateInput` | ||
| 50 | - `url: string` | ||
| 51 | - `waitUntil?: "load" | "domcontentloaded" | "network-idle" | "none"` default `"load"` | ||
| 52 | - `timeoutMs?: number` default `15000` | ||
| 53 | - `PreviewAutomationSnapshotInput` | ||
| 54 | - `includeScreenshot?: boolean` default `true` | ||
| 55 | - `includeDomSummary?: boolean` default `true` | ||
| 56 | - `includeAccessibilityTree?: boolean` default `true` | ||
| 57 | - `screenshotMaxWidth?: number` default `1280` | ||
| 58 | - `PreviewAutomationClickInput` | ||
| 59 | - one of: | ||
| 60 | - `{ selector: string }` | ||
| 61 | - `{ x: number; y: number }` | ||
| 62 | - `button?: "left" | "middle" | "right"` default `"left"` | ||
| 63 | - `clickCount?: number` default `1` | ||
| 64 | - `PreviewAutomationTypeInput` | ||
| 65 | - `text: string` | ||
| 66 | - `selector?: string` | ||
| 67 | - `clearFirst?: boolean` default `false` | ||
| 68 | - `PreviewAutomationPressInput` | ||
| 69 | - `key: string` | ||
| 70 | - `modifiers?: readonly ("alt" | "control" | "meta" | "shift")[]` | ||
| 71 | - `PreviewAutomationScrollInput` | ||
| 72 | - `deltaX?: number` | ||
| 73 | - `deltaY?: number` | ||
| 74 | - optional target `{ selector: string }` | ||
| 75 | - `PreviewAutomationEvaluateInput` | ||
| 76 | - `expression: string` | ||
| 77 | - `awaitPromise?: boolean` default `true` | ||
| 78 | - `returnByValue?: boolean` default `true` | ||
| 79 | - `PreviewAutomationWaitForInput` | ||
| 80 | - one of: | ||
| 81 | - `{ selector: string }` | ||
| 82 | - `{ text: string }` | ||
| 83 | - `{ urlIncludes: string }` | ||
| 84 | - `timeoutMs?: number` default `10000` | ||
| 85 | - `PreviewAutomationStatusResult` | ||
| 86 | - `available: boolean` | ||
| 87 | - `visible: boolean` | ||
| 88 | - `threadId` | ||
| 89 | - `tabId: string | null` | ||
| 90 | - `url: string | null` | ||
| 91 | - `title: string | null` | ||
| 92 | - `loading: boolean` | ||
| 93 | - `ownerClientId: string | null` | ||
| 94 | |||
| 95 | ### Result Shape | ||
| 96 | |||
| 97 | Every mutating or stateful operation returns: | ||
| 98 | |||
| 99 | ```ts | ||
| 100 | { | ||
| 101 | ok: boolean; | ||
| 102 | status: PreviewAutomationStatusResult; | ||
| 103 | message?: string; | ||
| 104 | } | ||
| 105 | ``` | ||
| 106 | |||
| 107 | `snapshot` additionally returns: | ||
| 108 | |||
| 109 | ```ts | ||
| 110 | { | ||
| 111 | status: PreviewAutomationStatusResult; | ||
| 112 | screenshot?: { | ||
| 113 | mimeType: "image/png"; | ||
| 114 | dataBase64: string; | ||
| 115 | width: number; | ||
| 116 | height: number; | ||
| 117 | }; | ||
| 118 | domSummary?: { | ||
| 119 | url: string; | ||
| 120 | title: string; | ||
| 121 | activeElement: string | null; | ||
| 122 | text: string; | ||
| 123 | interactiveElements: readonly { | ||
| 124 | index: number; | ||
| 125 | tag: string; | ||
| 126 | role: string | null; | ||
| 127 | name: string; | ||
| 128 | text: string; | ||
| 129 | selector: string | null; | ||
| 130 | rect: { x: number; y: number; width: number; height: number } | null; | ||
| 131 | }[]; | ||
| 132 | }; | ||
| 133 | accessibilityTree?: unknown; | ||
| 134 | } | ||
| 135 | ``` | ||
| 136 | |||
| 137 | ### Error Types | ||
| 138 | |||
| 139 | Add tagged errors: | ||
| 140 | |||
| 141 | - `PreviewAutomationUnavailableError` | ||
| 142 | - `PreviewAutomationNoFocusedOwnerError` | ||
| 143 | - `PreviewAutomationUnsupportedClientError` | ||
| 144 | - `PreviewAutomationTabNotFoundError` | ||
| 145 | - `PreviewAutomationTimeoutError` | ||
| 146 | - `PreviewAutomationExecutionError` | ||
| 147 | - `PreviewAutomationInvalidSelectorError` | ||
| 148 | |||
| 149 | ## Server-Side Broker | ||
| 150 | |||
| 151 | Add `apps/server/src/previewAutomation/Services/PreviewAutomationBroker.ts`. | ||
| 152 | |||
| 153 | Responsibilities: | ||
| 154 | |||
| 155 | - Track connected desktop automation clients. | ||
| 156 | - Track focus ownership by `(environmentId, threadId)`. | ||
| 157 | - Accept tool calls from MCP/stdin proxy. | ||
| 158 | - Route each call to the focused owner for the thread. | ||
| 159 | - Enforce timeouts and cleanup pending requests on disconnect. | ||
| 160 | - Return typed failures when no client/window is available. | ||
| 161 | |||
| 162 | ### Owner State | ||
| 163 | |||
| 164 | Store: | ||
| 165 | |||
| 166 | ```ts | ||
| 167 | { | ||
| 168 | clientId: PreviewAutomationClientId; | ||
| 169 | environmentId: EnvironmentId; | ||
| 170 | threadId: ThreadId; | ||
| 171 | tabId: PreviewTabId | null; | ||
| 172 | focusedAt: string; | ||
| 173 | visible: boolean; | ||
| 174 | supportsAutomation: boolean; | ||
| 175 | } | ||
| 176 | ``` | ||
| 177 | |||
| 178 | Ownership updates come from the web/desktop client when: | ||
| 179 | |||
| 180 | - route/thread changes, | ||
| 181 | - preview panel opens/closes, | ||
| 182 | - window focus changes, | ||
| 183 | - tab id changes, | ||
| 184 | - desktop bridge availability changes. | ||
| 185 | |||
| 186 | ## WS Bridge: Server to Desktop Client | ||
| 187 | |||
| 188 | The current preview RPCs are client-to-server plus server event streams. Add a request/response bridge using stream-style RPCs to avoid introducing bidirectional RPC infrastructure. | ||
| 189 | |||
| 190 | ### New WS Methods | ||
| 191 | |||
| 192 | Add to `packages/contracts/src/rpc.ts`: | ||
| 193 | |||
| 194 | - `previewAutomation.connect` | ||
| 195 | - client calls this as a long-lived stream | ||
| 196 | - input: | ||
| 197 | - `clientId` | ||
| 198 | - `capabilities` | ||
| 199 | - stream output: | ||
| 200 | - `PreviewAutomationClientRequest` | ||
| 201 | - `previewAutomation.respond` | ||
| 202 | - client sends response for a request id | ||
| 203 | - `previewAutomation.reportOwner` | ||
| 204 | - client reports focus/visibility/thread ownership | ||
| 205 | - `previewAutomation.clearOwner` | ||
| 206 | - client clears stale ownership on unmount/disconnect | ||
| 207 | |||
| 208 | ### Request Shape | ||
| 209 | |||
| 210 | ```ts | ||
| 211 | { | ||
| 212 | requestId: string; | ||
| 213 | threadId: string; | ||
| 214 | tabId?: string; | ||
| 215 | operation: | ||
| 216 | | "open" | ||
| 217 | | "navigate" | ||
| 218 | | "snapshot" | ||
| 219 | | "click" | ||
| 220 | | "type" | ||
| 221 | | "press" | ||
| 222 | | "scroll" | ||
| 223 | | "evaluate" | ||
| 224 | | "waitFor" | ||
| 225 | | "status"; | ||
| 226 | input: unknown; | ||
| 227 | timeoutMs: number; | ||
| 228 | } | ||
| 229 | ``` | ||
| 230 | |||
| 231 | ### Response Shape | ||
| 232 | |||
| 233 | ```ts | ||
| 234 | { | ||
| 235 | requestId: string; | ||
| 236 | ok: boolean; | ||
| 237 | result?: unknown; | ||
| 238 | error?: { | ||
| 239 | _tag: string; | ||
| 240 | message: string; | ||
| 241 | detail?: unknown; | ||
| 242 | }; | ||
| 243 | } | ||
| 244 | ``` | ||
| 245 | |||
| 246 | ## Desktop Preview Automation | ||
| 247 | |||
| 248 | Extend `apps/desktop/src/preview-view-manager.ts`. | ||
| 249 | |||
| 250 | ### Add Methods | ||
| 251 | |||
| 252 | - `getAutomationStatus(tabId)` | ||
| 253 | - `captureSnapshot(tabId, options)` | ||
| 254 | - `click(tabId, input)` | ||
| 255 | - `type(tabId, input)` | ||
| 256 | - `press(tabId, input)` | ||
| 257 | - `scroll(tabId, input)` | ||
| 258 | - `evaluate(tabId, input)` | ||
| 259 | - `waitFor(tabId, input)` | ||
| 260 | |||
| 261 | ### CDP Session Handling | ||
| 262 | |||
| 263 | Add a small helper inside desktop preview code: | ||
| 264 | |||
| 265 | - Attach `webContents.debugger` lazily per operation. | ||
| 266 | - Do not keep debugger attached forever unless needed. | ||
| 267 | - If already attached by DevTools or another debugger, return `PreviewAutomationExecutionError`. | ||
| 268 | - Use CDP domains: | ||
| 269 | - `Runtime.evaluate` | ||
| 270 | - `DOM.getDocument` | ||
| 271 | - `DOM.querySelector` | ||
| 272 | - `DOM.getBoxModel` | ||
| 273 | - `Accessibility.getFullAXTree` | ||
| 274 | - `Input.dispatchMouseEvent` | ||
| 275 | - `Input.dispatchKeyEvent` | ||
| 276 | - optionally `Page.captureScreenshot` if `webContents.capturePage()` is insufficient | ||
| 277 | |||
| 278 | For screenshots, prefer `webContents.capturePage()` first because it is already used safely for annotations. | ||
| 279 | |||
| 280 | ### DOM Summary Script | ||
| 281 | |||
| 282 | Use `Runtime.evaluate` with a bounded page script that returns: | ||
| 283 | |||
| 284 | - `document.URL` | ||
| 285 | - `document.title` | ||
| 286 | - active element summary | ||
| 287 | - visible text truncated to a fixed limit, e.g. 20k chars | ||
| 288 | - up to 200 interactive elements: | ||
| 289 | - buttons | ||
| 290 | - links | ||
| 291 | - inputs | ||
| 292 | - selects | ||
| 293 | - textareas | ||
| 294 | - elements with roles | ||
| 295 | - elements with click handlers where detectable | ||
| 296 | - stable-ish CSS selectors generated in page context | ||
| 297 | - bounding rects | ||
| 298 | |||
| 299 | Do not return full HTML by default. | ||
| 300 | |||
| 301 | ### Input Behavior | ||
| 302 | |||
| 303 | - `click(selector)`: | ||
| 304 | - resolve selector in page | ||
| 305 | - scroll into view | ||
| 306 | - compute center of bounding box | ||
| 307 | - dispatch mouse move/down/up through CDP | ||
| 308 | - `click(x, y)`: | ||
| 309 | - dispatch at viewport coordinates | ||
| 310 | - `type(selector, text)`: | ||
| 311 | - focus selector if provided | ||
| 312 | - optionally clear existing value with platform shortcut | ||
| 313 | - dispatch text via CDP keyboard events or `Input.insertText` | ||
| 314 | - `press(key)`: | ||
| 315 | - map common key names: Enter, Escape, Tab, Backspace, Arrow keys | ||
| 316 | - support modifiers | ||
| 317 | - `scroll`: | ||
| 318 | - use CDP mouse wheel or page `scrollBy` fallback | ||
| 319 | |||
| 320 | ## Web Client Changes | ||
| 321 | |||
| 322 | Update `apps/web/src/components/preview`. | ||
| 323 | |||
| 324 | ### Ownership Reporting | ||
| 325 | |||
| 326 | Add a hook, likely `usePreviewAutomationOwner`, mounted near `PreviewView`. | ||
| 327 | |||
| 328 | It reports owner state when: | ||
| 329 | |||
| 330 | - the current route thread ref changes, | ||
| 331 | - preview panel visibility changes, | ||
| 332 | - browser window focus/blur changes, | ||
| 333 | - tab id changes, | ||
| 334 | - desktop preview bridge exists. | ||
| 335 | |||
| 336 | Policy: | ||
| 337 | |||
| 338 | - Only Electron desktop clients with `window.desktopBridge.preview` can report `supportsAutomation: true`. | ||
| 339 | - A visible preview panel gets ownership. | ||
| 340 | - The most recently focused window wins. | ||
| 341 | - On unmount or preview close, clear ownership. | ||
| 342 | |||
| 343 | ### Handling Server Requests | ||
| 344 | |||
| 345 | Add a client-side subscriber using the new `previewAutomation.connect` stream. | ||
| 346 | |||
| 347 | When a request arrives: | ||
| 348 | |||
| 349 | - if operation is `open`, ensure preview panel is visible for the thread, call `api.preview.open` if needed, mount/create desktop tab, then navigate if URL is provided. | ||
| 350 | - for browser operations, call new `desktopBridge.preview.automation.*` methods. | ||
| 351 | - send `previewAutomation.respond`. | ||
| 352 | |||
| 353 | Opening behavior: | ||
| 354 | |||
| 355 | - If the right panel is closed, open it to preview. | ||
| 356 | - If the thread is not currently active in the UI, do not navigate the whole app in v1. Return `PreviewAutomationNoFocusedOwnerError`. | ||
| 357 | - If preview is supported but no tab exists and the agent calls `open`, create one. | ||
| 358 | - If no tab exists and the agent calls `snapshot/click/type/...`, return a clear “preview not open” error suggesting `preview_open`. | ||
| 359 | |||
| 360 | ## Desktop IPC / Preload | ||
| 361 | |||
| 362 | Extend `packages/contracts/src/ipc.ts` `DesktopPreviewBridge`. | ||
| 363 | |||
| 364 | Add: | ||
| 365 | |||
| 366 | ```ts | ||
| 367 | automation: { | ||
| 368 | status(tabId: string): Promise<PreviewAutomationStatusResult>; | ||
| 369 | snapshot(tabId: string, input: PreviewAutomationSnapshotInput): Promise<PreviewAutomationSnapshotResult>; | ||
| 370 | click(tabId: string, input: PreviewAutomationClickInput): Promise<PreviewAutomationActionResult>; | ||
| 371 | type(tabId: string, input: PreviewAutomationTypeInput): Promise<PreviewAutomationActionResult>; | ||
| 372 | press(tabId: string, input: PreviewAutomationPressInput): Promise<PreviewAutomationActionResult>; | ||
| 373 | scroll(tabId: string, input: PreviewAutomationScrollInput): Promise<PreviewAutomationActionResult>; | ||
| 374 | evaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise<PreviewAutomationEvaluateResult>; | ||
| 375 | waitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise<PreviewAutomationActionResult>; | ||
| 376 | } | ||
| 377 | ``` | ||
| 378 | |||
| 379 | Add IPC channels in `apps/desktop/src/ipc/channels.ts` and handlers in `apps/desktop/src/ipc/methods/preview.ts`. | ||
| 380 | |||
| 381 | ## Stdio MCP Server | ||
| 382 | |||
| 383 | Add package: `packages/preview-mcp`. | ||
| 384 | |||
| 385 | Purpose: | ||
| 386 | |||
| 387 | - Implements the MCP stdio protocol. | ||
| 388 | - Exposes T3 preview tools. | ||
| 389 | - Calls a private loopback endpoint or local JSON-RPC bridge on the environment server. | ||
| 390 | - Does not know Electron/CDP details. | ||
| 391 | |||
| 392 | ### Binary | ||
| 393 | |||
| 394 | Expose bin: | ||
| 395 | |||
| 396 | ```json | ||
| 397 | { | ||
| 398 | "bin": { | ||
| 399 | "t3-preview-mcp": "./dist/index.js" | ||
| 400 | } | ||
| 401 | } | ||
| 402 | ``` | ||
| 403 | |||
| 404 | During local dev, provider config can call the source runner via workspace package script; production package uses built JS. | ||
| 405 | |||
| 406 | ### MCP Environment Variables | ||
| 407 | |||
| 408 | Provider sessions launch the MCP server with: | ||
| 409 | |||
| 410 | - `T3_PREVIEW_MCP_SERVER_URL` | ||
| 411 | - environment server loopback URL | ||
| 412 | - `T3_PREVIEW_MCP_TOKEN` | ||
| 413 | - short-lived token scoped to the provider session/thread | ||
| 414 | - `T3_PREVIEW_ENVIRONMENT_ID` | ||
| 415 | - `T3_PREVIEW_THREAD_ID` | ||
| 416 | |||
| 417 | The token must be generated by the environment server and expire when the provider session ends. | ||
| 418 | |||
| 419 | ### MCP Tools | ||
| 420 | |||
| 421 | Expose these tools: | ||
| 422 | |||
| 423 | - `preview_status` | ||
| 424 | - `preview_open` | ||
| 425 | - `preview_navigate` | ||
| 426 | - `preview_snapshot` | ||
| 427 | - `preview_click` | ||
| 428 | - `preview_type` | ||
| 429 | - `preview_press` | ||
| 430 | - `preview_scroll` | ||
| 431 | - `preview_evaluate` | ||
| 432 | - `preview_wait_for` | ||
| 433 | |||
| 434 | Tool descriptions must explicitly say they operate the visible T3 preview browser for the current thread. | ||
| 435 | |||
| 436 | ### MCP Output | ||
| 437 | |||
| 438 | - Text results include concise status and URL/title. | ||
| 439 | - `preview_snapshot` returns: | ||
| 440 | - text summary | ||
| 441 | - MCP image content for screenshot when available | ||
| 442 | - Errors are MCP tool errors with the tagged T3 error message included. | ||
| 443 | |||
| 444 | ## Private MCP Bridge Endpoint | ||
| 445 | |||
| 446 | Add an internal server route under `apps/server`, not public app UI: | ||
| 447 | |||
| 448 | - `POST /internal/preview-automation/tool` | ||
| 449 | - Auth: bearer `T3_PREVIEW_MCP_TOKEN` | ||
| 450 | - Body: | ||
| 451 | - `{ tool: string, input: unknown }` | ||
| 452 | - Response: | ||
| 453 | - `{ ok: true, result } | { ok: false, error }` | ||
| 454 | |||
| 455 | This endpoint is only for the stdio MCP proxy. It calls `PreviewAutomationBroker`. | ||
| 456 | |||
| 457 | Bind it to the same host/port as the environment server, but require the short-lived token. The endpoint must reject requests without a token or with an expired/stale thread/session. | ||
| 458 | |||
| 459 | ## Provider Integration | ||
| 460 | |||
| 461 | ### Codex | ||
| 462 | |||
| 463 | When starting a Codex provider session, add the T3 preview MCP server to Codex configuration if the app-server config path supports per-thread MCP injection. | ||
| 464 | |||
| 465 | Implementation path: | ||
| 466 | |||
| 467 | 1. Add `PreviewMcpSessionService` in `apps/server`. | ||
| 468 | 2. On provider session start: | ||
| 469 | - create scoped MCP token for `(environmentId, threadId, providerSessionId)`. | ||
| 470 | - build MCP server config: | ||
| 471 | - name: `t3-preview` | ||
| 472 | - command: `t3-preview-mcp` | ||
| 473 | - env vars listed above | ||
| 474 | 3. Thread/session start passes the MCP server config through the provider’s supported config field. | ||
| 475 | 4. If Codex app-server cannot accept injected MCP servers through typed params, use its `config` override field with Codex-compatible MCP config. | ||
| 476 | |||
| 477 | ### ACP Providers | ||
| 478 | |||
| 479 | ACP session creation already passes `mcpServers: []`. Replace that with the same `t3-preview` MCP server config for providers that support MCP. | ||
| 480 | |||
| 481 | ### Provider Fallback | ||
| 482 | |||
| 483 | If a provider does not support MCP injection yet, do not add provider-specific native tools in v1. The shared MCP server remains available for later provider wiring. | ||
| 484 | |||
| 485 | ## Remote Environment Behavior | ||
| 486 | |||
| 487 | ### Mac mini dev server viewed from MacBook | ||
| 488 | |||
| 489 | Expected v1 workflow: | ||
| 490 | |||
| 491 | 1. Agent runs dev server on Mac mini. | ||
| 492 | 2. Agent or user opens a reachable URL, e.g.: | ||
| 493 | - `http://mac-mini.local:5173` | ||
| 494 | - `http://192.168.1.42:5173` | ||
| 495 | 3. T3 desktop on MacBook opens that URL in the local Electron preview. | ||
| 496 | 4. Agent uses MCP tools. | ||
| 497 | 5. T3 routes tool calls from Mac mini environment server to the focused MacBook preview client. | ||
| 498 | |||
| 499 | ### `localhost` Caveat | ||
| 500 | |||
| 501 | In v1, if an agent opens `http://localhost:5173` from a remote environment, the MacBook preview will interpret that as MacBook localhost. The tool result should include a warning when: | ||
| 502 | |||
| 503 | - environment is not the primary/local environment, and | ||
| 504 | - URL hostname is `localhost`, `127.0.0.1`, or `::1`. | ||
| 505 | |||
| 506 | Warning text: | ||
| 507 | |||
| 508 | `This URL is loopback on the preview client, not necessarily the remote environment. Use a client-reachable host/IP for remote dev servers.` | ||
| 509 | |||
| 510 | ### Future-Proofing | ||
| 511 | |||
| 512 | Do not bake manual URL assumptions into the automation layer. Represent opened URLs as: | ||
| 513 | |||
| 514 | ```ts | ||
| 515 | { | ||
| 516 | displayUrl: string; | ||
| 517 | requestedUrl: string; | ||
| 518 | resolutionKind: "direct"; | ||
| 519 | environmentId: string; | ||
| 520 | } | ||
| 521 | ``` | ||
| 522 | |||
| 523 | Later `resolutionKind` can add: | ||
| 524 | |||
| 525 | - `ssh-forward` | ||
| 526 | - `relay` | ||
| 527 | - `tailscale` | ||
| 528 | - `cloudflare-tunnel` | ||
| 529 | |||
| 530 | ## Security and Safety | ||
| 531 | |||
| 532 | - MCP tokens are scoped to one provider session/thread. | ||
| 533 | - MCP tokens expire on provider session stop and server restart. | ||
| 534 | - Browser automation only routes to focused desktop owner for that same thread. | ||
| 535 | - Do not allow MCP input to specify arbitrary `environmentId` or `threadId`; infer both from token/session. | ||
| 536 | - `preview_evaluate` is powerful. Keep it enabled in v1 because the user requested full control, but: | ||
| 537 | - limit result serialization size, e.g. 64 KB | ||
| 538 | - timeout evaluation | ||
| 539 | - return by value by default | ||
| 540 | - document that it executes in the preview page context | ||
| 541 | - Screenshot output should be bounded: | ||
| 542 | - max dimensions | ||
| 543 | - max base64 size | ||
| 544 | - return error if too large after scaling attempts | ||
| 545 | - Clear pending broker requests when desktop disconnects. | ||
| 546 | - CDP operations must timeout and detach debugger in `finally`. | ||
| 547 | |||
| 548 | ## Failure Modes | ||
| 549 | |||
| 550 | Return typed errors for: | ||
| 551 | |||
| 552 | - no desktop client connected | ||
| 553 | - desktop client connected but preview unsupported | ||
| 554 | - no focused owner for thread | ||
| 555 | - preview panel not open and operation is not `preview_open` | ||
| 556 | - webview not initialized yet | ||
| 557 | - navigation timeout | ||
| 558 | - selector not found | ||
| 559 | - CDP debugger unavailable | ||
| 560 | - page execution error | ||
| 561 | - screenshot too large | ||
| 562 | - stale request id or response after timeout | ||
| 563 | |||
| 564 | ## Tests | ||
| 565 | |||
| 566 | ### Contracts | ||
| 567 | |||
| 568 | Add tests for: | ||
| 569 | |||
| 570 | - schema decoding for every preview automation input/result | ||
| 571 | - error schema decoding | ||
| 572 | - invalid selector/click union inputs rejected | ||
| 573 | - snapshot options defaulting | ||
| 574 | |||
| 575 | ### Desktop Unit Tests | ||
| 576 | |||
| 577 | Add tests around `PreviewViewManager` helpers: | ||
| 578 | |||
| 579 | - owner-independent status when tab exists/does not exist | ||
| 580 | - selector summary script clamps output | ||
| 581 | - selector generation handles ids/classes/nth-child fallback | ||
| 582 | - error mapping for missing webContents | ||
| 583 | - screenshot result shape | ||
| 584 | |||
| 585 | Where Electron/CDP is hard to unit test, isolate pure helpers and cover IPC handler validation. | ||
| 586 | |||
| 587 | ### Server Broker Tests | ||
| 588 | |||
| 589 | Add tests for: | ||
| 590 | |||
| 591 | - registering clients | ||
| 592 | - ownership updates | ||
| 593 | - focused owner wins | ||
| 594 | - request routed to focused owner | ||
| 595 | - request timeout | ||
| 596 | - client disconnect fails pending requests | ||
| 597 | - response with unknown request id ignored/rejected | ||
| 598 | - token scoped to thread/session | ||
| 599 | - remote loopback URL warning generated | ||
| 600 | |||
| 601 | ### Web Client Tests | ||
| 602 | |||
| 603 | Add tests for: | ||
| 604 | |||
| 605 | - ownership report sent only in Electron preview-supported runtime | ||
| 606 | - opening preview panel on `preview_open` | ||
| 607 | - no route switch for background thread | ||
| 608 | - clear ownership on unmount | ||
| 609 | - response sent for successful and failed desktop bridge calls | ||
| 610 | |||
| 611 | ### MCP Tests | ||
| 612 | |||
| 613 | Add tests for: | ||
| 614 | |||
| 615 | - tool list includes all expected preview tools | ||
| 616 | - each tool maps to internal bridge request | ||
| 617 | - token missing/invalid returns MCP error | ||
| 618 | - snapshot maps screenshot to MCP image content | ||
| 619 | - tool errors preserve tagged T3 error message | ||
| 620 | |||
| 621 | ### Integration Tests | ||
| 622 | |||
| 623 | Add a focused integration test using mocked desktop client: | ||
| 624 | |||
| 625 | 1. Start server broker. | ||
| 626 | 2. Register fake desktop automation client. | ||
| 627 | 3. Mark it focused for thread. | ||
| 628 | 4. Invoke MCP `preview_open`. | ||
| 629 | 5. Assert fake client received open request. | ||
| 630 | 6. Respond success. | ||
| 631 | 7. Assert MCP response is successful. | ||
| 632 | |||
| 633 | Add a second integration test: | ||
| 634 | |||
| 635 | 1. No focused owner. | ||
| 636 | 2. Invoke `preview_snapshot`. | ||
| 637 | 3. Assert `PreviewAutomationNoFocusedOwnerError`. | ||
| 638 | |||
| 639 | ## Validation Commands | ||
| 640 | |||
| 641 | Before completion: | ||
| 642 | |||
| 643 | - `vp check` | ||
| 644 | - `vp run typecheck` | ||
| 645 | - `vp test` | ||
| 646 | |||
| 647 | No `vp run lint:mobile` required unless mobile code is changed. | ||
| 648 | |||
| 649 | ## Implementation Order | ||
| 650 | |||
| 651 | 1. Add contracts for preview automation schemas and WS methods. | ||
| 652 | 2. Add `PreviewAutomationBroker` server service. | ||
| 653 | 3. Add WS stream bridge for desktop clients. | ||
| 654 | 4. Add desktop IPC and `PreviewViewManager` CDP automation methods. | ||
| 655 | 5. Add web ownership reporting and request handling. | ||
| 656 | 6. Add private `/internal/preview-automation/tool` endpoint with scoped token auth. | ||
| 657 | 7. Add `packages/preview-mcp` stdio MCP server. | ||
| 658 | 8. Wire provider sessions to launch/register `t3-preview` MCP server where provider protocols support MCP config. | ||
| 659 | 9. Add remote loopback URL warning. | ||
| 660 | 10. Add tests. | ||
| 661 | 11. Run validation commands. | ||
| 662 | |||
| 663 | ## Assumptions | ||
| 664 | |||
| 665 | - v1 only supports Electron desktop preview clients. | ||
| 666 | - The active/focused thread preview is the right target for agent control. | ||
| 667 | - Manual client-reachable URLs are acceptable for remote dev servers. | ||
| 668 | - Full browser control includes `evaluate`. | ||
| 669 | - Stdio MCP is the agent-facing transport. | ||
| 670 | - A private server bridge behind the stdio MCP server is acceptable and necessary because the browser lives on the desktop client, not beside the remote agent. | ||
apps/desktop/scripts/dev-electron.mjs+7-2| ... | @@ -2,7 +2,11 @@ import { spawn, spawnSync } from "node:child_process"; | ... | @@ -2,7 +2,11 @@ import { spawn, spawnSync } from "node:child_process"; |
| 2 | import { watch } from "node:fs"; | 2 | import { watch } from "node:fs"; |
| 3 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | 4 | ||
| 5 | import { desktopDir, resolveDevProtocolClient, resolveElectronPath } from "./electron-launcher.mjs"; | 5 | import { |
| 6 | desktopDir, | ||
| 7 | resolveDevProtocolClient, | ||
| 8 | resolveElectronLaunchCommand, | ||
| 9 | } from "./electron-launcher.mjs"; | ||
| 6 | import { waitForResources } from "./wait-for-resources.mjs"; | 10 | import { waitForResources } from "./wait-for-resources.mjs"; |
| 7 | 11 | ||
| 8 | const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); | 12 | const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); |
| ... | @@ -79,7 +83,8 @@ function startApp() { | ... | @@ -79,7 +83,8 @@ function startApp() { |
| 79 | const launchArgs = devProtocolClient | 83 | const launchArgs = devProtocolClient |
| 80 | ? electronArgs | 84 | ? electronArgs |
| 81 | : [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"]; | 85 | : [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"]; |
| 82 | const app = spawn(resolveElectronPath(), launchArgs, { | 86 | const electronCommand = resolveElectronLaunchCommand(launchArgs); |
| 87 | const app = spawn(electronCommand.electronPath, electronCommand.args, { | ||
| 83 | cwd: desktopDir, | 88 | cwd: desktopDir, |
| 84 | env: childEnv, | 89 | env: childEnv, |
| 85 | stdio: "inherit", | 90 | stdio: "inherit", |
apps/desktop/scripts/electron-launcher.mjs+33| ... | @@ -307,6 +307,31 @@ function buildMacLauncher(electronBinaryPath) { | ... | @@ -307,6 +307,31 @@ function buildMacLauncher(electronBinaryPath) { |
| 307 | return targetBinaryPath; | 307 | return targetBinaryPath; |
| 308 | } | 308 | } |
| 309 | 309 | ||
| 310 | function isLinuxSetuidSandboxConfigured(electronBinaryPath) { | ||
| 311 | if (process.platform !== "linux") { | ||
| 312 | return true; | ||
| 313 | } | ||
| 314 | |||
| 315 | const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox"); | ||
| 316 | try { | ||
| 317 | const sandboxStat = statSync(sandboxPath); | ||
| 318 | return sandboxStat.uid === 0 && (sandboxStat.mode & 0o4777) === 0o4755; | ||
| 319 | } catch { | ||
| 320 | return false; | ||
| 321 | } | ||
| 322 | } | ||
| 323 | |||
| 324 | function resolveLinuxSandboxArgs(electronBinaryPath) { | ||
| 325 | if (isLinuxSetuidSandboxConfigured(electronBinaryPath)) { | ||
| 326 | return []; | ||
| 327 | } | ||
| 328 | |||
| 329 | console.warn( | ||
| 330 | "[desktop-launcher] Electron chrome-sandbox is not root-owned with mode 4755; launching local Electron with --no-sandbox.", | ||
| 331 | ); | ||
| 332 | return ["--no-sandbox"]; | ||
| 333 | } | ||
| 334 | |||
| 310 | export function resolveElectronPath() { | 335 | export function resolveElectronPath() { |
| 311 | ensureElectronRuntime(); | 336 | ensureElectronRuntime(); |
| 312 | 337 | ||
| ... | @@ -320,6 +345,14 @@ export function resolveElectronPath() { | ... | @@ -320,6 +345,14 @@ export function resolveElectronPath() { |
| 320 | return buildMacLauncher(electronBinaryPath); | 345 | return buildMacLauncher(electronBinaryPath); |
| 321 | } | 346 | } |
| 322 | 347 | ||
| 348 | export function resolveElectronLaunchCommand(args = []) { | ||
| 349 | const electronPath = resolveElectronPath(); | ||
| 350 | return { | ||
| 351 | electronPath, | ||
| 352 | args: [...resolveLinuxSandboxArgs(electronPath), ...args], | ||
| 353 | }; | ||
| 354 | } | ||
| 355 | |||
| 323 | export function resolveDevProtocolClient() { | 356 | export function resolveDevProtocolClient() { |
| 324 | if (process.platform !== "darwin" || !isDevelopment) { | 357 | if (process.platform !== "darwin" || !isDevelopment) { |
| 325 | return null; | 358 | return null; |
apps/desktop/scripts/smoke-test.mjs+3-2| ... | @@ -1,15 +1,16 @@ | ... | @@ -1,15 +1,16 @@ |
| 1 | import { spawn } from "node:child_process"; | 1 | import { spawn } from "node:child_process"; |
| 2 | import { dirname, resolve } from "node:path"; | 2 | import { dirname, resolve } from "node:path"; |
| 3 | import { fileURLToPath } from "node:url"; | 3 | import { fileURLToPath } from "node:url"; |
| 4 | import { resolveElectronLaunchCommand } from "./electron-launcher.mjs"; | ||
| 4 | 5 | ||
| 5 | const __dirname = dirname(fileURLToPath(import.meta.url)); | 6 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 6 | const desktopDir = resolve(__dirname, ".."); | 7 | const desktopDir = resolve(__dirname, ".."); |
| 7 | const electronBin = resolve(desktopDir, "node_modules/.bin/electron"); | ||
| 8 | const mainJs = resolve(desktopDir, "dist-electron/main.cjs"); | 8 | const mainJs = resolve(desktopDir, "dist-electron/main.cjs"); |
| 9 | 9 | ||
| 10 | console.log("\nLaunching Electron smoke test..."); | 10 | console.log("\nLaunching Electron smoke test..."); |
| 11 | 11 | ||
| 12 | const child = spawn(electronBin, [mainJs], { | 12 | const electronCommand = resolveElectronLaunchCommand([mainJs]); |
| 13 | const child = spawn(electronCommand.electronPath, electronCommand.args, { | ||
| 13 | stdio: ["pipe", "pipe", "pipe"], | 14 | stdio: ["pipe", "pipe", "pipe"], |
| 14 | env: { | 15 | env: { |
| 15 | ...process.env, | 16 | ...process.env, |
apps/desktop/scripts/start-electron.mjs+3-2| ... | @@ -1,11 +1,12 @@ | ... | @@ -1,11 +1,12 @@ |
| 1 | import { spawn } from "node:child_process"; | 1 | import { spawn } from "node:child_process"; |
| 2 | 2 | ||
| 3 | import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; | 3 | import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs"; |
| 4 | 4 | ||
| 5 | const childEnv = { ...process.env }; | 5 | const childEnv = { ...process.env }; |
| 6 | delete childEnv.ELECTRON_RUN_AS_NODE; | 6 | delete childEnv.ELECTRON_RUN_AS_NODE; |
| 7 | 7 | ||
| 8 | const child = spawn(resolveElectronPath(), ["dist-electron/main.cjs"], { | 8 | const electronCommand = resolveElectronLaunchCommand(["dist-electron/main.cjs"]); |
| 9 | const child = spawn(electronCommand.electronPath, electronCommand.args, { | ||
| 9 | stdio: "inherit", | 10 | stdio: "inherit", |
| 10 | cwd: desktopDir, | 11 | cwd: desktopDir, |
| 11 | env: childEnv, | 12 | env: childEnv, |
apps/desktop/src/ipc/channels.ts+8| ... | @@ -56,4 +56,12 @@ export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; | ... | @@ -56,4 +56,12 @@ export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; |
| 56 | export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; | 56 | export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; |
| 57 | export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; | 57 | export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; |
| 58 | export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; | 58 | export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; |
| 59 | export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status"; | ||
| 60 | export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot"; | ||
| 61 | export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click"; | ||
| 62 | export const PREVIEW_AUTOMATION_TYPE_CHANNEL = "desktop:preview-automation-type"; | ||
| 63 | export const PREVIEW_AUTOMATION_PRESS_CHANNEL = "desktop:preview-automation-press"; | ||
| 64 | export const PREVIEW_AUTOMATION_SCROLL_CHANNEL = "desktop:preview-automation-scroll"; | ||
| 65 | export const PREVIEW_AUTOMATION_EVALUATE_CHANNEL = "desktop:preview-automation-evaluate"; | ||
| 66 | export const PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL = "desktop:preview-automation-wait-for"; | ||
| 59 | export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; | 67 | export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; |
apps/desktop/src/ipc/methods/preview.ts+49| ... | @@ -28,6 +28,13 @@ const tabIdFrom = (raw: unknown): string => { | ... | @@ -28,6 +28,13 @@ const tabIdFrom = (raw: unknown): string => { |
| 28 | return tabId; | 28 | return tabId; |
| 29 | }; | 29 | }; |
| 30 | 30 | ||
| 31 | const inputFrom = (raw: unknown): unknown => { | ||
| 32 | if (typeof raw !== "object" || raw === null || !("input" in raw)) { | ||
| 33 | throw new Error("preview automation input is required"); | ||
| 34 | } | ||
| 35 | return raw.input; | ||
| 36 | }; | ||
| 37 | |||
| 31 | class PreviewIpcError extends Data.TaggedError("PreviewIpcError")<{ | 38 | class PreviewIpcError extends Data.TaggedError("PreviewIpcError")<{ |
| 32 | readonly cause: unknown; | 39 | readonly cause: unknown; |
| 33 | }> {} | 40 | }> {} |
| ... | @@ -102,4 +109,46 @@ export const previewMethods = [ | ... | @@ -102,4 +109,46 @@ export const previewMethods = [ |
| 102 | method(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, (raw) => | 109 | method(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, (raw) => |
| 103 | previewViewManager.cancelPickElement(tabIdFrom(raw)), | 110 | previewViewManager.cancelPickElement(tabIdFrom(raw)), |
| 104 | ), | 111 | ), |
| 112 | method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) => | ||
| 113 | previewViewManager.automationStatus(tabIdFrom(raw)), | ||
| 114 | ), | ||
| 115 | method(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, (raw) => | ||
| 116 | previewViewManager.automationSnapshot(tabIdFrom(raw)), | ||
| 117 | ), | ||
| 118 | method(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, (raw) => | ||
| 119 | previewViewManager.automationClick( | ||
| 120 | tabIdFrom(raw), | ||
| 121 | inputFrom(raw) as Parameters<typeof previewViewManager.automationClick>[1], | ||
| 122 | ), | ||
| 123 | ), | ||
| 124 | method(IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, (raw) => | ||
| 125 | previewViewManager.automationType( | ||
| 126 | tabIdFrom(raw), | ||
| 127 | inputFrom(raw) as Parameters<typeof previewViewManager.automationType>[1], | ||
| 128 | ), | ||
| 129 | ), | ||
| 130 | method(IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, (raw) => | ||
| 131 | previewViewManager.automationPress( | ||
| 132 | tabIdFrom(raw), | ||
| 133 | inputFrom(raw) as Parameters<typeof previewViewManager.automationPress>[1], | ||
| 134 | ), | ||
| 135 | ), | ||
| 136 | method(IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, (raw) => | ||
| 137 | previewViewManager.automationScroll( | ||
| 138 | tabIdFrom(raw), | ||
| 139 | inputFrom(raw) as Parameters<typeof previewViewManager.automationScroll>[1], | ||
| 140 | ), | ||
| 141 | ), | ||
| 142 | method(IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, (raw) => | ||
| 143 | previewViewManager.automationEvaluate( | ||
| 144 | tabIdFrom(raw), | ||
| 145 | inputFrom(raw) as Parameters<typeof previewViewManager.automationEvaluate>[1], | ||
| 146 | ), | ||
| 147 | ), | ||
| 148 | method(IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, (raw) => | ||
| 149 | previewViewManager.automationWaitFor( | ||
| 150 | tabIdFrom(raw), | ||
| 151 | inputFrom(raw) as Parameters<typeof previewViewManager.automationWaitFor>[1], | ||
| 152 | ), | ||
| 153 | ), | ||
| 105 | ] as const; | 154 | ] as const; |
apps/desktop/src/preload.ts+18| ... | @@ -163,6 +163,24 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -163,6 +163,24 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 163 | pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), | 163 | pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), |
| 164 | cancelPickElement: (tabId) => | 164 | cancelPickElement: (tabId) => |
| 165 | ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), | 165 | ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), |
| 166 | automation: { | ||
| 167 | status: (tabId) => | ||
| 168 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, { tabId }), | ||
| 169 | snapshot: (tabId) => | ||
| 170 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, { tabId }), | ||
| 171 | click: (tabId, input) => | ||
| 172 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, { tabId, input }), | ||
| 173 | type: (tabId, input) => | ||
| 174 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, { tabId, input }), | ||
| 175 | press: (tabId, input) => | ||
| 176 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, { tabId, input }), | ||
| 177 | scroll: (tabId, input) => | ||
| 178 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, { tabId, input }), | ||
| 179 | evaluate: (tabId, input) => | ||
| 180 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, { tabId, input }), | ||
| 181 | waitFor: (tabId, input) => | ||
| 182 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, { tabId, input }), | ||
| 183 | }, | ||
| 166 | onStateChange: (listener) => { | 184 | onStateChange: (listener) => { |
| 167 | const wrappedListener = ( | 185 | const wrappedListener = ( |
| 168 | _event: Electron.IpcRendererEvent, | 186 | _event: Electron.IpcRendererEvent, |
apps/desktop/src/preview-view-manager.test.ts created+44| ... | @@ -0,0 +1,44 @@ | ||
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | |||
| 3 | const fromId = vi.fn(() => null); | ||
| 4 | |||
| 5 | vi.mock("electron", () => ({ | ||
| 6 | session: { | ||
| 7 | fromPartition: vi.fn(), | ||
| 8 | }, | ||
| 9 | webContents: { | ||
| 10 | fromId, | ||
| 11 | }, | ||
| 12 | })); | ||
| 13 | |||
| 14 | describe("PreviewViewManager automation status", () => { | ||
| 15 | beforeEach(() => { | ||
| 16 | fromId.mockClear(); | ||
| 17 | }); | ||
| 18 | |||
| 19 | it("reports an unregistered webview as temporarily unavailable", async () => { | ||
| 20 | const { PreviewViewManager } = await import("./preview-view-manager.ts"); | ||
| 21 | const manager = new PreviewViewManager(); | ||
| 22 | |||
| 23 | expect(manager.automationStatus("tab_1")).toEqual({ | ||
| 24 | available: false, | ||
| 25 | visible: true, | ||
| 26 | tabId: "tab_1", | ||
| 27 | url: null, | ||
| 28 | title: null, | ||
| 29 | loading: false, | ||
| 30 | }); | ||
| 31 | |||
| 32 | manager.createTab("tab_1"); | ||
| 33 | |||
| 34 | expect(manager.automationStatus("tab_1")).toEqual({ | ||
| 35 | available: false, | ||
| 36 | visible: true, | ||
| 37 | tabId: "tab_1", | ||
| 38 | url: null, | ||
| 39 | title: null, | ||
| 40 | loading: false, | ||
| 41 | }); | ||
| 42 | expect(fromId).not.toHaveBeenCalled(); | ||
| 43 | }); | ||
| 44 | }); | ||
apps/desktop/src/preview-view-manager.ts+484-1| ... | @@ -6,9 +6,21 @@ | ... | @@ -6,9 +6,21 @@ |
| 6 | * elements live in the renderer; we only attach listeners and forward state | 6 | * elements live in the renderer; we only attach listeners and forward state |
| 7 | * here). Single layer-scoped browser session partition. | 7 | * here). Single layer-scoped browser session partition. |
| 8 | */ | 8 | */ |
| 9 | import type { PreviewAnnotationPayload, PreviewAnnotationRect } from "@t3tools/contracts"; | 9 | import type { |
| 10 | PreviewAnnotationPayload, | ||
| 11 | PreviewAnnotationRect, | ||
| 12 | PreviewAutomationClickInput, | ||
| 13 | PreviewAutomationEvaluateInput, | ||
| 14 | PreviewAutomationPressInput, | ||
| 15 | PreviewAutomationScrollInput, | ||
| 16 | PreviewAutomationSnapshot, | ||
| 17 | PreviewAutomationStatus, | ||
| 18 | PreviewAutomationTypeInput, | ||
| 19 | PreviewAutomationWaitForInput, | ||
| 20 | } from "@t3tools/contracts"; | ||
| 10 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; | 21 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 11 | import { type BrowserWindow, type Session, session, webContents } from "electron"; | 22 | import { type BrowserWindow, type Session, session, webContents } from "electron"; |
| 23 | import { setTimeout as sleep } from "node:timers/promises"; | ||
| 12 | 24 | ||
| 13 | import { isPreviewAnnotationPayload } from "./picked-element-payload.ts"; | 25 | import { isPreviewAnnotationPayload } from "./picked-element-payload.ts"; |
| 14 | 26 | ||
| ... | @@ -53,6 +65,36 @@ const ZOOM_LEVELS: ReadonlyArray<number> = [ | ... | @@ -53,6 +65,36 @@ const ZOOM_LEVELS: ReadonlyArray<number> = [ |
| 53 | 65 | ||
| 54 | const DEFAULT_ZOOM_FACTOR = 1.0; | 66 | const DEFAULT_ZOOM_FACTOR = 1.0; |
| 55 | const ZOOM_EPSILON = 0.001; | 67 | const ZOOM_EPSILON = 0.001; |
| 68 | const MAX_EVALUATION_BYTES = 64_000; | ||
| 69 | const MAX_VISIBLE_TEXT_LENGTH = 20_000; | ||
| 70 | const MAX_INTERACTIVE_ELEMENTS = 200; | ||
| 71 | const MAX_SCREENSHOT_WIDTH = 1280; | ||
| 72 | |||
| 73 | interface CdpEvaluationResult { | ||
| 74 | readonly result?: { | ||
| 75 | readonly value?: unknown; | ||
| 76 | readonly description?: string; | ||
| 77 | }; | ||
| 78 | readonly exceptionDetails?: { | ||
| 79 | readonly text?: string; | ||
| 80 | readonly exception?: { readonly description?: string }; | ||
| 81 | }; | ||
| 82 | } | ||
| 83 | |||
| 84 | const automationError = ( | ||
| 85 | tag: | ||
| 86 | | "PreviewAutomationExecutionError" | ||
| 87 | | "PreviewAutomationInvalidSelectorError" | ||
| 88 | | "PreviewAutomationResultTooLargeError" | ||
| 89 | | "PreviewAutomationTimeoutError", | ||
| 90 | message: string, | ||
| 91 | detail?: unknown, | ||
| 92 | ): Error & { detail?: unknown } => { | ||
| 93 | const error = new Error(message) as Error & { detail?: unknown }; | ||
| 94 | error.name = tag; | ||
| 95 | if (detail !== undefined) error.detail = detail; | ||
| 96 | return error; | ||
| 97 | }; | ||
| 56 | 98 | ||
| 57 | const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { | 99 | const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { |
| 58 | if (typeof value !== "object" || value === null) return null; | 100 | if (typeof value !== "object" || value === null) return null; |
| ... | @@ -455,6 +497,391 @@ export class PreviewViewManager { | ... | @@ -455,6 +497,391 @@ export class PreviewViewManager { |
| 455 | this.applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR); | 497 | this.applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR); |
| 456 | } | 498 | } |
| 457 | 499 | ||
| 500 | automationStatus(tabId: string): PreviewAutomationStatus { | ||
| 501 | const tab = this.tabs.get(tabId); | ||
| 502 | if (!tab || tab.webContentsId == null) { | ||
| 503 | const navStatus = tab?.navStatus; | ||
| 504 | return { | ||
| 505 | available: false, | ||
| 506 | visible: true, | ||
| 507 | tabId, | ||
| 508 | url: !navStatus || navStatus.kind === "Idle" ? null : navStatus.url, | ||
| 509 | title: !navStatus || navStatus.kind === "Idle" ? null : navStatus.title, | ||
| 510 | loading: navStatus?.kind === "Loading", | ||
| 511 | }; | ||
| 512 | } | ||
| 513 | const wc = webContents.fromId(tab.webContentsId); | ||
| 514 | if (!wc || wc.isDestroyed()) { | ||
| 515 | return { | ||
| 516 | available: false, | ||
| 517 | visible: true, | ||
| 518 | tabId, | ||
| 519 | url: null, | ||
| 520 | title: null, | ||
| 521 | loading: false, | ||
| 522 | }; | ||
| 523 | } | ||
| 524 | return { | ||
| 525 | available: true, | ||
| 526 | visible: true, | ||
| 527 | tabId, | ||
| 528 | url: wc.getURL() || null, | ||
| 529 | title: wc.getTitle() || null, | ||
| 530 | loading: wc.isLoading(), | ||
| 531 | }; | ||
| 532 | } | ||
| 533 | |||
| 534 | async automationSnapshot(tabId: string): Promise<PreviewAutomationSnapshot> { | ||
| 535 | const wc = this.requireWebContents(tabId); | ||
| 536 | return this.withDebugger(wc, async (send) => { | ||
| 537 | await Promise.all([send("Runtime.enable"), send("Accessibility.enable")]); | ||
| 538 | const page = await this.evaluateWithDebugger<{ | ||
| 539 | url: string; | ||
| 540 | title: string; | ||
| 541 | loading: boolean; | ||
| 542 | visibleText: string; | ||
| 543 | interactiveElements: PreviewAutomationSnapshot["interactiveElements"]; | ||
| 544 | }>( | ||
| 545 | send, | ||
| 546 | `(() => { | ||
| 547 | const selectorFor = (element) => { | ||
| 548 | if (element.id) return "#" + CSS.escape(element.id); | ||
| 549 | for (const attribute of ["data-testid", "name"]) { | ||
| 550 | const value = element.getAttribute(attribute); | ||
| 551 | if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]"; | ||
| 552 | } | ||
| 553 | const parts = []; | ||
| 554 | let current = element; | ||
| 555 | while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 8) { | ||
| 556 | let part = current.tagName.toLowerCase(); | ||
| 557 | const parent = current.parentElement; | ||
| 558 | if (parent) { | ||
| 559 | const siblings = Array.from(parent.children).filter((child) => child.tagName === current.tagName); | ||
| 560 | if (siblings.length > 1) part += ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")"; | ||
| 561 | } | ||
| 562 | parts.unshift(part); | ||
| 563 | current = parent; | ||
| 564 | } | ||
| 565 | return parts.join(" > "); | ||
| 566 | }; | ||
| 567 | const visible = (element) => { | ||
| 568 | const style = getComputedStyle(element); | ||
| 569 | const rect = element.getBoundingClientRect(); | ||
| 570 | return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0; | ||
| 571 | }; | ||
| 572 | const elements = Array.from(document.querySelectorAll( | ||
| 573 | "a[href],button,input,textarea,select,[role],[tabindex]" | ||
| 574 | )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => { | ||
| 575 | const rect = element.getBoundingClientRect(); | ||
| 576 | return { | ||
| 577 | tag: element.tagName.toLowerCase(), | ||
| 578 | role: element.getAttribute("role"), | ||
| 579 | name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "", | ||
| 580 | selector: selectorFor(element), | ||
| 581 | x: rect.x, | ||
| 582 | y: rect.y, | ||
| 583 | width: rect.width, | ||
| 584 | height: rect.height | ||
| 585 | }; | ||
| 586 | }); | ||
| 587 | return { | ||
| 588 | url: location.href, | ||
| 589 | title: document.title, | ||
| 590 | loading: document.readyState !== "complete", | ||
| 591 | visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}), | ||
| 592 | interactiveElements: elements | ||
| 593 | }; | ||
| 594 | })()`, | ||
| 595 | true, | ||
| 596 | ); | ||
| 597 | const accessibility = await send("Accessibility.getFullAXTree"); | ||
| 598 | let image = await wc.capturePage(); | ||
| 599 | let size = image.getSize(); | ||
| 600 | if (size.width > MAX_SCREENSHOT_WIDTH) { | ||
| 601 | image = image.resize({ width: MAX_SCREENSHOT_WIDTH }); | ||
| 602 | size = image.getSize(); | ||
| 603 | } | ||
| 604 | return { | ||
| 605 | ...page, | ||
| 606 | accessibilityTree: accessibility, | ||
| 607 | screenshot: { | ||
| 608 | mimeType: "image/png", | ||
| 609 | data: image.toPNG().toString("base64"), | ||
| 610 | width: size.width, | ||
| 611 | height: size.height, | ||
| 612 | }, | ||
| 613 | }; | ||
| 614 | }); | ||
| 615 | } | ||
| 616 | |||
| 617 | async automationClick(tabId: string, input: PreviewAutomationClickInput): Promise<void> { | ||
| 618 | const wc = this.requireWebContents(tabId); | ||
| 619 | await this.withDebugger(wc, async (send) => { | ||
| 620 | await Promise.all([ | ||
| 621 | send("Runtime.enable"), | ||
| 622 | send("Input.setIgnoreInputEvents", { ignore: false }), | ||
| 623 | ]); | ||
| 624 | let x: number; | ||
| 625 | let y: number; | ||
| 626 | if ("selector" in input) { | ||
| 627 | const point = await this.evaluateWithDebugger< | ||
| 628 | { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true } | ||
| 629 | >( | ||
| 630 | send, | ||
| 631 | `(() => { | ||
| 632 | try { | ||
| 633 | const element = document.querySelector(${JSON.stringify(input.selector)}); | ||
| 634 | if (!element) return { notFound: true }; | ||
| 635 | element.scrollIntoView({ block: "center", inline: "center" }); | ||
| 636 | const rect = element.getBoundingClientRect(); | ||
| 637 | return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; | ||
| 638 | } catch (error) { | ||
| 639 | return { invalidSelector: true, message: String(error) }; | ||
| 640 | } | ||
| 641 | })()`, | ||
| 642 | true, | ||
| 643 | ); | ||
| 644 | if ("invalidSelector" in point) { | ||
| 645 | throw automationError("PreviewAutomationInvalidSelectorError", point.message, { | ||
| 646 | selector: input.selector, | ||
| 647 | }); | ||
| 648 | } | ||
| 649 | if ("notFound" in point) { | ||
| 650 | throw automationError( | ||
| 651 | "PreviewAutomationExecutionError", | ||
| 652 | `No element matches selector ${input.selector}.`, | ||
| 653 | ); | ||
| 654 | } | ||
| 655 | x = point.x; | ||
| 656 | y = point.y; | ||
| 657 | } else { | ||
| 658 | x = input.x; | ||
| 659 | y = input.y; | ||
| 660 | } | ||
| 661 | const viewport = await this.evaluateWithDebugger<{ width: number; height: number }>( | ||
| 662 | send, | ||
| 663 | "({ width: window.innerWidth, height: window.innerHeight })", | ||
| 664 | true, | ||
| 665 | ); | ||
| 666 | if (x < 0 || y < 0 || x > viewport.width || y > viewport.height) { | ||
| 667 | throw automationError( | ||
| 668 | "PreviewAutomationExecutionError", | ||
| 669 | `Click coordinates (${x}, ${y}) are outside the preview viewport.`, | ||
| 670 | ); | ||
| 671 | } | ||
| 672 | await send("Input.dispatchMouseEvent", { | ||
| 673 | type: "mousePressed", | ||
| 674 | x, | ||
| 675 | y, | ||
| 676 | button: "left", | ||
| 677 | clickCount: 1, | ||
| 678 | }); | ||
| 679 | await send("Input.dispatchMouseEvent", { | ||
| 680 | type: "mouseReleased", | ||
| 681 | x, | ||
| 682 | y, | ||
| 683 | button: "left", | ||
| 684 | clickCount: 1, | ||
| 685 | }); | ||
| 686 | }); | ||
| 687 | } | ||
| 688 | |||
| 689 | async automationType(tabId: string, input: PreviewAutomationTypeInput): Promise<void> { | ||
| 690 | const wc = this.requireWebContents(tabId); | ||
| 691 | await this.withDebugger(wc, async (send) => { | ||
| 692 | await send("Runtime.enable"); | ||
| 693 | const focusResult = await this.evaluateWithDebugger< | ||
| 694 | { ok: true } | { invalidSelector: true; message: string } | { notFound: true } | ||
| 695 | >( | ||
| 696 | send, | ||
| 697 | `(() => { | ||
| 698 | try { | ||
| 699 | const element = ${ | ||
| 700 | input.selector | ||
| 701 | ? `document.querySelector(${JSON.stringify(input.selector)})` | ||
| 702 | : "document.activeElement" | ||
| 703 | }; | ||
| 704 | if (!element) return { notFound: true }; | ||
| 705 | element.focus(); | ||
| 706 | if (${input.clear ?? false}) { | ||
| 707 | if ("value" in element) element.value = ""; | ||
| 708 | else if (element.isContentEditable) element.textContent = ""; | ||
| 709 | element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" })); | ||
| 710 | } | ||
| 711 | return { ok: true }; | ||
| 712 | } catch (error) { | ||
| 713 | return { invalidSelector: true, message: String(error) }; | ||
| 714 | } | ||
| 715 | })()`, | ||
| 716 | true, | ||
| 717 | ); | ||
| 718 | if ("invalidSelector" in focusResult) { | ||
| 719 | throw automationError("PreviewAutomationInvalidSelectorError", focusResult.message, { | ||
| 720 | selector: input.selector ?? "", | ||
| 721 | }); | ||
| 722 | } | ||
| 723 | if ("notFound" in focusResult) { | ||
| 724 | throw automationError( | ||
| 725 | "PreviewAutomationExecutionError", | ||
| 726 | input.selector | ||
| 727 | ? `No element matches selector ${input.selector}.` | ||
| 728 | : "No element is focused in the preview.", | ||
| 729 | ); | ||
| 730 | } | ||
| 731 | await send("Input.insertText", { text: input.text }); | ||
| 732 | await this.evaluateWithDebugger( | ||
| 733 | send, | ||
| 734 | `(() => { | ||
| 735 | const element = document.activeElement; | ||
| 736 | element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${JSON.stringify(input.text)} })); | ||
| 737 | element?.dispatchEvent(new Event("change", { bubbles: true })); | ||
| 738 | })()`, | ||
| 739 | false, | ||
| 740 | ); | ||
| 741 | }); | ||
| 742 | } | ||
| 743 | |||
| 744 | async automationPress(tabId: string, input: PreviewAutomationPressInput): Promise<void> { | ||
| 745 | const wc = this.requireWebContents(tabId); | ||
| 746 | await this.withDebugger(wc, async (send) => { | ||
| 747 | const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { | ||
| 748 | switch (modifier) { | ||
| 749 | case "Alt": | ||
| 750 | return value | 1; | ||
| 751 | case "Control": | ||
| 752 | return value | 2; | ||
| 753 | case "Meta": | ||
| 754 | return value | 4; | ||
| 755 | case "Shift": | ||
| 756 | return value | 8; | ||
| 757 | } | ||
| 758 | }, 0); | ||
| 759 | const key = input.key; | ||
| 760 | const text = key.length === 1 ? key : undefined; | ||
| 761 | const params = { | ||
| 762 | key, | ||
| 763 | code: key.length === 1 ? `Key${key.toUpperCase()}` : key, | ||
| 764 | modifiers, | ||
| 765 | ...(text ? { text, unmodifiedText: text } : {}), | ||
| 766 | }; | ||
| 767 | await send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); | ||
| 768 | await send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); | ||
| 769 | }); | ||
| 770 | } | ||
| 771 | |||
| 772 | async automationScroll(tabId: string, input: PreviewAutomationScrollInput): Promise<void> { | ||
| 773 | const wc = this.requireWebContents(tabId); | ||
| 774 | await this.withDebugger(wc, async (send) => { | ||
| 775 | await send("Runtime.enable"); | ||
| 776 | const result = await this.evaluateWithDebugger< | ||
| 777 | { ok: true } | { invalidSelector: true; message: string } | { notFound: true } | ||
| 778 | >( | ||
| 779 | send, | ||
| 780 | `(() => { | ||
| 781 | try { | ||
| 782 | const target = ${ | ||
| 783 | input.selector | ||
| 784 | ? `document.querySelector(${JSON.stringify(input.selector)})` | ||
| 785 | : "window" | ||
| 786 | }; | ||
| 787 | if (!target) return { notFound: true }; | ||
| 788 | target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); | ||
| 789 | return { ok: true }; | ||
| 790 | } catch (error) { | ||
| 791 | return { invalidSelector: true, message: String(error) }; | ||
| 792 | } | ||
| 793 | })()`, | ||
| 794 | true, | ||
| 795 | ); | ||
| 796 | if ("invalidSelector" in result) { | ||
| 797 | throw automationError("PreviewAutomationInvalidSelectorError", result.message, { | ||
| 798 | selector: input.selector ?? "", | ||
| 799 | }); | ||
| 800 | } | ||
| 801 | if ("notFound" in result) { | ||
| 802 | throw automationError( | ||
| 803 | "PreviewAutomationExecutionError", | ||
| 804 | `No element matches selector ${input.selector}.`, | ||
| 805 | ); | ||
| 806 | } | ||
| 807 | }); | ||
| 808 | } | ||
| 809 | |||
| 810 | async automationEvaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise<unknown> { | ||
| 811 | const wc = this.requireWebContents(tabId); | ||
| 812 | return this.withDebugger(wc, async (send) => { | ||
| 813 | await send("Runtime.enable"); | ||
| 814 | const value = await this.evaluateWithDebugger( | ||
| 815 | send, | ||
| 816 | input.expression, | ||
| 817 | input.returnByValue ?? true, | ||
| 818 | input.awaitPromise ?? true, | ||
| 819 | ); | ||
| 820 | const serialized = JSON.stringify(value); | ||
| 821 | if ( | ||
| 822 | serialized !== undefined && | ||
| 823 | Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES | ||
| 824 | ) { | ||
| 825 | throw automationError( | ||
| 826 | "PreviewAutomationResultTooLargeError", | ||
| 827 | `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`, | ||
| 828 | { maximumBytes: MAX_EVALUATION_BYTES }, | ||
| 829 | ); | ||
| 830 | } | ||
| 831 | return value; | ||
| 832 | }); | ||
| 833 | } | ||
| 834 | |||
| 835 | async automationWaitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise<void> { | ||
| 836 | const wc = this.requireWebContents(tabId); | ||
| 837 | const timeoutMs = input.timeoutMs ?? 15_000; | ||
| 838 | await this.withDebugger(wc, async (send) => { | ||
| 839 | await send("Runtime.enable"); | ||
| 840 | const deadline = Date.now() + timeoutMs; | ||
| 841 | while (Date.now() <= deadline) { | ||
| 842 | const result = await this.evaluateWithDebugger< | ||
| 843 | { matched: boolean } | { invalidSelector: true; message: string } | ||
| 844 | >( | ||
| 845 | send, | ||
| 846 | `(() => { | ||
| 847 | try { | ||
| 848 | const selectorMatched = ${ | ||
| 849 | input.selector | ||
| 850 | ? `document.querySelector(${JSON.stringify(input.selector)}) !== null` | ||
| 851 | : "true" | ||
| 852 | }; | ||
| 853 | const textMatched = ${ | ||
| 854 | input.text | ||
| 855 | ? `(document.body?.innerText || "").includes(${JSON.stringify(input.text)})` | ||
| 856 | : "true" | ||
| 857 | }; | ||
| 858 | const urlMatched = ${ | ||
| 859 | input.urlIncludes | ||
| 860 | ? `location.href.includes(${JSON.stringify(input.urlIncludes)})` | ||
| 861 | : "true" | ||
| 862 | }; | ||
| 863 | return { matched: selectorMatched && textMatched && urlMatched }; | ||
| 864 | } catch (error) { | ||
| 865 | return { invalidSelector: true, message: String(error) }; | ||
| 866 | } | ||
| 867 | })()`, | ||
| 868 | true, | ||
| 869 | ); | ||
| 870 | if ("invalidSelector" in result) { | ||
| 871 | throw automationError("PreviewAutomationInvalidSelectorError", result.message, { | ||
| 872 | selector: input.selector ?? "", | ||
| 873 | }); | ||
| 874 | } | ||
| 875 | if (result.matched) return; | ||
| 876 | await sleep(100); | ||
| 877 | } | ||
| 878 | throw automationError( | ||
| 879 | "PreviewAutomationTimeoutError", | ||
| 880 | `Preview condition did not match within ${timeoutMs}ms.`, | ||
| 881 | ); | ||
| 882 | }); | ||
| 883 | } | ||
| 884 | |||
| 458 | private applyZoom(tabId: string, transform: (current: number) => number): void { | 885 | private applyZoom(tabId: string, transform: (current: number) => number): void { |
| 459 | const tab = this.tabs.get(tabId); | 886 | const tab = this.tabs.get(tabId); |
| 460 | if (!tab) return; | 887 | if (!tab) return; |
| ... | @@ -469,6 +896,62 @@ export class PreviewViewManager { | ... | @@ -469,6 +896,62 @@ export class PreviewViewManager { |
| 469 | this.update(tabId, { zoomFactor: next }); | 896 | this.update(tabId, { zoomFactor: next }); |
| 470 | } | 897 | } |
| 471 | 898 | ||
| 899 | private async withDebugger<A>( | ||
| 900 | wc: Electron.WebContents, | ||
| 901 | use: ( | ||
| 902 | send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>, | ||
| 903 | ) => Promise<A>, | ||
| 904 | ): Promise<A> { | ||
| 905 | if (wc.debugger.isAttached()) { | ||
| 906 | throw automationError( | ||
| 907 | "PreviewAutomationExecutionError", | ||
| 908 | "Preview automation is unavailable while another debugger is attached.", | ||
| 909 | ); | ||
| 910 | } | ||
| 911 | wc.debugger.attach("1.3"); | ||
| 912 | try { | ||
| 913 | return await use((method, commandParams) => wc.debugger.sendCommand(method, commandParams)); | ||
| 914 | } catch (cause) { | ||
| 915 | if (cause instanceof Error && cause.name.startsWith("PreviewAutomation")) throw cause; | ||
| 916 | throw automationError( | ||
| 917 | "PreviewAutomationExecutionError", | ||
| 918 | cause instanceof Error ? cause.message : String(cause), | ||
| 919 | cause, | ||
| 920 | ); | ||
| 921 | } finally { | ||
| 922 | if (wc.debugger.isAttached()) { | ||
| 923 | try { | ||
| 924 | wc.debugger.detach(); | ||
| 925 | } catch { | ||
| 926 | // The target can disappear while an operation is completing. | ||
| 927 | } | ||
| 928 | } | ||
| 929 | } | ||
| 930 | } | ||
| 931 | |||
| 932 | private async evaluateWithDebugger<A = unknown>( | ||
| 933 | send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>, | ||
| 934 | expression: string, | ||
| 935 | returnByValue: boolean, | ||
| 936 | awaitPromise = true, | ||
| 937 | ): Promise<A> { | ||
| 938 | const response = (await send("Runtime.evaluate", { | ||
| 939 | expression, | ||
| 940 | awaitPromise, | ||
| 941 | returnByValue, | ||
| 942 | userGesture: true, | ||
| 943 | })) as CdpEvaluationResult; | ||
| 944 | if (response.exceptionDetails) { | ||
| 945 | throw automationError( | ||
| 946 | "PreviewAutomationExecutionError", | ||
| 947 | response.exceptionDetails.exception?.description ?? | ||
| 948 | response.exceptionDetails.text ?? | ||
| 949 | "JavaScript evaluation failed.", | ||
| 950 | ); | ||
| 951 | } | ||
| 952 | return response.result?.value as A; | ||
| 953 | } | ||
| 954 | |||
| 472 | onStateChange(listener: Listener): () => void { | 955 | onStateChange(listener: Listener): () => void { |
| 473 | this.listeners.add(listener); | 956 | this.listeners.add(listener); |
| 474 | return () => { | 957 | return () => { |
apps/server/src/mcp/Layers/McpHttpServer.test.ts created+133| ... | @@ -0,0 +1,133 @@ | ||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Stream from "effect/Stream"; | ||
| 6 | import { McpSchema, McpServer } from "effect/unstable/ai"; | ||
| 7 | import { HttpServerResponse } from "effect/unstable/http"; | ||
| 8 | |||
| 9 | import { McpInvocationContext } from "../Services/McpInvocationContext.ts"; | ||
| 10 | import { normalizeMcpHttpResponse, PreviewToolkitRegistrationLive } from "./McpHttpServer.ts"; | ||
| 11 | import { previewAutomationBroker } from "./PreviewAutomationBroker.ts"; | ||
| 12 | |||
| 13 | const environmentId = EnvironmentId.make("environment-mcp-test"); | ||
| 14 | const threadId = ThreadId.make("thread-mcp-test"); | ||
| 15 | const tabId = PreviewTabId.make("tab-mcp-test"); | ||
| 16 | const invocation = { | ||
| 17 | environmentId, | ||
| 18 | threadId, | ||
| 19 | providerSessionId: "provider-session-mcp-test", | ||
| 20 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 21 | capabilities: new Set(["preview"] as const), | ||
| 22 | issuedAt: 1, | ||
| 23 | expiresAt: Number.MAX_SAFE_INTEGER, | ||
| 24 | }; | ||
| 25 | const client = McpSchema.McpServerClient.of({ | ||
| 26 | clientId: 1, | ||
| 27 | initializePayload: { | ||
| 28 | protocolVersion: "2025-03-26", | ||
| 29 | capabilities: {}, | ||
| 30 | clientInfo: { name: "mcp-test", version: "1.0.0" }, | ||
| 31 | }, | ||
| 32 | getClient: Effect.die("unused"), | ||
| 33 | }); | ||
| 34 | const TestLayer = PreviewToolkitRegistrationLive.pipe( | ||
| 35 | Layer.provideMerge(McpServer.McpServer.layer), | ||
| 36 | ); | ||
| 37 | |||
| 38 | it("normalizes empty successful notification responses to accepted", () => { | ||
| 39 | const notificationResponse = normalizeMcpHttpResponse( | ||
| 40 | HttpServerResponse.text("", { status: 200, contentType: "application/json" }), | ||
| 41 | ); | ||
| 42 | expect(notificationResponse.status).toBe(202); | ||
| 43 | |||
| 44 | const resultResponse = normalizeMcpHttpResponse( | ||
| 45 | HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: 1, result: {} }), | ||
| 46 | ); | ||
| 47 | expect(resultResponse.status).toBe(200); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it.effect("registers annotated tools and preserves authenticated request context", () => | ||
| 51 | Effect.scoped( | ||
| 52 | Effect.gen(function* () { | ||
| 53 | const server = yield* McpServer.McpServer; | ||
| 54 | const requests = yield* previewAutomationBroker.connect("mcp-test-client"); | ||
| 55 | yield* Stream.runForEach(requests, (request) => | ||
| 56 | previewAutomationBroker.respond({ | ||
| 57 | requestId: request.requestId, | ||
| 58 | ok: true, | ||
| 59 | result: | ||
| 60 | request.operation === "snapshot" | ||
| 61 | ? { | ||
| 62 | url: "http://example.test/", | ||
| 63 | title: "Example", | ||
| 64 | loading: false, | ||
| 65 | visibleText: "Example", | ||
| 66 | interactiveElements: [], | ||
| 67 | accessibilityTree: {}, | ||
| 68 | screenshot: { | ||
| 69 | mimeType: "image/png", | ||
| 70 | data: Buffer.from("png").toString("base64"), | ||
| 71 | width: 10, | ||
| 72 | height: 5, | ||
| 73 | }, | ||
| 74 | } | ||
| 75 | : { | ||
| 76 | available: true, | ||
| 77 | visible: true, | ||
| 78 | tabId, | ||
| 79 | url: "http://example.test/", | ||
| 80 | title: "Example", | ||
| 81 | loading: false, | ||
| 82 | }, | ||
| 83 | }), | ||
| 84 | ).pipe(Effect.forkScoped); | ||
| 85 | yield* Effect.yieldNow; | ||
| 86 | yield* previewAutomationBroker.reportOwner({ | ||
| 87 | clientId: "mcp-test-client", | ||
| 88 | environmentId, | ||
| 89 | threadId, | ||
| 90 | tabId, | ||
| 91 | visible: true, | ||
| 92 | supportsAutomation: true, | ||
| 93 | focusedAt: "2026-06-11T00:00:00.000Z", | ||
| 94 | }); | ||
| 95 | |||
| 96 | const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status"); | ||
| 97 | expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true); | ||
| 98 | expect(statusTool?.tool.annotations?.idempotentHint).toBe(true); | ||
| 99 | |||
| 100 | const status = yield* server | ||
| 101 | .callTool({ name: "preview_status", arguments: {} }) | ||
| 102 | .pipe( | ||
| 103 | Effect.provideService(McpInvocationContext, invocation), | ||
| 104 | Effect.provideService(McpSchema.McpServerClient, client), | ||
| 105 | ); | ||
| 106 | expect(status.isError).toBe(false); | ||
| 107 | expect(status.structuredContent).toMatchObject({ | ||
| 108 | available: true, | ||
| 109 | tabId, | ||
| 110 | }); | ||
| 111 | |||
| 112 | const malformed = yield* server | ||
| 113 | .callTool({ name: "preview_click", arguments: { selector: "" } }) | ||
| 114 | .pipe( | ||
| 115 | Effect.provideService(McpInvocationContext, invocation), | ||
| 116 | Effect.provideService(McpSchema.McpServerClient, client), | ||
| 117 | ); | ||
| 118 | expect(malformed.isError).toBe(true); | ||
| 119 | |||
| 120 | const snapshot = yield* server | ||
| 121 | .callTool({ name: "preview_snapshot", arguments: {} }) | ||
| 122 | .pipe( | ||
| 123 | Effect.provideService(McpInvocationContext, invocation), | ||
| 124 | Effect.provideService(McpSchema.McpServerClient, client), | ||
| 125 | ); | ||
| 126 | expect(snapshot.isError).toBe(false); | ||
| 127 | expect(snapshot.content.some((content) => content.type === "image")).toBe(true); | ||
| 128 | expect(snapshot.structuredContent).toMatchObject({ | ||
| 129 | screenshot: { mimeType: "image/png", width: 10, height: 5 }, | ||
| 130 | }); | ||
| 131 | }), | ||
| 132 | ).pipe(Effect.provide(TestLayer)), | ||
| 133 | ); | ||
apps/server/src/mcp/Layers/McpHttpServer.ts created+162| ... | @@ -0,0 +1,162 @@ | ||
| 1 | import * as Cause from "effect/Cause"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as Sink from "effect/Sink"; | ||
| 7 | import * as Stream from "effect/Stream"; | ||
| 8 | import { McpSchema, McpServer, Tool } from "effect/unstable/ai"; | ||
| 9 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | ||
| 10 | |||
| 11 | import packageJson from "../../../package.json" with { type: "json" }; | ||
| 12 | import { McpInvocationContext } from "../Services/McpInvocationContext.ts"; | ||
| 13 | import { McpSessionRegistry } from "../Services/McpSessionRegistry.ts"; | ||
| 14 | import { PreviewToolkitHandlersLive } from "../toolkits/preview/handlers.ts"; | ||
| 15 | import { PreviewToolkit } from "../toolkits/preview/tools.ts"; | ||
| 16 | |||
| 17 | const unauthorized = HttpServerResponse.jsonUnsafe( | ||
| 18 | { | ||
| 19 | error: "invalid_mcp_credential", | ||
| 20 | message: "A valid provider-scoped MCP bearer credential is required.", | ||
| 21 | }, | ||
| 22 | { | ||
| 23 | status: 401, | ||
| 24 | headers: { | ||
| 25 | "cache-control": "no-store", | ||
| 26 | "www-authenticate": "Bearer", | ||
| 27 | }, | ||
| 28 | }, | ||
| 29 | ); | ||
| 30 | |||
| 31 | export const normalizeMcpHttpResponse = ( | ||
| 32 | response: HttpServerResponse.HttpServerResponse, | ||
| 33 | ): HttpServerResponse.HttpServerResponse => { | ||
| 34 | const bodyIsEmpty = | ||
| 35 | response.body._tag === "Empty" || | ||
| 36 | (response.body._tag === "Uint8Array" && response.body.contentLength === 0) || | ||
| 37 | (response.body._tag === "Raw" && response.body.contentLength === 0); | ||
| 38 | return response.status === 200 && bodyIsEmpty | ||
| 39 | ? HttpServerResponse.setStatus(response, 202) | ||
| 40 | : response; | ||
| 41 | }; | ||
| 42 | |||
| 43 | const McpAuthMiddlewareLive = HttpRouter.middleware<{ | ||
| 44 | provides: McpInvocationContext; | ||
| 45 | }>()( | ||
| 46 | Effect.gen(function* () { | ||
| 47 | const registry = yield* McpSessionRegistry; | ||
| 48 | return (httpEffect) => | ||
| 49 | Effect.gen(function* () { | ||
| 50 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 51 | const authorization = request.headers.authorization; | ||
| 52 | const token = | ||
| 53 | authorization?.startsWith("Bearer ") === true | ||
| 54 | ? authorization.slice("Bearer ".length).trim() | ||
| 55 | : ""; | ||
| 56 | const invocation = yield* registry.resolve(token); | ||
| 57 | if (!invocation) return unauthorized; | ||
| 58 | return yield* httpEffect.pipe( | ||
| 59 | Effect.provideService(McpInvocationContext, invocation), | ||
| 60 | Effect.map(normalizeMcpHttpResponse), | ||
| 61 | ); | ||
| 62 | }); | ||
| 63 | }), | ||
| 64 | ).layer; | ||
| 65 | |||
| 66 | const McpTransportLive = McpServer.layerHttp({ | ||
| 67 | name: "T3 Code", | ||
| 68 | version: packageJson.version, | ||
| 69 | path: "/mcp", | ||
| 70 | }).pipe(Layer.provide(McpAuthMiddlewareLive)); | ||
| 71 | |||
| 72 | export const PreviewToolkitRegistrationLive = Layer.effectDiscard( | ||
| 73 | Effect.gen(function* () { | ||
| 74 | const server = yield* McpServer.McpServer; | ||
| 75 | const built = yield* PreviewToolkit; | ||
| 76 | const handleTool = built.handle as unknown as ( | ||
| 77 | name: keyof typeof built.tools, | ||
| 78 | payload: unknown, | ||
| 79 | ) => Effect.Effect< | ||
| 80 | Stream.Stream<{ readonly encodedResult: unknown }, Error>, | ||
| 81 | Error, | ||
| 82 | McpInvocationContext | ||
| 83 | >; | ||
| 84 | for (const tool of Object.values(built.tools)) { | ||
| 85 | yield* server.addTool({ | ||
| 86 | tool: new McpSchema.Tool({ | ||
| 87 | name: tool.name, | ||
| 88 | description: Tool.getDescription(tool), | ||
| 89 | inputSchema: Tool.getJsonSchema(tool), | ||
| 90 | annotations: { | ||
| 91 | ...Context.getOption(tool.annotations, Tool.Title).pipe( | ||
| 92 | Option.map((title) => ({ title })), | ||
| 93 | Option.getOrUndefined, | ||
| 94 | ), | ||
| 95 | readOnlyHint: Context.get(tool.annotations, Tool.Readonly), | ||
| 96 | destructiveHint: Context.get(tool.annotations, Tool.Destructive), | ||
| 97 | idempotentHint: Context.get(tool.annotations, Tool.Idempotent), | ||
| 98 | openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), | ||
| 99 | }, | ||
| 100 | }), | ||
| 101 | annotations: tool.annotations, | ||
| 102 | handle: (payload) => | ||
| 103 | handleTool(tool.name as keyof typeof built.tools, payload).pipe( | ||
| 104 | Stream.unwrap, | ||
| 105 | Stream.run(Sink.last()), | ||
| 106 | Effect.flatMap(Effect.fromOption), | ||
| 107 | Effect.matchCause({ | ||
| 108 | onFailure: (cause) => | ||
| 109 | new McpSchema.CallToolResult({ | ||
| 110 | isError: true, | ||
| 111 | content: [{ type: "text", text: Cause.pretty(cause) }], | ||
| 112 | }), | ||
| 113 | onSuccess: (result) => { | ||
| 114 | if (tool.name === "preview_snapshot") { | ||
| 115 | const snapshot = result.encodedResult as { | ||
| 116 | readonly screenshot: { | ||
| 117 | readonly mimeType: "image/png"; | ||
| 118 | readonly data: string; | ||
| 119 | readonly width: number; | ||
| 120 | readonly height: number; | ||
| 121 | }; | ||
| 122 | readonly [key: string]: unknown; | ||
| 123 | }; | ||
| 124 | const { screenshot, ...page } = snapshot; | ||
| 125 | const metadata = { | ||
| 126 | ...page, | ||
| 127 | screenshot: { | ||
| 128 | mimeType: screenshot.mimeType, | ||
| 129 | width: screenshot.width, | ||
| 130 | height: screenshot.height, | ||
| 131 | }, | ||
| 132 | }; | ||
| 133 | return new McpSchema.CallToolResult({ | ||
| 134 | isError: false, | ||
| 135 | structuredContent: metadata, | ||
| 136 | content: [ | ||
| 137 | { type: "text", text: JSON.stringify(metadata) }, | ||
| 138 | { | ||
| 139 | type: "image", | ||
| 140 | data: new Uint8Array(Buffer.from(screenshot.data, "base64")), | ||
| 141 | mimeType: screenshot.mimeType, | ||
| 142 | }, | ||
| 143 | ], | ||
| 144 | }); | ||
| 145 | } | ||
| 146 | return new McpSchema.CallToolResult({ | ||
| 147 | isError: false, | ||
| 148 | structuredContent: | ||
| 149 | typeof result.encodedResult === "object" ? result.encodedResult : undefined, | ||
| 150 | content: [{ type: "text", text: JSON.stringify(result.encodedResult) }], | ||
| 151 | }); | ||
| 152 | }, | ||
| 153 | }), | ||
| 154 | ) as unknown as Effect.Effect<McpSchema.CallToolResult, never, McpSchema.McpServerClient>, | ||
| 155 | }); | ||
| 156 | } | ||
| 157 | }), | ||
| 158 | ).pipe(Layer.provide(PreviewToolkitHandlersLive)); | ||
| 159 | |||
| 160 | export const McpHttpServerLive = Layer.mergeAll(PreviewToolkitRegistrationLive).pipe( | ||
| 161 | Layer.provideMerge(McpTransportLive), | ||
| 162 | ); | ||
apps/server/src/mcp/Layers/McpSessionRegistry.test.ts created+66| ... | @@ -0,0 +1,66 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { expect, it } from "@effect/vitest"; | ||
| 3 | import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import { HttpServer } from "effect/unstable/http"; | ||
| 6 | |||
| 7 | import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; | ||
| 8 | import { makeMcpSessionRegistry } from "./McpSessionRegistry.ts"; | ||
| 9 | |||
| 10 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 11 | const fakeHttpServer = HttpServer.HttpServer.of({ | ||
| 12 | address: { _tag: "TcpAddress", hostname: "127.0.0.1", port: 43123 }, | ||
| 13 | serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"], | ||
| 14 | }); | ||
| 15 | const fakeEnvironment = ServerEnvironment.of({ | ||
| 16 | getEnvironmentId: Effect.succeed(environmentId), | ||
| 17 | getDescriptor: Effect.die("unused"), | ||
| 18 | }); | ||
| 19 | |||
| 20 | const makeRegistry = (now: () => number) => | ||
| 21 | makeMcpSessionRegistry({ | ||
| 22 | now, | ||
| 23 | idleTimeoutMs: 100, | ||
| 24 | maximumLifetimeMs: 1_000, | ||
| 25 | }).pipe( | ||
| 26 | Effect.provideService(HttpServer.HttpServer, fakeHttpServer), | ||
| 27 | Effect.provideService(ServerEnvironment, fakeEnvironment), | ||
| 28 | Effect.provide(NodeServices.layer), | ||
| 29 | ); | ||
| 30 | |||
| 31 | it.effect("stores only a token hash, resolves the bearer token, and revokes by thread", () => | ||
| 32 | Effect.gen(function* () { | ||
| 33 | let timestamp = 1_000; | ||
| 34 | const registry = yield* makeRegistry(() => timestamp); | ||
| 35 | const threadId = ThreadId.make("thread-1"); | ||
| 36 | const issued = yield* registry.issue({ | ||
| 37 | threadId, | ||
| 38 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 39 | }); | ||
| 40 | expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp"); | ||
| 41 | const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); | ||
| 42 | expect(token.length).toBeGreaterThan(20); | ||
| 43 | |||
| 44 | const resolved = yield* registry.resolve(token); | ||
| 45 | expect(resolved?.threadId).toBe(threadId); | ||
| 46 | |||
| 47 | yield* registry.revokeThread(threadId); | ||
| 48 | expect(yield* registry.resolve(token)).toBeUndefined(); | ||
| 49 | |||
| 50 | timestamp += 2_000; | ||
| 51 | }), | ||
| 52 | ); | ||
| 53 | |||
| 54 | it.effect("expires credentials after inactivity", () => | ||
| 55 | Effect.gen(function* () { | ||
| 56 | let timestamp = 1_000; | ||
| 57 | const registry = yield* makeRegistry(() => timestamp); | ||
| 58 | const issued = yield* registry.issue({ | ||
| 59 | threadId: ThreadId.make("thread-2"), | ||
| 60 | providerInstanceId: ProviderInstanceId.make("claude"), | ||
| 61 | }); | ||
| 62 | const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); | ||
| 63 | timestamp += 101; | ||
| 64 | expect(yield* registry.resolve(token)).toBeUndefined(); | ||
| 65 | }), | ||
| 66 | ); | ||
apps/server/src/mcp/Layers/McpSessionRegistry.ts created+173| ... | @@ -0,0 +1,173 @@ | ||
| 1 | import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import * as Crypto from "effect/Crypto"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Ref from "effect/Ref"; | ||
| 6 | import { HttpServer } from "effect/unstable/http"; | ||
| 7 | |||
| 8 | import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; | ||
| 9 | import type { McpInvocationScope } from "../Services/McpInvocationContext.ts"; | ||
| 10 | import { | ||
| 11 | McpSessionRegistry, | ||
| 12 | type McpCredentialRequest, | ||
| 13 | type McpIssuedCredential, | ||
| 14 | type McpSessionRegistryShape, | ||
| 15 | } from "../Services/McpSessionRegistry.ts"; | ||
| 16 | |||
| 17 | interface CredentialRecord { | ||
| 18 | readonly tokenHash: string; | ||
| 19 | readonly scope: McpInvocationScope; | ||
| 20 | readonly lastUsedAt: number; | ||
| 21 | } | ||
| 22 | |||
| 23 | interface RegistryState { | ||
| 24 | readonly records: ReadonlyMap<string, CredentialRecord>; | ||
| 25 | } | ||
| 26 | |||
| 27 | export interface McpSessionRegistryOptions { | ||
| 28 | readonly idleTimeoutMs?: number; | ||
| 29 | readonly maximumLifetimeMs?: number; | ||
| 30 | readonly now?: () => number; | ||
| 31 | } | ||
| 32 | |||
| 33 | const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1_000; | ||
| 34 | const DEFAULT_MAXIMUM_LIFETIME_MS = 8 * 60 * 60 * 1_000; | ||
| 35 | |||
| 36 | const bytesToHex = (bytes: Uint8Array): string => | ||
| 37 | Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); | ||
| 38 | |||
| 39 | const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url"); | ||
| 40 | |||
| 41 | export const makeMcpSessionRegistry = (options: McpSessionRegistryOptions = {}) => | ||
| 42 | Effect.gen(function* () { | ||
| 43 | const crypto = yield* Crypto.Crypto; | ||
| 44 | const environment = yield* ServerEnvironment; | ||
| 45 | const environmentId = yield* environment.getEnvironmentId; | ||
| 46 | const httpServer = yield* HttpServer.HttpServer; | ||
| 47 | const state = yield* Ref.make<RegistryState>({ records: new Map() }); | ||
| 48 | const now = options.now ?? Date.now; | ||
| 49 | const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; | ||
| 50 | const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS; | ||
| 51 | const endpoint = | ||
| 52 | httpServer.address._tag === "TcpAddress" | ||
| 53 | ? `http://127.0.0.1:${httpServer.address.port}/mcp` | ||
| 54 | : "http://127.0.0.1/mcp"; | ||
| 55 | |||
| 56 | const hashToken = (token: string) => | ||
| 57 | crypto | ||
| 58 | .digest("SHA-256", new TextEncoder().encode(token)) | ||
| 59 | .pipe(Effect.map(bytesToHex), Effect.orDie); | ||
| 60 | |||
| 61 | const pruneExpired = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => { | ||
| 62 | let changed = false; | ||
| 63 | const next = new Map<string, CredentialRecord>(); | ||
| 64 | for (const [hash, record] of records) { | ||
| 65 | if (timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs) { | ||
| 66 | next.set(hash, record); | ||
| 67 | } else { | ||
| 68 | changed = true; | ||
| 69 | } | ||
| 70 | } | ||
| 71 | return changed ? next : records; | ||
| 72 | }; | ||
| 73 | |||
| 74 | const issue: McpSessionRegistryShape["issue"] = (request) => | ||
| 75 | Effect.gen(function* () { | ||
| 76 | const issuedAt = now(); | ||
| 77 | const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); | ||
| 78 | const rawToken = yield* crypto | ||
| 79 | .randomBytes(32) | ||
| 80 | .pipe(Effect.map(tokenFromBytes), Effect.orDie); | ||
| 81 | const tokenHash = yield* hashToken(rawToken); | ||
| 82 | const expiresAt = issuedAt + maximumLifetimeMs; | ||
| 83 | const scope: McpInvocationScope = { | ||
| 84 | environmentId, | ||
| 85 | threadId: ThreadId.make(request.threadId), | ||
| 86 | providerSessionId, | ||
| 87 | providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), | ||
| 88 | capabilities: new Set(["preview"]), | ||
| 89 | issuedAt, | ||
| 90 | expiresAt, | ||
| 91 | }; | ||
| 92 | yield* Ref.update(state, ({ records }) => { | ||
| 93 | const next = new Map(pruneExpired(records, issuedAt)); | ||
| 94 | next.set(tokenHash, { tokenHash, scope, lastUsedAt: issuedAt }); | ||
| 95 | return { records: next }; | ||
| 96 | }); | ||
| 97 | return { | ||
| 98 | config: { | ||
| 99 | environmentId, | ||
| 100 | threadId: scope.threadId, | ||
| 101 | providerSessionId, | ||
| 102 | providerInstanceId: scope.providerInstanceId, | ||
| 103 | endpoint, | ||
| 104 | authorizationHeader: `Bearer ${rawToken}`, | ||
| 105 | }, | ||
| 106 | expiresAt, | ||
| 107 | }; | ||
| 108 | }); | ||
| 109 | |||
| 110 | const resolve: McpSessionRegistryShape["resolve"] = (rawToken) => | ||
| 111 | Effect.gen(function* () { | ||
| 112 | if (rawToken.length === 0) return undefined; | ||
| 113 | const tokenHash = yield* hashToken(rawToken); | ||
| 114 | const timestamp = now(); | ||
| 115 | let resolved: McpInvocationScope | undefined; | ||
| 116 | yield* Ref.update(state, ({ records }) => { | ||
| 117 | const current = pruneExpired(records, timestamp); | ||
| 118 | const record = current.get(tokenHash); | ||
| 119 | if (!record) return { records: current }; | ||
| 120 | resolved = record.scope; | ||
| 121 | const next = new Map(current); | ||
| 122 | next.set(tokenHash, { ...record, lastUsedAt: timestamp }); | ||
| 123 | return { records: next }; | ||
| 124 | }); | ||
| 125 | return resolved; | ||
| 126 | }); | ||
| 127 | |||
| 128 | const revokeWhere = (predicate: (record: CredentialRecord) => boolean) => | ||
| 129 | Ref.update(state, ({ records }) => ({ | ||
| 130 | records: new Map(Array.from(records).filter(([, record]) => !predicate(record))), | ||
| 131 | })); | ||
| 132 | |||
| 133 | return McpSessionRegistry.of({ | ||
| 134 | issue, | ||
| 135 | resolve, | ||
| 136 | revokeProviderSession: (providerSessionId) => | ||
| 137 | revokeWhere((record) => record.scope.providerSessionId === providerSessionId), | ||
| 138 | revokeThread: (threadId) => revokeWhere((record) => record.scope.threadId === threadId), | ||
| 139 | revokeAll: Ref.set(state, { records: new Map() }), | ||
| 140 | }); | ||
| 141 | }); | ||
| 142 | |||
| 143 | let activeMcpSessionRegistry: McpSessionRegistryShape | undefined; | ||
| 144 | |||
| 145 | export const McpSessionRegistryLive: Layer.Layer< | ||
| 146 | McpSessionRegistry, | ||
| 147 | never, | ||
| 148 | Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer | ||
| 149 | > = Layer.effect( | ||
| 150 | McpSessionRegistry, | ||
| 151 | makeMcpSessionRegistry().pipe( | ||
| 152 | Effect.tap((registry) => | ||
| 153 | Effect.sync(() => { | ||
| 154 | activeMcpSessionRegistry = registry; | ||
| 155 | }), | ||
| 156 | ), | ||
| 157 | ), | ||
| 158 | ); | ||
| 159 | |||
| 160 | export const issueActiveMcpCredential = ( | ||
| 161 | request: McpCredentialRequest, | ||
| 162 | ): Effect.Effect<McpIssuedCredential | undefined> => | ||
| 163 | activeMcpSessionRegistry | ||
| 164 | ? activeMcpSessionRegistry | ||
| 165 | .revokeThread(request.threadId) | ||
| 166 | .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request))) | ||
| 167 | : Effect.sync((): McpIssuedCredential | undefined => undefined); | ||
| 168 | |||
| 169 | export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> => | ||
| 170 | activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void; | ||
| 171 | |||
| 172 | export const revokeAllActiveMcpCredentials = (): Effect.Effect<void> => | ||
| 173 | activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void; | ||
apps/server/src/mcp/Layers/PreviewAutomationBroker.test.ts created+65| ... | @@ -0,0 +1,65 @@ | ||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | EnvironmentId, | ||
| 4 | PreviewAutomationNoFocusedOwnerError, | ||
| 5 | ProviderInstanceId, | ||
| 6 | ThreadId, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Stream from "effect/Stream"; | ||
| 10 | |||
| 11 | import { makePreviewAutomationBroker } from "./PreviewAutomationBroker.ts"; | ||
| 12 | |||
| 13 | const scope = { | ||
| 14 | environmentId: EnvironmentId.make("environment-1"), | ||
| 15 | threadId: ThreadId.make("thread-1"), | ||
| 16 | providerSessionId: "provider-session-1", | ||
| 17 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 18 | capabilities: new Set(["preview"] as const), | ||
| 19 | issuedAt: 1, | ||
| 20 | expiresAt: 2, | ||
| 21 | }; | ||
| 22 | |||
| 23 | it.effect("routes a request to the focused owner and correlates its response", () => | ||
| 24 | Effect.scoped( | ||
| 25 | Effect.gen(function* () { | ||
| 26 | const broker = makePreviewAutomationBroker(); | ||
| 27 | const requests = yield* broker.connect("client-1"); | ||
| 28 | yield* Stream.runForEach(requests, (request) => | ||
| 29 | broker.respond({ | ||
| 30 | requestId: request.requestId, | ||
| 31 | ok: true, | ||
| 32 | result: { available: true }, | ||
| 33 | }), | ||
| 34 | ).pipe(Effect.forkScoped); | ||
| 35 | yield* Effect.yieldNow; | ||
| 36 | yield* broker.reportOwner({ | ||
| 37 | clientId: "client-1", | ||
| 38 | environmentId: scope.environmentId, | ||
| 39 | threadId: scope.threadId, | ||
| 40 | tabId: null, | ||
| 41 | visible: false, | ||
| 42 | supportsAutomation: true, | ||
| 43 | focusedAt: "2026-06-11T00:00:00.000Z", | ||
| 44 | }); | ||
| 45 | |||
| 46 | const result = yield* broker.invoke<{ available: boolean }>({ | ||
| 47 | scope, | ||
| 48 | operation: "open", | ||
| 49 | input: {}, | ||
| 50 | }); | ||
| 51 | |||
| 52 | expect(result).toEqual({ available: true }); | ||
| 53 | }), | ||
| 54 | ), | ||
| 55 | ); | ||
| 56 | |||
| 57 | it.effect("rejects calls when no focused owner exists", () => | ||
| 58 | Effect.gen(function* () { | ||
| 59 | const broker = makePreviewAutomationBroker(); | ||
| 60 | const error = yield* broker | ||
| 61 | .invoke<void>({ scope, operation: "status", input: {} }) | ||
| 62 | .pipe(Effect.flip); | ||
| 63 | expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); | ||
| 64 | }), | ||
| 65 | ); | ||
apps/server/src/mcp/Layers/PreviewAutomationBroker.ts created+268| ... | @@ -0,0 +1,268 @@ | ||
| 1 | import { | ||
| 2 | PreviewAutomationExecutionError, | ||
| 3 | PreviewAutomationInvalidSelectorError, | ||
| 4 | PreviewAutomationNoFocusedOwnerError, | ||
| 5 | PreviewAutomationResultTooLargeError, | ||
| 6 | PreviewAutomationTabNotFoundError, | ||
| 7 | PreviewAutomationTimeoutError, | ||
| 8 | PreviewAutomationUnavailableError, | ||
| 9 | PreviewAutomationUnsupportedClientError, | ||
| 10 | type PreviewAutomationError, | ||
| 11 | type PreviewAutomationOwner, | ||
| 12 | type PreviewAutomationResponse, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | import * as Deferred from "effect/Deferred"; | ||
| 15 | import * as Effect from "effect/Effect"; | ||
| 16 | import * as Layer from "effect/Layer"; | ||
| 17 | import * as Option from "effect/Option"; | ||
| 18 | import * as Queue from "effect/Queue"; | ||
| 19 | import * as Ref from "effect/Ref"; | ||
| 20 | import * as Stream from "effect/Stream"; | ||
| 21 | |||
| 22 | import { | ||
| 23 | PreviewAutomationBroker, | ||
| 24 | type PreviewAutomationBrokerShape, | ||
| 25 | } from "../Services/PreviewAutomationBroker.ts"; | ||
| 26 | |||
| 27 | interface ClientConnection { | ||
| 28 | readonly clientId: string; | ||
| 29 | readonly queue: Queue.Queue< | ||
| 30 | Parameters<PreviewAutomationBrokerShape["respond"]>[0] extends never | ||
| 31 | ? never | ||
| 32 | : import("@t3tools/contracts").PreviewAutomationRequest | ||
| 33 | >; | ||
| 34 | } | ||
| 35 | |||
| 36 | interface PendingRequest { | ||
| 37 | readonly clientId: string; | ||
| 38 | readonly deferred: Deferred.Deferred<unknown, PreviewAutomationError>; | ||
| 39 | } | ||
| 40 | |||
| 41 | interface BrokerState { | ||
| 42 | readonly clients: ReadonlyMap<string, ClientConnection>; | ||
| 43 | readonly owners: ReadonlyMap<string, PreviewAutomationOwner>; | ||
| 44 | readonly pending: ReadonlyMap<string, PendingRequest>; | ||
| 45 | } | ||
| 46 | |||
| 47 | const makeResponseError = ( | ||
| 48 | error: NonNullable<PreviewAutomationResponse["error"]>, | ||
| 49 | ): PreviewAutomationError => { | ||
| 50 | switch (error._tag) { | ||
| 51 | case "PreviewAutomationNoFocusedOwnerError": | ||
| 52 | return new PreviewAutomationNoFocusedOwnerError({ message: error.message }); | ||
| 53 | case "PreviewAutomationUnsupportedClientError": | ||
| 54 | return new PreviewAutomationUnsupportedClientError({ message: error.message }); | ||
| 55 | case "PreviewAutomationTabNotFoundError": | ||
| 56 | return new PreviewAutomationTabNotFoundError({ message: error.message }); | ||
| 57 | case "PreviewAutomationTimeoutError": | ||
| 58 | return new PreviewAutomationTimeoutError({ message: error.message }); | ||
| 59 | case "PreviewAutomationInvalidSelectorError": { | ||
| 60 | const detail = | ||
| 61 | typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; | ||
| 62 | return new PreviewAutomationInvalidSelectorError({ | ||
| 63 | message: error.message, | ||
| 64 | selector: | ||
| 65 | detail && "selector" in detail && typeof detail.selector === "string" | ||
| 66 | ? detail.selector | ||
| 67 | : "", | ||
| 68 | }); | ||
| 69 | } | ||
| 70 | case "PreviewAutomationResultTooLargeError": { | ||
| 71 | const detail = | ||
| 72 | typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; | ||
| 73 | return new PreviewAutomationResultTooLargeError({ | ||
| 74 | message: error.message, | ||
| 75 | maximumBytes: | ||
| 76 | detail && "maximumBytes" in detail && typeof detail.maximumBytes === "number" | ||
| 77 | ? detail.maximumBytes | ||
| 78 | : 64_000, | ||
| 79 | }); | ||
| 80 | } | ||
| 81 | case "PreviewAutomationUnavailableError": | ||
| 82 | return new PreviewAutomationUnavailableError({ message: error.message }); | ||
| 83 | default: | ||
| 84 | return new PreviewAutomationExecutionError({ | ||
| 85 | message: error.message, | ||
| 86 | detail: error.detail, | ||
| 87 | }); | ||
| 88 | } | ||
| 89 | }; | ||
| 90 | |||
| 91 | export const makePreviewAutomationBroker = (): PreviewAutomationBrokerShape => { | ||
| 92 | const state = Effect.runSync( | ||
| 93 | Ref.make<BrokerState>({ | ||
| 94 | clients: new Map(), | ||
| 95 | owners: new Map(), | ||
| 96 | pending: new Map(), | ||
| 97 | }), | ||
| 98 | ); | ||
| 99 | let requestSequence = 0; | ||
| 100 | |||
| 101 | const disconnect = (clientId: string, queue: ClientConnection["queue"]) => | ||
| 102 | Effect.gen(function* () { | ||
| 103 | const toFail: PendingRequest[] = []; | ||
| 104 | yield* Ref.update(state, (current) => { | ||
| 105 | if (current.clients.get(clientId)?.queue !== queue) return current; | ||
| 106 | const clients = new Map(current.clients); | ||
| 107 | const owners = new Map(current.owners); | ||
| 108 | const pending = new Map(current.pending); | ||
| 109 | clients.delete(clientId); | ||
| 110 | owners.delete(clientId); | ||
| 111 | for (const [requestId, entry] of pending) { | ||
| 112 | if (entry.clientId === clientId) { | ||
| 113 | pending.delete(requestId); | ||
| 114 | toFail.push(entry); | ||
| 115 | } | ||
| 116 | } | ||
| 117 | return { clients, owners, pending }; | ||
| 118 | }); | ||
| 119 | yield* Effect.forEach( | ||
| 120 | toFail, | ||
| 121 | ({ deferred }) => | ||
| 122 | Deferred.fail( | ||
| 123 | deferred, | ||
| 124 | new PreviewAutomationUnavailableError({ | ||
| 125 | message: "The preview automation client disconnected.", | ||
| 126 | }), | ||
| 127 | ), | ||
| 128 | { discard: true }, | ||
| 129 | ); | ||
| 130 | yield* Queue.shutdown(queue); | ||
| 131 | }); | ||
| 132 | |||
| 133 | const connect: PreviewAutomationBrokerShape["connect"] = (clientId) => | ||
| 134 | Effect.gen(function* () { | ||
| 135 | const queue = yield* Queue.unbounded<import("@t3tools/contracts").PreviewAutomationRequest>(); | ||
| 136 | let previous: ClientConnection | undefined; | ||
| 137 | yield* Ref.update(state, (current) => { | ||
| 138 | previous = current.clients.get(clientId); | ||
| 139 | const clients = new Map(current.clients); | ||
| 140 | clients.set(clientId, { clientId, queue }); | ||
| 141 | return { ...current, clients }; | ||
| 142 | }); | ||
| 143 | if (previous) yield* disconnect(clientId, previous.queue); | ||
| 144 | return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue))); | ||
| 145 | }); | ||
| 146 | |||
| 147 | const reportOwner: PreviewAutomationBrokerShape["reportOwner"] = (owner) => | ||
| 148 | Ref.update(state, (current) => { | ||
| 149 | const owners = new Map(current.owners); | ||
| 150 | owners.set(owner.clientId, owner); | ||
| 151 | return { ...current, owners }; | ||
| 152 | }); | ||
| 153 | |||
| 154 | const clearOwner: PreviewAutomationBrokerShape["clearOwner"] = (clientId) => | ||
| 155 | Ref.update(state, (current) => { | ||
| 156 | const owners = new Map(current.owners); | ||
| 157 | owners.delete(clientId); | ||
| 158 | return { ...current, owners }; | ||
| 159 | }); | ||
| 160 | |||
| 161 | const respond: PreviewAutomationBrokerShape["respond"] = (response) => | ||
| 162 | Effect.gen(function* () { | ||
| 163 | let pending: PendingRequest | undefined; | ||
| 164 | yield* Ref.update(state, (current) => { | ||
| 165 | pending = current.pending.get(response.requestId); | ||
| 166 | if (!pending) return current; | ||
| 167 | const next = new Map(current.pending); | ||
| 168 | next.delete(response.requestId); | ||
| 169 | return { ...current, pending: next }; | ||
| 170 | }); | ||
| 171 | if (!pending) return; | ||
| 172 | if (response.ok) { | ||
| 173 | yield* Deferred.succeed(pending.deferred, response.result); | ||
| 174 | } else { | ||
| 175 | yield* Deferred.fail( | ||
| 176 | pending.deferred, | ||
| 177 | response.error | ||
| 178 | ? makeResponseError(response.error) | ||
| 179 | : new PreviewAutomationExecutionError({ | ||
| 180 | message: "Preview automation failed without an error payload.", | ||
| 181 | }), | ||
| 182 | ); | ||
| 183 | } | ||
| 184 | }); | ||
| 185 | |||
| 186 | const invoke = <A = unknown>( | ||
| 187 | input: Parameters<PreviewAutomationBrokerShape["invoke"]>[0], | ||
| 188 | ): Effect.Effect<A, PreviewAutomationError> => | ||
| 189 | Effect.gen(function* () { | ||
| 190 | const current = yield* Ref.get(state); | ||
| 191 | const candidates = Array.from(current.owners.values()) | ||
| 192 | .filter( | ||
| 193 | (owner) => | ||
| 194 | owner.environmentId === input.scope.environmentId && | ||
| 195 | owner.threadId === input.scope.threadId && | ||
| 196 | owner.supportsAutomation && | ||
| 197 | (input.operation === "open" || input.operation === "status" || owner.visible), | ||
| 198 | ) | ||
| 199 | .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); | ||
| 200 | const owner = candidates[0]; | ||
| 201 | if (!owner) { | ||
| 202 | return yield* new PreviewAutomationNoFocusedOwnerError({ | ||
| 203 | message: "No focused desktop preview owner is available for this thread.", | ||
| 204 | }); | ||
| 205 | } | ||
| 206 | const connection = current.clients.get(owner.clientId); | ||
| 207 | if (!connection) { | ||
| 208 | return yield* new PreviewAutomationUnavailableError({ | ||
| 209 | message: "The focused preview owner is not connected.", | ||
| 210 | }); | ||
| 211 | } | ||
| 212 | if ( | ||
| 213 | input.operation !== "open" && | ||
| 214 | input.operation !== "status" && | ||
| 215 | !owner.tabId && | ||
| 216 | !input.tabId | ||
| 217 | ) { | ||
| 218 | return yield* new PreviewAutomationTabNotFoundError({ | ||
| 219 | message: "The focused preview owner does not have an active tab.", | ||
| 220 | }); | ||
| 221 | } | ||
| 222 | const requestId = `preview-${requestSequence++}`; | ||
| 223 | const timeoutMs = input.timeoutMs ?? 15_000; | ||
| 224 | const deferred = yield* Deferred.make<unknown, PreviewAutomationError>(); | ||
| 225 | yield* Ref.update(state, (next) => { | ||
| 226 | const pending = new Map(next.pending); | ||
| 227 | pending.set(requestId, { clientId: owner.clientId, deferred }); | ||
| 228 | return { ...next, pending }; | ||
| 229 | }); | ||
| 230 | const offered = yield* Queue.offer(connection.queue, { | ||
| 231 | requestId, | ||
| 232 | threadId: input.scope.threadId, | ||
| 233 | tabId: input.tabId ?? owner.tabId ?? undefined, | ||
| 234 | operation: input.operation, | ||
| 235 | input: input.input, | ||
| 236 | timeoutMs, | ||
| 237 | }); | ||
| 238 | if (!offered) { | ||
| 239 | return yield* new PreviewAutomationUnavailableError({ | ||
| 240 | message: "The preview automation client is no longer accepting requests.", | ||
| 241 | }); | ||
| 242 | } | ||
| 243 | const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs)); | ||
| 244 | yield* Ref.update(state, (next) => { | ||
| 245 | const pending = new Map(next.pending); | ||
| 246 | pending.delete(requestId); | ||
| 247 | return { ...next, pending }; | ||
| 248 | }); | ||
| 249 | return yield* Option.match(result, { | ||
| 250 | onNone: () => | ||
| 251 | Effect.fail( | ||
| 252 | new PreviewAutomationTimeoutError({ | ||
| 253 | message: `Preview automation timed out after ${timeoutMs}ms.`, | ||
| 254 | }), | ||
| 255 | ), | ||
| 256 | onSome: (value) => Effect.succeed(value as A), | ||
| 257 | }); | ||
| 258 | }); | ||
| 259 | |||
| 260 | return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke }); | ||
| 261 | }; | ||
| 262 | |||
| 263 | export const previewAutomationBroker = makePreviewAutomationBroker(); | ||
| 264 | |||
| 265 | export const PreviewAutomationBrokerLive: Layer.Layer<PreviewAutomationBroker> = Layer.succeed( | ||
| 266 | PreviewAutomationBroker, | ||
| 267 | previewAutomationBroker, | ||
| 268 | ); | ||
apps/server/src/mcp/Services/McpInvocationContext.ts created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { PreviewAutomationUnavailableError } from "@t3tools/contracts"; | ||
| 3 | import * as Context from "effect/Context"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | |||
| 6 | export type McpCapability = "preview"; | ||
| 7 | |||
| 8 | export interface McpInvocationScope { | ||
| 9 | readonly environmentId: EnvironmentId; | ||
| 10 | readonly threadId: ThreadId; | ||
| 11 | readonly providerSessionId: string; | ||
| 12 | readonly providerInstanceId: ProviderInstanceId; | ||
| 13 | readonly capabilities: ReadonlySet<McpCapability>; | ||
| 14 | readonly issuedAt: number; | ||
| 15 | readonly expiresAt: number; | ||
| 16 | } | ||
| 17 | |||
| 18 | export class McpInvocationContext extends Context.Service< | ||
| 19 | McpInvocationContext, | ||
| 20 | McpInvocationScope | ||
| 21 | >()("t3/mcp/Services/McpInvocationContext") {} | ||
| 22 | |||
| 23 | export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* ( | ||
| 24 | capability: McpCapability, | ||
| 25 | ) { | ||
| 26 | const invocation = yield* McpInvocationContext; | ||
| 27 | if (!invocation.capabilities.has(capability)) { | ||
| 28 | return yield* new PreviewAutomationUnavailableError({ | ||
| 29 | message: `MCP credential does not grant the ${capability} capability.`, | ||
| 30 | }); | ||
| 31 | } | ||
| 32 | return invocation; | ||
| 33 | }); | ||
apps/server/src/mcp/Services/McpProviderSession.ts created+34| ... | @@ -0,0 +1,34 @@ | ||
| 1 | import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | |||
| 4 | export interface McpProviderSessionConfig { | ||
| 5 | readonly environmentId: EnvironmentId; | ||
| 6 | readonly threadId: ThreadId; | ||
| 7 | readonly providerSessionId: string; | ||
| 8 | readonly providerInstanceId: ProviderInstanceId; | ||
| 9 | readonly endpoint: string; | ||
| 10 | readonly authorizationHeader: string; | ||
| 11 | } | ||
| 12 | |||
| 13 | export class McpProviderSession extends Context.Service< | ||
| 14 | McpProviderSession, | ||
| 15 | McpProviderSessionConfig | ||
| 16 | >()("t3/mcp/Services/McpProviderSession") {} | ||
| 17 | |||
| 18 | const sessionsByThread = new Map<ThreadId, McpProviderSessionConfig>(); | ||
| 19 | |||
| 20 | export function setMcpProviderSession(config: McpProviderSessionConfig): void { | ||
| 21 | sessionsByThread.set(config.threadId, config); | ||
| 22 | } | ||
| 23 | |||
| 24 | export function readMcpProviderSession(threadId: ThreadId): McpProviderSessionConfig | undefined { | ||
| 25 | return sessionsByThread.get(threadId); | ||
| 26 | } | ||
| 27 | |||
| 28 | export function clearMcpProviderSession(threadId: ThreadId): void { | ||
| 29 | sessionsByThread.delete(threadId); | ||
| 30 | } | ||
| 31 | |||
| 32 | export function clearAllMcpProviderSessions(): void { | ||
| 33 | sessionsByThread.clear(); | ||
| 34 | } | ||
apps/server/src/mcp/Services/McpSessionRegistry.ts created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | import type { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import type * as Effect from "effect/Effect"; | ||
| 4 | |||
| 5 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; | ||
| 6 | import type { McpProviderSessionConfig } from "./McpProviderSession.ts"; | ||
| 7 | |||
| 8 | export interface McpCredentialRequest { | ||
| 9 | readonly threadId: ThreadId; | ||
| 10 | readonly providerInstanceId: ProviderInstanceId; | ||
| 11 | } | ||
| 12 | |||
| 13 | export interface McpIssuedCredential { | ||
| 14 | readonly config: McpProviderSessionConfig; | ||
| 15 | readonly expiresAt: number; | ||
| 16 | } | ||
| 17 | |||
| 18 | export interface McpSessionRegistryShape { | ||
| 19 | readonly issue: (request: McpCredentialRequest) => Effect.Effect<McpIssuedCredential, never>; | ||
| 20 | readonly resolve: (rawToken: string) => Effect.Effect<McpInvocationScope | undefined, never>; | ||
| 21 | readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect<void>; | ||
| 22 | readonly revokeThread: (threadId: ThreadId) => Effect.Effect<void>; | ||
| 23 | readonly revokeAll: Effect.Effect<void>; | ||
| 24 | } | ||
| 25 | |||
| 26 | export class McpSessionRegistry extends Context.Service< | ||
| 27 | McpSessionRegistry, | ||
| 28 | McpSessionRegistryShape | ||
| 29 | >()("t3/mcp/Services/McpSessionRegistry") {} | ||
apps/server/src/mcp/Services/PreviewAutomationBroker.ts created+40| ... | @@ -0,0 +1,40 @@ | ||
| 1 | import type { | ||
| 2 | PreviewAutomationError, | ||
| 3 | PreviewAutomationOperation, | ||
| 4 | PreviewAutomationOwner, | ||
| 5 | PreviewAutomationRequest, | ||
| 6 | PreviewAutomationResponse, | ||
| 7 | PreviewTabId, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import * as Context from "effect/Context"; | ||
| 10 | import type * as Effect from "effect/Effect"; | ||
| 11 | import type * as Stream from "effect/Stream"; | ||
| 12 | |||
| 13 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; | ||
| 14 | |||
| 15 | export interface PreviewAutomationInvokeInput { | ||
| 16 | readonly scope: McpInvocationScope; | ||
| 17 | readonly operation: PreviewAutomationOperation; | ||
| 18 | readonly input: unknown; | ||
| 19 | readonly tabId?: PreviewTabId; | ||
| 20 | readonly timeoutMs?: number; | ||
| 21 | } | ||
| 22 | |||
| 23 | export interface PreviewAutomationBrokerShape { | ||
| 24 | readonly connect: (clientId: string) => Effect.Effect<Stream.Stream<PreviewAutomationRequest>>; | ||
| 25 | readonly reportOwner: ( | ||
| 26 | owner: PreviewAutomationOwner, | ||
| 27 | ) => Effect.Effect<void, PreviewAutomationError>; | ||
| 28 | readonly clearOwner: (clientId: string) => Effect.Effect<void>; | ||
| 29 | readonly respond: ( | ||
| 30 | response: PreviewAutomationResponse, | ||
| 31 | ) => Effect.Effect<void, PreviewAutomationError>; | ||
| 32 | readonly invoke: <A = unknown>( | ||
| 33 | request: PreviewAutomationInvokeInput, | ||
| 34 | ) => Effect.Effect<A, PreviewAutomationError>; | ||
| 35 | } | ||
| 36 | |||
| 37 | export class PreviewAutomationBroker extends Context.Service< | ||
| 38 | PreviewAutomationBroker, | ||
| 39 | PreviewAutomationBrokerShape | ||
| 40 | >()("t3/mcp/Services/PreviewAutomationBroker") {} | ||
apps/server/src/mcp/toolkits/preview/handlers.ts created+47| ... | @@ -0,0 +1,47 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import type { | ||
| 3 | PreviewAutomationOperation, | ||
| 4 | PreviewAutomationSnapshot, | ||
| 5 | PreviewAutomationStatus, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | import { requireMcpCapability } from "../../Services/McpInvocationContext.ts"; | ||
| 9 | import { previewAutomationBroker } from "../../Layers/PreviewAutomationBroker.ts"; | ||
| 10 | import { PreviewToolkit } from "./tools.ts"; | ||
| 11 | |||
| 12 | const invoke = <A>( | ||
| 13 | operation: PreviewAutomationOperation, | ||
| 14 | input: unknown, | ||
| 15 | timeoutMs?: number, | ||
| 16 | ): Effect.Effect< | ||
| 17 | A, | ||
| 18 | import("@t3tools/contracts").PreviewAutomationError, | ||
| 19 | import("../../Services/McpInvocationContext.ts").McpInvocationContext | ||
| 20 | > => | ||
| 21 | Effect.gen(function* () { | ||
| 22 | const scope = yield* requireMcpCapability("preview"); | ||
| 23 | return yield* previewAutomationBroker.invoke<A>({ | ||
| 24 | scope, | ||
| 25 | operation, | ||
| 26 | input, | ||
| 27 | ...(timeoutMs === undefined ? {} : { timeoutMs }), | ||
| 28 | }); | ||
| 29 | }); | ||
| 30 | |||
| 31 | export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({ | ||
| 32 | preview_status: () => invoke<PreviewAutomationStatus>("status", {}), | ||
| 33 | preview_open: (input) => | ||
| 34 | invoke<PreviewAutomationStatus>("open", { | ||
| 35 | ...input, | ||
| 36 | show: input.show ?? true, | ||
| 37 | reuseExistingTab: input.reuseExistingTab ?? true, | ||
| 38 | }), | ||
| 39 | preview_navigate: (input) => invoke<PreviewAutomationStatus>("navigate", input, input.timeoutMs), | ||
| 40 | preview_snapshot: () => invoke<PreviewAutomationSnapshot>("snapshot", {}), | ||
| 41 | preview_click: (input) => invoke<void>("click", input, input.timeoutMs), | ||
| 42 | preview_type: (input) => invoke<void>("type", input, input.timeoutMs), | ||
| 43 | preview_press: (input) => invoke<void>("press", input), | ||
| 44 | preview_scroll: (input) => invoke<void>("scroll", input), | ||
| 45 | preview_evaluate: (input) => invoke<unknown>("evaluate", input), | ||
| 46 | preview_wait_for: (input) => invoke<void>("waitFor", input, input.timeoutMs), | ||
| 47 | }); | ||
apps/server/src/mcp/toolkits/preview/tools.ts created+138| ... | @@ -0,0 +1,138 @@ | ||
| 1 | import { | ||
| 2 | PreviewAutomationClickInput, | ||
| 3 | PreviewAutomationError, | ||
| 4 | PreviewAutomationEvaluateInput, | ||
| 5 | PreviewAutomationNavigateInput, | ||
| 6 | PreviewAutomationOpenInput, | ||
| 7 | PreviewAutomationPressInput, | ||
| 8 | PreviewAutomationScrollInput, | ||
| 9 | PreviewAutomationSnapshot, | ||
| 10 | PreviewAutomationStatus, | ||
| 11 | PreviewAutomationTypeInput, | ||
| 12 | PreviewAutomationWaitForInput, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | import * as Schema from "effect/Schema"; | ||
| 15 | import { Tool, Toolkit } from "effect/unstable/ai"; | ||
| 16 | |||
| 17 | import { McpInvocationContext } from "../../Services/McpInvocationContext.ts"; | ||
| 18 | |||
| 19 | const browserTool = <T extends Tool.Any>(tool: T): T => | ||
| 20 | tool.annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, true) as T; | ||
| 21 | |||
| 22 | export const PreviewStatusTool = Tool.make("preview_status", { | ||
| 23 | description: | ||
| 24 | "Report whether the scoped thread has an automation-capable desktop preview, including its active tab, URL, title, visibility, and loading state.", | ||
| 25 | success: PreviewAutomationStatus, | ||
| 26 | failure: PreviewAutomationError, | ||
| 27 | dependencies: [McpInvocationContext], | ||
| 28 | }) | ||
| 29 | .annotate(Tool.Title, "Get preview status") | ||
| 30 | .annotate(Tool.Readonly, true) | ||
| 31 | .annotate(Tool.Destructive, false) | ||
| 32 | .annotate(Tool.Idempotent, true); | ||
| 33 | |||
| 34 | export const PreviewOpenTool = browserTool( | ||
| 35 | Tool.make("preview_open", { | ||
| 36 | description: | ||
| 37 | "Show and initialize the browser preview for the scoped thread, optionally reusing its current tab and navigating to a URL.", | ||
| 38 | parameters: PreviewAutomationOpenInput, | ||
| 39 | success: PreviewAutomationStatus, | ||
| 40 | failure: PreviewAutomationError, | ||
| 41 | dependencies: [McpInvocationContext], | ||
| 42 | }).annotate(Tool.Title, "Open browser preview"), | ||
| 43 | ); | ||
| 44 | |||
| 45 | export const PreviewNavigateTool = browserTool( | ||
| 46 | Tool.make("preview_navigate", { | ||
| 47 | description: | ||
| 48 | "Navigate the scoped thread's active preview tab to a URL and wait for the requested readiness condition.", | ||
| 49 | parameters: PreviewAutomationNavigateInput, | ||
| 50 | success: PreviewAutomationStatus, | ||
| 51 | failure: PreviewAutomationError, | ||
| 52 | dependencies: [McpInvocationContext], | ||
| 53 | }).annotate(Tool.Title, "Navigate browser preview"), | ||
| 54 | ); | ||
| 55 | |||
| 56 | export const PreviewSnapshotTool = Tool.make("preview_snapshot", { | ||
| 57 | description: | ||
| 58 | "Capture bounded page metadata, visible text, interactive elements, accessibility data, and a PNG screenshot from the scoped preview tab.", | ||
| 59 | success: PreviewAutomationSnapshot, | ||
| 60 | failure: PreviewAutomationError, | ||
| 61 | dependencies: [McpInvocationContext], | ||
| 62 | }) | ||
| 63 | .annotate(Tool.Title, "Capture preview snapshot") | ||
| 64 | .annotate(Tool.Readonly, true) | ||
| 65 | .annotate(Tool.Destructive, false); | ||
| 66 | |||
| 67 | export const PreviewClickTool = browserTool( | ||
| 68 | Tool.make("preview_click", { | ||
| 69 | description: | ||
| 70 | "Click an element selected by CSS selector or click viewport coordinates in the scoped preview tab.", | ||
| 71 | parameters: PreviewAutomationClickInput, | ||
| 72 | failure: PreviewAutomationError, | ||
| 73 | dependencies: [McpInvocationContext], | ||
| 74 | }).annotate(Tool.Title, "Click preview page"), | ||
| 75 | ); | ||
| 76 | |||
| 77 | export const PreviewTypeTool = browserTool( | ||
| 78 | Tool.make("preview_type", { | ||
| 79 | description: | ||
| 80 | "Type text into the focused element or a CSS-selected element, optionally clearing its existing value first.", | ||
| 81 | parameters: PreviewAutomationTypeInput, | ||
| 82 | failure: PreviewAutomationError, | ||
| 83 | dependencies: [McpInvocationContext], | ||
| 84 | }).annotate(Tool.Title, "Type into preview page"), | ||
| 85 | ); | ||
| 86 | |||
| 87 | export const PreviewPressTool = browserTool( | ||
| 88 | Tool.make("preview_press", { | ||
| 89 | description: "Dispatch a keyboard key with optional modifiers to the scoped preview tab.", | ||
| 90 | parameters: PreviewAutomationPressInput, | ||
| 91 | failure: PreviewAutomationError, | ||
| 92 | dependencies: [McpInvocationContext], | ||
| 93 | }).annotate(Tool.Title, "Press key in preview page"), | ||
| 94 | ); | ||
| 95 | |||
| 96 | export const PreviewScrollTool = browserTool( | ||
| 97 | Tool.make("preview_scroll", { | ||
| 98 | description: | ||
| 99 | "Scroll the preview viewport or a CSS-selected scroll container by the requested deltas.", | ||
| 100 | parameters: PreviewAutomationScrollInput, | ||
| 101 | failure: PreviewAutomationError, | ||
| 102 | dependencies: [McpInvocationContext], | ||
| 103 | }).annotate(Tool.Title, "Scroll preview page"), | ||
| 104 | ); | ||
| 105 | |||
| 106 | export const PreviewEvaluateTool = browserTool( | ||
| 107 | Tool.make("preview_evaluate", { | ||
| 108 | description: | ||
| 109 | "Evaluate bounded JavaScript in the scoped preview tab and return a serializable result of at most 64 KB.", | ||
| 110 | parameters: PreviewAutomationEvaluateInput, | ||
| 111 | success: Schema.Unknown, | ||
| 112 | failure: PreviewAutomationError, | ||
| 113 | dependencies: [McpInvocationContext], | ||
| 114 | }).annotate(Tool.Title, "Evaluate JavaScript in preview"), | ||
| 115 | ); | ||
| 116 | |||
| 117 | export const PreviewWaitForTool = browserTool( | ||
| 118 | Tool.make("preview_wait_for", { | ||
| 119 | description: | ||
| 120 | "Wait until a CSS selector, visible-text substring, or URL substring appears in the scoped preview tab.", | ||
| 121 | parameters: PreviewAutomationWaitForInput, | ||
| 122 | failure: PreviewAutomationError, | ||
| 123 | dependencies: [McpInvocationContext], | ||
| 124 | }).annotate(Tool.Title, "Wait for preview page condition"), | ||
| 125 | ); | ||
| 126 | |||
| 127 | export const PreviewToolkit = Toolkit.make( | ||
| 128 | PreviewStatusTool, | ||
| 129 | PreviewOpenTool, | ||
| 130 | PreviewNavigateTool, | ||
| 131 | PreviewSnapshotTool, | ||
| 132 | PreviewClickTool, | ||
| 133 | PreviewTypeTool, | ||
| 134 | PreviewPressTool, | ||
| 135 | PreviewScrollTool, | ||
| 136 | PreviewEvaluateTool, | ||
| 137 | PreviewWaitForTool, | ||
| 138 | ); | ||
apps/server/src/provider/Layers/ClaudeAdapter.ts+15| ... | @@ -69,6 +69,7 @@ import * as Stream from "effect/Stream"; | ... | @@ -69,6 +69,7 @@ import * as Stream from "effect/Stream"; |
| 69 | 69 | ||
| 70 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 70 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 71 | import { ServerConfig } from "../../config.ts"; | 71 | import { ServerConfig } from "../../config.ts"; |
| 72 | import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; | ||
| 72 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; | 73 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; |
| 73 | import { | 74 | import { |
| 74 | getClaudeModelCapabilities, | 75 | getClaudeModelCapabilities, |
| ... | @@ -3445,6 +3446,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -3445,6 +3446,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3445 | ...(fastMode ? { fastMode: true } : {}), | 3446 | ...(fastMode ? { fastMode: true } : {}), |
| 3446 | ...(ultracode ? { ultracode: true } : {}), | 3447 | ...(ultracode ? { ultracode: true } : {}), |
| 3447 | }; | 3448 | }; |
| 3449 | const mcpSession = readMcpProviderSession(input.threadId); | ||
| 3448 | const queryOptions: ClaudeQueryOptions = { | 3450 | const queryOptions: ClaudeQueryOptions = { |
| 3449 | ...(input.cwd ? { cwd: input.cwd } : {}), | 3451 | ...(input.cwd ? { cwd: input.cwd } : {}), |
| 3450 | ...(apiModelId ? { model: apiModelId } : {}), | 3452 | ...(apiModelId ? { model: apiModelId } : {}), |
| ... | @@ -3470,6 +3472,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -3470,6 +3472,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3470 | env: claudeEnvironment, | 3472 | env: claudeEnvironment, |
| 3471 | ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), | 3473 | ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), |
| 3472 | ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), | 3474 | ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), |
| 3475 | ...(mcpSession | ||
| 3476 | ? { | ||
| 3477 | mcpServers: { | ||
| 3478 | "t3-code": { | ||
| 3479 | type: "http", | ||
| 3480 | url: mcpSession.endpoint, | ||
| 3481 | headers: { | ||
| 3482 | Authorization: mcpSession.authorizationHeader, | ||
| 3483 | }, | ||
| 3484 | }, | ||
| 3485 | }, | ||
| 3486 | } | ||
| 3487 | : {}), | ||
| 3473 | }; | 3488 | }; |
| 3474 | 3489 | ||
| 3475 | yield* Effect.annotateCurrentSpan({ | 3490 | yield* Effect.annotateCurrentSpan({ |
apps/server/src/provider/Layers/CodexAdapter.ts+16| ... | @@ -39,6 +39,7 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema"; | ... | @@ -39,6 +39,7 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema"; |
| 39 | 39 | ||
| 40 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | 40 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 41 | import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; | 41 | import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; |
| 42 | import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; | ||
| 42 | 43 | ||
| 43 | import { | 44 | import { |
| 44 | ProviderAdapterRequestError, | 45 | ProviderAdapterRequestError, |
| ... | @@ -1382,6 +1383,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( | ... | @@ -1382,6 +1383,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 1382 | input.modelSelection?.instanceId === boundInstanceId | 1383 | input.modelSelection?.instanceId === boundInstanceId |
| 1383 | ? getCodexServiceTierOptionValue(input.modelSelection) | 1384 | ? getCodexServiceTierOptionValue(input.modelSelection) |
| 1384 | : undefined; | 1385 | : undefined; |
| 1386 | const mcpSession = readMcpProviderSession(input.threadId); | ||
| 1385 | const runtimeInput: CodexSessionRuntimeOptions = { | 1387 | const runtimeInput: CodexSessionRuntimeOptions = { |
| 1386 | threadId: input.threadId, | 1388 | threadId: input.threadId, |
| 1387 | providerInstanceId: boundInstanceId, | 1389 | providerInstanceId: boundInstanceId, |
| ... | @@ -1397,6 +1399,20 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( | ... | @@ -1397,6 +1399,20 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 1397 | ? { model: input.modelSelection.model } | 1399 | ? { model: input.modelSelection.model } |
| 1398 | : {}), | 1400 | : {}), |
| 1399 | ...(serviceTier ? { serviceTier } : {}), | 1401 | ...(serviceTier ? { serviceTier } : {}), |
| 1402 | ...(mcpSession | ||
| 1403 | ? { | ||
| 1404 | environment: { | ||
| 1405 | ...(options?.environment ?? process.env), | ||
| 1406 | T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""), | ||
| 1407 | }, | ||
| 1408 | appServerArgs: [ | ||
| 1409 | "-c", | ||
| 1410 | `mcp_servers.t3-code.url=${mcpSession.endpoint}`, | ||
| 1411 | "-c", | ||
| 1412 | 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"', | ||
| 1413 | ], | ||
| 1414 | } | ||
| 1415 | : {}), | ||
| 1400 | }; | 1416 | }; |
| 1401 | const sessionScope = yield* Scope.make("sequential"); | 1417 | const sessionScope = yield* Scope.make("sequential"); |
| 1402 | let sessionScopeTransferred = false; | 1418 | let sessionScopeTransferred = false; |
apps/server/src/provider/Layers/CodexSessionRuntime.ts+2-1| ... | @@ -103,6 +103,7 @@ export interface CodexSessionRuntimeOptions { | ... | @@ -103,6 +103,7 @@ export interface CodexSessionRuntimeOptions { |
| 103 | readonly model?: string; | 103 | readonly model?: string; |
| 104 | readonly serviceTier?: CodexServiceTier | undefined; | 104 | readonly serviceTier?: CodexServiceTier | undefined; |
| 105 | readonly resumeCursor?: CodexResumeCursor; | 105 | readonly resumeCursor?: CodexResumeCursor; |
| 106 | readonly appServerArgs?: ReadonlyArray<string>; | ||
| 106 | } | 107 | } |
| 107 | 108 | ||
| 108 | export interface CodexSessionRuntimeSendTurnInput { | 109 | export interface CodexSessionRuntimeSendTurnInput { |
| ... | @@ -720,7 +721,7 @@ export const makeCodexSessionRuntime = ( | ... | @@ -720,7 +721,7 @@ export const makeCodexSessionRuntime = ( |
| 720 | }; | 721 | }; |
| 721 | const child = yield* spawner | 722 | const child = yield* spawner |
| 722 | .spawn( | 723 | .spawn( |
| 723 | ChildProcess.make(options.binaryPath, ["app-server"], { | 724 | ChildProcess.make(options.binaryPath, ["app-server", ...(options.appServerArgs ?? [])], { |
| 724 | cwd: options.cwd, | 725 | cwd: options.cwd, |
| 725 | env, | 726 | env, |
| 726 | forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, | 727 | forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, |
apps/server/src/provider/Layers/CursorAdapter.ts+19| ... | @@ -42,6 +42,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; | ... | @@ -42,6 +42,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; |
| 42 | 42 | ||
| 43 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 43 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 44 | import { ServerConfig } from "../../config.ts"; | 44 | import { ServerConfig } from "../../config.ts"; |
| 45 | import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; | ||
| 45 | import { | 46 | import { |
| 46 | ProviderAdapterProcessError, | 47 | ProviderAdapterProcessError, |
| 47 | ProviderAdapterRequestError, | 48 | ProviderAdapterRequestError, |
| ... | @@ -530,6 +531,7 @@ export function makeCursorAdapter( | ... | @@ -530,6 +531,7 @@ export function makeCursorAdapter( |
| 530 | ? yield* options.resolveSettings | 531 | ? yield* options.resolveSettings |
| 531 | : cursorSettings; | 532 | : cursorSettings; |
| 532 | 533 | ||
| 534 | const mcpSession = readMcpProviderSession(input.threadId); | ||
| 533 | const acp = yield* makeCursorAcpRuntime({ | 535 | const acp = yield* makeCursorAcpRuntime({ |
| 534 | cursorSettings: effectiveCursorSettings, | 536 | cursorSettings: effectiveCursorSettings, |
| 535 | ...(options?.environment ? { environment: options.environment } : {}), | 537 | ...(options?.environment ? { environment: options.environment } : {}), |
| ... | @@ -537,6 +539,23 @@ export function makeCursorAdapter( | ... | @@ -537,6 +539,23 @@ export function makeCursorAdapter( |
| 537 | cwd, | 539 | cwd, |
| 538 | ...(resumeSessionId ? { resumeSessionId } : {}), | 540 | ...(resumeSessionId ? { resumeSessionId } : {}), |
| 539 | clientInfo: { name: "t3-code", version: "0.0.0" }, | 541 | clientInfo: { name: "t3-code", version: "0.0.0" }, |
| 542 | ...(mcpSession | ||
| 543 | ? { | ||
| 544 | mcpServers: [ | ||
| 545 | { | ||
| 546 | type: "http" as const, | ||
| 547 | name: "t3-code", | ||
| 548 | url: mcpSession.endpoint, | ||
| 549 | headers: [ | ||
| 550 | { | ||
| 551 | name: "Authorization", | ||
| 552 | value: mcpSession.authorizationHeader, | ||
| 553 | }, | ||
| 554 | ], | ||
| 555 | }, | ||
| 556 | ], | ||
| 557 | } | ||
| 558 | : {}), | ||
| 540 | ...acpNativeLoggers, | 559 | ...acpNativeLoggers, |
| 541 | }).pipe( | 560 | }).pipe( |
| 542 | Effect.provideService(Scope.Scope, sessionScope), | 561 | Effect.provideService(Scope.Scope, sessionScope), |
apps/server/src/provider/Layers/GrokAdapter.ts+19| ... | @@ -33,6 +33,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; | ... | @@ -33,6 +33,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; |
| 33 | 33 | ||
| 34 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 34 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 35 | import { ServerConfig } from "../../config.ts"; | 35 | import { ServerConfig } from "../../config.ts"; |
| 36 | import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; | ||
| 36 | import { | 37 | import { |
| 37 | ProviderAdapterProcessError, | 38 | ProviderAdapterProcessError, |
| 38 | ProviderAdapterRequestError, | 39 | ProviderAdapterRequestError, |
| ... | @@ -374,6 +375,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -374,6 +375,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 374 | threadId: input.threadId, | 375 | threadId: input.threadId, |
| 375 | }); | 376 | }); |
| 376 | 377 | ||
| 378 | const mcpSession = readMcpProviderSession(input.threadId); | ||
| 377 | const acp = yield* makeGrokAcpRuntime({ | 379 | const acp = yield* makeGrokAcpRuntime({ |
| 378 | grokSettings, | 380 | grokSettings, |
| 379 | ...(options?.environment ? { environment: options.environment } : {}), | 381 | ...(options?.environment ? { environment: options.environment } : {}), |
| ... | @@ -381,6 +383,23 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -381,6 +383,23 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 381 | cwd, | 383 | cwd, |
| 382 | ...(resumeSessionId ? { resumeSessionId } : {}), | 384 | ...(resumeSessionId ? { resumeSessionId } : {}), |
| 383 | clientInfo: { name: "t3-code", version: "0.0.0" }, | 385 | clientInfo: { name: "t3-code", version: "0.0.0" }, |
| 386 | ...(mcpSession | ||
| 387 | ? { | ||
| 388 | mcpServers: [ | ||
| 389 | { | ||
| 390 | type: "http" as const, | ||
| 391 | name: "t3-code", | ||
| 392 | url: mcpSession.endpoint, | ||
| 393 | headers: [ | ||
| 394 | { | ||
| 395 | name: "Authorization", | ||
| 396 | value: mcpSession.authorizationHeader, | ||
| 397 | }, | ||
| 398 | ], | ||
| 399 | }, | ||
| 400 | ], | ||
| 401 | } | ||
| 402 | : {}), | ||
| 384 | ...acpNativeLoggers, | 403 | ...acpNativeLoggers, |
| 385 | }).pipe( | 404 | }).pipe( |
| 386 | Effect.provideService(Scope.Scope, sessionScope), | 405 | Effect.provideService(Scope.Scope, sessionScope), |
apps/server/src/provider/Layers/OpenCodeAdapter.ts+17| ... | @@ -26,6 +26,7 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | ... | @@ -26,6 +26,7 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 26 | 26 | ||
| 27 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 27 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 28 | import { ServerConfig } from "../../config.ts"; | 28 | import { ServerConfig } from "../../config.ts"; |
| 29 | import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; | ||
| 29 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 30 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 30 | import { | 31 | import { |
| 31 | ProviderAdapterProcessError, | 32 | ProviderAdapterProcessError, |
| ... | @@ -1053,6 +1054,22 @@ export function makeOpenCodeAdapter( | ... | @@ -1053,6 +1054,22 @@ export function makeOpenCodeAdapter( |
| 1053 | directory, | 1054 | directory, |
| 1054 | ...(server.external && serverPassword ? { serverPassword } : {}), | 1055 | ...(server.external && serverPassword ? { serverPassword } : {}), |
| 1055 | }); | 1056 | }); |
| 1057 | const mcpSession = readMcpProviderSession(input.threadId); | ||
| 1058 | if (mcpSession && !server.external) { | ||
| 1059 | yield* runOpenCodeSdk("mcp.add", () => | ||
| 1060 | client.mcp.add({ | ||
| 1061 | name: "t3-code", | ||
| 1062 | config: { | ||
| 1063 | type: "remote", | ||
| 1064 | url: mcpSession.endpoint, | ||
| 1065 | headers: { | ||
| 1066 | Authorization: mcpSession.authorizationHeader, | ||
| 1067 | }, | ||
| 1068 | oauth: false, | ||
| 1069 | }, | ||
| 1070 | }), | ||
| 1071 | ); | ||
| 1072 | } | ||
| 1056 | const openCodeSession = yield* runOpenCodeSdk("session.create", () => | 1073 | const openCodeSession = yield* runOpenCodeSdk("session.create", () => |
| 1057 | client.session.create({ | 1074 | client.session.create({ |
| 1058 | title: `T3 Code ${input.threadId}`, | 1075 | title: `T3 Code ${input.threadId}`, |
apps/server/src/provider/Layers/ProviderService.ts+46-15| ... | @@ -56,6 +56,16 @@ import { | ... | @@ -56,6 +56,16 @@ import { |
| 56 | import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 56 | import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 57 | import { ProviderEventLoggers } from "./ProviderEventLoggers.ts"; | 57 | import { ProviderEventLoggers } from "./ProviderEventLoggers.ts"; |
| 58 | import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; | 58 | import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; |
| 59 | import { | ||
| 60 | issueActiveMcpCredential, | ||
| 61 | revokeActiveMcpThread, | ||
| 62 | revokeAllActiveMcpCredentials, | ||
| 63 | } from "../../mcp/Layers/McpSessionRegistry.ts"; | ||
| 64 | import { | ||
| 65 | clearAllMcpProviderSessions, | ||
| 66 | clearMcpProviderSession, | ||
| 67 | setMcpProviderSession, | ||
| 68 | } from "../../mcp/Services/McpProviderSession.ts"; | ||
| 59 | const isModelSelection = Schema.is(ModelSelection); | 69 | const isModelSelection = Schema.is(ModelSelection); |
| 60 | 70 | ||
| 61 | /** | 71 | /** |
| ... | @@ -212,6 +222,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -212,6 +222,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 212 | const directory = yield* ProviderSessionDirectory; | 222 | const directory = yield* ProviderSessionDirectory; |
| 213 | const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); | 223 | const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); |
| 214 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | 224 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 225 | const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => | ||
| 226 | issueActiveMcpCredential({ threadId, providerInstanceId }).pipe( | ||
| 227 | Effect.tap((credential) => | ||
| 228 | credential ? Effect.sync(() => setMcpProviderSession(credential.config)) : Effect.void, | ||
| 229 | ), | ||
| 230 | ); | ||
| 231 | const clearMcpSession = (threadId: ThreadId) => | ||
| 232 | revokeActiveMcpThread(threadId).pipe( | ||
| 233 | Effect.tap(() => Effect.sync(() => clearMcpProviderSession(threadId))), | ||
| 234 | ); | ||
| 215 | 235 | ||
| 216 | const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => | 236 | const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => |
| 217 | Effect.succeed(event).pipe( | 237 | Effect.succeed(event).pipe( |
| ... | @@ -383,16 +403,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -383,16 +403,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 383 | const persistedCwd = readPersistedCwd(input.binding.runtimePayload); | 403 | const persistedCwd = readPersistedCwd(input.binding.runtimePayload); |
| 384 | const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); | 404 | const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); |
| 385 | 405 | ||
| 386 | const resumed = yield* adapter.startSession({ | 406 | yield* prepareMcpSession(input.binding.threadId, bindingInstanceId); |
| 387 | threadId: input.binding.threadId, | 407 | const resumed = yield* adapter |
| 388 | provider: input.binding.provider, | 408 | .startSession({ |
| 389 | providerInstanceId: bindingInstanceId, | 409 | threadId: input.binding.threadId, |
| 390 | ...(persistedCwd ? { cwd: persistedCwd } : {}), | 410 | provider: input.binding.provider, |
| 391 | ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), | 411 | providerInstanceId: bindingInstanceId, |
| 392 | ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), | 412 | ...(persistedCwd ? { cwd: persistedCwd } : {}), |
| 393 | runtimeMode: input.binding.runtimeMode ?? "full-access", | 413 | ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), |
| 394 | }); | 414 | ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), |
| 415 | runtimeMode: input.binding.runtimeMode ?? "full-access", | ||
| 416 | }) | ||
| 417 | .pipe(Effect.onError(() => clearMcpSession(input.binding.threadId))); | ||
| 395 | if (resumed.provider !== adapter.provider) { | 418 | if (resumed.provider !== adapter.provider) { |
| 419 | yield* clearMcpSession(input.binding.threadId); | ||
| 396 | return yield* toValidationError( | 420 | return yield* toValidationError( |
| 397 | input.operation, | 421 | input.operation, |
| 398 | `Adapter/provider mismatch while recovering thread '${input.binding.threadId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, | 422 | `Adapter/provider mismatch while recovering thread '${input.binding.threadId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, |
| ... | @@ -572,14 +596,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -572,14 +596,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 572 | "provider.cwd.effective": effectiveCwd ?? "", | 596 | "provider.cwd.effective": effectiveCwd ?? "", |
| 573 | }); | 597 | }); |
| 574 | const adapter = yield* registry.getByInstance(resolvedInstanceId); | 598 | const adapter = yield* registry.getByInstance(resolvedInstanceId); |
| 575 | const session = yield* adapter.startSession({ | 599 | yield* prepareMcpSession(threadId, resolvedInstanceId); |
| 576 | ...input, | 600 | const session = yield* adapter |
| 577 | providerInstanceId: resolvedInstanceId, | 601 | .startSession({ |
| 578 | ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), | 602 | ...input, |
| 579 | ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), | 603 | providerInstanceId: resolvedInstanceId, |
| 580 | }); | 604 | ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), |
| 605 | ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), | ||
| 606 | }) | ||
| 607 | .pipe(Effect.onError(() => clearMcpSession(threadId))); | ||
| 581 | 608 | ||
| 582 | if (session.provider !== adapter.provider) { | 609 | if (session.provider !== adapter.provider) { |
| 610 | yield* clearMcpSession(threadId); | ||
| 583 | return yield* toValidationError( | 611 | return yield* toValidationError( |
| 584 | "ProviderService.startSession", | 612 | "ProviderService.startSession", |
| 585 | `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, | 613 | `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, |
| ... | @@ -827,6 +855,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -827,6 +855,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 827 | if (routed.isActive) { | 855 | if (routed.isActive) { |
| 828 | yield* routed.adapter.stopSession(routed.threadId); | 856 | yield* routed.adapter.stopSession(routed.threadId); |
| 829 | } | 857 | } |
| 858 | yield* clearMcpSession(input.threadId); | ||
| 830 | yield* directory.upsert({ | 859 | yield* directory.upsert({ |
| 831 | threadId: input.threadId, | 860 | threadId: input.threadId, |
| 832 | provider: routed.adapter.provider, | 861 | provider: routed.adapter.provider, |
| ... | @@ -998,6 +1027,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -998,6 +1027,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 998 | ), | 1027 | ), |
| 999 | ).pipe(Effect.asVoid); | 1028 | ).pipe(Effect.asVoid); |
| 1000 | yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); | 1029 | yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); |
| 1030 | yield* revokeAllActiveMcpCredentials(); | ||
| 1031 | clearAllMcpProviderSessions(); | ||
| 1001 | const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => [])); | 1032 | const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => [])); |
| 1002 | yield* Effect.forEach(bindings, (binding) => | 1033 | yield* Effect.forEach(bindings, (binding) => |
| 1003 | Effect.gen(function* () { | 1034 | Effect.gen(function* () { |
apps/server/src/provider/acp/AcpSessionRuntime.ts+4-3| ... | @@ -47,6 +47,7 @@ export interface AcpSessionRuntimeOptions { | ... | @@ -47,6 +47,7 @@ export interface AcpSessionRuntimeOptions { |
| 47 | readonly version: string; | 47 | readonly version: string; |
| 48 | }; | 48 | }; |
| 49 | readonly authMethodId: string; | 49 | readonly authMethodId: string; |
| 50 | readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>; | ||
| 50 | readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>; | 51 | readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>; |
| 51 | readonly protocolLogging?: { | 52 | readonly protocolLogging?: { |
| 52 | readonly logIncoming?: boolean; | 53 | readonly logIncoming?: boolean; |
| ... | @@ -400,7 +401,7 @@ const makeAcpSessionRuntime = ( | ... | @@ -400,7 +401,7 @@ const makeAcpSessionRuntime = ( |
| 400 | const loadPayload = { | 401 | const loadPayload = { |
| 401 | sessionId: options.resumeSessionId, | 402 | sessionId: options.resumeSessionId, |
| 402 | cwd: options.cwd, | 403 | cwd: options.cwd, |
| 403 | mcpServers: [], | 404 | mcpServers: options.mcpServers ?? [], |
| 404 | } satisfies EffectAcpSchema.LoadSessionRequest; | 405 | } satisfies EffectAcpSchema.LoadSessionRequest; |
| 405 | const resumed = yield* runLoggedRequest( | 406 | const resumed = yield* runLoggedRequest( |
| 406 | "session/load", | 407 | "session/load", |
| ... | @@ -413,7 +414,7 @@ const makeAcpSessionRuntime = ( | ... | @@ -413,7 +414,7 @@ const makeAcpSessionRuntime = ( |
| 413 | } else { | 414 | } else { |
| 414 | const createPayload = { | 415 | const createPayload = { |
| 415 | cwd: options.cwd, | 416 | cwd: options.cwd, |
| 416 | mcpServers: [], | 417 | mcpServers: options.mcpServers ?? [], |
| 417 | } satisfies EffectAcpSchema.NewSessionRequest; | 418 | } satisfies EffectAcpSchema.NewSessionRequest; |
| 418 | const created = yield* runLoggedRequest( | 419 | const created = yield* runLoggedRequest( |
| 419 | "session/new", | 420 | "session/new", |
| ... | @@ -426,7 +427,7 @@ const makeAcpSessionRuntime = ( | ... | @@ -426,7 +427,7 @@ const makeAcpSessionRuntime = ( |
| 426 | } else { | 427 | } else { |
| 427 | const createPayload = { | 428 | const createPayload = { |
| 428 | cwd: options.cwd, | 429 | cwd: options.cwd, |
| 429 | mcpServers: [], | 430 | mcpServers: options.mcpServers ?? [], |
| 430 | } satisfies EffectAcpSchema.NewSessionRequest; | 431 | } satisfies EffectAcpSchema.NewSessionRequest; |
| 431 | const created = yield* runLoggedRequest( | 432 | const created = yield* runLoggedRequest( |
| 432 | "session/new", | 433 | "session/new", |
apps/server/src/server.ts+16-11| ... | @@ -37,6 +37,8 @@ import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/Provide | ... | @@ -37,6 +37,8 @@ import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/Provide |
| 37 | import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; | 37 | import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; |
| 38 | import { PreviewManagerLive } from "./preview/Layers/Manager.ts"; | 38 | import { PreviewManagerLive } from "./preview/Layers/Manager.ts"; |
| 39 | import { PreviewPortScannerLive } from "./preview/Layers/PortScanner.ts"; | 39 | import { PreviewPortScannerLive } from "./preview/Layers/PortScanner.ts"; |
| 40 | import { McpHttpServerLive } from "./mcp/Layers/McpHttpServer.ts"; | ||
| 41 | import { McpSessionRegistryLive } from "./mcp/Layers/McpSessionRegistry.ts"; | ||
| 40 | import * as ProcessRunner from "./processRunner.ts"; | 42 | import * as ProcessRunner from "./processRunner.ts"; |
| 41 | import * as GitManager from "./git/GitManager.ts"; | 43 | import * as GitManager from "./git/GitManager.ts"; |
| 42 | import { KeybindingsLive } from "./keybindings.ts"; | 44 | import { KeybindingsLive } from "./keybindings.ts"; |
| ... | @@ -335,18 +337,21 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( | ... | @@ -335,18 +337,21 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( |
| 335 | ); | 337 | ); |
| 336 | 338 | ||
| 337 | export const makeRoutesLayer = Layer.mergeAll( | 339 | export const makeRoutesLayer = Layer.mergeAll( |
| 338 | HttpApiBuilder.layer(EnvironmentHttpApi).pipe( | 340 | Layer.mergeAll( |
| 339 | Layer.provide(authHttpApiLayer), | 341 | HttpApiBuilder.layer(EnvironmentHttpApi).pipe( |
| 340 | Layer.provide(connectHttpApiLayer), | 342 | Layer.provide(authHttpApiLayer), |
| 341 | Layer.provide(orchestrationHttpApiLayer), | 343 | Layer.provide(connectHttpApiLayer), |
| 342 | Layer.provide(serverEnvironmentHttpApiLayer), | 344 | Layer.provide(orchestrationHttpApiLayer), |
| 343 | Layer.provide(environmentAuthenticatedAuthLayer), | 345 | Layer.provide(serverEnvironmentHttpApiLayer), |
| 346 | Layer.provide(environmentAuthenticatedAuthLayer), | ||
| 347 | ), | ||
| 348 | attachmentsRouteLayer, | ||
| 349 | otlpTracesProxyRouteLayer, | ||
| 350 | projectFaviconRouteLayer, | ||
| 351 | staticAndDevRouteLayer, | ||
| 352 | websocketRpcRouteLayer, | ||
| 344 | ), | 353 | ), |
| 345 | attachmentsRouteLayer, | 354 | McpHttpServerLive.pipe(Layer.provide(McpSessionRegistryLive)), |
| 346 | otlpTracesProxyRouteLayer, | ||
| 347 | projectFaviconRouteLayer, | ||
| 348 | staticAndDevRouteLayer, | ||
| 349 | websocketRpcRouteLayer, | ||
| 350 | ).pipe(Layer.provide(browserApiCorsLayer)); | 355 | ).pipe(Layer.provide(browserApiCorsLayer)); |
| 351 | 356 | ||
| 352 | export const makeServerLayer = Layer.unwrap( | 357 | export const makeServerLayer = Layer.unwrap( |
apps/server/src/ws.ts+29| ... | @@ -73,6 +73,7 @@ import { redactServerSettingsForClient, ServerSettingsService } from "./serverSe | ... | @@ -73,6 +73,7 @@ import { redactServerSettingsForClient, ServerSettingsService } from "./serverSe |
| 73 | import { TerminalManager } from "./terminal/Services/Manager.ts"; | 73 | import { TerminalManager } from "./terminal/Services/Manager.ts"; |
| 74 | import { PreviewManager } from "./preview/Services/Manager.ts"; | 74 | import { PreviewManager } from "./preview/Services/Manager.ts"; |
| 75 | import { PreviewPortScanner } from "./preview/Services/PortScanner.ts"; | 75 | import { PreviewPortScanner } from "./preview/Services/PortScanner.ts"; |
| 76 | import { previewAutomationBroker } from "./mcp/Layers/PreviewAutomationBroker.ts"; | ||
| 76 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; | 77 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; |
| 77 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; | 78 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; |
| 78 | import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; | 79 | import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; |
| ... | @@ -189,6 +190,10 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ | ... | @@ -189,6 +190,10 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ |
| 189 | [WS_METHODS.previewClose, AuthOrchestrationOperateScope], | 190 | [WS_METHODS.previewClose, AuthOrchestrationOperateScope], |
| 190 | [WS_METHODS.previewList, AuthOrchestrationReadScope], | 191 | [WS_METHODS.previewList, AuthOrchestrationReadScope], |
| 191 | [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope], | 192 | [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope], |
| 193 | [WS_METHODS.previewAutomationConnect, AuthOrchestrationOperateScope], | ||
| 194 | [WS_METHODS.previewAutomationRespond, AuthOrchestrationOperateScope], | ||
| 195 | [WS_METHODS.previewAutomationReportOwner, AuthOrchestrationOperateScope], | ||
| 196 | [WS_METHODS.previewAutomationClearOwner, AuthOrchestrationOperateScope], | ||
| 192 | [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope], | 197 | [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope], |
| 193 | [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope], | 198 | [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope], |
| 194 | [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope], | 199 | [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope], |
| ... | @@ -1387,6 +1392,30 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => | ... | @@ -1387,6 +1392,30 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 1387 | observeRpcEffect(WS_METHODS.previewReportStatus, previewManager.reportStatus(input), { | 1392 | observeRpcEffect(WS_METHODS.previewReportStatus, previewManager.reportStatus(input), { |
| 1388 | "rpc.aggregate": "preview", | 1393 | "rpc.aggregate": "preview", |
| 1389 | }), | 1394 | }), |
| 1395 | [WS_METHODS.previewAutomationConnect]: (input) => | ||
| 1396 | observeRpcStreamEffect( | ||
| 1397 | WS_METHODS.previewAutomationConnect, | ||
| 1398 | previewAutomationBroker.connect(input.clientId), | ||
| 1399 | { "rpc.aggregate": "preview-automation" }, | ||
| 1400 | ), | ||
| 1401 | [WS_METHODS.previewAutomationRespond]: (input) => | ||
| 1402 | observeRpcEffect( | ||
| 1403 | WS_METHODS.previewAutomationRespond, | ||
| 1404 | previewAutomationBroker.respond(input), | ||
| 1405 | { "rpc.aggregate": "preview-automation" }, | ||
| 1406 | ), | ||
| 1407 | [WS_METHODS.previewAutomationReportOwner]: (input) => | ||
| 1408 | observeRpcEffect( | ||
| 1409 | WS_METHODS.previewAutomationReportOwner, | ||
| 1410 | previewAutomationBroker.reportOwner(input), | ||
| 1411 | { "rpc.aggregate": "preview-automation" }, | ||
| 1412 | ), | ||
| 1413 | [WS_METHODS.previewAutomationClearOwner]: (input) => | ||
| 1414 | observeRpcEffect( | ||
| 1415 | WS_METHODS.previewAutomationClearOwner, | ||
| 1416 | previewAutomationBroker.clearOwner(input.clientId), | ||
| 1417 | { "rpc.aggregate": "preview-automation" }, | ||
| 1418 | ), | ||
| 1390 | [WS_METHODS.subscribePreviewEvents]: (_input) => | 1419 | [WS_METHODS.subscribePreviewEvents]: (_input) => |
| 1391 | observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, { | 1420 | observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, { |
| 1392 | "rpc.aggregate": "preview", | 1421 | "rpc.aggregate": "preview", |
apps/web/src/components/ChatView.browser.tsx+6| ... | @@ -285,6 +285,12 @@ function createMockEnvironmentApi(input: { | ... | @@ -285,6 +285,12 @@ function createMockEnvironmentApi(input: { |
| 285 | reportStatus: () => { | 285 | reportStatus: () => { |
| 286 | throw new Error("Not implemented in browser test."); | 286 | throw new Error("Not implemented in browser test."); |
| 287 | }, | 287 | }, |
| 288 | automation: { | ||
| 289 | connect: () => () => undefined, | ||
| 290 | respond: () => Promise.resolve(), | ||
| 291 | reportOwner: () => Promise.resolve(), | ||
| 292 | clearOwner: () => Promise.resolve(), | ||
| 293 | }, | ||
| 288 | onEvent: () => () => undefined, | 294 | onEvent: () => () => undefined, |
| 289 | subscribePorts: () => () => undefined, | 295 | subscribePorts: () => () => undefined, |
| 290 | } as EnvironmentApi["preview"], | 296 | } as EnvironmentApi["preview"], |
apps/web/src/components/ChatView.logic.test.ts+46| ... | @@ -12,6 +12,7 @@ import { type EnvironmentState, useStore } from "../store"; | ... | @@ -12,6 +12,7 @@ import { type EnvironmentState, useStore } from "../store"; |
| 12 | import { type Thread } from "../types"; | 12 | import { type Thread } from "../types"; |
| 13 | 13 | ||
| 14 | import { | 14 | import { |
| 15 | MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, | ||
| 15 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, | 16 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, |
| 16 | buildExpiredTerminalContextToastCopy, | 17 | buildExpiredTerminalContextToastCopy, |
| 17 | createLocalDispatchSnapshot, | 18 | createLocalDispatchSnapshot, |
| ... | @@ -19,6 +20,7 @@ import { | ... | @@ -19,6 +20,7 @@ import { |
| 19 | getStartedThreadModelChangeBlockReason, | 20 | getStartedThreadModelChangeBlockReason, |
| 20 | hasServerAcknowledgedLocalDispatch, | 21 | hasServerAcknowledgedLocalDispatch, |
| 21 | reconcileMountedTerminalThreadIds, | 22 | reconcileMountedTerminalThreadIds, |
| 23 | reconcileRetainedMountedThreadIds, | ||
| 22 | resolveSendEnvMode, | 24 | resolveSendEnvMode, |
| 23 | shouldWriteThreadErrorToCurrentServerThread, | 25 | shouldWriteThreadErrorToCurrentServerThread, |
| 24 | waitForStartedServerThread, | 26 | waitForStartedServerThread, |
| ... | @@ -274,6 +276,50 @@ describe("reconcileMountedTerminalThreadIds", () => { | ... | @@ -274,6 +276,50 @@ describe("reconcileMountedTerminalThreadIds", () => { |
| 274 | }); | 276 | }); |
| 275 | }); | 277 | }); |
| 276 | 278 | ||
| 279 | describe("reconcileRetainedMountedThreadIds", () => { | ||
| 280 | it("retains hidden open threads and adds the active open thread", () => { | ||
| 281 | expect( | ||
| 282 | reconcileRetainedMountedThreadIds({ | ||
| 283 | currentThreadIds: [ThreadId.make("thread-hidden")], | ||
| 284 | openThreadIds: [ThreadId.make("thread-hidden")], | ||
| 285 | activeThreadId: ThreadId.make("thread-active"), | ||
| 286 | activeThreadOpen: true, | ||
| 287 | maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, | ||
| 288 | }), | ||
| 289 | ).toEqual([ThreadId.make("thread-hidden"), ThreadId.make("thread-active")]); | ||
| 290 | }); | ||
| 291 | |||
| 292 | it("can retain the active thread as hidden when it is inactive", () => { | ||
| 293 | expect( | ||
| 294 | reconcileRetainedMountedThreadIds({ | ||
| 295 | currentThreadIds: [ThreadId.make("thread-active")], | ||
| 296 | openThreadIds: [ThreadId.make("thread-active")], | ||
| 297 | activeThreadId: ThreadId.make("thread-active"), | ||
| 298 | activeThreadOpen: false, | ||
| 299 | maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, | ||
| 300 | retainInactiveActiveThread: true, | ||
| 301 | }), | ||
| 302 | ).toEqual([ThreadId.make("thread-active")]); | ||
| 303 | }); | ||
| 304 | |||
| 305 | it("evicts the oldest hidden threads beyond the configured cap", () => { | ||
| 306 | const currentThreadIds = Array.from( | ||
| 307 | { length: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS + 2 }, | ||
| 308 | (_, index) => ThreadId.make(`thread-${index + 1}`), | ||
| 309 | ); | ||
| 310 | |||
| 311 | expect( | ||
| 312 | reconcileRetainedMountedThreadIds({ | ||
| 313 | currentThreadIds, | ||
| 314 | openThreadIds: currentThreadIds, | ||
| 315 | activeThreadId: null, | ||
| 316 | activeThreadOpen: false, | ||
| 317 | maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, | ||
| 318 | }), | ||
| 319 | ).toEqual(currentThreadIds.slice(-MAX_HIDDEN_MOUNTED_PREVIEW_THREADS)); | ||
| 320 | }); | ||
| 321 | }); | ||
| 322 | |||
| 277 | describe("shouldWriteThreadErrorToCurrentServerThread", () => { | 323 | describe("shouldWriteThreadErrorToCurrentServerThread", () => { |
| 278 | it("routes errors to the active server thread when route and target match", () => { | 324 | it("routes errors to the active server thread when route and target match", () => { |
| 279 | const threadId = ThreadId.make("thread-1"); | 325 | const threadId = ThreadId.make("thread-1"); |
apps/web/src/components/ChatView.logic.ts+23-6| ... | @@ -22,6 +22,7 @@ import type { DraftThreadEnvMode } from "../composerDraftStore"; | ... | @@ -22,6 +22,7 @@ import type { DraftThreadEnvMode } from "../composerDraftStore"; |
| 22 | 22 | ||
| 23 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; | 23 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; |
| 24 | export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; | 24 | export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; |
| 25 | export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; | ||
| 25 | 26 | ||
| 26 | export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); | 27 | export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); |
| 27 | 28 | ||
| ... | @@ -79,15 +80,31 @@ export function reconcileMountedTerminalThreadIds(input: { | ... | @@ -79,15 +80,31 @@ export function reconcileMountedTerminalThreadIds(input: { |
| 79 | activeThreadId: string | null; | 80 | activeThreadId: string | null; |
| 80 | activeThreadTerminalOpen: boolean; | 81 | activeThreadTerminalOpen: boolean; |
| 81 | maxHiddenThreadCount?: number; | 82 | maxHiddenThreadCount?: number; |
| 83 | }): string[] { | ||
| 84 | return reconcileRetainedMountedThreadIds({ | ||
| 85 | currentThreadIds: input.currentThreadIds, | ||
| 86 | openThreadIds: input.openThreadIds, | ||
| 87 | activeThreadId: input.activeThreadId, | ||
| 88 | activeThreadOpen: input.activeThreadTerminalOpen, | ||
| 89 | maxHiddenThreadCount: input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, | ||
| 90 | }); | ||
| 91 | } | ||
| 92 | |||
| 93 | export function reconcileRetainedMountedThreadIds(input: { | ||
| 94 | currentThreadIds: ReadonlyArray<string>; | ||
| 95 | openThreadIds: ReadonlyArray<string>; | ||
| 96 | activeThreadId: string | null; | ||
| 97 | activeThreadOpen: boolean; | ||
| 98 | maxHiddenThreadCount: number; | ||
| 99 | retainInactiveActiveThread?: boolean; | ||
| 82 | }): string[] { | 100 | }): string[] { |
| 83 | const openThreadIdSet = new Set(input.openThreadIds); | 101 | const openThreadIdSet = new Set(input.openThreadIds); |
| 84 | const hiddenThreadIds = input.currentThreadIds.filter( | 102 | const hiddenThreadIds = input.currentThreadIds.filter( |
| 85 | (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId), | 103 | (threadId) => |
| 86 | ); | 104 | (threadId !== input.activeThreadId || input.retainInactiveActiveThread === true) && |
| 87 | const maxHiddenThreadCount = Math.max( | 105 | openThreadIdSet.has(threadId), |
| 88 | 0, | ||
| 89 | input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, | ||
| 90 | ); | 106 | ); |
| 107 | const maxHiddenThreadCount = Math.max(0, input.maxHiddenThreadCount); | ||
| 91 | const nextThreadIds = | 108 | const nextThreadIds = |
| 92 | hiddenThreadIds.length > maxHiddenThreadCount | 109 | hiddenThreadIds.length > maxHiddenThreadCount |
| 93 | ? hiddenThreadIds.slice(-maxHiddenThreadCount) | 110 | ? hiddenThreadIds.slice(-maxHiddenThreadCount) |
| ... | @@ -95,7 +112,7 @@ export function reconcileMountedTerminalThreadIds(input: { | ... | @@ -95,7 +112,7 @@ export function reconcileMountedTerminalThreadIds(input: { |
| 95 | 112 | ||
| 96 | if ( | 113 | if ( |
| 97 | input.activeThreadId && | 114 | input.activeThreadId && |
| 98 | input.activeThreadTerminalOpen && | 115 | input.activeThreadOpen && |
| 99 | !nextThreadIds.includes(input.activeThreadId) | 116 | !nextThreadIds.includes(input.activeThreadId) |
| 100 | ) { | 117 | ) { |
| 101 | nextThreadIds.push(input.activeThreadId); | 118 | nextThreadIds.push(input.activeThreadId); |
apps/web/src/components/ChatView.tsx+73-6| ... | @@ -99,7 +99,8 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; | ... | @@ -99,7 +99,8 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; |
| 99 | import { useMediaQuery } from "../hooks/useMediaQuery"; | 99 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 100 | import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; | 100 | import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; |
| 101 | import { selectActiveRightPanelKindWithUrl, useRightPanelStore } from "../rightPanelStore"; | 101 | import { selectActiveRightPanelKindWithUrl, useRightPanelStore } from "../rightPanelStore"; |
| 102 | import { isPreviewSupportedInRuntime } from "../previewStateStore"; | 102 | import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; |
| 103 | import { isPreviewSupportedInRuntime, usePreviewStateStore } from "../previewStateStore"; | ||
| 103 | import { subscribePreviewAction } from "./preview/previewActionBus"; | 104 | import { subscribePreviewAction } from "./preview/previewActionBus"; |
| 104 | // Lazy: keeps the entire preview component graph (webview host, favicon | 105 | // Lazy: keeps the entire preview component graph (webview host, favicon |
| 105 | // helper, Chromium error icon) out of the web bundle until first open. | 106 | // helper, Chromium error icon) out of the web bundle until first open. |
| ... | @@ -166,6 +167,7 @@ import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; | ... | @@ -166,6 +167,7 @@ import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; |
| 166 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; | 167 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; |
| 167 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; | 168 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; |
| 168 | import { | 169 | import { |
| 170 | MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, | ||
| 169 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, | 171 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, |
| 170 | buildExpiredTerminalContextToastCopy, | 172 | buildExpiredTerminalContextToastCopy, |
| 171 | buildLocalDraftThread, | 173 | buildLocalDraftThread, |
| ... | @@ -182,6 +184,7 @@ import { | ... | @@ -182,6 +184,7 @@ import { |
| 182 | deriveLockedProvider, | 184 | deriveLockedProvider, |
| 183 | readFileAsDataUrl, | 185 | readFileAsDataUrl, |
| 184 | reconcileMountedTerminalThreadIds, | 186 | reconcileMountedTerminalThreadIds, |
| 187 | reconcileRetainedMountedThreadIds, | ||
| 185 | resolveSendEnvMode, | 188 | resolveSendEnvMode, |
| 186 | revokeBlobPreviewUrl, | 189 | revokeBlobPreviewUrl, |
| 187 | revokeUserMessagePreviewUrls, | 190 | revokeUserMessagePreviewUrls, |
| ... | @@ -996,6 +999,22 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -996,6 +999,22 @@ export default function ChatView(props: ChatViewProps) { |
| 996 | }), | 999 | }), |
| 997 | [mountedTerminalThreadKeys], | 1000 | [mountedTerminalThreadKeys], |
| 998 | ); | 1001 | ); |
| 1002 | const previewSessionThreadKeys = usePreviewStateStore( | ||
| 1003 | useShallow((state) => | ||
| 1004 | Object.entries(state.byThreadKey).flatMap(([nextThreadKey, nextPreviewState]) => | ||
| 1005 | nextPreviewState.snapshot ? [nextThreadKey] : [], | ||
| 1006 | ), | ||
| 1007 | ), | ||
| 1008 | ); | ||
| 1009 | const [mountedPreviewThreadKeys, setMountedPreviewThreadKeys] = useState<string[]>([]); | ||
| 1010 | const mountedPreviewThreadRefs = useMemo( | ||
| 1011 | () => | ||
| 1012 | mountedPreviewThreadKeys.flatMap((mountedThreadKey) => { | ||
| 1013 | const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); | ||
| 1014 | return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; | ||
| 1015 | }), | ||
| 1016 | [mountedPreviewThreadKeys], | ||
| 1017 | ); | ||
| 999 | 1018 | ||
| 1000 | const fallbackDraftProjectRef = draftThread | 1019 | const fallbackDraftProjectRef = draftThread |
| 1001 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) | 1020 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) |
| ... | @@ -1092,6 +1111,12 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1092,6 +1111,12 @@ export default function ChatView(props: ChatViewProps) { |
| 1092 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); | 1111 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); |
| 1093 | return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); | 1112 | return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); |
| 1094 | }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); | 1113 | }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); |
| 1114 | const existingPreviewSessionThreadKeys = useMemo(() => { | ||
| 1115 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); | ||
| 1116 | return previewSessionThreadKeys.filter((nextThreadKey) => | ||
| 1117 | existingThreadKeys.has(nextThreadKey), | ||
| 1118 | ); | ||
| 1119 | }, [draftThreadKeys, previewSessionThreadKeys, serverThreadKeys]); | ||
| 1095 | const activeLatestTurn = activeThread?.latestTurn ?? null; | 1120 | const activeLatestTurn = activeThread?.latestTurn ?? null; |
| 1096 | const threadPlanCatalog = useThreadPlanCatalog( | 1121 | const threadPlanCatalog = useThreadPlanCatalog( |
| 1097 | useMemo(() => { | 1122 | useMemo(() => { |
| ... | @@ -1121,6 +1146,27 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1121,6 +1146,27 @@ export default function ChatView(props: ChatViewProps) { |
| 1121 | : nextThreadIds; | 1146 | : nextThreadIds; |
| 1122 | }); | 1147 | }); |
| 1123 | }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); | 1148 | }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); |
| 1149 | useEffect(() => { | ||
| 1150 | setMountedPreviewThreadKeys((currentThreadIds) => { | ||
| 1151 | const nextThreadIds = reconcileRetainedMountedThreadIds({ | ||
| 1152 | currentThreadIds, | ||
| 1153 | openThreadIds: existingPreviewSessionThreadKeys, | ||
| 1154 | activeThreadId: activeThreadKey, | ||
| 1155 | activeThreadOpen: Boolean(activeThreadKey && !shouldUsePlanSidebarSheet), | ||
| 1156 | maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, | ||
| 1157 | retainInactiveActiveThread: true, | ||
| 1158 | }); | ||
| 1159 | return currentThreadIds.length === nextThreadIds.length && | ||
| 1160 | currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) | ||
| 1161 | ? currentThreadIds | ||
| 1162 | : nextThreadIds; | ||
| 1163 | }); | ||
| 1164 | }, [ | ||
| 1165 | activeThreadKey, | ||
| 1166 | existingPreviewSessionThreadKeys, | ||
| 1167 | previewPanelOpen, | ||
| 1168 | shouldUsePlanSidebarSheet, | ||
| 1169 | ]); | ||
| 1124 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); | 1170 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| 1125 | const activeProjectRef = activeThread | 1171 | const activeProjectRef = activeThread |
| 1126 | ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) | 1172 | ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) |
| ... | @@ -4148,14 +4194,35 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -4148,14 +4194,35 @@ export default function ChatView(props: ChatViewProps) { |
| 4148 | onClose={closePlanSidebar} | 4194 | onClose={closePlanSidebar} |
| 4149 | /> | 4195 | /> |
| 4150 | ) : null} | 4196 | ) : null} |
| 4151 | {previewPanelOpen && !shouldUsePlanSidebarSheet && activeThreadRef ? ( | 4197 | {!shouldUsePlanSidebarSheet |
| 4152 | <Suspense fallback={null}> | 4198 | ? mountedPreviewThreadRefs.map( |
| 4153 | <PreviewPanel mode="inline" threadRef={activeThreadRef} visible /> | 4199 | ({ key: mountedThreadKey, threadRef: mountedThreadRef }) => { |
| 4154 | </Suspense> | 4200 | const visible = previewPanelOpen && mountedThreadKey === activeThreadKey; |
| 4155 | ) : null} | 4201 | return ( |
| 4202 | <div | ||
| 4203 | key={mountedThreadKey} | ||
| 4204 | className={cn( | ||
| 4205 | visible | ||
| 4206 | ? "contents" | ||
| 4207 | : "pointer-events-none fixed -left-[10000px] top-0 h-px w-px overflow-hidden opacity-0", | ||
| 4208 | )} | ||
| 4209 | aria-hidden={visible ? undefined : true} | ||
| 4210 | > | ||
| 4211 | <Suspense fallback={null}> | ||
| 4212 | <PreviewPanel mode="inline" threadRef={mountedThreadRef} visible={visible} /> | ||
| 4213 | </Suspense> | ||
| 4214 | </div> | ||
| 4215 | ); | ||
| 4216 | }, | ||
| 4217 | ) | ||
| 4218 | : null} | ||
| 4156 | </div> | 4219 | </div> |
| 4157 | {/* end horizontal flex container */} | 4220 | {/* end horizontal flex container */} |
| 4158 | 4221 | ||
| 4222 | {activeThreadRef ? ( | ||
| 4223 | <PreviewAutomationOwner threadRef={activeThreadRef} visible={previewPanelOpen} /> | ||
| 4224 | ) : null} | ||
| 4225 | |||
| 4159 | {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( | 4226 | {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( |
| 4160 | <PersistentThreadTerminalDrawer | 4227 | <PersistentThreadTerminalDrawer |
| 4161 | key={mountedThreadKey} | 4228 | key={mountedThreadKey} |
apps/web/src/components/preview/PreviewAutomationOwner.tsx created+287| ... | @@ -0,0 +1,287 @@ | ||
| 1 | "use client"; | ||
| 2 | // @effect-diagnostics cryptoRandomUUID:off | ||
| 3 | |||
| 4 | import { scopedThreadKey } from "@t3tools/client-runtime"; | ||
| 5 | import type { | ||
| 6 | PreviewAutomationNavigateInput, | ||
| 7 | PreviewAutomationOpenInput, | ||
| 8 | PreviewAutomationRequest, | ||
| 9 | PreviewAutomationResponse, | ||
| 10 | PreviewAutomationStatus, | ||
| 11 | ScopedThreadRef, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import { useCallback, useEffect, useRef } from "react"; | ||
| 14 | |||
| 15 | import { ensureEnvironmentApi } from "~/environmentApi"; | ||
| 16 | import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; | ||
| 17 | import { useRightPanelStore } from "~/rightPanelStore"; | ||
| 18 | |||
| 19 | import { previewBridge } from "./previewBridge"; | ||
| 20 | |||
| 21 | const automationClientId = | ||
| 22 | typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" | ||
| 23 | ? crypto.randomUUID() | ||
| 24 | : `preview-${Math.random().toString(36).slice(2)}`; | ||
| 25 | |||
| 26 | const waitForDesktopOverlay = async ( | ||
| 27 | threadRef: ScopedThreadRef, | ||
| 28 | timeoutMs: number, | ||
| 29 | ): Promise<void> => { | ||
| 30 | const deadline = Date.now() + timeoutMs; | ||
| 31 | while (Date.now() <= deadline) { | ||
| 32 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, threadRef); | ||
| 33 | const tabId = state.snapshot?.tabId; | ||
| 34 | if (tabId && state.desktopOverlay && previewBridge) { | ||
| 35 | const status = await previewBridge.automation.status(tabId); | ||
| 36 | if (status.available) return; | ||
| 37 | } | ||
| 38 | await new Promise<void>((resolve) => window.setTimeout(resolve, 50)); | ||
| 39 | } | ||
| 40 | const error = new Error(`Preview webview did not register within ${timeoutMs}ms.`); | ||
| 41 | error.name = "PreviewAutomationTimeoutError"; | ||
| 42 | throw error; | ||
| 43 | }; | ||
| 44 | |||
| 45 | const waitForNavigationReadiness = async ( | ||
| 46 | tabId: string, | ||
| 47 | readiness: PreviewAutomationNavigateInput["readiness"], | ||
| 48 | timeoutMs: number, | ||
| 49 | ): Promise<void> => { | ||
| 50 | if (!previewBridge || readiness === "none") return; | ||
| 51 | const deadline = Date.now() + timeoutMs; | ||
| 52 | while (Date.now() <= deadline) { | ||
| 53 | if (readiness === "domContentLoaded") { | ||
| 54 | const readyState = await previewBridge.automation.evaluate(tabId, { | ||
| 55 | expression: "document.readyState", | ||
| 56 | }); | ||
| 57 | if (readyState === "interactive" || readyState === "complete") return; | ||
| 58 | } else { | ||
| 59 | const status = await previewBridge.automation.status(tabId); | ||
| 60 | if (!status.loading) return; | ||
| 61 | } | ||
| 62 | await new Promise<void>((resolve) => window.setTimeout(resolve, 50)); | ||
| 63 | } | ||
| 64 | const error = new Error(`Preview navigation did not become ready within ${timeoutMs}ms.`); | ||
| 65 | error.name = "PreviewAutomationTimeoutError"; | ||
| 66 | throw error; | ||
| 67 | }; | ||
| 68 | |||
| 69 | const currentStatus = async ( | ||
| 70 | threadRef: ScopedThreadRef, | ||
| 71 | visible: boolean, | ||
| 72 | ): Promise<PreviewAutomationStatus> => { | ||
| 73 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, threadRef); | ||
| 74 | const tabId = state.snapshot?.tabId ?? null; | ||
| 75 | if (tabId && previewBridge && state.desktopOverlay) { | ||
| 76 | const status = await previewBridge.automation.status(tabId); | ||
| 77 | return { ...status, visible }; | ||
| 78 | } | ||
| 79 | const navStatus = state.snapshot?.navStatus; | ||
| 80 | return { | ||
| 81 | available: Boolean(previewBridge?.automation), | ||
| 82 | visible, | ||
| 83 | tabId, | ||
| 84 | url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null, | ||
| 85 | title: navStatus && navStatus._tag !== "Idle" ? navStatus.title : null, | ||
| 86 | loading: navStatus?._tag === "Loading", | ||
| 87 | }; | ||
| 88 | }; | ||
| 89 | |||
| 90 | const serializeError = (error: unknown): NonNullable<PreviewAutomationResponse["error"]> => { | ||
| 91 | if (error instanceof Error) { | ||
| 92 | const detail = | ||
| 93 | "detail" in error && (error as { detail?: unknown }).detail !== undefined | ||
| 94 | ? (error as { detail?: unknown }).detail | ||
| 95 | : undefined; | ||
| 96 | return { | ||
| 97 | _tag: error.name.startsWith("PreviewAutomation") | ||
| 98 | ? error.name | ||
| 99 | : "PreviewAutomationExecutionError", | ||
| 100 | message: error.message, | ||
| 101 | ...(detail === undefined ? {} : { detail }), | ||
| 102 | }; | ||
| 103 | } | ||
| 104 | return { | ||
| 105 | _tag: "PreviewAutomationExecutionError", | ||
| 106 | message: String(error), | ||
| 107 | }; | ||
| 108 | }; | ||
| 109 | |||
| 110 | export function PreviewAutomationOwner(props: { | ||
| 111 | readonly threadRef: ScopedThreadRef; | ||
| 112 | readonly visible: boolean; | ||
| 113 | }) { | ||
| 114 | const { threadRef, visible } = props; | ||
| 115 | const ownerStateRef = useRef({ threadRef, visible }); | ||
| 116 | const handlerRef = useRef<(request: PreviewAutomationRequest) => Promise<unknown>>( | ||
| 117 | async () => undefined, | ||
| 118 | ); | ||
| 119 | ownerStateRef.current = { threadRef, visible }; | ||
| 120 | |||
| 121 | const handleRequest = useCallback( | ||
| 122 | async (request: PreviewAutomationRequest): Promise<unknown> => { | ||
| 123 | if (request.threadId !== threadRef.threadId) { | ||
| 124 | const error = new Error("Preview automation request targeted a stale thread owner."); | ||
| 125 | error.name = "PreviewAutomationUnavailableError"; | ||
| 126 | throw error; | ||
| 127 | } | ||
| 128 | const api = ensureEnvironmentApi(threadRef.environmentId); | ||
| 129 | const state = selectThreadPreviewState( | ||
| 130 | usePreviewStateStore.getState().byThreadKey, | ||
| 131 | threadRef, | ||
| 132 | ); | ||
| 133 | const tabId = request.tabId ?? state.snapshot?.tabId ?? null; | ||
| 134 | switch (request.operation) { | ||
| 135 | case "status": | ||
| 136 | return currentStatus(threadRef, visible); | ||
| 137 | case "open": { | ||
| 138 | const input = request.input as PreviewAutomationOpenInput; | ||
| 139 | if (input.show ?? true) { | ||
| 140 | useRightPanelStore.getState().open(threadRef, "preview"); | ||
| 141 | } | ||
| 142 | let activeTabId = | ||
| 143 | (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; | ||
| 144 | if (!activeTabId) { | ||
| 145 | const snapshot = await api.preview.open({ | ||
| 146 | threadId: threadRef.threadId, | ||
| 147 | ...(input.url ? { url: input.url } : {}), | ||
| 148 | }); | ||
| 149 | usePreviewStateStore.getState().applyServerSnapshot(threadRef, snapshot); | ||
| 150 | activeTabId = snapshot.tabId; | ||
| 151 | } else if (input.url && previewBridge) { | ||
| 152 | await previewBridge.navigate(activeTabId, input.url); | ||
| 153 | } | ||
| 154 | await waitForDesktopOverlay(threadRef, request.timeoutMs); | ||
| 155 | return currentStatus(threadRef, input.show ?? true); | ||
| 156 | } | ||
| 157 | case "navigate": { | ||
| 158 | if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); | ||
| 159 | const input = request.input as PreviewAutomationNavigateInput; | ||
| 160 | await previewBridge.navigate(tabId, input.url); | ||
| 161 | await waitForNavigationReadiness( | ||
| 162 | tabId, | ||
| 163 | input.readiness ?? "load", | ||
| 164 | input.timeoutMs ?? request.timeoutMs, | ||
| 165 | ); | ||
| 166 | return currentStatus(threadRef, visible); | ||
| 167 | } | ||
| 168 | case "snapshot": | ||
| 169 | if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); | ||
| 170 | return previewBridge.automation.snapshot(tabId); | ||
| 171 | case "click": | ||
| 172 | if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); | ||
| 173 | return previewBridge.automation.click( | ||
| 174 | tabId, | ||
| 175 | request.input as Parameters<typeof previewBridge.automation.click>[1], | ||
| 176 | ); | ||
| 177 | case "type": | ||
| 178 | if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); | ||
| 179 | return previewBridge.automation.type( | ||
| 180 | tabId, | ||
| 181 | request.input as Parameters<typeof previewBridge.automation.type>[1], | ||
| 182 | ); | ||
| 183 | case "press": | ||
| 184 | if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); | ||
| 185 | return previewBridge.automation.press( | ||
| 186 | tabId, | ||
| 187 | request.input as Parameters<typeof previewBridge.automation.press>[1], | ||
| 188 | ); | ||
| 189 | case "scroll": | ||
| 190 | if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); | ||
| 191 | return previewBridge.automation.scroll( | ||
| 192 | tabId, | ||
| 193 | request.input as Parameters<typeof previewBridge.automation.scroll>[1], | ||
| 194 | ); | ||
| 195 | case "evaluate": | ||
| 196 | if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); | ||
| 197 | return previewBridge.automation.evaluate( | ||
| 198 | tabId, | ||
| 199 | request.input as Parameters<typeof previewBridge.automation.evaluate>[1], | ||
| 200 | ); | ||
| 201 | case "waitFor": | ||
| 202 | if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); | ||
| 203 | return previewBridge.automation.waitFor( | ||
| 204 | tabId, | ||
| 205 | request.input as Parameters<typeof previewBridge.automation.waitFor>[1], | ||
| 206 | ); | ||
| 207 | } | ||
| 208 | }, | ||
| 209 | [threadRef, visible], | ||
| 210 | ); | ||
| 211 | handlerRef.current = handleRequest; | ||
| 212 | |||
| 213 | useEffect(() => { | ||
| 214 | const api = ensureEnvironmentApi(threadRef.environmentId); | ||
| 215 | return api.preview.automation.connect( | ||
| 216 | { clientId: automationClientId }, | ||
| 217 | (request) => { | ||
| 218 | void handlerRef.current(request).then( | ||
| 219 | (result) => | ||
| 220 | api.preview.automation.respond({ | ||
| 221 | requestId: request.requestId, | ||
| 222 | ok: true, | ||
| 223 | ...(result === undefined ? {} : { result }), | ||
| 224 | }), | ||
| 225 | (error) => | ||
| 226 | api.preview.automation.respond({ | ||
| 227 | requestId: request.requestId, | ||
| 228 | ok: false, | ||
| 229 | error: serializeError(error), | ||
| 230 | }), | ||
| 231 | ); | ||
| 232 | }, | ||
| 233 | { | ||
| 234 | onResubscribe: () => { | ||
| 235 | const ownerState = ownerStateRef.current; | ||
| 236 | const state = selectThreadPreviewState( | ||
| 237 | usePreviewStateStore.getState().byThreadKey, | ||
| 238 | ownerState.threadRef, | ||
| 239 | ); | ||
| 240 | void api.preview.automation.reportOwner({ | ||
| 241 | clientId: automationClientId, | ||
| 242 | environmentId: ownerState.threadRef.environmentId, | ||
| 243 | threadId: ownerState.threadRef.threadId, | ||
| 244 | tabId: state.snapshot?.tabId ?? null, | ||
| 245 | visible: ownerState.visible, | ||
| 246 | supportsAutomation: Boolean(previewBridge?.automation), | ||
| 247 | focusedAt: new Date().toISOString(), | ||
| 248 | }); | ||
| 249 | }, | ||
| 250 | }, | ||
| 251 | ); | ||
| 252 | }, [threadRef.environmentId]); | ||
| 253 | |||
| 254 | useEffect(() => { | ||
| 255 | const api = ensureEnvironmentApi(threadRef.environmentId); | ||
| 256 | const report = () => { | ||
| 257 | const state = selectThreadPreviewState( | ||
| 258 | usePreviewStateStore.getState().byThreadKey, | ||
| 259 | threadRef, | ||
| 260 | ); | ||
| 261 | void api.preview.automation.reportOwner({ | ||
| 262 | clientId: automationClientId, | ||
| 263 | environmentId: threadRef.environmentId, | ||
| 264 | threadId: threadRef.threadId, | ||
| 265 | tabId: state.snapshot?.tabId ?? null, | ||
| 266 | visible, | ||
| 267 | supportsAutomation: Boolean(previewBridge?.automation), | ||
| 268 | focusedAt: new Date().toISOString(), | ||
| 269 | }); | ||
| 270 | }; | ||
| 271 | report(); | ||
| 272 | window.addEventListener("focus", report); | ||
| 273 | const unsubscribe = usePreviewStateStore.subscribe((state, previous) => { | ||
| 274 | const key = scopedThreadKey(threadRef); | ||
| 275 | if (state.byThreadKey[key]?.snapshot?.tabId !== previous.byThreadKey[key]?.snapshot?.tabId) { | ||
| 276 | report(); | ||
| 277 | } | ||
| 278 | }); | ||
| 279 | return () => { | ||
| 280 | window.removeEventListener("focus", report); | ||
| 281 | unsubscribe(); | ||
| 282 | void api.preview.automation.clearOwner({ clientId: automationClientId }); | ||
| 283 | }; | ||
| 284 | }, [threadRef, visible]); | ||
| 285 | |||
| 286 | return null; | ||
| 287 | } | ||
apps/web/src/environmentApi.ts+7| ... | @@ -65,6 +65,13 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { | ... | @@ -65,6 +65,13 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { |
| 65 | close: (input) => rpcClient.preview.close(input as never), | 65 | close: (input) => rpcClient.preview.close(input as never), |
| 66 | list: (input) => rpcClient.preview.list(input as never), | 66 | list: (input) => rpcClient.preview.list(input as never), |
| 67 | reportStatus: (input) => rpcClient.preview.reportStatus(input as never), | 67 | reportStatus: (input) => rpcClient.preview.reportStatus(input as never), |
| 68 | automation: { | ||
| 69 | connect: (input, callback, options) => | ||
| 70 | rpcClient.preview.automation.connect(input as never, callback, options), | ||
| 71 | respond: (response) => rpcClient.preview.automation.respond(response as never), | ||
| 72 | reportOwner: (owner) => rpcClient.preview.automation.reportOwner(owner as never), | ||
| 73 | clearOwner: (input) => rpcClient.preview.automation.clearOwner(input as never), | ||
| 74 | }, | ||
| 68 | onEvent: (callback) => rpcClient.preview.onEvent(callback), | 75 | onEvent: (callback) => rpcClient.preview.onEvent(callback), |
| 69 | subscribePorts: (callback, options) => rpcClient.preview.subscribePorts(callback, options), | 76 | subscribePorts: (callback, options) => rpcClient.preview.subscribePorts(callback, options), |
| 70 | }, | 77 | }, |
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+6| ... | @@ -111,6 +111,12 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { | ... | @@ -111,6 +111,12 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { |
| 111 | close: vi.fn(), | 111 | close: vi.fn(), |
| 112 | list: vi.fn(), | 112 | list: vi.fn(), |
| 113 | reportStatus: vi.fn(), | 113 | reportStatus: vi.fn(), |
| 114 | automation: { | ||
| 115 | connect: vi.fn(() => () => undefined), | ||
| 116 | respond: vi.fn(), | ||
| 117 | reportOwner: vi.fn(), | ||
| 118 | clearOwner: vi.fn(), | ||
| 119 | }, | ||
| 114 | onEvent: vi.fn(() => () => undefined), | 120 | onEvent: vi.fn(() => () => undefined), |
| 115 | subscribePorts: vi.fn(() => () => undefined), | 121 | subscribePorts: vi.fn(() => () => undefined), |
| 116 | }, | 122 | }, |
packages/client-runtime/src/wsRpcClient.ts+20| ... | @@ -86,6 +86,12 @@ export interface WsRpcClient { | ... | @@ -86,6 +86,12 @@ export interface WsRpcClient { |
| 86 | readonly close: RpcUnaryMethod<typeof WS_METHODS.previewClose>; | 86 | readonly close: RpcUnaryMethod<typeof WS_METHODS.previewClose>; |
| 87 | readonly list: RpcUnaryMethod<typeof WS_METHODS.previewList>; | 87 | readonly list: RpcUnaryMethod<typeof WS_METHODS.previewList>; |
| 88 | readonly reportStatus: RpcUnaryMethod<typeof WS_METHODS.previewReportStatus>; | 88 | readonly reportStatus: RpcUnaryMethod<typeof WS_METHODS.previewReportStatus>; |
| 89 | readonly automation: { | ||
| 90 | readonly connect: RpcInputStreamMethod<typeof WS_METHODS.previewAutomationConnect>; | ||
| 91 | readonly respond: RpcUnaryMethod<typeof WS_METHODS.previewAutomationRespond>; | ||
| 92 | readonly reportOwner: RpcUnaryMethod<typeof WS_METHODS.previewAutomationReportOwner>; | ||
| 93 | readonly clearOwner: RpcUnaryMethod<typeof WS_METHODS.previewAutomationClearOwner>; | ||
| 94 | }; | ||
| 89 | readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribePreviewEvents>; | 95 | readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribePreviewEvents>; |
| 90 | readonly subscribePorts: RpcStreamMethod<typeof WS_METHODS.subscribeDiscoveredLocalServers>; | 96 | readonly subscribePorts: RpcStreamMethod<typeof WS_METHODS.subscribeDiscoveredLocalServers>; |
| 91 | }; | 97 | }; |
| ... | @@ -230,6 +236,20 @@ export function createWsRpcClient( | ... | @@ -230,6 +236,20 @@ export function createWsRpcClient( |
| 230 | list: (input) => transport.request((client) => client[WS_METHODS.previewList](input)), | 236 | list: (input) => transport.request((client) => client[WS_METHODS.previewList](input)), |
| 231 | reportStatus: (input) => | 237 | reportStatus: (input) => |
| 232 | transport.request((client) => client[WS_METHODS.previewReportStatus](input)), | 238 | transport.request((client) => client[WS_METHODS.previewReportStatus](input)), |
| 239 | automation: { | ||
| 240 | connect: (input, listener, options) => | ||
| 241 | transport.subscribe( | ||
| 242 | (client) => client[WS_METHODS.previewAutomationConnect](input), | ||
| 243 | listener, | ||
| 244 | subscriptionOptions(options, WS_METHODS.previewAutomationConnect), | ||
| 245 | ), | ||
| 246 | respond: (input) => | ||
| 247 | transport.request((client) => client[WS_METHODS.previewAutomationRespond](input)), | ||
| 248 | reportOwner: (input) => | ||
| 249 | transport.request((client) => client[WS_METHODS.previewAutomationReportOwner](input)), | ||
| 250 | clearOwner: (input) => | ||
| 251 | transport.request((client) => client[WS_METHODS.previewAutomationClearOwner](input)), | ||
| 252 | }, | ||
| 233 | onEvent: (listener, options) => | 253 | onEvent: (listener, options) => |
| 234 | transport.subscribe( | 254 | transport.subscribe( |
| 235 | (client) => client[WS_METHODS.subscribePreviewEvents]({}), | 255 | (client) => client[WS_METHODS.subscribePreviewEvents]({}), |
packages/contracts/src/index.ts+1| ... | @@ -23,4 +23,5 @@ export * from "./project.ts"; | ... | @@ -23,4 +23,5 @@ export * from "./project.ts"; |
| 23 | export * from "./filesystem.ts"; | 23 | export * from "./filesystem.ts"; |
| 24 | export * from "./review.ts"; | 24 | export * from "./review.ts"; |
| 25 | export * from "./preview.ts"; | 25 | export * from "./preview.ts"; |
| 26 | export * from "./previewAutomation.ts"; | ||
| 26 | export * from "./rpc.ts"; | 27 | export * from "./rpc.ts"; |
packages/contracts/src/ipc.ts+33| ... | @@ -66,6 +66,19 @@ import type { | ... | @@ -66,6 +66,19 @@ import type { |
| 66 | PreviewReportStatusInput, | 66 | PreviewReportStatusInput, |
| 67 | PreviewSessionSnapshot, | 67 | PreviewSessionSnapshot, |
| 68 | } from "./preview.ts"; | 68 | } from "./preview.ts"; |
| 69 | import type { | ||
| 70 | PreviewAutomationClickInput, | ||
| 71 | PreviewAutomationEvaluateInput, | ||
| 72 | PreviewAutomationOwner, | ||
| 73 | PreviewAutomationPressInput, | ||
| 74 | PreviewAutomationRequest, | ||
| 75 | PreviewAutomationResponse, | ||
| 76 | PreviewAutomationScrollInput, | ||
| 77 | PreviewAutomationSnapshot, | ||
| 78 | PreviewAutomationStatus, | ||
| 79 | PreviewAutomationTypeInput, | ||
| 80 | PreviewAutomationWaitForInput, | ||
| 81 | } from "./previewAutomation.ts"; | ||
| 69 | import type { | 82 | import type { |
| 70 | ClientOrchestrationCommand, | 83 | ClientOrchestrationCommand, |
| 71 | OrchestrationGetFullThreadDiffInput, | 84 | OrchestrationGetFullThreadDiffInput, |
| ... | @@ -668,6 +681,16 @@ export interface DesktopPreviewBridge { | ... | @@ -668,6 +681,16 @@ export interface DesktopPreviewBridge { |
| 668 | pickElement: (tabId: string) => Promise<PreviewAnnotationPayload | null>; | 681 | pickElement: (tabId: string) => Promise<PreviewAnnotationPayload | null>; |
| 669 | /** Cancel an in-flight preview annotation session. */ | 682 | /** Cancel an in-flight preview annotation session. */ |
| 670 | cancelPickElement: (tabId: string) => Promise<void>; | 683 | cancelPickElement: (tabId: string) => Promise<void>; |
| 684 | automation: { | ||
| 685 | status: (tabId: string) => Promise<PreviewAutomationStatus>; | ||
| 686 | snapshot: (tabId: string) => Promise<PreviewAutomationSnapshot>; | ||
| 687 | click: (tabId: string, input: PreviewAutomationClickInput) => Promise<void>; | ||
| 688 | type: (tabId: string, input: PreviewAutomationTypeInput) => Promise<void>; | ||
| 689 | press: (tabId: string, input: PreviewAutomationPressInput) => Promise<void>; | ||
| 690 | scroll: (tabId: string, input: PreviewAutomationScrollInput) => Promise<void>; | ||
| 691 | evaluate: (tabId: string, input: PreviewAutomationEvaluateInput) => Promise<unknown>; | ||
| 692 | waitFor: (tabId: string, input: PreviewAutomationWaitForInput) => Promise<void>; | ||
| 693 | }; | ||
| 671 | onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; | 694 | onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; |
| 672 | } | 695 | } |
| 673 | 696 | ||
| ... | @@ -835,6 +858,16 @@ export interface EnvironmentApi { | ... | @@ -835,6 +858,16 @@ export interface EnvironmentApi { |
| 835 | close: (input: typeof PreviewCloseInput.Encoded) => Promise<void>; | 858 | close: (input: typeof PreviewCloseInput.Encoded) => Promise<void>; |
| 836 | list: (input: typeof PreviewListInput.Encoded) => Promise<PreviewListResult>; | 859 | list: (input: typeof PreviewListInput.Encoded) => Promise<PreviewListResult>; |
| 837 | reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise<void>; | 860 | reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise<void>; |
| 861 | automation: { | ||
| 862 | connect: ( | ||
| 863 | input: { clientId: string }, | ||
| 864 | callback: (request: PreviewAutomationRequest) => void, | ||
| 865 | options?: { onResubscribe?: () => void }, | ||
| 866 | ) => () => void; | ||
| 867 | respond: (response: PreviewAutomationResponse) => Promise<void>; | ||
| 868 | reportOwner: (owner: PreviewAutomationOwner) => Promise<void>; | ||
| 869 | clearOwner: (input: { clientId: string }) => Promise<void>; | ||
| 870 | }; | ||
| 838 | onEvent: (callback: (event: PreviewEvent) => void) => () => void; | 871 | onEvent: (callback: (event: PreviewEvent) => void) => () => void; |
| 839 | subscribePorts: ( | 872 | subscribePorts: ( |
| 840 | callback: (servers: DiscoveredLocalServerList) => void, | 873 | callback: (servers: DiscoveredLocalServerList) => void, |
packages/contracts/src/previewAutomation.ts created+219| ... | @@ -0,0 +1,219 @@ | ||
| 1 | import { Schema } from "effect"; | ||
| 2 | |||
| 3 | import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | ||
| 4 | import { PreviewTabId } from "./preview.ts"; | ||
| 5 | |||
| 6 | const BoundedUrl = TrimmedNonEmptyString.check(Schema.isMaxLength(2048)); | ||
| 7 | const OptionalTimeoutMs = Schema.optional( | ||
| 8 | Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThanOrEqualTo(60_000)), | ||
| 9 | ); | ||
| 10 | |||
| 11 | export const PreviewAutomationOperation = Schema.Literals([ | ||
| 12 | "status", | ||
| 13 | "open", | ||
| 14 | "navigate", | ||
| 15 | "snapshot", | ||
| 16 | "click", | ||
| 17 | "type", | ||
| 18 | "press", | ||
| 19 | "scroll", | ||
| 20 | "evaluate", | ||
| 21 | "waitFor", | ||
| 22 | ]); | ||
| 23 | export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type; | ||
| 24 | |||
| 25 | export const PreviewAutomationStatus = Schema.Struct({ | ||
| 26 | available: Schema.Boolean, | ||
| 27 | visible: Schema.Boolean, | ||
| 28 | tabId: Schema.NullOr(PreviewTabId), | ||
| 29 | url: Schema.NullOr(Schema.String), | ||
| 30 | title: Schema.NullOr(Schema.String), | ||
| 31 | loading: Schema.Boolean, | ||
| 32 | }); | ||
| 33 | export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type; | ||
| 34 | |||
| 35 | export const PreviewAutomationOpenInput = Schema.Struct({ | ||
| 36 | url: Schema.optional(BoundedUrl), | ||
| 37 | show: Schema.optional(Schema.Boolean), | ||
| 38 | reuseExistingTab: Schema.optional(Schema.Boolean), | ||
| 39 | }); | ||
| 40 | export type PreviewAutomationOpenInput = typeof PreviewAutomationOpenInput.Type; | ||
| 41 | |||
| 42 | export const PreviewAutomationNavigateInput = Schema.Struct({ | ||
| 43 | url: BoundedUrl, | ||
| 44 | readiness: Schema.optional(Schema.Literals(["load", "domContentLoaded", "none"])), | ||
| 45 | timeoutMs: OptionalTimeoutMs, | ||
| 46 | }); | ||
| 47 | export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type; | ||
| 48 | |||
| 49 | export const PreviewAutomationClickInput = Schema.Union([ | ||
| 50 | Schema.Struct({ | ||
| 51 | selector: TrimmedNonEmptyString, | ||
| 52 | timeoutMs: OptionalTimeoutMs, | ||
| 53 | }), | ||
| 54 | Schema.Struct({ | ||
| 55 | x: Schema.Number, | ||
| 56 | y: Schema.Number, | ||
| 57 | timeoutMs: OptionalTimeoutMs, | ||
| 58 | }), | ||
| 59 | ]); | ||
| 60 | export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Type; | ||
| 61 | |||
| 62 | export const PreviewAutomationTypeInput = Schema.Struct({ | ||
| 63 | text: Schema.String, | ||
| 64 | selector: Schema.optional(TrimmedNonEmptyString), | ||
| 65 | clear: Schema.optional(Schema.Boolean), | ||
| 66 | timeoutMs: OptionalTimeoutMs, | ||
| 67 | }); | ||
| 68 | export type PreviewAutomationTypeInput = typeof PreviewAutomationTypeInput.Type; | ||
| 69 | |||
| 70 | export const PreviewAutomationPressInput = Schema.Struct({ | ||
| 71 | key: TrimmedNonEmptyString, | ||
| 72 | modifiers: Schema.optional(Schema.Array(Schema.Literals(["Alt", "Control", "Meta", "Shift"]))), | ||
| 73 | }); | ||
| 74 | export type PreviewAutomationPressInput = typeof PreviewAutomationPressInput.Type; | ||
| 75 | |||
| 76 | export const PreviewAutomationScrollInput = Schema.Struct({ | ||
| 77 | deltaX: Schema.optional(Schema.Number), | ||
| 78 | deltaY: Schema.optional(Schema.Number), | ||
| 79 | selector: Schema.optional(TrimmedNonEmptyString), | ||
| 80 | }); | ||
| 81 | export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.Type; | ||
| 82 | |||
| 83 | export const PreviewAutomationEvaluateInput = Schema.Struct({ | ||
| 84 | expression: TrimmedNonEmptyString.check(Schema.isMaxLength(64_000)), | ||
| 85 | awaitPromise: Schema.optional(Schema.Boolean), | ||
| 86 | returnByValue: Schema.optional(Schema.Boolean), | ||
| 87 | }); | ||
| 88 | export type PreviewAutomationEvaluateInput = typeof PreviewAutomationEvaluateInput.Type; | ||
| 89 | |||
| 90 | export const PreviewAutomationWaitForInput = Schema.Struct({ | ||
| 91 | selector: Schema.optional(TrimmedNonEmptyString), | ||
| 92 | text: Schema.optional(TrimmedNonEmptyString), | ||
| 93 | urlIncludes: Schema.optional(TrimmedNonEmptyString), | ||
| 94 | timeoutMs: OptionalTimeoutMs, | ||
| 95 | }); | ||
| 96 | export type PreviewAutomationWaitForInput = typeof PreviewAutomationWaitForInput.Type; | ||
| 97 | |||
| 98 | export const PreviewAutomationElement = Schema.Struct({ | ||
| 99 | tag: Schema.String, | ||
| 100 | role: Schema.NullOr(Schema.String), | ||
| 101 | name: Schema.String, | ||
| 102 | selector: Schema.String, | ||
| 103 | x: Schema.Number, | ||
| 104 | y: Schema.Number, | ||
| 105 | width: Schema.Number, | ||
| 106 | height: Schema.Number, | ||
| 107 | }); | ||
| 108 | export type PreviewAutomationElement = typeof PreviewAutomationElement.Type; | ||
| 109 | |||
| 110 | export const PreviewAutomationSnapshot = Schema.Struct({ | ||
| 111 | url: Schema.String, | ||
| 112 | title: Schema.String, | ||
| 113 | loading: Schema.Boolean, | ||
| 114 | visibleText: Schema.String, | ||
| 115 | interactiveElements: Schema.Array(PreviewAutomationElement), | ||
| 116 | accessibilityTree: Schema.Unknown, | ||
| 117 | screenshot: Schema.Struct({ | ||
| 118 | mimeType: Schema.Literal("image/png"), | ||
| 119 | data: Schema.String, | ||
| 120 | width: Schema.Int, | ||
| 121 | height: Schema.Int, | ||
| 122 | }), | ||
| 123 | }); | ||
| 124 | export type PreviewAutomationSnapshot = typeof PreviewAutomationSnapshot.Type; | ||
| 125 | |||
| 126 | export const PreviewAutomationOwner = Schema.Struct({ | ||
| 127 | clientId: TrimmedNonEmptyString, | ||
| 128 | environmentId: EnvironmentId, | ||
| 129 | threadId: ThreadId, | ||
| 130 | tabId: Schema.NullOr(PreviewTabId), | ||
| 131 | visible: Schema.Boolean, | ||
| 132 | supportsAutomation: Schema.Boolean, | ||
| 133 | focusedAt: Schema.String, | ||
| 134 | }); | ||
| 135 | export type PreviewAutomationOwner = typeof PreviewAutomationOwner.Type; | ||
| 136 | |||
| 137 | export const PreviewAutomationRequest = Schema.Struct({ | ||
| 138 | requestId: TrimmedNonEmptyString, | ||
| 139 | threadId: ThreadId, | ||
| 140 | tabId: Schema.optional(PreviewTabId), | ||
| 141 | operation: PreviewAutomationOperation, | ||
| 142 | input: Schema.Unknown, | ||
| 143 | timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), | ||
| 144 | }); | ||
| 145 | export type PreviewAutomationRequest = typeof PreviewAutomationRequest.Type; | ||
| 146 | |||
| 147 | export const PreviewAutomationResponse = Schema.Struct({ | ||
| 148 | requestId: TrimmedNonEmptyString, | ||
| 149 | ok: Schema.Boolean, | ||
| 150 | result: Schema.optional(Schema.Unknown), | ||
| 151 | error: Schema.optional( | ||
| 152 | Schema.Struct({ | ||
| 153 | _tag: TrimmedNonEmptyString, | ||
| 154 | message: Schema.String, | ||
| 155 | detail: Schema.optional(Schema.Unknown), | ||
| 156 | }), | ||
| 157 | ), | ||
| 158 | }); | ||
| 159 | export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type; | ||
| 160 | |||
| 161 | export class PreviewAutomationUnavailableError extends Schema.TaggedErrorClass<PreviewAutomationUnavailableError>()( | ||
| 162 | "PreviewAutomationUnavailableError", | ||
| 163 | { message: Schema.String }, | ||
| 164 | ) {} | ||
| 165 | |||
| 166 | export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClass<PreviewAutomationNoFocusedOwnerError>()( | ||
| 167 | "PreviewAutomationNoFocusedOwnerError", | ||
| 168 | { message: Schema.String }, | ||
| 169 | ) {} | ||
| 170 | |||
| 171 | export class PreviewAutomationUnsupportedClientError extends Schema.TaggedErrorClass<PreviewAutomationUnsupportedClientError>()( | ||
| 172 | "PreviewAutomationUnsupportedClientError", | ||
| 173 | { message: Schema.String }, | ||
| 174 | ) {} | ||
| 175 | |||
| 176 | export class PreviewAutomationTabNotFoundError extends Schema.TaggedErrorClass<PreviewAutomationTabNotFoundError>()( | ||
| 177 | "PreviewAutomationTabNotFoundError", | ||
| 178 | { message: Schema.String }, | ||
| 179 | ) {} | ||
| 180 | |||
| 181 | export class PreviewAutomationTimeoutError extends Schema.TaggedErrorClass<PreviewAutomationTimeoutError>()( | ||
| 182 | "PreviewAutomationTimeoutError", | ||
| 183 | { message: Schema.String }, | ||
| 184 | ) {} | ||
| 185 | |||
| 186 | export class PreviewAutomationExecutionError extends Schema.TaggedErrorClass<PreviewAutomationExecutionError>()( | ||
| 187 | "PreviewAutomationExecutionError", | ||
| 188 | { message: Schema.String, detail: Schema.optional(Schema.Unknown) }, | ||
| 189 | ) {} | ||
| 190 | |||
| 191 | export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorClass<PreviewAutomationInvalidSelectorError>()( | ||
| 192 | "PreviewAutomationInvalidSelectorError", | ||
| 193 | { message: Schema.String, selector: Schema.String }, | ||
| 194 | ) {} | ||
| 195 | |||
| 196 | export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass<PreviewAutomationResultTooLargeError>()( | ||
| 197 | "PreviewAutomationResultTooLargeError", | ||
| 198 | { message: Schema.String, maximumBytes: Schema.Int }, | ||
| 199 | ) {} | ||
| 200 | |||
| 201 | export const PreviewAutomationError = Schema.Union([ | ||
| 202 | PreviewAutomationUnavailableError, | ||
| 203 | PreviewAutomationNoFocusedOwnerError, | ||
| 204 | PreviewAutomationUnsupportedClientError, | ||
| 205 | PreviewAutomationTabNotFoundError, | ||
| 206 | PreviewAutomationTimeoutError, | ||
| 207 | PreviewAutomationExecutionError, | ||
| 208 | PreviewAutomationInvalidSelectorError, | ||
| 209 | PreviewAutomationResultTooLargeError, | ||
| 210 | ]); | ||
| 211 | export type PreviewAutomationError = typeof PreviewAutomationError.Type; | ||
| 212 | |||
| 213 | export const PreviewUrlResolution = Schema.Struct({ | ||
| 214 | requestedUrl: Schema.String, | ||
| 215 | resolvedUrl: Schema.String, | ||
| 216 | resolutionKind: Schema.Literal("direct"), | ||
| 217 | environmentId: EnvironmentId, | ||
| 218 | }); | ||
| 219 | export type PreviewUrlResolution = typeof PreviewUrlResolution.Type; | ||
packages/contracts/src/rpc.ts+36| ... | @@ -98,6 +98,12 @@ import { | ... | @@ -98,6 +98,12 @@ import { |
| 98 | PreviewReportStatusInput, | 98 | PreviewReportStatusInput, |
| 99 | PreviewSessionSnapshot, | 99 | PreviewSessionSnapshot, |
| 100 | } from "./preview.ts"; | 100 | } from "./preview.ts"; |
| 101 | import { | ||
| 102 | PreviewAutomationError, | ||
| 103 | PreviewAutomationOwner, | ||
| 104 | PreviewAutomationRequest, | ||
| 105 | PreviewAutomationResponse, | ||
| 106 | } from "./previewAutomation.ts"; | ||
| 101 | import { | 107 | import { |
| 102 | ServerConfigStreamEvent, | 108 | ServerConfigStreamEvent, |
| 103 | ServerConfig, | 109 | ServerConfig, |
| ... | @@ -177,6 +183,10 @@ export const WS_METHODS = { | ... | @@ -177,6 +183,10 @@ export const WS_METHODS = { |
| 177 | previewClose: "preview.close", | 183 | previewClose: "preview.close", |
| 178 | previewList: "preview.list", | 184 | previewList: "preview.list", |
| 179 | previewReportStatus: "preview.reportStatus", | 185 | previewReportStatus: "preview.reportStatus", |
| 186 | previewAutomationConnect: "previewAutomation.connect", | ||
| 187 | previewAutomationRespond: "previewAutomation.respond", | ||
| 188 | previewAutomationReportOwner: "previewAutomation.reportOwner", | ||
| 189 | previewAutomationClearOwner: "previewAutomation.clearOwner", | ||
| 180 | 190 | ||
| 181 | // Server meta | 191 | // Server meta |
| 182 | serverGetConfig: "server.getConfig", | 192 | serverGetConfig: "server.getConfig", |
| ... | @@ -510,6 +520,28 @@ export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, | ... | @@ -510,6 +520,28 @@ export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, |
| 510 | error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), | 520 | error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), |
| 511 | }); | 521 | }); |
| 512 | 522 | ||
| 523 | export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, { | ||
| 524 | payload: Schema.Struct({ clientId: Schema.String }), | ||
| 525 | success: PreviewAutomationRequest, | ||
| 526 | error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), | ||
| 527 | stream: true, | ||
| 528 | }); | ||
| 529 | |||
| 530 | export const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomationRespond, { | ||
| 531 | payload: PreviewAutomationResponse, | ||
| 532 | error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), | ||
| 533 | }); | ||
| 534 | |||
| 535 | export const WsPreviewAutomationReportOwnerRpc = Rpc.make(WS_METHODS.previewAutomationReportOwner, { | ||
| 536 | payload: PreviewAutomationOwner, | ||
| 537 | error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), | ||
| 538 | }); | ||
| 539 | |||
| 540 | export const WsPreviewAutomationClearOwnerRpc = Rpc.make(WS_METHODS.previewAutomationClearOwner, { | ||
| 541 | payload: Schema.Struct({ clientId: Schema.String }), | ||
| 542 | error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), | ||
| 543 | }); | ||
| 544 | |||
| 513 | export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { | 545 | export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { |
| 514 | payload: Schema.Struct({}), | 546 | payload: Schema.Struct({}), |
| 515 | success: PreviewEvent, | 547 | success: PreviewEvent, |
| ... | @@ -668,6 +700,10 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -668,6 +700,10 @@ export const WsRpcGroup = RpcGroup.make( |
| 668 | WsPreviewCloseRpc, | 700 | WsPreviewCloseRpc, |
| 669 | WsPreviewListRpc, | 701 | WsPreviewListRpc, |
| 670 | WsPreviewReportStatusRpc, | 702 | WsPreviewReportStatusRpc, |
| 703 | WsPreviewAutomationConnectRpc, | ||
| 704 | WsPreviewAutomationRespondRpc, | ||
| 705 | WsPreviewAutomationReportOwnerRpc, | ||
| 706 | WsPreviewAutomationClearOwnerRpc, | ||
| 671 | WsSubscribePreviewEventsRpc, | 707 | WsSubscribePreviewEventsRpc, |
| 672 | WsSubscribeDiscoveredLocalServersRpc, | 708 | WsSubscribeDiscoveredLocalServersRpc, |
| 673 | WsSubscribeServerConfigRpc, | 709 | WsSubscribeServerConfigRpc, |
vite.config.ts+6| ... | @@ -1,7 +1,13 @@ | ... | @@ -1,7 +1,13 @@ |
| 1 | import "vite-plus/test/config"; | 1 | import "vite-plus/test/config"; |
| 2 | import { defineConfig } from "vite-plus"; | 2 | import { defineConfig } from "vite-plus"; |
| 3 | import { fileURLToPath } from "node:url"; | ||
| 3 | 4 | ||
| 4 | export default defineConfig({ | 5 | export default defineConfig({ |
| 6 | resolve: { | ||
| 7 | alias: { | ||
| 8 | "~": fileURLToPath(new URL("./apps/web/src", import.meta.url)), | ||
| 9 | }, | ||
| 10 | }, | ||
| 5 | test: { | 11 | test: { |
| 6 | environment: "node", | 12 | environment: "node", |
| 7 | exclude: [ | 13 | exclude: [ |