| author | |
| committer | |
| log | d8aa2f85dfd76dde3698754f195c9b4bb9c613a3 |
| tree | daf1cfb2feafb9db3ab04a19db18e86b25f5f6b1 |
| parent | 64d6938cf40de16554c3243572bc0eb38214434d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>116 files changed, 6676 insertions(+), 6648 deletions(-)
.oxfmtrc.json+1| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | { |
| 2 | 2 | "$schema": "./node_modules/oxfmt/configuration_schema.json", |
| 3 | 3 | "ignorePatterns": [ |
| 4 | ".reference", | |
| 4 | 5 | ".plans", |
| 5 | 6 | "dist", |
| 6 | 7 | "dist-electron", |
.plans/effect-atom.md created+89| ... | ... | @@ -0,0 +1,89 @@ |
| 1 | # Replace React Query With AtomRpc + Atom State | |
| 2 | ||
| 3 | ## Summary | |
| 4 | - Use `effect/unstable/reactivity/AtomRpc` over the existing `WsRpcGroup`; stop wrapping RPC in promises via [wsRpcClient.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsRpcClient.ts) and [wsNativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApi.ts). | |
| 5 | - Keep Zustand for orchestration read model and UI state. | |
| 6 | - Keep a narrow `desktopBridge` adapter for dialogs, menus, external links, theme, and updater APIs. | |
| 7 | - Do not introduce Suspense in this migration. Atom-backed hooks should keep returning `data`, `error`, `isLoading|isPending`, `refresh`, and `mutateAsync`-style surfaces so component churn stays low. | |
| 8 | ||
| 9 | ## Target Architecture | |
| 10 | - Extract the websocket `RpcClient.Protocol` layer from [wsTransport.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsTransport.ts) into `rpc/protocol.ts`. | |
| 11 | - Define one `AtomRpc.Service` for `WsRpcGroup` in `rpc/client.ts`. | |
| 12 | - Add `rpc/invalidation.ts` with explicit scoped invalidation keys: `git:${cwd}`, `project:${cwd}`, `checkpoint:${threadId}`, `server-config`. | |
| 13 | - Add `platform/desktopBridge.ts` as the only browser/desktop facade. | |
| 14 | - Remove from web by the end: [wsNativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApi.ts), [nativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/nativeApi.ts), [wsNativeApiState.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApiState.ts), [wsNativeApiAtoms.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApiAtoms.tsx), [wsRpcClient.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsRpcClient.ts), and all `*ReactQuery.ts` modules. | |
| 15 | ||
| 16 | ## Phase 1: Infrastructure First | |
| 17 | 1. Extract the shared websocket RPC protocol layer from [wsTransport.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsTransport.ts) without changing behavior. | |
| 18 | 2. Build the AtomRpc client on top of that layer. | |
| 19 | 3. Add one temporary `runRpc` helper for imperative handlers that still want `Promise` ergonomics; it must call the AtomRpc service directly and must not reintroduce a facade object. | |
| 20 | 4. Replace manual registry wiring with one app-level registry provider based on `@effect/atom-react`. | |
| 21 | 5. Land this as a no-behavior-change PR. | |
| 22 | ||
| 23 | ## Phase 2: Replace `wsNativeApi`-Owned Push State | |
| 24 | 1. Migrate welcome/config/provider/settings state first, because it is already atom-shaped and is the lowest-risk way to delete `wsNativeApi` responsibilities. | |
| 25 | 2. Replace [wsNativeApiState.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApiState.ts) with `rpc/serverState.ts`, updated directly from `subscribeServerLifecycle` and `subscribeServerConfig`. | |
| 26 | 3. Keep the current hook names for one PR: `useServerConfig`, `useServerSettings`, `useServerProviders`, `useServerKeybindings`, `useServerWelcomeSubscription`, `useServerConfigUpdatedSubscription`. | |
| 27 | 4. Move bootstrap side effects out of [wsNativeApiAtoms.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApiAtoms.tsx) into a new root bootstrap component mounted from [__root.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/routes/__root.tsx). | |
| 28 | 5. Delete the `server.getConfig()` fallback logic from [wsNativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApi.ts); snapshot fetch now lives beside the stream atoms. | |
| 29 | ||
| 30 | ## Phase 3: Replace React Query Domain By Domain | |
| 31 | 1. Replace [gitReactQuery.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/lib/gitReactQuery.ts) first. | |
| 32 | 2. Add `rpc/gitAtoms.ts` and `rpc/useGit.ts` with `useGitStatus`, `useGitBranches`, `useResolvePullRequest`, and `useGitMutation`. | |
| 33 | 3. Mutation settlement must invalidate scoped keys, not a global cache. `checkout`, `pull`, `init`, `createWorktree`, `removeWorktree`, `preparePullRequestThread`, and stacked actions invalidate `git:${cwd}`. Worktree create/remove also invalidates `project:${cwd}`. | |
| 34 | 4. Replace [projectReactQuery.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/lib/projectReactQuery.ts) second. `useProjectSearchEntries` must preserve current “keep previous results while loading” behavior. | |
| 35 | 5. Replace [providerReactQuery.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/lib/providerReactQuery.ts) third. Preserve current checkpoint error normalization and retry/backoff semantics inside the atom effect. Invalidate by `checkpoint:${threadId}`. | |
| 36 | 6. Defer the desktop updater until the last phase. | |
| 37 | ||
| 38 | ## Phase 4: Move Root Invalidation Off `queryClient` | |
| 39 | 1. In [__root.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/routes/__root.tsx), remove `QueryClient` usage and replace the throttled `invalidateQueries` block with throttled invalidation helpers. | |
| 40 | 2. Keep Zustand orchestration/event application unchanged. | |
| 41 | 3. Map current effects exactly: | |
| 42 | - git or checkpoint-affecting orchestration events touch `checkpoint:${threadId}` | |
| 43 | - file creation/deletion/restoration touches `project:${cwd}` | |
| 44 | - config-affecting server events touch `server-config` | |
| 45 | ||
| 46 | ## Phase 5: Remove Imperative `NativeApi` Usage | |
| 47 | 1. Create narrow modules instead of a replacement mega-facade: | |
| 48 | - `rpc/orchestrationActions.ts` | |
| 49 | - `rpc/terminalActions.ts` | |
| 50 | - `rpc/gitActions.ts` | |
| 51 | - `rpc/projectActions.ts` | |
| 52 | - `platform/desktopBridge.ts` | |
| 53 | 2. Migrate direct [nativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/nativeApi.ts) callers by domain, not file-by-file: git-heavy components first, then orchestration/thread actions, then shell/dialog helpers. | |
| 54 | 3. After the last caller is gone, delete [nativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/nativeApi.ts) and the `window.nativeApi` fallback entirely. | |
| 55 | 4. In the final cleanup PR, remove `NativeApi` from [ipc.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/packages/contracts/src/ipc.ts) if nothing outside web still needs it. | |
| 56 | ||
| 57 | ## Phase 6: Remove React Query Completely | |
| 58 | 1. Delete `@tanstack/react-query` from `apps/web/package.json`. | |
| 59 | 2. Remove `QueryClientProvider` and router context from [router.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/router.ts) and [__root.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/routes/__root.tsx). | |
| 60 | 3. Replace [desktopUpdateReactQuery.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/lib/desktopUpdateReactQuery.ts) with a writable atom plus `desktopBridge.onUpdateState`. | |
| 61 | 4. Delete the old query-option tests. | |
| 62 | ||
| 63 | ## Public Interfaces And Types | |
| 64 | - Preserve the current server-state hook names during the transition. | |
| 65 | - Add permanent domain hooks: `useGitStatus`, `useGitBranches`, `useResolvePullRequest`, `useProjectSearchEntries`, `useCheckpointDiff`, `useDesktopUpdateState`. | |
| 66 | - Do not expose raw AtomRpc clients to components. | |
| 67 | - Do not add Suspense as part of this migration. | |
| 68 | - Final boundary is direct RPC for server features plus `desktopBridge` for local desktop features. | |
| 69 | ||
| 70 | ## Test Plan | |
| 71 | - Add unit tests for `rpc/serverState.ts`: snapshot bootstrapping, stream replay, provider/settings updates. | |
| 72 | - Add unit tests for git/project/checkpoint hooks: loading, error mapping, retry behavior, invalidation, keep-previous-result behavior. | |
| 73 | - Update the browser harness in [wsRpcHarness.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/test/wsRpcHarness.ts) to assert direct RPC + atom behavior instead of `__resetNativeApiForTests`. | |
| 74 | - Replace [wsNativeApi.test.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApi.test.ts), `gitReactQuery.test.ts`, `providerReactQuery.test.ts`, and `desktopUpdateReactQuery.test.ts` with equivalent atom-backed coverage. | |
| 75 | - Acceptance scenarios: | |
| 76 | - welcome still bootstraps snapshot and navigation | |
| 77 | - keybindings toast still responds to config stream updates | |
| 78 | - git status/branches refresh after checkout/pull/worktree actions | |
| 79 | - PR resolve dialog keeps cached result while typing | |
| 80 | - `@` path search refreshes after file mutations and orchestration events | |
| 81 | - diff panel refreshes when checkpoints arrive | |
| 82 | - desktop updater still reflects push events and button actions | |
| 83 | ||
| 84 | ## Assumptions And Defaults | |
| 85 | - Zustand stays in scope; only `react-query` is being removed. | |
| 86 | - `desktopBridge` remains the only non-RPC boundary. | |
| 87 | - The migration lands as 5-6 small PRs, each green independently. | |
| 88 | - Invalidations are explicit and scoped; do not recreate a global cache client abstraction. | |
| 89 | - Orchestration recovery/order logic stays as-is; only the data-fetching and mutation layer changes. |
apps/desktop/scripts/dev-electron.mjs+2-2| ... | ... | @@ -10,11 +10,11 @@ const devServerUrl = `http://localhost:${port}`; |
| 10 | 10 | const requiredFiles = [ |
| 11 | 11 | "dist-electron/main.js", |
| 12 | 12 | "dist-electron/preload.js", |
| 13 | "../server/dist/index.mjs", | |
| 13 | "../server/dist/bin.mjs", | |
| 14 | 14 | ]; |
| 15 | 15 | const watchedDirectories = [ |
| 16 | 16 | { directory: "dist-electron", files: new Set(["main.js", "preload.js"]) }, |
| 17 | { directory: "../server/dist", files: new Set(["index.mjs"]) }, | |
| 17 | { directory: "../server/dist", files: new Set(["bin.mjs"]) }, | |
| 18 | 18 | ]; |
| 19 | 19 | const forcedShutdownTimeoutMs = 1_500; |
| 20 | 20 | const restartDebounceMs = 120; |
apps/desktop/src/main.ts+1-1| ... | ... | @@ -399,7 +399,7 @@ function resolveAboutCommitHash(): string | null { |
| 399 | 399 | } |
| 400 | 400 | |
| 401 | 401 | function resolveBackendEntry(): string { |
| 402 | return Path.join(resolveAppRoot(), "apps/server/dist/index.mjs"); | |
| 402 | return Path.join(resolveAppRoot(), "apps/server/dist/bin.mjs"); | |
| 403 | 403 | } |
| 404 | 404 | |
| 405 | 405 | function resolveBackendCwd(): string { |
apps/server/integration/OrchestrationEngineHarness.integration.ts+4-1| ... | ... | @@ -69,6 +69,7 @@ import { |
| 69 | 69 | } from "./TestProviderAdapter.integration.ts"; |
| 70 | 70 | import { deriveServerPaths, ServerConfig } from "../src/config.ts"; |
| 71 | 71 | import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts"; |
| 72 | import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts"; | |
| 72 | 73 | |
| 73 | 74 | function runGit(cwd: string, args: ReadonlyArray<string>) { |
| 74 | 75 | return execFileSync("git", args, { |
| ... | ... | @@ -125,7 +126,7 @@ function waitFor<A, E>( |
| 125 | 126 | read: Effect.Effect<A, E>, |
| 126 | 127 | predicate: (value: A) => boolean, |
| 127 | 128 | description: string, |
| 128 | timeoutMs = 10_000, | |
| 129 | timeoutMs = 40_000, | |
| 129 | 130 | ): Effect.Effect<A, never> { |
| 130 | 131 | const RETRY_SIGNAL = "wait_for_retry"; |
| 131 | 132 | const retryIntervalMs = 10; |
| ... | ... | @@ -320,10 +321,12 @@ export const makeOrchestrationIntegrationHarness = ( |
| 320 | 321 | Layer.provideMerge(runtimeServicesLayer), |
| 321 | 322 | Layer.provideMerge( |
| 322 | 323 | WorkspaceEntriesLive.pipe( |
| 324 | Layer.provide(WorkspacePathsLive), | |
| 323 | 325 | Layer.provideMerge(gitCoreLayer), |
| 324 | 326 | Layer.provide(NodeServices.layer), |
| 325 | 327 | ), |
| 326 | 328 | ), |
| 329 | Layer.provideMerge(WorkspacePathsLive), | |
| 327 | 330 | ); |
| 328 | 331 | const orchestrationReactorLayer = OrchestrationReactorLive.pipe( |
| 329 | 332 | Layer.provideMerge(runtimeIngestionLayer), |
apps/server/integration/orchestrationEngine.integration.test.ts+1-1| ... | ... | @@ -57,7 +57,7 @@ function waitForSync<A>( |
| 57 | 57 | read: () => A, |
| 58 | 58 | predicate: (value: A) => boolean, |
| 59 | 59 | description: string, |
| 60 | timeoutMs = 3000, | |
| 60 | timeoutMs = 10_000, | |
| 61 | 61 | ): Effect.Effect<A, never> { |
| 62 | 62 | return Effect.gen(function* () { |
| 63 | 63 | const deadline = Date.now() + timeoutMs; |
apps/server/package.json+5-6| ... | ... | @@ -8,29 +8,29 @@ |
| 8 | 8 | "directory": "apps/server" |
| 9 | 9 | }, |
| 10 | 10 | "bin": { |
| 11 | "t3": "./dist/index.mjs" | |
| 11 | "t3": "./dist/bin.mjs" | |
| 12 | 12 | }, |
| 13 | 13 | "files": [ |
| 14 | 14 | "dist" |
| 15 | 15 | ], |
| 16 | 16 | "type": "module", |
| 17 | 17 | "scripts": { |
| 18 | "dev": "bun run src/index.ts", | |
| 18 | "dev": "bun run src/bin.ts", | |
| 19 | 19 | "build": "node scripts/cli.ts build", |
| 20 | "start": "node dist/index.mjs", | |
| 20 | "start": "node dist/bin.mjs", | |
| 21 | 21 | "prepare": "effect-language-service patch", |
| 22 | 22 | "typecheck": "tsc --noEmit", |
| 23 | 23 | "test": "vitest run" |
| 24 | 24 | }, |
| 25 | 25 | "dependencies": { |
| 26 | 26 | "@anthropic-ai/claude-agent-sdk": "^0.2.77", |
| 27 | "@effect/platform-bun": "catalog:", | |
| 27 | 28 | "@effect/platform-node": "catalog:", |
| 28 | 29 | "@effect/sql-sqlite-bun": "catalog:", |
| 29 | 30 | "@pierre/diffs": "^1.1.0-beta.16", |
| 30 | 31 | "effect": "catalog:", |
| 31 | 32 | "node-pty": "^1.1.0", |
| 32 | "open": "^10.1.0", | |
| 33 | "ws": "^8.18.0" | |
| 33 | "open": "^10.1.0" | |
| 34 | 34 | }, |
| 35 | 35 | "devDependencies": { |
| 36 | 36 | "@effect/language-service": "catalog:", |
| ... | ... | @@ -40,7 +40,6 @@ |
| 40 | 40 | "@t3tools/web": "workspace:*", |
| 41 | 41 | "@types/bun": "catalog:", |
| 42 | 42 | "@types/node": "catalog:", |
| 43 | "@types/ws": "^8.5.13", | |
| 44 | 43 | "tsdown": "catalog:", |
| 45 | 44 | "typescript": "catalog:", |
| 46 | 45 | "vitest": "catalog:" |
apps/server/src/bin.ts created+17| ... | ... | @@ -0,0 +1,17 @@ |
| 1 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import { Command } from "effect/unstable/cli"; | |
| 6 | ||
| 7 | import { NetService } from "@t3tools/shared/Net"; | |
| 8 | import { cli } from "./cli"; | |
| 9 | import { version } from "../package.json" with { type: "json" }; | |
| 10 | ||
| 11 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); | |
| 12 | ||
| 13 | Command.run(cli, { version }).pipe( | |
| 14 | Effect.scoped, | |
| 15 | Effect.provide(CliRuntimeLayer), | |
| 16 | NodeRuntime.runMain, | |
| 17 | ); |
apps/server/src/checkpointing/Errors.ts+1-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { Schema } from "effect"; |
| 2 | 2 | import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; |
| 3 | import { GitCommandError } from "../git/Errors.ts"; | |
| 3 | import { GitCommandError } from "@t3tools/contracts"; | |
| 4 | 4 | |
| 5 | 5 | /** |
| 6 | 6 | * CheckpointUnavailableError - Expected checkpoint does not exist. |
apps/server/src/checkpointing/Layers/CheckpointStore.test.ts+1-1| ... | ... | @@ -10,7 +10,7 @@ import { CheckpointStoreLive } from "./CheckpointStore.ts"; |
| 10 | 10 | import { CheckpointStore } from "../Services/CheckpointStore.ts"; |
| 11 | 11 | import { GitCoreLive } from "../../git/Layers/GitCore.ts"; |
| 12 | 12 | import { GitCore } from "../../git/Services/GitCore.ts"; |
| 13 | import { GitCommandError } from "../../git/Errors.ts"; | |
| 13 | import { GitCommandError } from "@t3tools/contracts"; | |
| 14 | 14 | import { ServerConfig } from "../../config.ts"; |
| 15 | 15 | import { ThreadId } from "@t3tools/contracts"; |
| 16 | 16 |
apps/server/src/checkpointing/Layers/CheckpointStore.ts+1-1| ... | ... | @@ -14,7 +14,7 @@ import { randomUUID } from "node:crypto"; |
| 14 | 14 | import { Effect, Layer, FileSystem, Path } from "effect"; |
| 15 | 15 | |
| 16 | 16 | import { CheckpointInvariantError } from "../Errors.ts"; |
| 17 | import { GitCommandError } from "../../git/Errors.ts"; | |
| 17 | import { GitCommandError } from "@t3tools/contracts"; | |
| 18 | 18 | import { GitCore } from "../../git/Services/GitCore.ts"; |
| 19 | 19 | import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; |
| 20 | 20 | import { CheckpointRef } from "@t3tools/contracts"; |
apps/server/src/cli-config.test.ts created+318| ... | ... | @@ -0,0 +1,318 @@ |
| 1 | import os from "node:os"; | |
| 2 | ||
| 3 | import { assert, expect, it } from "@effect/vitest"; | |
| 4 | import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect"; | |
| 5 | ||
| 6 | import { NetService } from "@t3tools/shared/Net"; | |
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 8 | import { deriveServerPaths } from "./config"; | |
| 9 | import { resolveServerConfig } from "./cli"; | |
| 10 | ||
| 11 | it.layer(NodeServices.layer)("cli config resolution", (it) => { | |
| 12 | const openBootstrapFd = Effect.fn(function* (payload: Record<string, unknown>) { | |
| 13 | const fs = yield* FileSystem.FileSystem; | |
| 14 | const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); | |
| 15 | yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`); | |
| 16 | const { fd } = yield* fs.open(filePath, { flag: "r" }); | |
| 17 | return fd; | |
| 18 | }); | |
| 19 | ||
| 20 | it.effect("falls back to effect/config values when flags are omitted", () => | |
| 21 | Effect.gen(function* () { | |
| 22 | const { join } = yield* Path.Path; | |
| 23 | const baseDir = join(os.tmpdir(), "t3-cli-config-env-base"); | |
| 24 | const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173")); | |
| 25 | const resolved = yield* resolveServerConfig( | |
| 26 | { | |
| 27 | mode: Option.none(), | |
| 28 | port: Option.none(), | |
| 29 | host: Option.none(), | |
| 30 | baseDir: Option.none(), | |
| 31 | devUrl: Option.none(), | |
| 32 | noBrowser: Option.none(), | |
| 33 | authToken: Option.none(), | |
| 34 | bootstrapFd: Option.none(), | |
| 35 | autoBootstrapProjectFromCwd: Option.none(), | |
| 36 | logWebSocketEvents: Option.none(), | |
| 37 | }, | |
| 38 | Option.none(), | |
| 39 | ).pipe( | |
| 40 | Effect.provide( | |
| 41 | Layer.mergeAll( | |
| 42 | ConfigProvider.layer( | |
| 43 | ConfigProvider.fromEnv({ | |
| 44 | env: { | |
| 45 | T3CODE_LOG_LEVEL: "Warn", | |
| 46 | T3CODE_MODE: "desktop", | |
| 47 | T3CODE_PORT: "4001", | |
| 48 | T3CODE_HOST: "0.0.0.0", | |
| 49 | T3CODE_HOME: baseDir, | |
| 50 | VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", | |
| 51 | T3CODE_NO_BROWSER: "true", | |
| 52 | T3CODE_AUTH_TOKEN: "env-token", | |
| 53 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", | |
| 54 | T3CODE_LOG_WS_EVENTS: "true", | |
| 55 | }, | |
| 56 | }), | |
| 57 | ), | |
| 58 | NetService.layer, | |
| 59 | ), | |
| 60 | ), | |
| 61 | ); | |
| 62 | ||
| 63 | expect(resolved).toEqual({ | |
| 64 | logLevel: "Warn", | |
| 65 | mode: "desktop", | |
| 66 | port: 4001, | |
| 67 | cwd: process.cwd(), | |
| 68 | baseDir, | |
| 69 | ...derivedPaths, | |
| 70 | host: "0.0.0.0", | |
| 71 | staticDir: undefined, | |
| 72 | devUrl: new URL("http://127.0.0.1:5173"), | |
| 73 | noBrowser: true, | |
| 74 | authToken: "env-token", | |
| 75 | autoBootstrapProjectFromCwd: false, | |
| 76 | logWebSocketEvents: true, | |
| 77 | }); | |
| 78 | }), | |
| 79 | ); | |
| 80 | ||
| 81 | it.effect("uses CLI flags when provided", () => | |
| 82 | Effect.gen(function* () { | |
| 83 | const { join } = yield* Path.Path; | |
| 84 | const baseDir = join(os.tmpdir(), "t3-cli-config-flags-base"); | |
| 85 | const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); | |
| 86 | const resolved = yield* resolveServerConfig( | |
| 87 | { | |
| 88 | mode: Option.some("web"), | |
| 89 | port: Option.some(8788), | |
| 90 | host: Option.some("127.0.0.1"), | |
| 91 | baseDir: Option.some(baseDir), | |
| 92 | devUrl: Option.some(new URL("http://127.0.0.1:4173")), | |
| 93 | noBrowser: Option.some(true), | |
| 94 | authToken: Option.some("flag-token"), | |
| 95 | bootstrapFd: Option.none(), | |
| 96 | autoBootstrapProjectFromCwd: Option.some(true), | |
| 97 | logWebSocketEvents: Option.some(true), | |
| 98 | }, | |
| 99 | Option.some("Debug"), | |
| 100 | ).pipe( | |
| 101 | Effect.provide( | |
| 102 | Layer.mergeAll( | |
| 103 | ConfigProvider.layer( | |
| 104 | ConfigProvider.fromEnv({ | |
| 105 | env: { | |
| 106 | T3CODE_LOG_LEVEL: "Warn", | |
| 107 | T3CODE_MODE: "desktop", | |
| 108 | T3CODE_PORT: "4001", | |
| 109 | T3CODE_HOST: "0.0.0.0", | |
| 110 | T3CODE_HOME: join(os.tmpdir(), "ignored-base"), | |
| 111 | VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", | |
| 112 | T3CODE_NO_BROWSER: "false", | |
| 113 | T3CODE_AUTH_TOKEN: "ignored-token", | |
| 114 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", | |
| 115 | T3CODE_LOG_WS_EVENTS: "false", | |
| 116 | }, | |
| 117 | }), | |
| 118 | ), | |
| 119 | NetService.layer, | |
| 120 | ), | |
| 121 | ), | |
| 122 | ); | |
| 123 | ||
| 124 | expect(resolved).toEqual({ | |
| 125 | logLevel: "Debug", | |
| 126 | mode: "web", | |
| 127 | port: 8788, | |
| 128 | cwd: process.cwd(), | |
| 129 | baseDir, | |
| 130 | ...derivedPaths, | |
| 131 | host: "127.0.0.1", | |
| 132 | staticDir: undefined, | |
| 133 | devUrl: new URL("http://127.0.0.1:4173"), | |
| 134 | noBrowser: true, | |
| 135 | authToken: "flag-token", | |
| 136 | autoBootstrapProjectFromCwd: true, | |
| 137 | logWebSocketEvents: true, | |
| 138 | }); | |
| 139 | }), | |
| 140 | ); | |
| 141 | ||
| 142 | it.effect("uses bootstrap envelope values as fallbacks when flags and env are absent", () => | |
| 143 | Effect.gen(function* () { | |
| 144 | const { join } = yield* Path.Path; | |
| 145 | const baseDir = "/tmp/t3-bootstrap-home"; | |
| 146 | const fd = yield* openBootstrapFd({ | |
| 147 | mode: "desktop", | |
| 148 | port: 4888, | |
| 149 | host: "127.0.0.2", | |
| 150 | t3Home: baseDir, | |
| 151 | devUrl: "http://127.0.0.1:5173", | |
| 152 | noBrowser: true, | |
| 153 | authToken: "bootstrap-token", | |
| 154 | autoBootstrapProjectFromCwd: false, | |
| 155 | logWebSocketEvents: true, | |
| 156 | }); | |
| 157 | const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173")); | |
| 158 | ||
| 159 | const resolved = yield* resolveServerConfig( | |
| 160 | { | |
| 161 | mode: Option.none(), | |
| 162 | port: Option.none(), | |
| 163 | host: Option.none(), | |
| 164 | baseDir: Option.none(), | |
| 165 | devUrl: Option.none(), | |
| 166 | noBrowser: Option.none(), | |
| 167 | authToken: Option.none(), | |
| 168 | bootstrapFd: Option.none(), | |
| 169 | autoBootstrapProjectFromCwd: Option.none(), | |
| 170 | logWebSocketEvents: Option.none(), | |
| 171 | }, | |
| 172 | Option.none(), | |
| 173 | ).pipe( | |
| 174 | Effect.provide( | |
| 175 | Layer.mergeAll( | |
| 176 | ConfigProvider.layer( | |
| 177 | ConfigProvider.fromEnv({ | |
| 178 | env: { | |
| 179 | T3CODE_BOOTSTRAP_FD: String(fd), | |
| 180 | }, | |
| 181 | }), | |
| 182 | ), | |
| 183 | NetService.layer, | |
| 184 | ), | |
| 185 | ), | |
| 186 | ); | |
| 187 | ||
| 188 | expect(resolved).toEqual({ | |
| 189 | logLevel: "Info", | |
| 190 | mode: "desktop", | |
| 191 | port: 4888, | |
| 192 | cwd: process.cwd(), | |
| 193 | baseDir, | |
| 194 | ...derivedPaths, | |
| 195 | host: "127.0.0.2", | |
| 196 | staticDir: undefined, | |
| 197 | devUrl: new URL("http://127.0.0.1:5173"), | |
| 198 | noBrowser: true, | |
| 199 | authToken: "bootstrap-token", | |
| 200 | autoBootstrapProjectFromCwd: false, | |
| 201 | logWebSocketEvents: true, | |
| 202 | }); | |
| 203 | assert.equal(join(baseDir, "dev"), resolved.stateDir); | |
| 204 | }), | |
| 205 | ); | |
| 206 | ||
| 207 | it.effect("creates derived runtime directories during config resolution", () => | |
| 208 | Effect.gen(function* () { | |
| 209 | const fs = yield* FileSystem.FileSystem; | |
| 210 | const path = yield* Path.Path; | |
| 211 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-dirs-" }); | |
| 212 | ||
| 213 | const resolved = yield* resolveServerConfig( | |
| 214 | { | |
| 215 | mode: Option.some("desktop"), | |
| 216 | port: Option.some(4888), | |
| 217 | host: Option.none(), | |
| 218 | baseDir: Option.some(baseDir), | |
| 219 | devUrl: Option.some(new URL("http://127.0.0.1:5173")), | |
| 220 | noBrowser: Option.none(), | |
| 221 | authToken: Option.none(), | |
| 222 | bootstrapFd: Option.none(), | |
| 223 | autoBootstrapProjectFromCwd: Option.none(), | |
| 224 | logWebSocketEvents: Option.none(), | |
| 225 | }, | |
| 226 | Option.none(), | |
| 227 | ).pipe( | |
| 228 | Effect.provide( | |
| 229 | Layer.mergeAll( | |
| 230 | ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), | |
| 231 | NetService.layer, | |
| 232 | ), | |
| 233 | ), | |
| 234 | ); | |
| 235 | ||
| 236 | for (const directory of [ | |
| 237 | resolved.stateDir, | |
| 238 | resolved.logsDir, | |
| 239 | resolved.providerLogsDir, | |
| 240 | resolved.terminalLogsDir, | |
| 241 | resolved.attachmentsDir, | |
| 242 | resolved.worktreesDir, | |
| 243 | path.dirname(resolved.serverLogPath), | |
| 244 | ]) { | |
| 245 | expect(yield* fs.exists(directory)).toBe(true); | |
| 246 | } | |
| 247 | }), | |
| 248 | ); | |
| 249 | ||
| 250 | it.effect("applies flag then env precedence over bootstrap envelope values", () => | |
| 251 | Effect.gen(function* () { | |
| 252 | const { join } = yield* Path.Path; | |
| 253 | const baseDir = join(os.tmpdir(), "t3-cli-config-env-wins"); | |
| 254 | const fd = yield* openBootstrapFd({ | |
| 255 | mode: "desktop", | |
| 256 | port: 4888, | |
| 257 | host: "127.0.0.2", | |
| 258 | t3Home: "/tmp/t3-bootstrap-home", | |
| 259 | devUrl: "http://127.0.0.1:5173", | |
| 260 | noBrowser: false, | |
| 261 | authToken: "bootstrap-token", | |
| 262 | autoBootstrapProjectFromCwd: false, | |
| 263 | logWebSocketEvents: false, | |
| 264 | }); | |
| 265 | const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); | |
| 266 | ||
| 267 | const resolved = yield* resolveServerConfig( | |
| 268 | { | |
| 269 | mode: Option.none(), | |
| 270 | port: Option.some(8788), | |
| 271 | host: Option.some("127.0.0.1"), | |
| 272 | baseDir: Option.none(), | |
| 273 | devUrl: Option.some(new URL("http://127.0.0.1:4173")), | |
| 274 | noBrowser: Option.none(), | |
| 275 | authToken: Option.some("flag-token"), | |
| 276 | bootstrapFd: Option.none(), | |
| 277 | autoBootstrapProjectFromCwd: Option.none(), | |
| 278 | logWebSocketEvents: Option.none(), | |
| 279 | }, | |
| 280 | Option.some("Debug"), | |
| 281 | ).pipe( | |
| 282 | Effect.provide( | |
| 283 | Layer.mergeAll( | |
| 284 | ConfigProvider.layer( | |
| 285 | ConfigProvider.fromEnv({ | |
| 286 | env: { | |
| 287 | T3CODE_MODE: "web", | |
| 288 | T3CODE_BOOTSTRAP_FD: String(fd), | |
| 289 | T3CODE_HOME: baseDir, | |
| 290 | T3CODE_NO_BROWSER: "true", | |
| 291 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true", | |
| 292 | T3CODE_LOG_WS_EVENTS: "true", | |
| 293 | }, | |
| 294 | }), | |
| 295 | ), | |
| 296 | NetService.layer, | |
| 297 | ), | |
| 298 | ), | |
| 299 | ); | |
| 300 | ||
| 301 | expect(resolved).toEqual({ | |
| 302 | logLevel: "Debug", | |
| 303 | mode: "web", | |
| 304 | port: 8788, | |
| 305 | cwd: process.cwd(), | |
| 306 | baseDir, | |
| 307 | ...derivedPaths, | |
| 308 | host: "127.0.0.1", | |
| 309 | staticDir: undefined, | |
| 310 | devUrl: new URL("http://127.0.0.1:4173"), | |
| 311 | noBrowser: true, | |
| 312 | authToken: "flag-token", | |
| 313 | autoBootstrapProjectFromCwd: true, | |
| 314 | logWebSocketEvents: true, | |
| 315 | }); | |
| 316 | }), | |
| 317 | ); | |
| 318 | }); |
apps/server/src/cli.test.ts created+37| ... | ... | @@ -0,0 +1,37 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { NetService } from "@t3tools/shared/Net"; | |
| 3 | import { assert, it } from "@effect/vitest"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as CliError from "effect/unstable/cli/CliError"; | |
| 7 | import { Command } from "effect/unstable/cli"; | |
| 8 | ||
| 9 | import { cli } from "./cli.ts"; | |
| 10 | ||
| 11 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); | |
| 12 | ||
| 13 | it.layer(NodeServices.layer)("cli log-level parsing", (it) => { | |
| 14 | it.effect("accepts the built-in lowercase log-level flag values", () => | |
| 15 | Command.runWith(cli, { version: "0.0.0" })(["--log-level", "debug", "--version"]).pipe( | |
| 16 | Effect.provide(CliRuntimeLayer), | |
| 17 | ), | |
| 18 | ); | |
| 19 | ||
| 20 | it.effect("rejects invalid log-level casing before launching the server", () => | |
| 21 | Effect.gen(function* () { | |
| 22 | const error = yield* Command.runWith(cli, { version: "0.0.0" })([ | |
| 23 | "--log-level", | |
| 24 | "Debug", | |
| 25 | ]).pipe(Effect.provide(CliRuntimeLayer), Effect.flip); | |
| 26 | ||
| 27 | if (!CliError.isCliError(error)) { | |
| 28 | assert.fail(`Expected CliError, got ${String(error)}`); | |
| 29 | } | |
| 30 | if (error._tag !== "InvalidValue") { | |
| 31 | assert.fail(`Expected InvalidValue, got ${error._tag}`); | |
| 32 | } | |
| 33 | assert.equal(error.option, "log-level"); | |
| 34 | assert.equal(error.value, "Debug"); | |
| 35 | }), | |
| 36 | ); | |
| 37 | }); |
apps/server/src/cli.ts created+292| ... | ... | @@ -0,0 +1,292 @@ |
| 1 | import { NetService } from "@t3tools/shared/Net"; | |
| 2 | import { Config, Effect, LogLevel, Option, Schema } from "effect"; | |
| 3 | import { Command, Flag, GlobalFlag } from "effect/unstable/cli"; | |
| 4 | ||
| 5 | import { | |
| 6 | DEFAULT_PORT, | |
| 7 | deriveServerPaths, | |
| 8 | ensureServerDirectories, | |
| 9 | resolveStaticDir, | |
| 10 | ServerConfig, | |
| 11 | RuntimeMode, | |
| 12 | type ServerConfigShape, | |
| 13 | } from "./config"; | |
| 14 | import { readBootstrapEnvelope } from "./bootstrap"; | |
| 15 | import { resolveBaseDir } from "./os-jank"; | |
| 16 | import { runServer } from "./server"; | |
| 17 | ||
| 18 | const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); | |
| 19 | ||
| 20 | const BootstrapEnvelopeSchema = Schema.Struct({ | |
| 21 | mode: Schema.optional(RuntimeMode), | |
| 22 | port: Schema.optional(PortSchema), | |
| 23 | host: Schema.optional(Schema.String), | |
| 24 | t3Home: Schema.optional(Schema.String), | |
| 25 | devUrl: Schema.optional(Schema.URLFromString), | |
| 26 | noBrowser: Schema.optional(Schema.Boolean), | |
| 27 | authToken: Schema.optional(Schema.String), | |
| 28 | autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), | |
| 29 | logWebSocketEvents: Schema.optional(Schema.Boolean), | |
| 30 | }); | |
| 31 | ||
| 32 | const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe( | |
| 33 | Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), | |
| 34 | Flag.optional, | |
| 35 | ); | |
| 36 | const portFlag = Flag.integer("port").pipe( | |
| 37 | Flag.withSchema(PortSchema), | |
| 38 | Flag.withDescription("Port for the HTTP/WebSocket server."), | |
| 39 | Flag.optional, | |
| 40 | ); | |
| 41 | const hostFlag = Flag.string("host").pipe( | |
| 42 | Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), | |
| 43 | Flag.optional, | |
| 44 | ); | |
| 45 | const baseDirFlag = Flag.string("base-dir").pipe( | |
| 46 | Flag.withDescription("Base directory path (equivalent to T3CODE_HOME)."), | |
| 47 | Flag.optional, | |
| 48 | ); | |
| 49 | const devUrlFlag = Flag.string("dev-url").pipe( | |
| 50 | Flag.withSchema(Schema.URLFromString), | |
| 51 | Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."), | |
| 52 | Flag.optional, | |
| 53 | ); | |
| 54 | const noBrowserFlag = Flag.boolean("no-browser").pipe( | |
| 55 | Flag.withDescription("Disable automatic browser opening."), | |
| 56 | Flag.optional, | |
| 57 | ); | |
| 58 | const authTokenFlag = Flag.string("auth-token").pipe( | |
| 59 | Flag.withDescription("Auth token required for WebSocket connections."), | |
| 60 | Flag.withAlias("token"), | |
| 61 | Flag.optional, | |
| 62 | ); | |
| 63 | const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( | |
| 64 | Flag.withSchema(Schema.Int), | |
| 65 | Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), | |
| 66 | Flag.optional, | |
| 67 | ); | |
| 68 | const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( | |
| 69 | Flag.withDescription( | |
| 70 | "Create a project for the current working directory on startup when missing.", | |
| 71 | ), | |
| 72 | Flag.optional, | |
| 73 | ); | |
| 74 | const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( | |
| 75 | Flag.withDescription( | |
| 76 | "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).", | |
| 77 | ), | |
| 78 | Flag.withAlias("log-ws-events"), | |
| 79 | Flag.optional, | |
| 80 | ); | |
| 81 | ||
| 82 | const EnvServerConfig = Config.all({ | |
| 83 | logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), | |
| 84 | mode: Config.schema(RuntimeMode, "T3CODE_MODE").pipe( | |
| 85 | Config.option, | |
| 86 | Config.map(Option.getOrUndefined), | |
| 87 | ), | |
| 88 | port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), | |
| 89 | host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), | |
| 90 | t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), | |
| 91 | devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), | |
| 92 | noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( | |
| 93 | Config.option, | |
| 94 | Config.map(Option.getOrUndefined), | |
| 95 | ), | |
| 96 | authToken: Config.string("T3CODE_AUTH_TOKEN").pipe( | |
| 97 | Config.option, | |
| 98 | Config.map(Option.getOrUndefined), | |
| 99 | ), | |
| 100 | bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( | |
| 101 | Config.option, | |
| 102 | Config.map(Option.getOrUndefined), | |
| 103 | ), | |
| 104 | autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( | |
| 105 | Config.option, | |
| 106 | Config.map(Option.getOrUndefined), | |
| 107 | ), | |
| 108 | logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe( | |
| 109 | Config.option, | |
| 110 | Config.map(Option.getOrUndefined), | |
| 111 | ), | |
| 112 | }); | |
| 113 | ||
| 114 | interface CliServerFlags { | |
| 115 | readonly mode: Option.Option<RuntimeMode>; | |
| 116 | readonly port: Option.Option<number>; | |
| 117 | readonly host: Option.Option<string>; | |
| 118 | readonly baseDir: Option.Option<string>; | |
| 119 | readonly devUrl: Option.Option<URL>; | |
| 120 | readonly noBrowser: Option.Option<boolean>; | |
| 121 | readonly authToken: Option.Option<string>; | |
| 122 | readonly bootstrapFd: Option.Option<number>; | |
| 123 | readonly autoBootstrapProjectFromCwd: Option.Option<boolean>; | |
| 124 | readonly logWebSocketEvents: Option.Option<boolean>; | |
| 125 | } | |
| 126 | ||
| 127 | const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) => | |
| 128 | Option.getOrElse(Option.filter(flag, Boolean), () => envValue); | |
| 129 | ||
| 130 | const resolveOptionPrecedence = <Value>( | |
| 131 | ...values: ReadonlyArray<Option.Option<Value>> | |
| 132 | ): Option.Option<Value> => Option.firstSomeOf(values); | |
| 133 | ||
| 134 | export const resolveServerConfig = ( | |
| 135 | flags: CliServerFlags, | |
| 136 | cliLogLevel: Option.Option<LogLevel.LogLevel>, | |
| 137 | ) => | |
| 138 | Effect.gen(function* () { | |
| 139 | const { findAvailablePort } = yield* NetService; | |
| 140 | const env = yield* EnvServerConfig; | |
| 141 | const bootstrapFd = Option.getOrUndefined(flags.bootstrapFd) ?? env.bootstrapFd; | |
| 142 | const bootstrapEnvelope = | |
| 143 | bootstrapFd !== undefined | |
| 144 | ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd) | |
| 145 | : Option.none(); | |
| 146 | ||
| 147 | const mode: RuntimeMode = Option.getOrElse( | |
| 148 | resolveOptionPrecedence( | |
| 149 | flags.mode, | |
| 150 | Option.fromUndefinedOr(env.mode), | |
| 151 | Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.mode)), | |
| 152 | ), | |
| 153 | () => "web", | |
| 154 | ); | |
| 155 | ||
| 156 | const port = yield* Option.match( | |
| 157 | resolveOptionPrecedence( | |
| 158 | flags.port, | |
| 159 | Option.fromUndefinedOr(env.port), | |
| 160 | Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.port)), | |
| 161 | ), | |
| 162 | { | |
| 163 | onSome: (value) => Effect.succeed(value), | |
| 164 | onNone: () => { | |
| 165 | if (mode === "desktop") { | |
| 166 | return Effect.succeed(DEFAULT_PORT); | |
| 167 | } | |
| 168 | return findAvailablePort(DEFAULT_PORT); | |
| 169 | }, | |
| 170 | }, | |
| 171 | ); | |
| 172 | const devUrl = Option.getOrElse( | |
| 173 | resolveOptionPrecedence( | |
| 174 | flags.devUrl, | |
| 175 | Option.fromUndefinedOr(env.devUrl), | |
| 176 | Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.devUrl)), | |
| 177 | ), | |
| 178 | () => undefined, | |
| 179 | ); | |
| 180 | const baseDir = yield* resolveBaseDir( | |
| 181 | Option.getOrUndefined( | |
| 182 | resolveOptionPrecedence( | |
| 183 | flags.baseDir, | |
| 184 | Option.fromUndefinedOr(env.t3Home), | |
| 185 | Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 186 | Option.fromUndefinedOr(bootstrap.t3Home), | |
| 187 | ), | |
| 188 | ), | |
| 189 | ), | |
| 190 | ); | |
| 191 | const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); | |
| 192 | yield* ensureServerDirectories(derivedPaths); | |
| 193 | const noBrowser = resolveBooleanFlag( | |
| 194 | flags.noBrowser, | |
| 195 | Option.getOrElse( | |
| 196 | resolveOptionPrecedence( | |
| 197 | Option.fromUndefinedOr(env.noBrowser), | |
| 198 | Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 199 | Option.fromUndefinedOr(bootstrap.noBrowser), | |
| 200 | ), | |
| 201 | ), | |
| 202 | () => mode === "desktop", | |
| 203 | ), | |
| 204 | ); | |
| 205 | const authToken = Option.getOrUndefined( | |
| 206 | resolveOptionPrecedence( | |
| 207 | flags.authToken, | |
| 208 | Option.fromUndefinedOr(env.authToken), | |
| 209 | Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 210 | Option.fromUndefinedOr(bootstrap.authToken), | |
| 211 | ), | |
| 212 | ), | |
| 213 | ); | |
| 214 | const autoBootstrapProjectFromCwd = resolveBooleanFlag( | |
| 215 | flags.autoBootstrapProjectFromCwd, | |
| 216 | Option.getOrElse( | |
| 217 | resolveOptionPrecedence( | |
| 218 | Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), | |
| 219 | Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 220 | Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd), | |
| 221 | ), | |
| 222 | ), | |
| 223 | () => mode === "web", | |
| 224 | ), | |
| 225 | ); | |
| 226 | const logWebSocketEvents = resolveBooleanFlag( | |
| 227 | flags.logWebSocketEvents, | |
| 228 | Option.getOrElse( | |
| 229 | resolveOptionPrecedence( | |
| 230 | Option.fromUndefinedOr(env.logWebSocketEvents), | |
| 231 | Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 232 | Option.fromUndefinedOr(bootstrap.logWebSocketEvents), | |
| 233 | ), | |
| 234 | ), | |
| 235 | () => Boolean(devUrl), | |
| 236 | ), | |
| 237 | ); | |
| 238 | const staticDir = devUrl ? undefined : yield* resolveStaticDir(); | |
| 239 | const host = Option.getOrElse( | |
| 240 | resolveOptionPrecedence( | |
| 241 | flags.host, | |
| 242 | Option.fromUndefinedOr(env.host), | |
| 243 | Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)), | |
| 244 | ), | |
| 245 | () => (mode === "desktop" ? "127.0.0.1" : undefined), | |
| 246 | ); | |
| 247 | const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); | |
| 248 | ||
| 249 | const config: ServerConfigShape = { | |
| 250 | logLevel, | |
| 251 | mode, | |
| 252 | port, | |
| 253 | cwd: process.cwd(), | |
| 254 | baseDir, | |
| 255 | ...derivedPaths, | |
| 256 | host, | |
| 257 | staticDir, | |
| 258 | devUrl, | |
| 259 | noBrowser, | |
| 260 | authToken, | |
| 261 | autoBootstrapProjectFromCwd, | |
| 262 | logWebSocketEvents, | |
| 263 | }; | |
| 264 | ||
| 265 | return config; | |
| 266 | }); | |
| 267 | ||
| 268 | const commandFlags = { | |
| 269 | mode: modeFlag, | |
| 270 | port: portFlag, | |
| 271 | host: hostFlag, | |
| 272 | baseDir: baseDirFlag, | |
| 273 | devUrl: devUrlFlag, | |
| 274 | noBrowser: noBrowserFlag, | |
| 275 | authToken: authTokenFlag, | |
| 276 | bootstrapFd: bootstrapFdFlag, | |
| 277 | autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, | |
| 278 | logWebSocketEvents: logWebSocketEventsFlag, | |
| 279 | } as const; | |
| 280 | ||
| 281 | const rootCommand = Command.make("t3", commandFlags).pipe( | |
| 282 | Command.withDescription("Run the T3 Code server."), | |
| 283 | Command.withHandler((flags) => | |
| 284 | Effect.gen(function* () { | |
| 285 | const logLevel = yield* GlobalFlag.LogLevel; | |
| 286 | const config = yield* resolveServerConfig(flags, logLevel); | |
| 287 | return yield* runServer.pipe(Effect.provideService(ServerConfig, config)); | |
| 288 | }), | |
| 289 | ), | |
| 290 | ); | |
| 291 | ||
| 292 | export const cli = rootCommand; |
apps/server/src/config.ts+26-6| ... | ... | @@ -6,11 +6,12 @@ |
| 6 | 6 | * |
| 7 | 7 | * @module ServerConfig |
| 8 | 8 | */ |
| 9 | import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect"; | |
| 9 | import { Effect, FileSystem, Layer, LogLevel, Path, Schema, ServiceMap } from "effect"; | |
| 10 | 10 | |
| 11 | 11 | export const DEFAULT_PORT = 3773; |
| 12 | 12 | |
| 13 | export type RuntimeMode = "web" | "desktop"; | |
| 13 | export const RuntimeMode = Schema.Literals(["web", "desktop"]); | |
| 14 | export type RuntimeMode = typeof RuntimeMode.Type; | |
| 14 | 15 | |
| 15 | 16 | /** |
| 16 | 17 | * ServerDerivedPaths - Derived paths from the base directory. |
| ... | ... | @@ -34,6 +35,7 @@ export interface ServerDerivedPaths { |
| 34 | 35 | * ServerConfigShape - Process/runtime configuration required by the server. |
| 35 | 36 | */ |
| 36 | 37 | export interface ServerConfigShape extends ServerDerivedPaths { |
| 38 | readonly logLevel: LogLevel.LogLevel; | |
| 37 | 39 | readonly mode: RuntimeMode; |
| 38 | 40 | readonly port: number; |
| 39 | 41 | readonly host: string | undefined; |
| ... | ... | @@ -73,6 +75,26 @@ export const deriveServerPaths = Effect.fn(function* ( |
| 73 | 75 | }; |
| 74 | 76 | }); |
| 75 | 77 | |
| 78 | export const ensureServerDirectories = Effect.fn(function* (derivedPaths: ServerDerivedPaths) { | |
| 79 | const fs = yield* FileSystem.FileSystem; | |
| 80 | const path = yield* Path.Path; | |
| 81 | ||
| 82 | yield* Effect.all( | |
| 83 | [ | |
| 84 | fs.makeDirectory(derivedPaths.stateDir, { recursive: true }), | |
| 85 | fs.makeDirectory(derivedPaths.logsDir, { recursive: true }), | |
| 86 | fs.makeDirectory(derivedPaths.providerLogsDir, { recursive: true }), | |
| 87 | fs.makeDirectory(derivedPaths.terminalLogsDir, { recursive: true }), | |
| 88 | fs.makeDirectory(derivedPaths.attachmentsDir, { recursive: true }), | |
| 89 | fs.makeDirectory(derivedPaths.worktreesDir, { recursive: true }), | |
| 90 | fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }), | |
| 91 | fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }), | |
| 92 | fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }), | |
| 93 | ], | |
| 94 | { concurrency: "unbounded" }, | |
| 95 | ); | |
| 96 | }); | |
| 97 | ||
| 76 | 98 | /** |
| 77 | 99 | * ServerConfig - Service tag for server runtime configuration. |
| 78 | 100 | */ |
| ... | ... | @@ -91,12 +113,10 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS |
| 91 | 113 | ? baseDirOrPrefix |
| 92 | 114 | : yield* fs.makeTempDirectoryScoped({ prefix: baseDirOrPrefix.prefix }); |
| 93 | 115 | const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); |
| 94 | ||
| 95 | yield* fs.makeDirectory(derivedPaths.stateDir, { recursive: true }); | |
| 96 | yield* fs.makeDirectory(derivedPaths.logsDir, { recursive: true }); | |
| 97 | yield* fs.makeDirectory(derivedPaths.attachmentsDir, { recursive: true }); | |
| 116 | yield* ensureServerDirectories(derivedPaths); | |
| 98 | 117 | |
| 99 | 118 | return { |
| 119 | logLevel: "Error", | |
| 100 | 120 | cwd, |
| 101 | 121 | baseDir, |
| 102 | 122 | ...derivedPaths, |
apps/server/src/git/Errors.ts deleted-67| ... | ... | @@ -1,67 +0,0 @@ |
| 1 | import { Schema } from "effect"; | |
| 2 | ||
| 3 | /** | |
| 4 | * GitCommandError - Git command execution failed. | |
| 5 | */ | |
| 6 | export class GitCommandError extends Schema.TaggedErrorClass<GitCommandError>()("GitCommandError", { | |
| 7 | operation: Schema.String, | |
| 8 | command: Schema.String, | |
| 9 | cwd: Schema.String, | |
| 10 | detail: Schema.String, | |
| 11 | cause: Schema.optional(Schema.Defect), | |
| 12 | }) { | |
| 13 | override get message(): string { | |
| 14 | return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; | |
| 15 | } | |
| 16 | } | |
| 17 | ||
| 18 | /** | |
| 19 | * GitHubCliError - GitHub CLI execution or authentication failed. | |
| 20 | */ | |
| 21 | export class GitHubCliError extends Schema.TaggedErrorClass<GitHubCliError>()("GitHubCliError", { | |
| 22 | operation: Schema.String, | |
| 23 | detail: Schema.String, | |
| 24 | cause: Schema.optional(Schema.Defect), | |
| 25 | }) { | |
| 26 | override get message(): string { | |
| 27 | return `GitHub CLI failed in ${this.operation}: ${this.detail}`; | |
| 28 | } | |
| 29 | } | |
| 30 | ||
| 31 | /** | |
| 32 | * TextGenerationError - Commit or PR text generation failed. | |
| 33 | */ | |
| 34 | export class TextGenerationError extends Schema.TaggedErrorClass<TextGenerationError>()( | |
| 35 | "TextGenerationError", | |
| 36 | { | |
| 37 | operation: Schema.String, | |
| 38 | detail: Schema.String, | |
| 39 | cause: Schema.optional(Schema.Defect), | |
| 40 | }, | |
| 41 | ) { | |
| 42 | override get message(): string { | |
| 43 | return `Text generation failed in ${this.operation}: ${this.detail}`; | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * GitManagerError - Stacked Git workflow orchestration failed. | |
| 49 | */ | |
| 50 | export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()("GitManagerError", { | |
| 51 | operation: Schema.String, | |
| 52 | detail: Schema.String, | |
| 53 | cause: Schema.optional(Schema.Defect), | |
| 54 | }) { | |
| 55 | override get message(): string { | |
| 56 | return `Git manager failed in ${this.operation}: ${this.detail}`; | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | /** | |
| 61 | * GitManagerServiceError - Errors emitted by stacked Git workflow orchestration. | |
| 62 | */ | |
| 63 | export type GitManagerServiceError = | |
| 64 | | GitManagerError | |
| 65 | | GitCommandError | |
| 66 | | GitHubCliError | |
| 67 | | TextGenerationError; |
apps/server/src/git/Layers/ClaudeTextGeneration.ts+1-1| ... | ... | @@ -14,7 +14,7 @@ import { ClaudeModelSelection } from "@t3tools/contracts"; |
| 14 | 14 | import { resolveApiModelId } from "@t3tools/shared/model"; |
| 15 | 15 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 16 | 16 | |
| 17 | import { TextGenerationError } from "../Errors.ts"; | |
| 17 | import { TextGenerationError } from "@t3tools/contracts"; | |
| 18 | 18 | import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; |
| 19 | 19 | import { |
| 20 | 20 | buildBranchNamePrompt, |
apps/server/src/git/Layers/CodexTextGeneration.test.ts+1-1| ... | ... | @@ -5,7 +5,7 @@ import { expect } from "vitest"; |
| 5 | 5 | |
| 6 | 6 | import { ServerConfig } from "../../config.ts"; |
| 7 | 7 | import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; |
| 8 | import { TextGenerationError } from "../Errors.ts"; | |
| 8 | import { TextGenerationError } from "@t3tools/contracts"; | |
| 9 | 9 | import { TextGeneration } from "../Services/TextGeneration.ts"; |
| 10 | 10 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 11 | 11 |
apps/server/src/git/Layers/CodexTextGeneration.ts+1-1| ... | ... | @@ -8,7 +8,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar |
| 8 | 8 | |
| 9 | 9 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 10 | 10 | import { ServerConfig } from "../../config.ts"; |
| 11 | import { TextGenerationError } from "../Errors.ts"; | |
| 11 | import { TextGenerationError } from "@t3tools/contracts"; | |
| 12 | 12 | import { |
| 13 | 13 | type BranchNameGenerationInput, |
| 14 | 14 | type ThreadTitleGenerationResult, |
apps/server/src/git/Layers/GitCore.test.ts+1-1| ... | ... | @@ -8,7 +8,7 @@ import { describe, expect, vi } from "vitest"; |
| 8 | 8 | |
| 9 | 9 | import { GitCoreLive, makeGitCore } from "./GitCore.ts"; |
| 10 | 10 | import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; |
| 11 | import { GitCommandError } from "../Errors.ts"; | |
| 11 | import { GitCommandError } from "@t3tools/contracts"; | |
| 12 | 12 | import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; |
| 13 | 13 | import { ServerConfig } from "../../config.ts"; |
| 14 | 14 |
apps/server/src/git/Layers/GitCore.ts+4-3| ... | ... | @@ -18,7 +18,7 @@ import { |
| 18 | 18 | } from "effect"; |
| 19 | 19 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 20 | 20 | |
| 21 | import { GitCommandError } from "../Errors.ts"; | |
| 21 | import { GitCommandError } from "@t3tools/contracts"; | |
| 22 | 22 | import { |
| 23 | 23 | GitCore, |
| 24 | 24 | type ExecuteGitProgress, |
| ... | ... | @@ -1941,8 +1941,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1941 | 1941 | }); |
| 1942 | 1942 | |
| 1943 | 1943 | // Refresh upstream refs in the background so checkout remains responsive. |
| 1944 | yield* Effect.forkScoped( | |
| 1945 | refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })), | |
| 1944 | yield* refreshCheckedOutBranchUpstream(input.cwd).pipe( | |
| 1945 | Effect.ignoreCause({ log: true }), | |
| 1946 | Effect.forkDetach({ startImmediately: true }), | |
| 1946 | 1947 | ); |
| 1947 | 1948 | }, |
| 1948 | 1949 | ); |
apps/server/src/git/Layers/GitHubCli.ts+1-1| ... | ... | @@ -2,7 +2,7 @@ import { Effect, Layer, Schema } from "effect"; |
| 2 | 2 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; |
| 3 | 3 | |
| 4 | 4 | import { runProcess } from "../../processRunner"; |
| 5 | import { GitHubCliError } from "../Errors.ts"; | |
| 5 | import { GitHubCliError } from "@t3tools/contracts"; | |
| 6 | 6 | import { |
| 7 | 7 | GitHubCli, |
| 8 | 8 | type GitHubRepositoryCloneUrls, |
apps/server/src/git/Layers/GitManager.test.ts+1-1| ... | ... | @@ -8,7 +8,7 @@ import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; |
| 8 | 8 | import { expect } from "vitest"; |
| 9 | 9 | import type { GitActionProgressEvent, ModelSelection } from "@t3tools/contracts"; |
| 10 | 10 | |
| 11 | import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts"; | |
| 11 | import { GitCommandError, GitHubCliError, TextGenerationError } from "@t3tools/contracts"; | |
| 12 | 12 | import { type GitManagerShape } from "../Services/GitManager.ts"; |
| 13 | 13 | import { |
| 14 | 14 | type GitHubCliShape, |
apps/server/src/git/Layers/GitManager.ts+2-2| ... | ... | @@ -14,7 +14,7 @@ import { |
| 14 | 14 | sanitizeFeatureBranchName, |
| 15 | 15 | } from "@t3tools/shared/git"; |
| 16 | 16 | |
| 17 | import { GitManagerError } from "../Errors.ts"; | |
| 17 | import { GitManagerError } from "@t3tools/contracts"; | |
| 18 | 18 | import { |
| 19 | 19 | GitManager, |
| 20 | 20 | type GitActionProgressReporter, |
| ... | ... | @@ -25,7 +25,7 @@ import { GitCore } from "../Services/GitCore.ts"; |
| 25 | 25 | import { GitHubCli } from "../Services/GitHubCli.ts"; |
| 26 | 26 | import { TextGeneration } from "../Services/TextGeneration.ts"; |
| 27 | 27 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 28 | import type { GitManagerServiceError } from "../Errors.ts"; | |
| 28 | import type { GitManagerServiceError } from "@t3tools/contracts"; | |
| 29 | 29 | |
| 30 | 30 | const COMMIT_TIMEOUT_MS = 10 * 60_000; |
| 31 | 31 | const MAX_PROGRESS_TEXT_LENGTH = 500; |
apps/server/src/git/Prompts.test.ts+1-1| ... | ... | @@ -7,7 +7,7 @@ import { |
| 7 | 7 | buildThreadTitlePrompt, |
| 8 | 8 | } from "./Prompts.ts"; |
| 9 | 9 | import { normalizeCliError, sanitizeThreadTitle } from "./Utils.ts"; |
| 10 | import { TextGenerationError } from "./Errors.ts"; | |
| 10 | import { TextGenerationError } from "@t3tools/contracts"; | |
| 11 | 11 | |
| 12 | 12 | describe("buildCommitMessagePrompt", () => { |
| 13 | 13 | it("includes staged patch and summary in the prompt", () => { |
apps/server/src/git/Services/GitCore.ts+1-1| ... | ... | @@ -22,7 +22,7 @@ import type { |
| 22 | 22 | GitStatusResult, |
| 23 | 23 | } from "@t3tools/contracts"; |
| 24 | 24 | |
| 25 | import type { GitCommandError } from "../Errors.ts"; | |
| 25 | import type { GitCommandError } from "@t3tools/contracts"; | |
| 26 | 26 | |
| 27 | 27 | export interface ExecuteGitInput { |
| 28 | 28 | readonly operation: string; |
apps/server/src/git/Services/GitHubCli.ts+1-1| ... | ... | @@ -9,7 +9,7 @@ import { ServiceMap } from "effect"; |
| 9 | 9 | import type { Effect } from "effect"; |
| 10 | 10 | |
| 11 | 11 | import type { ProcessRunResult } from "../../processRunner"; |
| 12 | import type { GitHubCliError } from "../Errors.ts"; | |
| 12 | import type { GitHubCliError } from "@t3tools/contracts"; | |
| 13 | 13 | |
| 14 | 14 | export interface GitHubPullRequestSummary { |
| 15 | 15 | readonly number: number; |
apps/server/src/git/Services/GitManager.ts+1-1| ... | ... | @@ -19,7 +19,7 @@ import { |
| 19 | 19 | } from "@t3tools/contracts"; |
| 20 | 20 | import { ServiceMap } from "effect"; |
| 21 | 21 | import type { Effect } from "effect"; |
| 22 | import type { GitManagerServiceError } from "../Errors.ts"; | |
| 22 | import type { GitManagerServiceError } from "@t3tools/contracts"; | |
| 23 | 23 | |
| 24 | 24 | export interface GitActionProgressReporter { |
| 25 | 25 | readonly publish: (event: GitActionProgressEvent) => Effect.Effect<void, never>; |
apps/server/src/git/Services/TextGeneration.ts+1-1| ... | ... | @@ -10,7 +10,7 @@ import { ServiceMap } from "effect"; |
| 10 | 10 | import type { Effect } from "effect"; |
| 11 | 11 | import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; |
| 12 | 12 | |
| 13 | import type { TextGenerationError } from "../Errors.ts"; | |
| 13 | import type { TextGenerationError } from "@t3tools/contracts"; | |
| 14 | 14 | |
| 15 | 15 | /** Providers that support git text generation (commit messages, PR content, branch names). */ |
| 16 | 16 | export type TextGenerationProvider = "codex" | "claudeAgent"; |
apps/server/src/git/Utils.ts+1-1| ... | ... | @@ -5,7 +5,7 @@ |
| 5 | 5 | */ |
| 6 | 6 | import { Schema } from "effect"; |
| 7 | 7 | |
| 8 | import { TextGenerationError } from "./Errors.ts"; | |
| 8 | import { TextGenerationError } from "@t3tools/contracts"; | |
| 9 | 9 | |
| 10 | 10 | import { existsSync } from "node:fs"; |
| 11 | 11 | import { join } from "node:path"; |
apps/server/src/http.ts created+197| ... | ... | @@ -0,0 +1,197 @@ |
| 1 | import Mime from "@effect/platform-node/Mime"; | |
| 2 | import { Effect, FileSystem, Option, Path } from "effect"; | |
| 3 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 4 | ||
| 5 | import { | |
| 6 | ATTACHMENTS_ROUTE_PREFIX, | |
| 7 | normalizeAttachmentRelativePath, | |
| 8 | resolveAttachmentRelativePath, | |
| 9 | } from "./attachmentPaths"; | |
| 10 | import { resolveAttachmentPathById } from "./attachmentStore"; | |
| 11 | import { ServerConfig } from "./config"; | |
| 12 | import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver"; | |
| 13 | ||
| 14 | const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; | |
| 15 | const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`; | |
| 16 | ||
| 17 | export const attachmentsRouteLayer = HttpRouter.add( | |
| 18 | "GET", | |
| 19 | `${ATTACHMENTS_ROUTE_PREFIX}/*`, | |
| 20 | Effect.gen(function* () { | |
| 21 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 22 | const url = HttpServerRequest.toURL(request); | |
| 23 | if (Option.isNone(url)) { | |
| 24 | return HttpServerResponse.text("Bad Request", { status: 400 }); | |
| 25 | } | |
| 26 | ||
| 27 | const config = yield* ServerConfig; | |
| 28 | const rawRelativePath = url.value.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); | |
| 29 | const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); | |
| 30 | if (!normalizedRelativePath) { | |
| 31 | return HttpServerResponse.text("Invalid attachment path", { status: 400 }); | |
| 32 | } | |
| 33 | ||
| 34 | const isIdLookup = | |
| 35 | !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes("."); | |
| 36 | const filePath = isIdLookup | |
| 37 | ? resolveAttachmentPathById({ | |
| 38 | attachmentsDir: config.attachmentsDir, | |
| 39 | attachmentId: normalizedRelativePath, | |
| 40 | }) | |
| 41 | : resolveAttachmentRelativePath({ | |
| 42 | attachmentsDir: config.attachmentsDir, | |
| 43 | relativePath: normalizedRelativePath, | |
| 44 | }); | |
| 45 | if (!filePath) { | |
| 46 | return HttpServerResponse.text(isIdLookup ? "Not Found" : "Invalid attachment path", { | |
| 47 | status: isIdLookup ? 404 : 400, | |
| 48 | }); | |
| 49 | } | |
| 50 | ||
| 51 | const fileSystem = yield* FileSystem.FileSystem; | |
| 52 | const fileInfo = yield* fileSystem | |
| 53 | .stat(filePath) | |
| 54 | .pipe(Effect.catch(() => Effect.succeed(null))); | |
| 55 | if (!fileInfo || fileInfo.type !== "File") { | |
| 56 | return HttpServerResponse.text("Not Found", { status: 404 }); | |
| 57 | } | |
| 58 | ||
| 59 | return yield* HttpServerResponse.file(filePath, { | |
| 60 | status: 200, | |
| 61 | headers: { | |
| 62 | "Cache-Control": "public, max-age=31536000, immutable", | |
| 63 | }, | |
| 64 | }).pipe( | |
| 65 | Effect.catch(() => | |
| 66 | Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), | |
| 67 | ), | |
| 68 | ); | |
| 69 | }), | |
| 70 | ); | |
| 71 | ||
| 72 | export const projectFaviconRouteLayer = HttpRouter.add( | |
| 73 | "GET", | |
| 74 | "/api/project-favicon", | |
| 75 | Effect.gen(function* () { | |
| 76 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 77 | const url = HttpServerRequest.toURL(request); | |
| 78 | if (Option.isNone(url)) { | |
| 79 | return HttpServerResponse.text("Bad Request", { status: 400 }); | |
| 80 | } | |
| 81 | ||
| 82 | const projectCwd = url.value.searchParams.get("cwd"); | |
| 83 | if (!projectCwd) { | |
| 84 | return HttpServerResponse.text("Missing cwd parameter", { status: 400 }); | |
| 85 | } | |
| 86 | ||
| 87 | const faviconResolver = yield* ProjectFaviconResolver; | |
| 88 | const faviconFilePath = yield* faviconResolver.resolvePath(projectCwd); | |
| 89 | if (!faviconFilePath) { | |
| 90 | return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, { | |
| 91 | status: 200, | |
| 92 | contentType: "image/svg+xml", | |
| 93 | headers: { | |
| 94 | "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL, | |
| 95 | }, | |
| 96 | }); | |
| 97 | } | |
| 98 | ||
| 99 | return yield* HttpServerResponse.file(faviconFilePath, { | |
| 100 | status: 200, | |
| 101 | headers: { | |
| 102 | "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL, | |
| 103 | }, | |
| 104 | }).pipe( | |
| 105 | Effect.catch(() => | |
| 106 | Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), | |
| 107 | ), | |
| 108 | ); | |
| 109 | }), | |
| 110 | ); | |
| 111 | ||
| 112 | export const staticAndDevRouteLayer = HttpRouter.add( | |
| 113 | "GET", | |
| 114 | "*", | |
| 115 | Effect.gen(function* () { | |
| 116 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 117 | const url = HttpServerRequest.toURL(request); | |
| 118 | if (Option.isNone(url)) { | |
| 119 | return HttpServerResponse.text("Bad Request", { status: 400 }); | |
| 120 | } | |
| 121 | ||
| 122 | const config = yield* ServerConfig; | |
| 123 | if (config.devUrl) { | |
| 124 | return HttpServerResponse.redirect(config.devUrl.href, { status: 302 }); | |
| 125 | } | |
| 126 | ||
| 127 | if (!config.staticDir) { | |
| 128 | return HttpServerResponse.text("No static directory configured and no dev URL set.", { | |
| 129 | status: 503, | |
| 130 | }); | |
| 131 | } | |
| 132 | ||
| 133 | const fileSystem = yield* FileSystem.FileSystem; | |
| 134 | const path = yield* Path.Path; | |
| 135 | const staticRoot = path.resolve(config.staticDir); | |
| 136 | const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname; | |
| 137 | const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); | |
| 138 | const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); | |
| 139 | const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, ""); | |
| 140 | const hasPathTraversalSegment = staticRelativePath.startsWith(".."); | |
| 141 | if ( | |
| 142 | staticRelativePath.length === 0 || | |
| 143 | hasRawLeadingParentSegment || | |
| 144 | hasPathTraversalSegment || | |
| 145 | staticRelativePath.includes("\0") | |
| 146 | ) { | |
| 147 | return HttpServerResponse.text("Invalid static file path", { status: 400 }); | |
| 148 | } | |
| 149 | ||
| 150 | const isWithinStaticRoot = (candidate: string) => | |
| 151 | candidate === staticRoot || | |
| 152 | candidate.startsWith(staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`); | |
| 153 | ||
| 154 | let filePath = path.resolve(staticRoot, staticRelativePath); | |
| 155 | if (!isWithinStaticRoot(filePath)) { | |
| 156 | return HttpServerResponse.text("Invalid static file path", { status: 400 }); | |
| 157 | } | |
| 158 | ||
| 159 | const ext = path.extname(filePath); | |
| 160 | if (!ext) { | |
| 161 | filePath = path.resolve(filePath, "index.html"); | |
| 162 | if (!isWithinStaticRoot(filePath)) { | |
| 163 | return HttpServerResponse.text("Invalid static file path", { status: 400 }); | |
| 164 | } | |
| 165 | } | |
| 166 | ||
| 167 | const fileInfo = yield* fileSystem | |
| 168 | .stat(filePath) | |
| 169 | .pipe(Effect.catch(() => Effect.succeed(null))); | |
| 170 | if (!fileInfo || fileInfo.type !== "File") { | |
| 171 | const indexPath = path.resolve(staticRoot, "index.html"); | |
| 172 | const indexData = yield* fileSystem | |
| 173 | .readFile(indexPath) | |
| 174 | .pipe(Effect.catch(() => Effect.succeed(null))); | |
| 175 | if (!indexData) { | |
| 176 | return HttpServerResponse.text("Not Found", { status: 404 }); | |
| 177 | } | |
| 178 | return HttpServerResponse.uint8Array(indexData, { | |
| 179 | status: 200, | |
| 180 | contentType: "text/html; charset=utf-8", | |
| 181 | }); | |
| 182 | } | |
| 183 | ||
| 184 | const contentType = Mime.getType(filePath) ?? "application/octet-stream"; | |
| 185 | const data = yield* fileSystem | |
| 186 | .readFile(filePath) | |
| 187 | .pipe(Effect.catch(() => Effect.succeed(null))); | |
| 188 | if (!data) { | |
| 189 | return HttpServerResponse.text("Internal Server Error", { status: 500 }); | |
| 190 | } | |
| 191 | ||
| 192 | return HttpServerResponse.uint8Array(data, { | |
| 193 | status: 200, | |
| 194 | contentType, | |
| 195 | }); | |
| 196 | }), | |
| 197 | ); |
apps/server/src/index.ts deleted-23| ... | ... | @@ -1,23 +0,0 @@ |
| 1 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | ||
| 6 | import { CliConfig, t3Cli } from "./main"; | |
| 7 | import { OpenLive } from "./open"; | |
| 8 | import { Command } from "effect/unstable/cli"; | |
| 9 | import { version } from "../package.json" with { type: "json" }; | |
| 10 | import { ServerLive } from "./wsServer"; | |
| 11 | import { NetService } from "@t3tools/shared/Net"; | |
| 12 | import { FetchHttpClient } from "effect/unstable/http"; | |
| 13 | ||
| 14 | const RuntimeLayer = Layer.empty.pipe( | |
| 15 | Layer.provideMerge(CliConfig.layer), | |
| 16 | Layer.provideMerge(ServerLive), | |
| 17 | Layer.provideMerge(OpenLive), | |
| 18 | Layer.provideMerge(NetService.layer), | |
| 19 | Layer.provideMerge(NodeServices.layer), | |
| 20 | Layer.provideMerge(FetchHttpClient.layer), | |
| 21 | ); | |
| 22 | ||
| 23 | Command.run(t3Cli, { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain); |
apps/server/src/keybindings.test.ts+1-1| ... | ... | @@ -8,13 +8,13 @@ import { ServerConfig } from "./config"; |
| 8 | 8 | import { |
| 9 | 9 | DEFAULT_KEYBINDINGS, |
| 10 | 10 | Keybindings, |
| 11 | KeybindingsConfigError, | |
| 12 | 11 | KeybindingsLive, |
| 13 | 12 | ResolvedKeybindingFromConfig, |
| 14 | 13 | compileResolvedKeybindingRule, |
| 15 | 14 | compileResolvedKeybindingsConfig, |
| 16 | 15 | parseKeybindingShortcut, |
| 17 | 16 | } from "./keybindings"; |
| 17 | import { KeybindingsConfigError } from "@t3tools/contracts"; | |
| 18 | 18 | |
| 19 | 19 | const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); |
| 20 | 20 | const makeKeybindingsLayer = () => { |
apps/server/src/keybindings.ts+1-13| ... | ... | @@ -9,6 +9,7 @@ |
| 9 | 9 | import { |
| 10 | 10 | KeybindingRule, |
| 11 | 11 | KeybindingsConfig, |
| 12 | KeybindingsConfigError, | |
| 12 | 13 | KeybindingShortcut, |
| 13 | 14 | KeybindingWhenNode, |
| 14 | 15 | MAX_KEYBINDINGS_COUNT, |
| ... | ... | @@ -46,19 +47,6 @@ import * as Semaphore from "effect/Semaphore"; |
| 46 | 47 | import { ServerConfig } from "./config"; |
| 47 | 48 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 48 | 49 | |
| 49 | export class KeybindingsConfigError extends Schema.TaggedErrorClass<KeybindingsConfigError>()( | |
| 50 | "KeybindingsConfigParseError", | |
| 51 | { | |
| 52 | configPath: Schema.String, | |
| 53 | detail: Schema.String, | |
| 54 | cause: Schema.optional(Schema.Defect), | |
| 55 | }, | |
| 56 | ) { | |
| 57 | override get message(): string { | |
| 58 | return `Unable to parse keybindings config at ${this.configPath}: ${this.detail}`; | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | 50 | type WhenToken = |
| 63 | 51 | | { type: "identifier"; value: string } |
| 64 | 52 | | { type: "not" } |
apps/server/src/logger.ts deleted-103| ... | ... | @@ -1,103 +0,0 @@ |
| 1 | import util from "node:util"; | |
| 2 | ||
| 3 | type LogLevel = "info" | "warn" | "error" | "event"; | |
| 4 | ||
| 5 | type LogContext = Record<string, unknown>; | |
| 6 | ||
| 7 | const ANSI = { | |
| 8 | reset: "\u001b[0m", | |
| 9 | dim: "\u001b[2m", | |
| 10 | cyan: "\u001b[36m", | |
| 11 | yellow: "\u001b[33m", | |
| 12 | red: "\u001b[31m", | |
| 13 | magenta: "\u001b[35m", | |
| 14 | } as const; | |
| 15 | ||
| 16 | const LEVEL_LABEL: Record<LogLevel, string> = { | |
| 17 | info: "INFO", | |
| 18 | warn: "WARN", | |
| 19 | error: "ERROR", | |
| 20 | event: "EVENT", | |
| 21 | }; | |
| 22 | ||
| 23 | const LEVEL_COLOR: Record<LogLevel, string> = { | |
| 24 | info: ANSI.cyan, | |
| 25 | warn: ANSI.yellow, | |
| 26 | error: ANSI.red, | |
| 27 | event: ANSI.magenta, | |
| 28 | }; | |
| 29 | ||
| 30 | function useColors() { | |
| 31 | return Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined; | |
| 32 | } | |
| 33 | ||
| 34 | function colorize(value: string, color: string, enabled: boolean) { | |
| 35 | return enabled ? `${color}${value}${ANSI.reset}` : value; | |
| 36 | } | |
| 37 | ||
| 38 | function timeStamp() { | |
| 39 | return new Date().toISOString().slice(11, 23); | |
| 40 | } | |
| 41 | ||
| 42 | function formatValue(value: unknown) { | |
| 43 | if (typeof value === "string") { | |
| 44 | return JSON.stringify(value); | |
| 45 | } | |
| 46 | if ( | |
| 47 | typeof value === "number" || | |
| 48 | typeof value === "boolean" || | |
| 49 | value === null || | |
| 50 | value === undefined | |
| 51 | ) { | |
| 52 | return String(value); | |
| 53 | } | |
| 54 | return util.inspect(value, { | |
| 55 | depth: 4, | |
| 56 | breakLength: Infinity, | |
| 57 | compact: true, | |
| 58 | maxArrayLength: 25, | |
| 59 | maxStringLength: 320, | |
| 60 | }); | |
| 61 | } | |
| 62 | ||
| 63 | function formatContext(context: LogContext | undefined) { | |
| 64 | if (!context) return ""; | |
| 65 | const entries = Object.entries(context).filter(([, value]) => value !== undefined); | |
| 66 | if (entries.length === 0) return ""; | |
| 67 | return entries.map(([key, value]) => `${key}=${formatValue(value)}`).join(" "); | |
| 68 | } | |
| 69 | ||
| 70 | function write(level: LogLevel, scope: string, message: string, context?: LogContext) { | |
| 71 | const colorEnabled = useColors(); | |
| 72 | const ts = colorize(timeStamp(), ANSI.dim, colorEnabled); | |
| 73 | const levelLabel = colorize(LEVEL_LABEL[level], LEVEL_COLOR[level], colorEnabled); | |
| 74 | const contextText = formatContext(context); | |
| 75 | const line = `${ts} ${levelLabel} [${scope}] ${message}${contextText ? ` ${contextText}` : ""}`; | |
| 76 | ||
| 77 | if (level === "warn") { | |
| 78 | console.warn(line); | |
| 79 | return; | |
| 80 | } | |
| 81 | if (level === "error") { | |
| 82 | console.error(line); | |
| 83 | return; | |
| 84 | } | |
| 85 | console.log(line); | |
| 86 | } | |
| 87 | ||
| 88 | export function createLogger(scope: string) { | |
| 89 | return { | |
| 90 | info(message: string, context?: LogContext) { | |
| 91 | write("info", scope, message, context); | |
| 92 | }, | |
| 93 | warn(message: string, context?: LogContext) { | |
| 94 | write("warn", scope, message, context); | |
| 95 | }, | |
| 96 | error(message: string, context?: LogContext) { | |
| 97 | write("error", scope, message, context); | |
| 98 | }, | |
| 99 | event(message: string, context?: LogContext) { | |
| 100 | write("event", scope, message, context); | |
| 101 | }, | |
| 102 | }; | |
| 103 | } |
apps/server/src/main.test.ts deleted-413| ... | ... | @@ -1,413 +0,0 @@ |
| 1 | import * as Http from "node:http"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import { assert, it, vi } from "@effect/vitest"; | |
| 4 | import type { OrchestrationReadModel } from "@t3tools/contracts"; | |
| 5 | import * as ConfigProvider from "effect/ConfigProvider"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as FileSystem from "effect/FileSystem"; | |
| 8 | import * as Layer from "effect/Layer"; | |
| 9 | import * as Command from "effect/unstable/cli/Command"; | |
| 10 | import { FetchHttpClient } from "effect/unstable/http"; | |
| 11 | import { beforeEach } from "vitest"; | |
| 12 | import { NetService } from "@t3tools/shared/Net"; | |
| 13 | ||
| 14 | import { CliConfig, recordStartupHeartbeat, t3Cli, type CliConfigShape } from "./main"; | |
| 15 | import { ServerConfig, type ServerConfigShape } from "./config"; | |
| 16 | import { Open, type OpenShape } from "./open"; | |
| 17 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; | |
| 18 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; | |
| 19 | import { Server, type ServerShape } from "./wsServer"; | |
| 20 | import { ServerSettingsService } from "./serverSettings"; | |
| 21 | ||
| 22 | const start = vi.fn(() => undefined); | |
| 23 | const stop = vi.fn(() => undefined); | |
| 24 | const fixPath = vi.fn(() => undefined); | |
| 25 | let resolvedConfig: ServerConfigShape | null = null; | |
| 26 | const serverStart = Effect.acquireRelease( | |
| 27 | Effect.gen(function* () { | |
| 28 | resolvedConfig = yield* ServerConfig; | |
| 29 | start(); | |
| 30 | return {} as unknown as Http.Server; | |
| 31 | }), | |
| 32 | () => Effect.sync(() => stop()), | |
| 33 | ); | |
| 34 | const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred)); | |
| 35 | ||
| 36 | // Shared service layer used by this CLI test suite. | |
| 37 | const testLayer = Layer.mergeAll( | |
| 38 | Layer.succeed(CliConfig, { | |
| 39 | cwd: "/tmp/t3-test-workspace", | |
| 40 | fixPath: Effect.sync(fixPath), | |
| 41 | resolveStaticDir: Effect.undefined, | |
| 42 | } satisfies CliConfigShape), | |
| 43 | Layer.succeed(NetService, { | |
| 44 | canListenOnHost: () => Effect.succeed(true), | |
| 45 | isPortAvailableOnLoopback: () => Effect.succeed(true), | |
| 46 | reserveLoopbackPort: () => Effect.succeed(0), | |
| 47 | findAvailablePort, | |
| 48 | }), | |
| 49 | Layer.succeed(Server, { | |
| 50 | start: serverStart, | |
| 51 | stopSignal: Effect.void, | |
| 52 | } satisfies ServerShape), | |
| 53 | Layer.succeed(Open, { | |
| 54 | openBrowser: (_target: string) => Effect.void, | |
| 55 | openInEditor: () => Effect.void, | |
| 56 | } satisfies OpenShape), | |
| 57 | ServerSettingsService.layerTest(), | |
| 58 | AnalyticsService.layerTest, | |
| 59 | FetchHttpClient.layer, | |
| 60 | NodeServices.layer, | |
| 61 | ); | |
| 62 | ||
| 63 | const runCli = ( | |
| 64 | args: ReadonlyArray<string>, | |
| 65 | env: Record<string, string> = { T3CODE_NO_BROWSER: "true" }, | |
| 66 | ) => { | |
| 67 | return Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe( | |
| 68 | Effect.provide( | |
| 69 | ConfigProvider.layer( | |
| 70 | ConfigProvider.fromEnv({ | |
| 71 | env: { | |
| 72 | ...env, | |
| 73 | }, | |
| 74 | }), | |
| 75 | ), | |
| 76 | ), | |
| 77 | ); | |
| 78 | }; | |
| 79 | ||
| 80 | beforeEach(() => { | |
| 81 | vi.clearAllMocks(); | |
| 82 | resolvedConfig = null; | |
| 83 | start.mockImplementation(() => undefined); | |
| 84 | stop.mockImplementation(() => undefined); | |
| 85 | fixPath.mockImplementation(() => undefined); | |
| 86 | findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred)); | |
| 87 | }); | |
| 88 | ||
| 89 | it.layer(testLayer)("server CLI command", (it) => { | |
| 90 | it.effect("parses all CLI flags and wires scoped start/stop", () => | |
| 91 | Effect.gen(function* () { | |
| 92 | yield* runCli([ | |
| 93 | "--mode", | |
| 94 | "desktop", | |
| 95 | "--port", | |
| 96 | "4010", | |
| 97 | "--host", | |
| 98 | "0.0.0.0", | |
| 99 | "--home-dir", | |
| 100 | "/tmp/t3-cli-home", | |
| 101 | "--dev-url", | |
| 102 | "http://127.0.0.1:5173", | |
| 103 | "--no-browser", | |
| 104 | "--auth-token", | |
| 105 | "auth-secret", | |
| 106 | ]); | |
| 107 | ||
| 108 | assert.equal(start.mock.calls.length, 1); | |
| 109 | assert.equal(resolvedConfig?.mode, "desktop"); | |
| 110 | assert.equal(resolvedConfig?.port, 4010); | |
| 111 | assert.equal(resolvedConfig?.host, "0.0.0.0"); | |
| 112 | assert.equal(resolvedConfig?.baseDir, "/tmp/t3-cli-home"); | |
| 113 | assert.equal(resolvedConfig?.stateDir, "/tmp/t3-cli-home/dev"); | |
| 114 | assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/"); | |
| 115 | assert.equal(resolvedConfig?.noBrowser, true); | |
| 116 | assert.equal(resolvedConfig?.authToken, "auth-secret"); | |
| 117 | assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false); | |
| 118 | assert.equal(resolvedConfig?.logWebSocketEvents, true); | |
| 119 | assert.equal(stop.mock.calls.length, 1); | |
| 120 | }), | |
| 121 | ); | |
| 122 | ||
| 123 | it.effect("supports --token as an alias for --auth-token", () => | |
| 124 | Effect.gen(function* () { | |
| 125 | yield* runCli(["--token", "token-secret"]); | |
| 126 | ||
| 127 | assert.equal(start.mock.calls.length, 1); | |
| 128 | assert.equal(resolvedConfig?.authToken, "token-secret"); | |
| 129 | }), | |
| 130 | ); | |
| 131 | ||
| 132 | it.effect("uses env fallbacks when flags are not provided", () => | |
| 133 | Effect.gen(function* () { | |
| 134 | yield* runCli([], { | |
| 135 | T3CODE_MODE: "desktop", | |
| 136 | T3CODE_PORT: "4999", | |
| 137 | T3CODE_HOST: "100.88.10.4", | |
| 138 | T3CODE_HOME: "/tmp/t3-env-home", | |
| 139 | VITE_DEV_SERVER_URL: "http://localhost:5173", | |
| 140 | T3CODE_NO_BROWSER: "true", | |
| 141 | T3CODE_AUTH_TOKEN: "env-token", | |
| 142 | }); | |
| 143 | ||
| 144 | assert.equal(start.mock.calls.length, 1); | |
| 145 | assert.equal(resolvedConfig?.mode, "desktop"); | |
| 146 | assert.equal(resolvedConfig?.port, 4999); | |
| 147 | assert.equal(resolvedConfig?.host, "100.88.10.4"); | |
| 148 | assert.equal(resolvedConfig?.baseDir, "/tmp/t3-env-home"); | |
| 149 | assert.equal(resolvedConfig?.stateDir, "/tmp/t3-env-home/dev"); | |
| 150 | assert.equal(resolvedConfig?.devUrl?.toString(), "http://localhost:5173/"); | |
| 151 | assert.equal(resolvedConfig?.noBrowser, true); | |
| 152 | assert.equal(resolvedConfig?.authToken, "env-token"); | |
| 153 | assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false); | |
| 154 | assert.equal(resolvedConfig?.logWebSocketEvents, true); | |
| 155 | assert.equal(findAvailablePort.mock.calls.length, 0); | |
| 156 | }), | |
| 157 | ); | |
| 158 | ||
| 159 | const openBootstrapFd = Effect.fn(function* (payload: Record<string, unknown>) { | |
| 160 | const fs = yield* FileSystem.FileSystem; | |
| 161 | const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); | |
| 162 | yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`); | |
| 163 | const { fd } = yield* fs.open(filePath, { flag: "r" }); | |
| 164 | return fd; | |
| 165 | }); | |
| 166 | ||
| 167 | it.effect("recognizes bootstrap fd from environment config", () => | |
| 168 | Effect.gen(function* () { | |
| 169 | const fd = yield* openBootstrapFd({ authToken: "bootstrap-token" }); | |
| 170 | ||
| 171 | yield* runCli([], { | |
| 172 | T3CODE_MODE: "web", | |
| 173 | T3CODE_BOOTSTRAP_FD: String(fd), | |
| 174 | T3CODE_AUTH_TOKEN: "env-token", | |
| 175 | T3CODE_NO_BROWSER: "true", | |
| 176 | }); | |
| 177 | ||
| 178 | assert.equal(start.mock.calls.length, 1); | |
| 179 | assert.equal(resolvedConfig?.mode, "web"); | |
| 180 | assert.equal(resolvedConfig?.authToken, "env-token"); | |
| 181 | }), | |
| 182 | ); | |
| 183 | ||
| 184 | it.effect("uses bootstrap envelope values as fallbacks when CLI and env are absent", () => | |
| 185 | Effect.gen(function* () { | |
| 186 | const fd = yield* openBootstrapFd({ | |
| 187 | mode: "desktop", | |
| 188 | port: 4888, | |
| 189 | host: "127.0.0.2", | |
| 190 | t3Home: "/tmp/t3-bootstrap-home", | |
| 191 | devUrl: "http://127.0.0.1:5173", | |
| 192 | noBrowser: true, | |
| 193 | authToken: "bootstrap-token", | |
| 194 | autoBootstrapProjectFromCwd: false, | |
| 195 | logWebSocketEvents: true, | |
| 196 | }); | |
| 197 | ||
| 198 | yield* runCli([], { | |
| 199 | T3CODE_BOOTSTRAP_FD: String(fd), | |
| 200 | }); | |
| 201 | ||
| 202 | assert.equal(start.mock.calls.length, 1); | |
| 203 | assert.equal(resolvedConfig?.mode, "desktop"); | |
| 204 | assert.equal(resolvedConfig?.port, 4888); | |
| 205 | assert.equal(resolvedConfig?.host, "127.0.0.2"); | |
| 206 | assert.equal(resolvedConfig?.baseDir, "/tmp/t3-bootstrap-home"); | |
| 207 | assert.equal(resolvedConfig?.stateDir, "/tmp/t3-bootstrap-home/dev"); | |
| 208 | assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/"); | |
| 209 | assert.equal(resolvedConfig?.noBrowser, true); | |
| 210 | assert.equal(resolvedConfig?.authToken, "bootstrap-token"); | |
| 211 | assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false); | |
| 212 | assert.equal(resolvedConfig?.logWebSocketEvents, true); | |
| 213 | }), | |
| 214 | ); | |
| 215 | ||
| 216 | it.effect("applies CLI then env precedence over bootstrap envelope values", () => | |
| 217 | Effect.gen(function* () { | |
| 218 | const fd = yield* openBootstrapFd({ | |
| 219 | mode: "desktop", | |
| 220 | port: 4888, | |
| 221 | host: "127.0.0.2", | |
| 222 | t3Home: "/tmp/t3-bootstrap-home", | |
| 223 | devUrl: "http://127.0.0.1:5173", | |
| 224 | noBrowser: false, | |
| 225 | authToken: "bootstrap-token", | |
| 226 | autoBootstrapProjectFromCwd: false, | |
| 227 | logWebSocketEvents: false, | |
| 228 | }); | |
| 229 | ||
| 230 | yield* runCli(["--port", "4999", "--host", "0.0.0.0", "--auth-token", "cli-token"], { | |
| 231 | T3CODE_MODE: "web", | |
| 232 | T3CODE_BOOTSTRAP_FD: String(fd), | |
| 233 | T3CODE_HOME: "/tmp/t3-env-home", | |
| 234 | T3CODE_NO_BROWSER: "true", | |
| 235 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true", | |
| 236 | T3CODE_LOG_WS_EVENTS: "true", | |
| 237 | }); | |
| 238 | ||
| 239 | assert.equal(start.mock.calls.length, 1); | |
| 240 | assert.equal(resolvedConfig?.mode, "web"); | |
| 241 | assert.equal(resolvedConfig?.port, 4999); | |
| 242 | assert.equal(resolvedConfig?.host, "0.0.0.0"); | |
| 243 | assert.equal(resolvedConfig?.baseDir, "/tmp/t3-env-home"); | |
| 244 | assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/"); | |
| 245 | assert.equal(resolvedConfig?.noBrowser, true); | |
| 246 | assert.equal(resolvedConfig?.authToken, "cli-token"); | |
| 247 | assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, true); | |
| 248 | assert.equal(resolvedConfig?.logWebSocketEvents, true); | |
| 249 | }), | |
| 250 | ); | |
| 251 | ||
| 252 | it.effect("prefers --mode over T3CODE_MODE", () => | |
| 253 | Effect.gen(function* () { | |
| 254 | findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(4666)); | |
| 255 | yield* runCli(["--mode", "web"], { | |
| 256 | T3CODE_MODE: "desktop", | |
| 257 | T3CODE_NO_BROWSER: "true", | |
| 258 | }); | |
| 259 | ||
| 260 | assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]); | |
| 261 | assert.equal(start.mock.calls.length, 1); | |
| 262 | assert.equal(resolvedConfig?.mode, "web"); | |
| 263 | assert.equal(resolvedConfig?.port, 4666); | |
| 264 | assert.equal(resolvedConfig?.host, undefined); | |
| 265 | }), | |
| 266 | ); | |
| 267 | ||
| 268 | it.effect("prefers --no-browser over T3CODE_NO_BROWSER", () => | |
| 269 | Effect.gen(function* () { | |
| 270 | yield* runCli(["--no-browser"], { | |
| 271 | T3CODE_NO_BROWSER: "false", | |
| 272 | }); | |
| 273 | ||
| 274 | assert.equal(start.mock.calls.length, 1); | |
| 275 | assert.equal(resolvedConfig?.noBrowser, true); | |
| 276 | }), | |
| 277 | ); | |
| 278 | ||
| 279 | it.effect("uses dynamic port discovery in web mode when port is omitted", () => | |
| 280 | Effect.gen(function* () { | |
| 281 | findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(5444)); | |
| 282 | yield* runCli([]); | |
| 283 | ||
| 284 | assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]); | |
| 285 | assert.equal(start.mock.calls.length, 1); | |
| 286 | assert.equal(resolvedConfig?.port, 5444); | |
| 287 | assert.equal(resolvedConfig?.mode, "web"); | |
| 288 | }), | |
| 289 | ); | |
| 290 | ||
| 291 | it.effect("uses fixed localhost defaults in desktop mode", () => | |
| 292 | Effect.gen(function* () { | |
| 293 | yield* runCli([], { | |
| 294 | T3CODE_MODE: "desktop", | |
| 295 | T3CODE_NO_BROWSER: "true", | |
| 296 | }); | |
| 297 | ||
| 298 | assert.equal(findAvailablePort.mock.calls.length, 0); | |
| 299 | assert.equal(start.mock.calls.length, 1); | |
| 300 | assert.equal(resolvedConfig?.port, 3773); | |
| 301 | assert.equal(resolvedConfig?.host, "127.0.0.1"); | |
| 302 | assert.equal(resolvedConfig?.mode, "desktop"); | |
| 303 | }), | |
| 304 | ); | |
| 305 | ||
| 306 | it.effect("allows overriding desktop host with --host", () => | |
| 307 | Effect.gen(function* () { | |
| 308 | yield* runCli(["--host", "0.0.0.0"], { | |
| 309 | T3CODE_MODE: "desktop", | |
| 310 | T3CODE_NO_BROWSER: "true", | |
| 311 | }); | |
| 312 | ||
| 313 | assert.equal(start.mock.calls.length, 1); | |
| 314 | assert.equal(resolvedConfig?.mode, "desktop"); | |
| 315 | assert.equal(resolvedConfig?.host, "0.0.0.0"); | |
| 316 | }), | |
| 317 | ); | |
| 318 | ||
| 319 | it.effect("supports CLI and env for bootstrap/log websocket toggles", () => | |
| 320 | Effect.gen(function* () { | |
| 321 | yield* runCli(["--auto-bootstrap-project-from-cwd"], { | |
| 322 | T3CODE_MODE: "desktop", | |
| 323 | T3CODE_LOG_WS_EVENTS: "false", | |
| 324 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", | |
| 325 | T3CODE_NO_BROWSER: "true", | |
| 326 | }); | |
| 327 | ||
| 328 | assert.equal(start.mock.calls.length, 1); | |
| 329 | assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, true); | |
| 330 | assert.equal(resolvedConfig?.logWebSocketEvents, false); | |
| 331 | }), | |
| 332 | ); | |
| 333 | ||
| 334 | it.effect("hydrates PATH before server startup", () => | |
| 335 | Effect.gen(function* () { | |
| 336 | yield* runCli([]); | |
| 337 | ||
| 338 | assert.equal(fixPath.mock.calls.length, 1); | |
| 339 | assert.equal(start.mock.calls.length, 1); | |
| 340 | const fixPathOrder = fixPath.mock.invocationCallOrder[0]; | |
| 341 | const startOrder = start.mock.invocationCallOrder[0]; | |
| 342 | if (typeof fixPathOrder !== "number" || typeof startOrder !== "number") { | |
| 343 | assert.fail("Expected fixPath and start to be called"); | |
| 344 | } | |
| 345 | assert.isTrue(fixPathOrder < startOrder); | |
| 346 | }), | |
| 347 | ); | |
| 348 | ||
| 349 | it.effect("records a startup heartbeat with thread/project counts", () => | |
| 350 | Effect.gen(function* () { | |
| 351 | const recordTelemetry = vi.fn( | |
| 352 | (_event: string, _properties?: Readonly<Record<string, unknown>>) => Effect.void, | |
| 353 | ); | |
| 354 | const getSnapshot = vi.fn(() => | |
| 355 | Effect.succeed({ | |
| 356 | snapshotSequence: 2, | |
| 357 | projects: [{} as OrchestrationReadModel["projects"][number]], | |
| 358 | threads: [ | |
| 359 | {} as OrchestrationReadModel["threads"][number], | |
| 360 | {} as OrchestrationReadModel["threads"][number], | |
| 361 | ], | |
| 362 | updatedAt: new Date(1).toISOString(), | |
| 363 | } satisfies OrchestrationReadModel), | |
| 364 | ); | |
| 365 | ||
| 366 | yield* recordStartupHeartbeat.pipe( | |
| 367 | Effect.provideService(ProjectionSnapshotQuery, { | |
| 368 | getSnapshot, | |
| 369 | }), | |
| 370 | Effect.provideService(AnalyticsService, { | |
| 371 | record: recordTelemetry, | |
| 372 | flush: Effect.void, | |
| 373 | }), | |
| 374 | ); | |
| 375 | ||
| 376 | assert.deepEqual(recordTelemetry.mock.calls[0], [ | |
| 377 | "server.boot.heartbeat", | |
| 378 | { | |
| 379 | threadCount: 2, | |
| 380 | projectCount: 1, | |
| 381 | }, | |
| 382 | ]); | |
| 383 | }), | |
| 384 | ); | |
| 385 | ||
| 386 | it.effect("does not start server for invalid --mode values", () => | |
| 387 | Effect.gen(function* () { | |
| 388 | yield* runCli(["--mode", "invalid"]).pipe(Effect.catch(() => Effect.void)); | |
| 389 | ||
| 390 | assert.equal(start.mock.calls.length, 0); | |
| 391 | assert.equal(stop.mock.calls.length, 0); | |
| 392 | }), | |
| 393 | ); | |
| 394 | ||
| 395 | it.effect("does not start server for invalid --dev-url values", () => | |
| 396 | Effect.gen(function* () { | |
| 397 | yield* runCli(["--dev-url", "not-a-url"]).pipe(Effect.catch(() => Effect.void)); | |
| 398 | ||
| 399 | assert.equal(start.mock.calls.length, 0); | |
| 400 | assert.equal(stop.mock.calls.length, 0); | |
| 401 | }), | |
| 402 | ); | |
| 403 | ||
| 404 | it.effect("does not start server for out-of-range --port values", () => | |
| 405 | Effect.gen(function* () { | |
| 406 | yield* runCli(["--port", "70000"]).pipe(Effect.catch(() => Effect.void)); | |
| 407 | ||
| 408 | // effect/unstable/cli renders help/errors for parse failures and returns success. | |
| 409 | assert.equal(start.mock.calls.length, 0); | |
| 410 | assert.equal(stop.mock.calls.length, 0); | |
| 411 | }), | |
| 412 | ); | |
| 413 | }); |
apps/server/src/main.ts deleted-456| ... | ... | @@ -1,456 +0,0 @@ |
| 1 | /** | |
| 2 | * CliConfig - CLI/runtime bootstrap service definitions. | |
| 3 | * | |
| 4 | * Defines startup-only service contracts used while resolving process config | |
| 5 | * and constructing server runtime layers. | |
| 6 | * | |
| 7 | * @module CliConfig | |
| 8 | */ | |
| 9 | import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect"; | |
| 10 | import { Command, Flag } from "effect/unstable/cli"; | |
| 11 | import { NetService } from "@t3tools/shared/Net"; | |
| 12 | import { | |
| 13 | DEFAULT_PORT, | |
| 14 | deriveServerPaths, | |
| 15 | resolveStaticDir, | |
| 16 | ServerConfig, | |
| 17 | type RuntimeMode, | |
| 18 | type ServerConfigShape, | |
| 19 | } from "./config"; | |
| 20 | import { fixPath, resolveBaseDir } from "./os-jank"; | |
| 21 | import { Open } from "./open"; | |
| 22 | import * as SqlitePersistence from "./persistence/Layers/Sqlite"; | |
| 23 | import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; | |
| 24 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; | |
| 25 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry"; | |
| 26 | import { Server } from "./wsServer"; | |
| 27 | import { ServerLoggerLive } from "./serverLogger"; | |
| 28 | import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; | |
| 29 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; | |
| 30 | import { readBootstrapEnvelope } from "./bootstrap"; | |
| 31 | import { ServerSettingsLive } from "./serverSettings"; | |
| 32 | ||
| 33 | export class StartupError extends Data.TaggedError("StartupError")<{ | |
| 34 | readonly message: string; | |
| 35 | readonly cause?: unknown; | |
| 36 | }> {} | |
| 37 | ||
| 38 | const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); | |
| 39 | ||
| 40 | const BootstrapEnvelopeSchema = Schema.Struct({ | |
| 41 | mode: Schema.optional(Schema.String), | |
| 42 | port: Schema.optional(PortSchema), | |
| 43 | host: Schema.optional(Schema.String), | |
| 44 | t3Home: Schema.optional(Schema.String), | |
| 45 | devUrl: Schema.optional(Schema.URLFromString), | |
| 46 | noBrowser: Schema.optional(Schema.Boolean), | |
| 47 | authToken: Schema.optional(Schema.String), | |
| 48 | autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), | |
| 49 | logWebSocketEvents: Schema.optional(Schema.Boolean), | |
| 50 | }); | |
| 51 | ||
| 52 | interface CliInput { | |
| 53 | readonly mode: Option.Option<RuntimeMode>; | |
| 54 | readonly port: Option.Option<number>; | |
| 55 | readonly host: Option.Option<string>; | |
| 56 | readonly t3Home: Option.Option<string>; | |
| 57 | readonly devUrl: Option.Option<URL>; | |
| 58 | readonly noBrowser: Option.Option<boolean>; | |
| 59 | readonly authToken: Option.Option<string>; | |
| 60 | readonly bootstrapFd: Option.Option<number>; | |
| 61 | readonly autoBootstrapProjectFromCwd: Option.Option<boolean>; | |
| 62 | readonly logWebSocketEvents: Option.Option<boolean>; | |
| 63 | } | |
| 64 | ||
| 65 | /** | |
| 66 | * CliConfigShape - Startup helpers required while building server layers. | |
| 67 | */ | |
| 68 | export interface CliConfigShape { | |
| 69 | /** | |
| 70 | * Current process working directory. | |
| 71 | */ | |
| 72 | readonly cwd: string; | |
| 73 | ||
| 74 | /** | |
| 75 | * Apply OS-specific PATH normalization. | |
| 76 | */ | |
| 77 | readonly fixPath: Effect.Effect<void>; | |
| 78 | ||
| 79 | /** | |
| 80 | * Resolve static web asset directory for server mode. | |
| 81 | */ | |
| 82 | readonly resolveStaticDir: Effect.Effect<string | undefined>; | |
| 83 | } | |
| 84 | ||
| 85 | /** | |
| 86 | * CliConfig - Service tag for startup CLI/runtime helpers. | |
| 87 | */ | |
| 88 | export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()( | |
| 89 | "t3/main/CliConfig", | |
| 90 | ) { | |
| 91 | static readonly layer = Layer.effect( | |
| 92 | CliConfig, | |
| 93 | Effect.gen(function* () { | |
| 94 | const fileSystem = yield* FileSystem.FileSystem; | |
| 95 | const path = yield* Path.Path; | |
| 96 | return { | |
| 97 | cwd: process.cwd(), | |
| 98 | fixPath: Effect.sync(fixPath), | |
| 99 | resolveStaticDir: resolveStaticDir().pipe( | |
| 100 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 101 | Effect.provideService(Path.Path, path), | |
| 102 | ), | |
| 103 | } satisfies CliConfigShape; | |
| 104 | }), | |
| 105 | ); | |
| 106 | } | |
| 107 | ||
| 108 | const CliEnvConfig = Config.all({ | |
| 109 | mode: Config.string("T3CODE_MODE").pipe( | |
| 110 | Config.option, | |
| 111 | Config.map(Option.map((value) => (value === "desktop" ? "desktop" : "web"))), | |
| 112 | Config.map(Option.getOrUndefined), | |
| 113 | ), | |
| 114 | port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), | |
| 115 | host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), | |
| 116 | t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), | |
| 117 | devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), | |
| 118 | noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( | |
| 119 | Config.option, | |
| 120 | Config.map(Option.getOrUndefined), | |
| 121 | ), | |
| 122 | authToken: Config.string("T3CODE_AUTH_TOKEN").pipe( | |
| 123 | Config.option, | |
| 124 | Config.map(Option.getOrUndefined), | |
| 125 | ), | |
| 126 | bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( | |
| 127 | Config.option, | |
| 128 | Config.map(Option.getOrUndefined), | |
| 129 | ), | |
| 130 | autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( | |
| 131 | Config.option, | |
| 132 | Config.map(Option.getOrUndefined), | |
| 133 | ), | |
| 134 | logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe( | |
| 135 | Config.option, | |
| 136 | Config.map(Option.getOrUndefined), | |
| 137 | ), | |
| 138 | }); | |
| 139 | ||
| 140 | const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) => | |
| 141 | Option.getOrElse(Option.filter(flag, Boolean), () => envValue); | |
| 142 | ||
| 143 | const resolveOptionPrecedence = <Value>( | |
| 144 | ...values: ReadonlyArray<Option.Option<Value>> | |
| 145 | ): Option.Option<Value> => Option.firstSomeOf(values); | |
| 146 | ||
| 147 | const isValidPort = (value: number): boolean => value >= 1 && value <= 65_535; | |
| 148 | const isRuntimeMode = (value: string): value is RuntimeMode => | |
| 149 | value === "web" || value === "desktop"; | |
| 150 | ||
| 151 | const ServerConfigLive = (input: CliInput) => | |
| 152 | Layer.effect( | |
| 153 | ServerConfig, | |
| 154 | Effect.gen(function* () { | |
| 155 | const cliConfig = yield* CliConfig; | |
| 156 | const { findAvailablePort } = yield* NetService; | |
| 157 | const env = yield* CliEnvConfig.asEffect().pipe( | |
| 158 | Effect.mapError( | |
| 159 | (cause) => | |
| 160 | new StartupError({ message: "Failed to read environment configuration", cause }), | |
| 161 | ), | |
| 162 | ); | |
| 163 | ||
| 164 | const bootstrapFd = Option.getOrUndefined(input.bootstrapFd) ?? env.bootstrapFd; | |
| 165 | const bootstrapEnvelope = | |
| 166 | bootstrapFd !== undefined | |
| 167 | ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd) | |
| 168 | : Option.none(); | |
| 169 | ||
| 170 | const mode: RuntimeMode = Option.getOrElse( | |
| 171 | resolveOptionPrecedence( | |
| 172 | input.mode, | |
| 173 | Option.fromUndefinedOr(env.mode), | |
| 174 | Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 175 | Option.filter(Option.fromUndefinedOr(bootstrap.mode), isRuntimeMode), | |
| 176 | ), | |
| 177 | ), | |
| 178 | () => "web", | |
| 179 | ); | |
| 180 | const port = yield* Option.match( | |
| 181 | resolveOptionPrecedence( | |
| 182 | input.port, | |
| 183 | Option.fromUndefinedOr(env.port), | |
| 184 | Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 185 | Option.filter(Option.fromUndefinedOr(bootstrap.port), isValidPort), | |
| 186 | ), | |
| 187 | ), | |
| 188 | { | |
| 189 | onSome: (value) => Effect.succeed(value), | |
| 190 | onNone: () => { | |
| 191 | if (mode === "desktop") { | |
| 192 | return Effect.succeed(DEFAULT_PORT); | |
| 193 | } | |
| 194 | return findAvailablePort(DEFAULT_PORT); | |
| 195 | }, | |
| 196 | }, | |
| 197 | ); | |
| 198 | ||
| 199 | const devUrl = Option.getOrElse( | |
| 200 | resolveOptionPrecedence( | |
| 201 | input.devUrl, | |
| 202 | Option.fromUndefinedOr(env.devUrl), | |
| 203 | Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 204 | Option.fromUndefinedOr(bootstrap.devUrl), | |
| 205 | ), | |
| 206 | ), | |
| 207 | () => undefined, | |
| 208 | ); | |
| 209 | const baseDir = yield* resolveBaseDir( | |
| 210 | Option.getOrUndefined( | |
| 211 | resolveOptionPrecedence( | |
| 212 | input.t3Home, | |
| 213 | Option.fromUndefinedOr(env.t3Home), | |
| 214 | Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 215 | Option.fromUndefinedOr(bootstrap.t3Home), | |
| 216 | ), | |
| 217 | ), | |
| 218 | ), | |
| 219 | ); | |
| 220 | const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); | |
| 221 | const noBrowser = resolveBooleanFlag( | |
| 222 | input.noBrowser, | |
| 223 | Option.getOrElse( | |
| 224 | resolveOptionPrecedence( | |
| 225 | Option.fromUndefinedOr(env.noBrowser), | |
| 226 | Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 227 | Option.fromUndefinedOr(bootstrap.noBrowser), | |
| 228 | ), | |
| 229 | ), | |
| 230 | () => mode === "desktop", | |
| 231 | ), | |
| 232 | ); | |
| 233 | const authToken = resolveOptionPrecedence( | |
| 234 | input.authToken, | |
| 235 | Option.fromUndefinedOr(env.authToken), | |
| 236 | Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 237 | Option.fromUndefinedOr(bootstrap.authToken), | |
| 238 | ), | |
| 239 | ); | |
| 240 | const autoBootstrapProjectFromCwd = resolveBooleanFlag( | |
| 241 | input.autoBootstrapProjectFromCwd, | |
| 242 | Option.getOrElse( | |
| 243 | resolveOptionPrecedence( | |
| 244 | Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), | |
| 245 | Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 246 | Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd), | |
| 247 | ), | |
| 248 | ), | |
| 249 | () => mode === "web", | |
| 250 | ), | |
| 251 | ); | |
| 252 | const logWebSocketEvents = resolveBooleanFlag( | |
| 253 | input.logWebSocketEvents, | |
| 254 | Option.getOrElse( | |
| 255 | resolveOptionPrecedence( | |
| 256 | Option.fromUndefinedOr(env.logWebSocketEvents), | |
| 257 | Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 258 | Option.fromUndefinedOr(bootstrap.logWebSocketEvents), | |
| 259 | ), | |
| 260 | ), | |
| 261 | () => Boolean(devUrl), | |
| 262 | ), | |
| 263 | ); | |
| 264 | const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; | |
| 265 | const host = Option.getOrElse( | |
| 266 | resolveOptionPrecedence( | |
| 267 | input.host, | |
| 268 | Option.fromUndefinedOr(env.host), | |
| 269 | Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)), | |
| 270 | ), | |
| 271 | () => (mode === "desktop" ? "127.0.0.1" : undefined), | |
| 272 | ); | |
| 273 | ||
| 274 | const config: ServerConfigShape = { | |
| 275 | mode, | |
| 276 | port, | |
| 277 | cwd: cliConfig.cwd, | |
| 278 | host, | |
| 279 | baseDir, | |
| 280 | ...derivedPaths, | |
| 281 | staticDir, | |
| 282 | devUrl, | |
| 283 | noBrowser, | |
| 284 | authToken: Option.getOrUndefined(authToken), | |
| 285 | autoBootstrapProjectFromCwd, | |
| 286 | logWebSocketEvents, | |
| 287 | } satisfies ServerConfigShape; | |
| 288 | ||
| 289 | return config; | |
| 290 | }), | |
| 291 | ); | |
| 292 | ||
| 293 | const LayerLive = (input: CliInput) => | |
| 294 | Layer.empty.pipe( | |
| 295 | Layer.provideMerge(makeServerRuntimeServicesLayer()), | |
| 296 | Layer.provideMerge(makeServerProviderLayer()), | |
| 297 | Layer.provideMerge(ProviderRegistryLive), | |
| 298 | Layer.provideMerge(SqlitePersistence.layerConfig), | |
| 299 | Layer.provideMerge(ServerLoggerLive), | |
| 300 | Layer.provideMerge(AnalyticsServiceLayerLive), | |
| 301 | Layer.provideMerge(ServerSettingsLive), | |
| 302 | Layer.provideMerge(ServerConfigLive(input)), | |
| 303 | ); | |
| 304 | ||
| 305 | const isWildcardHost = (host: string | undefined): boolean => | |
| 306 | host === "0.0.0.0" || host === "::" || host === "[::]"; | |
| 307 | ||
| 308 | const formatHostForUrl = (host: string): string => | |
| 309 | host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; | |
| 310 | ||
| 311 | export const recordStartupHeartbeat = Effect.gen(function* () { | |
| 312 | const analytics = yield* AnalyticsService; | |
| 313 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | |
| 314 | ||
| 315 | const { threadCount, projectCount } = yield* projectionSnapshotQuery.getSnapshot().pipe( | |
| 316 | Effect.map((snapshot) => ({ | |
| 317 | threadCount: snapshot.threads.length, | |
| 318 | projectCount: snapshot.projects.length, | |
| 319 | })), | |
| 320 | Effect.catch((cause) => | |
| 321 | Effect.logWarning("failed to gather startup snapshot for telemetry", { cause }).pipe( | |
| 322 | Effect.as({ | |
| 323 | threadCount: 0, | |
| 324 | projectCount: 0, | |
| 325 | }), | |
| 326 | ), | |
| 327 | ), | |
| 328 | ); | |
| 329 | ||
| 330 | yield* analytics.record("server.boot.heartbeat", { | |
| 331 | threadCount, | |
| 332 | projectCount, | |
| 333 | }); | |
| 334 | }); | |
| 335 | ||
| 336 | const makeServerRuntimeProgram = (input: CliInput) => | |
| 337 | Effect.gen(function* () { | |
| 338 | const { start, stopSignal } = yield* Server; | |
| 339 | const openDeps = yield* Open; | |
| 340 | ||
| 341 | const config = yield* ServerConfig; | |
| 342 | ||
| 343 | if (!config.devUrl && !config.staticDir) { | |
| 344 | yield* Effect.logWarning( | |
| 345 | "web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", | |
| 346 | { | |
| 347 | hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.", | |
| 348 | }, | |
| 349 | ); | |
| 350 | } | |
| 351 | ||
| 352 | yield* start; | |
| 353 | yield* Effect.forkChild(recordStartupHeartbeat); | |
| 354 | ||
| 355 | const localUrl = `http://localhost:${config.port}`; | |
| 356 | const bindUrl = | |
| 357 | config.host && !isWildcardHost(config.host) | |
| 358 | ? `http://${formatHostForUrl(config.host)}:${config.port}` | |
| 359 | : localUrl; | |
| 360 | const { authToken, devUrl, ...safeConfig } = config; | |
| 361 | yield* Effect.logInfo("T3 Code running", { | |
| 362 | ...safeConfig, | |
| 363 | devUrl: devUrl?.toString(), | |
| 364 | authEnabled: Boolean(authToken), | |
| 365 | }); | |
| 366 | ||
| 367 | if (!config.noBrowser) { | |
| 368 | const target = config.devUrl?.toString() ?? bindUrl; | |
| 369 | yield* openDeps.openBrowser(target).pipe( | |
| 370 | Effect.catch(() => | |
| 371 | Effect.logInfo("browser auto-open unavailable", { | |
| 372 | hint: `Open ${target} in your browser.`, | |
| 373 | }), | |
| 374 | ), | |
| 375 | ); | |
| 376 | } | |
| 377 | ||
| 378 | return yield* stopSignal; | |
| 379 | }).pipe(Effect.provide(LayerLive(input))); | |
| 380 | ||
| 381 | const makeServerProgram = (input: CliInput) => | |
| 382 | Effect.gen(function* () { | |
| 383 | const cliConfig = yield* CliConfig; | |
| 384 | yield* cliConfig.fixPath; | |
| 385 | return yield* makeServerRuntimeProgram(input); | |
| 386 | }); | |
| 387 | ||
| 388 | /** | |
| 389 | * These flags mirrors the environment variables and the config shape. | |
| 390 | */ | |
| 391 | ||
| 392 | const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe( | |
| 393 | Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), | |
| 394 | Flag.optional, | |
| 395 | ); | |
| 396 | const portFlag = Flag.integer("port").pipe( | |
| 397 | Flag.withSchema(PortSchema), | |
| 398 | Flag.withDescription("Port for the HTTP/WebSocket server."), | |
| 399 | Flag.optional, | |
| 400 | ); | |
| 401 | const hostFlag = Flag.string("host").pipe( | |
| 402 | Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), | |
| 403 | Flag.optional, | |
| 404 | ); | |
| 405 | const t3HomeFlag = Flag.string("home-dir").pipe( | |
| 406 | Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."), | |
| 407 | Flag.optional, | |
| 408 | ); | |
| 409 | const devUrlFlag = Flag.string("dev-url").pipe( | |
| 410 | Flag.withSchema(Schema.URLFromString), | |
| 411 | Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."), | |
| 412 | Flag.optional, | |
| 413 | ); | |
| 414 | const noBrowserFlag = Flag.boolean("no-browser").pipe( | |
| 415 | Flag.withDescription("Disable automatic browser opening."), | |
| 416 | Flag.optional, | |
| 417 | ); | |
| 418 | const authTokenFlag = Flag.string("auth-token").pipe( | |
| 419 | Flag.withDescription("Auth token required for WebSocket connections."), | |
| 420 | Flag.withAlias("token"), | |
| 421 | Flag.optional, | |
| 422 | ); | |
| 423 | const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( | |
| 424 | Flag.withSchema(Schema.Int), | |
| 425 | Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), | |
| 426 | Flag.optional, | |
| 427 | ); | |
| 428 | const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( | |
| 429 | Flag.withDescription( | |
| 430 | "Create a project for the current working directory on startup when missing.", | |
| 431 | ), | |
| 432 | Flag.optional, | |
| 433 | ); | |
| 434 | const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( | |
| 435 | Flag.withDescription( | |
| 436 | "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).", | |
| 437 | ), | |
| 438 | Flag.withAlias("log-ws-events"), | |
| 439 | Flag.optional, | |
| 440 | ); | |
| 441 | ||
| 442 | export const t3Cli = Command.make("t3", { | |
| 443 | mode: modeFlag, | |
| 444 | port: portFlag, | |
| 445 | host: hostFlag, | |
| 446 | t3Home: t3HomeFlag, | |
| 447 | devUrl: devUrlFlag, | |
| 448 | noBrowser: noBrowserFlag, | |
| 449 | authToken: authTokenFlag, | |
| 450 | bootstrapFd: bootstrapFdFlag, | |
| 451 | autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, | |
| 452 | logWebSocketEvents: logWebSocketEventsFlag, | |
| 453 | }).pipe( | |
| 454 | Command.withDescription("Run the T3 Code server."), | |
| 455 | Command.withHandler((input) => Effect.scoped(makeServerProgram(input))), | |
| 456 | ); |
apps/server/src/open.ts+3-6| ... | ... | @@ -10,17 +10,14 @@ import { spawn } from "node:child_process"; |
| 10 | 10 | import { accessSync, constants, statSync } from "node:fs"; |
| 11 | 11 | import { extname, join } from "node:path"; |
| 12 | 12 | |
| 13 | import { EDITORS, type EditorId } from "@t3tools/contracts"; | |
| 14 | import { ServiceMap, Schema, Effect, Layer } from "effect"; | |
| 13 | import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts"; | |
| 14 | import { ServiceMap, Effect, Layer } from "effect"; | |
| 15 | 15 | |
| 16 | 16 | // ============================== |
| 17 | 17 | // Definitions |
| 18 | 18 | // ============================== |
| 19 | 19 | |
| 20 | export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", { | |
| 21 | message: Schema.String, | |
| 22 | cause: Schema.optional(Schema.Defect), | |
| 23 | }) {} | |
| 20 | export { OpenError }; | |
| 24 | 21 | |
| 25 | 22 | export interface OpenInEditorInput { |
| 26 | 23 | readonly cwd: string; |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+7-16| ... | ... | @@ -39,6 +39,7 @@ import { |
| 39 | 39 | import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; |
| 40 | 40 | import { ServerConfig } from "../../config.ts"; |
| 41 | 41 | import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts"; |
| 42 | import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; | |
| 42 | 43 | |
| 43 | 44 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 44 | 45 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); |
| ... | ... | @@ -115,7 +116,7 @@ async function waitForThread( |
| 115 | 116 | checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; |
| 116 | 117 | activities: ReadonlyArray<{ kind: string }>; |
| 117 | 118 | }) => boolean, |
| 118 | timeoutMs = 5000, | |
| 119 | timeoutMs = 15_000, | |
| 119 | 120 | ) { |
| 120 | 121 | const deadline = Date.now() + timeoutMs; |
| 121 | 122 | const poll = async (): Promise<{ |
| ... | ... | @@ -140,7 +141,7 @@ async function waitForThread( |
| 140 | 141 | async function waitForEvent( |
| 141 | 142 | engine: OrchestrationEngineShape, |
| 142 | 143 | predicate: (event: { type: string }) => boolean, |
| 143 | timeoutMs = 5000, | |
| 144 | timeoutMs = 15_000, | |
| 144 | 145 | ) { |
| 145 | 146 | const deadline = Date.now() + timeoutMs; |
| 146 | 147 | const poll = async () => { |
| ... | ... | @@ -191,7 +192,7 @@ function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string { |
| 191 | 192 | return runGit(cwd, ["show", `${ref}:${filePath}`]); |
| 192 | 193 | } |
| 193 | 194 | |
| 194 | async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 5000) { | |
| 195 | async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 15_000) { | |
| 195 | 196 | const deadline = Date.now() + timeoutMs; |
| 196 | 197 | const poll = async (): Promise<void> => { |
| 197 | 198 | if (gitRefExists(cwd, ref)) { |
| ... | ... | @@ -263,7 +264,8 @@ describe("CheckpointReactor", () => { |
| 263 | 264 | Layer.provideMerge(RuntimeReceiptBusLive), |
| 264 | 265 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), |
| 265 | 266 | Layer.provideMerge(CheckpointStoreLive), |
| 266 | Layer.provideMerge(WorkspaceEntriesLive), | |
| 267 | Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), | |
| 268 | Layer.provideMerge(WorkspacePathsLive), | |
| 267 | 269 | Layer.provideMerge(GitCoreLive), |
| 268 | 270 | Layer.provideMerge(ServerConfigLayer), |
| 269 | 271 | Layer.provideMerge(NodeServices.layer), |
| ... | ... | @@ -1007,18 +1009,7 @@ describe("CheckpointReactor", () => { |
| 1007 | 1009 | }), |
| 1008 | 1010 | ); |
| 1009 | 1011 | |
| 1010 | const deadline = Date.now() + 2000; | |
| 1011 | const waitForRollbackCalls = async (): Promise<void> => { | |
| 1012 | if (harness.provider.rollbackConversation.mock.calls.length >= 2) { | |
| 1013 | return; | |
| 1014 | } | |
| 1015 | if (Date.now() >= deadline) { | |
| 1016 | throw new Error("Timed out waiting for rollbackConversation calls."); | |
| 1017 | } | |
| 1018 | await new Promise((resolve) => setTimeout(resolve, 10)); | |
| 1019 | return waitForRollbackCalls(); | |
| 1020 | }; | |
| 1021 | await waitForRollbackCalls(); | |
| 1012 | await harness.drain(); | |
| 1022 | 1013 | |
| 1023 | 1014 | expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2); |
| 1024 | 1015 | expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({ |
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+1-1| ... | ... | @@ -205,7 +205,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 205 | 205 | |
| 206 | 206 | const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); |
| 207 | 207 | yield* Effect.forkScoped(worker); |
| 208 | yield* Effect.log("orchestration engine started").pipe( | |
| 208 | yield* Effect.logDebug("orchestration engine started").pipe( | |
| 209 | 209 | Effect.annotateLogs({ sequence: readModel.snapshotSequence }), |
| 210 | 210 | ); |
| 211 | 211 |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+1-3| ... | ... | @@ -3,7 +3,6 @@ import { |
| 3 | 3 | type ChatAttachment, |
| 4 | 4 | type OrchestrationEvent, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 7 | 6 | import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; |
| 8 | 7 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 9 | 8 | |
| ... | ... | @@ -1271,7 +1270,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1271 | 1270 | Effect.provideService(ServerConfig, serverConfig), |
| 1272 | 1271 | Effect.asVoid, |
| 1273 | 1272 | Effect.tap(() => |
| 1274 | Effect.log("orchestration projection pipeline bootstrapped").pipe( | |
| 1273 | Effect.logDebug("orchestration projection pipeline bootstrapped").pipe( | |
| 1275 | 1274 | Effect.annotateLogs({ projectors: projectors.length }), |
| 1276 | 1275 | ), |
| 1277 | 1276 | ), |
| ... | ... | @@ -1291,7 +1290,6 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( |
| 1291 | 1290 | OrchestrationProjectionPipeline, |
| 1292 | 1291 | makeOrchestrationProjectionPipeline(), |
| 1293 | 1292 | ).pipe( |
| 1294 | Layer.provideMerge(NodeServices.layer), | |
| 1295 | 1293 | Layer.provideMerge(ProjectionProjectRepositoryLive), |
| 1296 | 1294 | Layer.provideMerge(ProjectionThreadRepositoryLive), |
| 1297 | 1295 | Layer.provideMerge(ProjectionThreadMessageRepositoryLive), |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+1-1| ... | ... | @@ -17,7 +17,7 @@ import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effe |
| 17 | 17 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 18 | 18 | |
| 19 | 19 | import { deriveServerPaths, ServerConfig } from "../../config.ts"; |
| 20 | import { TextGenerationError } from "../../git/Errors.ts"; | |
| 20 | import { TextGenerationError } from "@t3tools/contracts"; | |
| 21 | 21 | import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; |
| 22 | 22 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; |
| 23 | 23 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; |
apps/server/src/orchestration/Normalizer.ts created+121| ... | ... | @@ -0,0 +1,121 @@ |
| 1 | import { Effect, FileSystem, Path } from "effect"; | |
| 2 | import { | |
| 3 | type ClientOrchestrationCommand, | |
| 4 | type OrchestrationCommand, | |
| 5 | OrchestrationDispatchCommandError, | |
| 6 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | ||
| 9 | import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore"; | |
| 10 | import { ServerConfig } from "../config"; | |
| 11 | import { parseBase64DataUrl } from "../imageMime"; | |
| 12 | import { WorkspacePaths } from "../workspace/Services/WorkspacePaths"; | |
| 13 | ||
| 14 | export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => | |
| 15 | Effect.gen(function* () { | |
| 16 | const fileSystem = yield* FileSystem.FileSystem; | |
| 17 | const path = yield* Path.Path; | |
| 18 | const serverConfig = yield* ServerConfig; | |
| 19 | const workspacePaths = yield* WorkspacePaths; | |
| 20 | ||
| 21 | const normalizeProjectWorkspaceRoot = (workspaceRoot: string) => | |
| 22 | workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe( | |
| 23 | Effect.mapError( | |
| 24 | (cause) => | |
| 25 | new OrchestrationDispatchCommandError({ | |
| 26 | message: cause.message, | |
| 27 | }), | |
| 28 | ), | |
| 29 | ); | |
| 30 | ||
| 31 | if (command.type === "project.create") { | |
| 32 | return { | |
| 33 | ...command, | |
| 34 | workspaceRoot: yield* normalizeProjectWorkspaceRoot(command.workspaceRoot), | |
| 35 | } satisfies OrchestrationCommand; | |
| 36 | } | |
| 37 | ||
| 38 | if (command.type === "project.meta.update" && command.workspaceRoot !== undefined) { | |
| 39 | return { | |
| 40 | ...command, | |
| 41 | workspaceRoot: yield* normalizeProjectWorkspaceRoot(command.workspaceRoot), | |
| 42 | } satisfies OrchestrationCommand; | |
| 43 | } | |
| 44 | ||
| 45 | if (command.type !== "thread.turn.start") { | |
| 46 | return command as OrchestrationCommand; | |
| 47 | } | |
| 48 | ||
| 49 | const normalizedAttachments = yield* Effect.forEach( | |
| 50 | command.message.attachments, | |
| 51 | (attachment) => | |
| 52 | Effect.gen(function* () { | |
| 53 | const parsed = parseBase64DataUrl(attachment.dataUrl); | |
| 54 | if (!parsed || !parsed.mimeType.startsWith("image/")) { | |
| 55 | return yield* new OrchestrationDispatchCommandError({ | |
| 56 | message: `Invalid image attachment payload for '${attachment.name}'.`, | |
| 57 | }); | |
| 58 | } | |
| 59 | ||
| 60 | const bytes = Buffer.from(parsed.base64, "base64"); | |
| 61 | if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { | |
| 62 | return yield* new OrchestrationDispatchCommandError({ | |
| 63 | message: `Image attachment '${attachment.name}' is empty or too large.`, | |
| 64 | }); | |
| 65 | } | |
| 66 | ||
| 67 | const attachmentId = createAttachmentId(command.threadId); | |
| 68 | if (!attachmentId) { | |
| 69 | return yield* new OrchestrationDispatchCommandError({ | |
| 70 | message: "Failed to create a safe attachment id.", | |
| 71 | }); | |
| 72 | } | |
| 73 | ||
| 74 | const persistedAttachment = { | |
| 75 | type: "image" as const, | |
| 76 | id: attachmentId, | |
| 77 | name: attachment.name, | |
| 78 | mimeType: parsed.mimeType.toLowerCase(), | |
| 79 | sizeBytes: bytes.byteLength, | |
| 80 | }; | |
| 81 | ||
| 82 | const attachmentPath = resolveAttachmentPath({ | |
| 83 | attachmentsDir: serverConfig.attachmentsDir, | |
| 84 | attachment: persistedAttachment, | |
| 85 | }); | |
| 86 | if (!attachmentPath) { | |
| 87 | return yield* new OrchestrationDispatchCommandError({ | |
| 88 | message: `Failed to resolve persisted path for '${attachment.name}'.`, | |
| 89 | }); | |
| 90 | } | |
| 91 | ||
| 92 | yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe( | |
| 93 | Effect.mapError( | |
| 94 | () => | |
| 95 | new OrchestrationDispatchCommandError({ | |
| 96 | message: `Failed to create attachment directory for '${attachment.name}'.`, | |
| 97 | }), | |
| 98 | ), | |
| 99 | ); | |
| 100 | yield* fileSystem.writeFile(attachmentPath, bytes).pipe( | |
| 101 | Effect.mapError( | |
| 102 | () => | |
| 103 | new OrchestrationDispatchCommandError({ | |
| 104 | message: `Failed to persist attachment '${attachment.name}'.`, | |
| 105 | }), | |
| 106 | ), | |
| 107 | ); | |
| 108 | ||
| 109 | return persistedAttachment; | |
| 110 | }), | |
| 111 | { concurrency: 1 }, | |
| 112 | ); | |
| 113 | ||
| 114 | return { | |
| 115 | ...command, | |
| 116 | message: { | |
| 117 | ...command.message, | |
| 118 | attachments: normalizedAttachments, | |
| 119 | }, | |
| 120 | } satisfies OrchestrationCommand; | |
| 121 | }); |
apps/server/src/projectFaviconRoute.test.ts deleted-189| ... | ... | @@ -1,189 +0,0 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import http from "node:http"; | |
| 3 | import os from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | ||
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 7 | import { Effect } from "effect"; | |
| 8 | import { afterEach, describe, expect, it } from "vitest"; | |
| 9 | ||
| 10 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; | |
| 11 | import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; | |
| 12 | ||
| 13 | interface HttpResponse { | |
| 14 | statusCode: number; | |
| 15 | contentType: string | null; | |
| 16 | body: string; | |
| 17 | } | |
| 18 | ||
| 19 | const tempDirs: string[] = []; | |
| 20 | ||
| 21 | function makeTempDir(prefix: string): string { | |
| 22 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 23 | tempDirs.push(dir); | |
| 24 | return dir; | |
| 25 | } | |
| 26 | ||
| 27 | async function withRouteServer(run: (baseUrl: string) => Promise<void>): Promise<void> { | |
| 28 | const server = http.createServer((req, res) => { | |
| 29 | const url = new URL(req.url ?? "/", "http://127.0.0.1"); | |
| 30 | void Effect.runPromise( | |
| 31 | tryHandleProjectFaviconRequest(url, res).pipe( | |
| 32 | Effect.provide(ProjectFaviconResolverLive), | |
| 33 | Effect.provide(NodeServices.layer), | |
| 34 | Effect.flatMap((handled) => | |
| 35 | handled | |
| 36 | ? Effect.void | |
| 37 | : Effect.sync(() => { | |
| 38 | res.writeHead(404, { "Content-Type": "text/plain" }); | |
| 39 | res.end("Not Found"); | |
| 40 | }), | |
| 41 | ), | |
| 42 | ), | |
| 43 | ).catch(() => { | |
| 44 | if (!res.headersSent) { | |
| 45 | res.writeHead(500, { "Content-Type": "text/plain" }); | |
| 46 | res.end("Internal Server Error"); | |
| 47 | } | |
| 48 | }); | |
| 49 | }); | |
| 50 | ||
| 51 | await new Promise<void>((resolve, reject) => { | |
| 52 | server.listen(0, "127.0.0.1", (error?: Error) => { | |
| 53 | if (error) { | |
| 54 | reject(error); | |
| 55 | return; | |
| 56 | } | |
| 57 | resolve(); | |
| 58 | }); | |
| 59 | }); | |
| 60 | ||
| 61 | const address = server.address(); | |
| 62 | if (typeof address !== "object" || address === null) { | |
| 63 | throw new Error("Expected server address to be an object"); | |
| 64 | } | |
| 65 | const baseUrl = `http://127.0.0.1:${address.port}`; | |
| 66 | ||
| 67 | try { | |
| 68 | await run(baseUrl); | |
| 69 | } finally { | |
| 70 | await new Promise<void>((resolve, reject) => { | |
| 71 | server.close((error?: Error) => { | |
| 72 | if (error) { | |
| 73 | reject(error); | |
| 74 | return; | |
| 75 | } | |
| 76 | resolve(); | |
| 77 | }); | |
| 78 | }); | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | async function request(baseUrl: string, pathname: string): Promise<HttpResponse> { | |
| 83 | const response = await fetch(`${baseUrl}${pathname}`); | |
| 84 | return { | |
| 85 | statusCode: response.status, | |
| 86 | contentType: response.headers.get("content-type"), | |
| 87 | body: await response.text(), | |
| 88 | }; | |
| 89 | } | |
| 90 | ||
| 91 | describe("tryHandleProjectFaviconRequest", () => { | |
| 92 | afterEach(() => { | |
| 93 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 94 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 95 | } | |
| 96 | }); | |
| 97 | ||
| 98 | it("returns 400 when cwd is missing", async () => { | |
| 99 | await withRouteServer(async (baseUrl) => { | |
| 100 | const response = await request(baseUrl, "/api/project-favicon"); | |
| 101 | expect(response.statusCode).toBe(400); | |
| 102 | expect(response.body).toBe("Missing cwd parameter"); | |
| 103 | }); | |
| 104 | }); | |
| 105 | ||
| 106 | it("serves a well-known favicon file from the project root", async () => { | |
| 107 | const projectDir = makeTempDir("t3code-favicon-route-root-"); | |
| 108 | fs.writeFileSync(path.join(projectDir, "favicon.svg"), "<svg>favicon</svg>", "utf8"); | |
| 109 | ||
| 110 | await withRouteServer(async (baseUrl) => { | |
| 111 | const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; | |
| 112 | const response = await request(baseUrl, pathname); | |
| 113 | expect(response.statusCode).toBe(200); | |
| 114 | expect(response.contentType).toContain("image/svg+xml"); | |
| 115 | expect(response.body).toBe("<svg>favicon</svg>"); | |
| 116 | }); | |
| 117 | }); | |
| 118 | ||
| 119 | it("resolves icon href from source files when no well-known favicon exists", async () => { | |
| 120 | const projectDir = makeTempDir("t3code-favicon-route-source-"); | |
| 121 | const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); | |
| 122 | fs.mkdirSync(path.dirname(iconPath), { recursive: true }); | |
| 123 | fs.writeFileSync( | |
| 124 | path.join(projectDir, "index.html"), | |
| 125 | '<link rel="icon" href="/brand/logo.svg">', | |
| 126 | ); | |
| 127 | fs.writeFileSync(iconPath, "<svg>brand</svg>", "utf8"); | |
| 128 | ||
| 129 | await withRouteServer(async (baseUrl) => { | |
| 130 | const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; | |
| 131 | const response = await request(baseUrl, pathname); | |
| 132 | expect(response.statusCode).toBe(200); | |
| 133 | expect(response.contentType).toContain("image/svg+xml"); | |
| 134 | expect(response.body).toBe("<svg>brand</svg>"); | |
| 135 | }); | |
| 136 | }); | |
| 137 | ||
| 138 | it("resolves icon link when href appears before rel in HTML", async () => { | |
| 139 | const projectDir = makeTempDir("t3code-favicon-route-html-order-"); | |
| 140 | const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); | |
| 141 | fs.mkdirSync(path.dirname(iconPath), { recursive: true }); | |
| 142 | fs.writeFileSync( | |
| 143 | path.join(projectDir, "index.html"), | |
| 144 | '<link href="/brand/logo.svg" rel="icon">', | |
| 145 | ); | |
| 146 | fs.writeFileSync(iconPath, "<svg>brand-html-order</svg>", "utf8"); | |
| 147 | ||
| 148 | await withRouteServer(async (baseUrl) => { | |
| 149 | const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; | |
| 150 | const response = await request(baseUrl, pathname); | |
| 151 | expect(response.statusCode).toBe(200); | |
| 152 | expect(response.contentType).toContain("image/svg+xml"); | |
| 153 | expect(response.body).toBe("<svg>brand-html-order</svg>"); | |
| 154 | }); | |
| 155 | }); | |
| 156 | ||
| 157 | it("resolves object-style icon metadata when href appears before rel", async () => { | |
| 158 | const projectDir = makeTempDir("t3code-favicon-route-obj-order-"); | |
| 159 | const iconPath = path.join(projectDir, "public", "brand", "obj.svg"); | |
| 160 | fs.mkdirSync(path.dirname(iconPath), { recursive: true }); | |
| 161 | fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); | |
| 162 | fs.writeFileSync( | |
| 163 | path.join(projectDir, "src", "root.tsx"), | |
| 164 | 'const links = [{ href: "/brand/obj.svg", rel: "icon" }];', | |
| 165 | "utf8", | |
| 166 | ); | |
| 167 | fs.writeFileSync(iconPath, "<svg>brand-obj-order</svg>", "utf8"); | |
| 168 | ||
| 169 | await withRouteServer(async (baseUrl) => { | |
| 170 | const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; | |
| 171 | const response = await request(baseUrl, pathname); | |
| 172 | expect(response.statusCode).toBe(200); | |
| 173 | expect(response.contentType).toContain("image/svg+xml"); | |
| 174 | expect(response.body).toBe("<svg>brand-obj-order</svg>"); | |
| 175 | }); | |
| 176 | }); | |
| 177 | ||
| 178 | it("serves a fallback favicon when no icon exists", async () => { | |
| 179 | const projectDir = makeTempDir("t3code-favicon-route-fallback-"); | |
| 180 | ||
| 181 | await withRouteServer(async (baseUrl) => { | |
| 182 | const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; | |
| 183 | const response = await request(baseUrl, pathname); | |
| 184 | expect(response.statusCode).toBe(200); | |
| 185 | expect(response.contentType).toContain("image/svg+xml"); | |
| 186 | expect(response.body).toContain('data-fallback="project-favicon"'); | |
| 187 | }); | |
| 188 | }); | |
| 189 | }); |
apps/server/src/projectFaviconRoute.ts deleted-76| ... | ... | @@ -1,76 +0,0 @@ |
| 1 | import http from "node:http"; | |
| 2 | import path from "node:path"; | |
| 3 | import { Effect, FileSystem } from "effect"; | |
| 4 | ||
| 5 | import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; | |
| 6 | ||
| 7 | const FAVICON_MIME_TYPES: Record<string, string> = { | |
| 8 | ".png": "image/png", | |
| 9 | ".jpg": "image/jpeg", | |
| 10 | ".svg": "image/svg+xml", | |
| 11 | ".ico": "image/x-icon", | |
| 12 | }; | |
| 13 | ||
| 14 | const FALLBACK_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`; | |
| 15 | ||
| 16 | export const tryHandleProjectFaviconRequest = Effect.fn("tryHandleProjectFaviconRequest")( | |
| 17 | function* ( | |
| 18 | url: URL, | |
| 19 | res: http.ServerResponse, | |
| 20 | ): Effect.fn.Return<boolean, never, ProjectFaviconResolver | FileSystem.FileSystem> { | |
| 21 | const respond = ( | |
| 22 | statusCode: number, | |
| 23 | headers: Record<string, string>, | |
| 24 | body: string | Uint8Array, | |
| 25 | ) => { | |
| 26 | res.writeHead(statusCode, headers); | |
| 27 | res.end(body); | |
| 28 | }; | |
| 29 | ||
| 30 | if (url.pathname !== "/api/project-favicon") { | |
| 31 | return false; | |
| 32 | } | |
| 33 | ||
| 34 | const projectCwd = url.searchParams.get("cwd"); | |
| 35 | if (!projectCwd) { | |
| 36 | respond(400, { "Content-Type": "text/plain" }, "Missing cwd parameter"); | |
| 37 | return true; | |
| 38 | } | |
| 39 | ||
| 40 | const fileSystem = yield* FileSystem.FileSystem; | |
| 41 | const faviconResolver = yield* ProjectFaviconResolver; | |
| 42 | const resolvedPath = yield* faviconResolver.resolvePath(projectCwd); | |
| 43 | ||
| 44 | if (!resolvedPath) { | |
| 45 | respond( | |
| 46 | 200, | |
| 47 | { | |
| 48 | "Content-Type": "image/svg+xml", | |
| 49 | "Cache-Control": "public, max-age=3600", | |
| 50 | }, | |
| 51 | FALLBACK_FAVICON_SVG, | |
| 52 | ); | |
| 53 | return true; | |
| 54 | } | |
| 55 | ||
| 56 | const data = yield* fileSystem | |
| 57 | .readFile(resolvedPath) | |
| 58 | .pipe(Effect.catch(() => Effect.succeed(null))); | |
| 59 | if (!data) { | |
| 60 | respond(500, { "Content-Type": "text/plain" }, "Read error"); | |
| 61 | return true; | |
| 62 | } | |
| 63 | ||
| 64 | const ext = path.extname(resolvedPath).toLowerCase(); | |
| 65 | const contentType = FAVICON_MIME_TYPES[ext] ?? "application/octet-stream"; | |
| 66 | respond( | |
| 67 | 200, | |
| 68 | { | |
| 69 | "Content-Type": contentType, | |
| 70 | "Cache-Control": "public, max-age=3600", | |
| 71 | }, | |
| 72 | data, | |
| 73 | ); | |
| 74 | return true; | |
| 75 | }, | |
| 76 | ); |
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+3| ... | ... | @@ -208,6 +208,9 @@ async function readFirstPromptText( |
| 208 | 208 | if (next.done) { |
| 209 | 209 | return undefined; |
| 210 | 210 | } |
| 211 | if (typeof next.value.message.content === "string") { | |
| 212 | return next.value.message.content; | |
| 213 | } | |
| 211 | 214 | const content = next.value.message.content[0]; |
| 212 | 215 | if (!content || content.type !== "text") { |
| 213 | 216 | return undefined; |
apps/server/src/provider/Layers/ClaudeAdapter.ts+1-1| ... | ... | @@ -533,7 +533,7 @@ function buildUserMessage(input: { |
| 533 | 533 | parent_tool_use_id: null, |
| 534 | 534 | message: { |
| 535 | 535 | role: "user", |
| 536 | content: input.sdkContent, | |
| 536 | content: input.sdkContent as unknown as SDKUserMessage["message"]["content"], | |
| 537 | 537 | }, |
| 538 | 538 | } as SDKUserMessage; |
| 539 | 539 | } |
apps/server/src/provider/Layers/ClaudeProvider.ts+2-1| ... | ... | @@ -24,7 +24,8 @@ import { |
| 24 | 24 | } from "../providerSnapshot"; |
| 25 | 25 | import { makeManagedServerProvider } from "../makeManagedServerProvider"; |
| 26 | 26 | import { ClaudeProvider } from "../Services/ClaudeProvider"; |
| 27 | import { ServerSettingsError, ServerSettingsService } from "../../serverSettings"; | |
| 27 | import { ServerSettingsService } from "../../serverSettings"; | |
| 28 | import { ServerSettingsError } from "@t3tools/contracts"; | |
| 28 | 29 | |
| 29 | 30 | const PROVIDER = "claudeAgent" as const; |
| 30 | 31 | const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [ |
apps/server/src/provider/Layers/CodexProvider.ts+2-1| ... | ... | @@ -46,7 +46,8 @@ import { |
| 46 | 46 | } from "../codexAccount"; |
| 47 | 47 | import { probeCodexAccount } from "../codexAppServer"; |
| 48 | 48 | import { CodexProvider } from "../Services/CodexProvider"; |
| 49 | import { ServerSettingsError, ServerSettingsService } from "../../serverSettings"; | |
| 49 | import { ServerSettingsService } from "../../serverSettings"; | |
| 50 | import { ServerSettingsError } from "@t3tools/contracts"; | |
| 50 | 51 | |
| 51 | 52 | const PROVIDER = "codex" as const; |
| 52 | 53 | const OPENAI_AUTH_PROVIDERS = new Set(["openai"]); |
apps/server/src/provider/makeManagedServerProvider.ts+1-1| ... | ... | @@ -3,7 +3,7 @@ import { Duration, Effect, PubSub, Ref, Scope, Stream } from "effect"; |
| 3 | 3 | import * as Semaphore from "effect/Semaphore"; |
| 4 | 4 | |
| 5 | 5 | import type { ServerProviderShape } from "./Services/ServerProvider"; |
| 6 | import { ServerSettingsError } from "../serverSettings"; | |
| 6 | import { ServerSettingsError } from "@t3tools/contracts"; | |
| 7 | 7 | |
| 8 | 8 | export function makeManagedServerProvider<Settings>(input: { |
| 9 | 9 | readonly getSettings: Effect.Effect<Settings>; |
apps/server/src/server.test.ts created+1391| ... | ... | @@ -0,0 +1,1391 @@ |
| 1 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | |
| 2 | import * as NodeSocket from "@effect/platform-node/NodeSocket"; | |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | import { | |
| 5 | CommandId, | |
| 6 | DEFAULT_SERVER_SETTINGS, | |
| 7 | GitCommandError, | |
| 8 | KeybindingRule, | |
| 9 | OpenError, | |
| 10 | TerminalNotRunningError, | |
| 11 | type OrchestrationEvent, | |
| 12 | ORCHESTRATION_WS_METHODS, | |
| 13 | ProjectId, | |
| 14 | ResolvedKeybindingRule, | |
| 15 | ThreadId, | |
| 16 | WS_METHODS, | |
| 17 | WsRpcGroup, | |
| 18 | EditorId, | |
| 19 | } from "@t3tools/contracts"; | |
| 20 | import { assert, it } from "@effect/vitest"; | |
| 21 | import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; | |
| 22 | import { Effect, FileSystem, Layer, Path, Stream } from "effect"; | |
| 23 | import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; | |
| 24 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; | |
| 25 | ||
| 26 | import type { ServerConfigShape } from "./config.ts"; | |
| 27 | import { deriveServerPaths, ServerConfig } from "./config.ts"; | |
| 28 | import { makeRoutesLayer } from "./server.ts"; | |
| 29 | import { resolveAttachmentRelativePath } from "./attachmentPaths.ts"; | |
| 30 | import { | |
| 31 | CheckpointDiffQuery, | |
| 32 | type CheckpointDiffQueryShape, | |
| 33 | } from "./checkpointing/Services/CheckpointDiffQuery.ts"; | |
| 34 | import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts"; | |
| 35 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; | |
| 36 | import { Keybindings, type KeybindingsShape } from "./keybindings.ts"; | |
| 37 | import { Open, type OpenShape } from "./open.ts"; | |
| 38 | import { | |
| 39 | OrchestrationEngineService, | |
| 40 | type OrchestrationEngineShape, | |
| 41 | } from "./orchestration/Services/OrchestrationEngine.ts"; | |
| 42 | import { | |
| 43 | ProjectionSnapshotQuery, | |
| 44 | type ProjectionSnapshotQueryShape, | |
| 45 | } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 46 | import { PersistenceSqlError } from "./persistence/Errors.ts"; | |
| 47 | import { | |
| 48 | ProviderRegistry, | |
| 49 | type ProviderRegistryShape, | |
| 50 | } from "./provider/Services/ProviderRegistry.ts"; | |
| 51 | import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts"; | |
| 52 | import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; | |
| 53 | import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; | |
| 54 | import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; | |
| 55 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; | |
| 56 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; | |
| 57 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; | |
| 58 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; | |
| 59 | ||
| 60 | const defaultProjectId = ProjectId.makeUnsafe("project-default"); | |
| 61 | const defaultThreadId = ThreadId.makeUnsafe("thread-default"); | |
| 62 | const defaultModelSelection = { | |
| 63 | provider: "codex", | |
| 64 | model: "gpt-5-codex", | |
| 65 | } as const; | |
| 66 | ||
| 67 | const makeDefaultOrchestrationReadModel = () => { | |
| 68 | const now = new Date().toISOString(); | |
| 69 | return { | |
| 70 | snapshotSequence: 0, | |
| 71 | updatedAt: now, | |
| 72 | projects: [ | |
| 73 | { | |
| 74 | id: defaultProjectId, | |
| 75 | title: "Default Project", | |
| 76 | workspaceRoot: "/tmp/default-project", | |
| 77 | defaultModelSelection, | |
| 78 | scripts: [], | |
| 79 | createdAt: now, | |
| 80 | updatedAt: now, | |
| 81 | deletedAt: null, | |
| 82 | }, | |
| 83 | ], | |
| 84 | threads: [ | |
| 85 | { | |
| 86 | id: defaultThreadId, | |
| 87 | projectId: defaultProjectId, | |
| 88 | title: "Default Thread", | |
| 89 | modelSelection: defaultModelSelection, | |
| 90 | interactionMode: "default" as const, | |
| 91 | runtimeMode: "full-access" as const, | |
| 92 | branch: null, | |
| 93 | worktreePath: null, | |
| 94 | createdAt: now, | |
| 95 | updatedAt: now, | |
| 96 | archivedAt: null, | |
| 97 | latestTurn: null, | |
| 98 | messages: [], | |
| 99 | session: null, | |
| 100 | activities: [], | |
| 101 | proposedPlans: [], | |
| 102 | checkpoints: [], | |
| 103 | deletedAt: null, | |
| 104 | }, | |
| 105 | ], | |
| 106 | }; | |
| 107 | }; | |
| 108 | ||
| 109 | const workspaceAndProjectServicesLayer = Layer.mergeAll( | |
| 110 | WorkspacePathsLive, | |
| 111 | WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive)), | |
| 112 | WorkspaceFileSystemLive.pipe( | |
| 113 | Layer.provide(WorkspacePathsLive), | |
| 114 | Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), | |
| 115 | ), | |
| 116 | ProjectFaviconResolverLive, | |
| 117 | ); | |
| 118 | ||
| 119 | const buildAppUnderTest = (options?: { | |
| 120 | config?: Partial<ServerConfigShape>; | |
| 121 | layers?: { | |
| 122 | keybindings?: Partial<KeybindingsShape>; | |
| 123 | providerRegistry?: Partial<ProviderRegistryShape>; | |
| 124 | serverSettings?: Partial<ServerSettingsShape>; | |
| 125 | open?: Partial<OpenShape>; | |
| 126 | gitCore?: Partial<GitCoreShape>; | |
| 127 | gitManager?: Partial<GitManagerShape>; | |
| 128 | terminalManager?: Partial<TerminalManagerShape>; | |
| 129 | orchestrationEngine?: Partial<OrchestrationEngineShape>; | |
| 130 | projectionSnapshotQuery?: Partial<ProjectionSnapshotQueryShape>; | |
| 131 | checkpointDiffQuery?: Partial<CheckpointDiffQueryShape>; | |
| 132 | serverLifecycleEvents?: Partial<ServerLifecycleEventsShape>; | |
| 133 | serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>; | |
| 134 | }; | |
| 135 | }) => | |
| 136 | Effect.gen(function* () { | |
| 137 | const fileSystem = yield* FileSystem.FileSystem; | |
| 138 | const tempBaseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-test-" }); | |
| 139 | const baseDir = options?.config?.baseDir ?? tempBaseDir; | |
| 140 | const devUrl = options?.config?.devUrl; | |
| 141 | const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); | |
| 142 | const config = { | |
| 143 | logLevel: "Info", | |
| 144 | mode: "web", | |
| 145 | port: 0, | |
| 146 | host: "127.0.0.1", | |
| 147 | cwd: process.cwd(), | |
| 148 | baseDir, | |
| 149 | ...derivedPaths, | |
| 150 | staticDir: undefined, | |
| 151 | devUrl, | |
| 152 | noBrowser: true, | |
| 153 | authToken: undefined, | |
| 154 | autoBootstrapProjectFromCwd: false, | |
| 155 | logWebSocketEvents: false, | |
| 156 | ...options?.config, | |
| 157 | } satisfies ServerConfigShape; | |
| 158 | const layerConfig = Layer.succeed(ServerConfig, config); | |
| 159 | ||
| 160 | const appLayer = HttpRouter.serve(makeRoutesLayer, { | |
| 161 | disableListenLog: true, | |
| 162 | disableLogger: true, | |
| 163 | }).pipe( | |
| 164 | Layer.provide( | |
| 165 | Layer.mock(Keybindings)({ | |
| 166 | streamChanges: Stream.empty, | |
| 167 | ...options?.layers?.keybindings, | |
| 168 | }), | |
| 169 | ), | |
| 170 | Layer.provide( | |
| 171 | Layer.mock(ProviderRegistry)({ | |
| 172 | getProviders: Effect.succeed([]), | |
| 173 | refresh: () => Effect.succeed([]), | |
| 174 | streamChanges: Stream.empty, | |
| 175 | ...options?.layers?.providerRegistry, | |
| 176 | }), | |
| 177 | ), | |
| 178 | Layer.provide( | |
| 179 | Layer.mock(ServerSettingsService)({ | |
| 180 | start: Effect.void, | |
| 181 | ready: Effect.void, | |
| 182 | getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), | |
| 183 | updateSettings: () => Effect.succeed(DEFAULT_SERVER_SETTINGS), | |
| 184 | streamChanges: Stream.empty, | |
| 185 | ...options?.layers?.serverSettings, | |
| 186 | }), | |
| 187 | ), | |
| 188 | Layer.provide( | |
| 189 | Layer.mock(Open)({ | |
| 190 | ...options?.layers?.open, | |
| 191 | }), | |
| 192 | ), | |
| 193 | Layer.provide( | |
| 194 | Layer.mock(GitCore)({ | |
| 195 | ...options?.layers?.gitCore, | |
| 196 | }), | |
| 197 | ), | |
| 198 | Layer.provide( | |
| 199 | Layer.mock(GitManager)({ | |
| 200 | ...options?.layers?.gitManager, | |
| 201 | }), | |
| 202 | ), | |
| 203 | Layer.provide( | |
| 204 | Layer.mock(TerminalManager)({ | |
| 205 | ...options?.layers?.terminalManager, | |
| 206 | }), | |
| 207 | ), | |
| 208 | Layer.provide( | |
| 209 | Layer.mock(OrchestrationEngineService)({ | |
| 210 | getReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()), | |
| 211 | readEvents: () => Stream.empty, | |
| 212 | dispatch: () => Effect.succeed({ sequence: 0 }), | |
| 213 | streamDomainEvents: Stream.empty, | |
| 214 | ...options?.layers?.orchestrationEngine, | |
| 215 | }), | |
| 216 | ), | |
| 217 | Layer.provide( | |
| 218 | Layer.mock(ProjectionSnapshotQuery)({ | |
| 219 | getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()), | |
| 220 | ...options?.layers?.projectionSnapshotQuery, | |
| 221 | }), | |
| 222 | ), | |
| 223 | Layer.provide( | |
| 224 | Layer.mock(CheckpointDiffQuery)({ | |
| 225 | getTurnDiff: () => | |
| 226 | Effect.succeed({ | |
| 227 | threadId: defaultThreadId, | |
| 228 | fromTurnCount: 0, | |
| 229 | toTurnCount: 0, | |
| 230 | diff: "", | |
| 231 | }), | |
| 232 | getFullThreadDiff: () => | |
| 233 | Effect.succeed({ | |
| 234 | threadId: defaultThreadId, | |
| 235 | fromTurnCount: 0, | |
| 236 | toTurnCount: 0, | |
| 237 | diff: "", | |
| 238 | }), | |
| 239 | ...options?.layers?.checkpointDiffQuery, | |
| 240 | }), | |
| 241 | ), | |
| 242 | Layer.provide( | |
| 243 | Layer.mock(ServerLifecycleEvents)({ | |
| 244 | publish: (event) => Effect.succeed({ ...(event as any), sequence: 1 }), | |
| 245 | snapshot: Effect.succeed({ sequence: 0, events: [] }), | |
| 246 | stream: Stream.empty, | |
| 247 | ...options?.layers?.serverLifecycleEvents, | |
| 248 | }), | |
| 249 | ), | |
| 250 | Layer.provide( | |
| 251 | Layer.mock(ServerRuntimeStartup)({ | |
| 252 | awaitCommandReady: Effect.void, | |
| 253 | markHttpListening: Effect.void, | |
| 254 | enqueueCommand: (effect) => effect, | |
| 255 | ...options?.layers?.serverRuntimeStartup, | |
| 256 | }), | |
| 257 | ), | |
| 258 | Layer.provide(workspaceAndProjectServicesLayer), | |
| 259 | Layer.provide(layerConfig), | |
| 260 | ); | |
| 261 | ||
| 262 | yield* Layer.build(appLayer); | |
| 263 | return config; | |
| 264 | }); | |
| 265 | ||
| 266 | const wsRpcProtocolLayer = (wsUrl: string) => | |
| 267 | RpcClient.layerProtocolSocket().pipe( | |
| 268 | Layer.provide(NodeSocket.layerWebSocket(wsUrl)), | |
| 269 | Layer.provide(RpcSerialization.layerJson), | |
| 270 | ); | |
| 271 | ||
| 272 | const makeWsRpcClient = RpcClient.make(WsRpcGroup); | |
| 273 | type WsRpcClient = | |
| 274 | typeof makeWsRpcClient extends Effect.Effect<infer Client, any, any> ? Client : never; | |
| 275 | ||
| 276 | const withWsRpcClient = <A, E, R>( | |
| 277 | wsUrl: string, | |
| 278 | f: (client: WsRpcClient) => Effect.Effect<A, E, R>, | |
| 279 | ) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl))); | |
| 280 | ||
| 281 | const getHttpServerUrl = (pathname = "") => | |
| 282 | Effect.gen(function* () { | |
| 283 | const server = yield* HttpServer.HttpServer; | |
| 284 | const address = server.address as HttpServer.TcpAddress; | |
| 285 | return `http://127.0.0.1:${address.port}${pathname}`; | |
| 286 | }); | |
| 287 | ||
| 288 | const getWsServerUrl = (pathname = "") => | |
| 289 | Effect.gen(function* () { | |
| 290 | const server = yield* HttpServer.HttpServer; | |
| 291 | const address = server.address as HttpServer.TcpAddress; | |
| 292 | return `ws://127.0.0.1:${address.port}${pathname}`; | |
| 293 | }); | |
| 294 | ||
| 295 | it.layer(NodeServices.layer)("server router seam", (it) => { | |
| 296 | it.effect("serves static index content for GET / when staticDir is configured", () => | |
| 297 | Effect.gen(function* () { | |
| 298 | const fileSystem = yield* FileSystem.FileSystem; | |
| 299 | const path = yield* Path.Path; | |
| 300 | const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-static-" }); | |
| 301 | const indexPath = path.join(staticDir, "index.html"); | |
| 302 | yield* fileSystem.writeFileString(indexPath, "<html>router-static-ok</html>"); | |
| 303 | ||
| 304 | yield* buildAppUnderTest({ config: { staticDir } }); | |
| 305 | ||
| 306 | const response = yield* HttpClient.get("/"); | |
| 307 | assert.equal(response.status, 200); | |
| 308 | assert.include(yield* response.text, "router-static-ok"); | |
| 309 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 310 | ); | |
| 311 | ||
| 312 | it.effect("redirects to dev URL when configured", () => | |
| 313 | Effect.gen(function* () { | |
| 314 | yield* buildAppUnderTest({ | |
| 315 | config: { devUrl: new URL("http://127.0.0.1:5173") }, | |
| 316 | }); | |
| 317 | ||
| 318 | const url = yield* getHttpServerUrl("/foo/bar"); | |
| 319 | const response = yield* Effect.promise(() => fetch(url, { redirect: "manual" })); | |
| 320 | ||
| 321 | assert.equal(response.status, 302); | |
| 322 | assert.equal(response.headers.get("location"), "http://127.0.0.1:5173/"); | |
| 323 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 324 | ); | |
| 325 | ||
| 326 | it.effect("serves project favicon requests before the dev URL redirect", () => | |
| 327 | Effect.gen(function* () { | |
| 328 | const fileSystem = yield* FileSystem.FileSystem; | |
| 329 | const path = yield* Path.Path; | |
| 330 | const projectDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 331 | prefix: "t3-router-project-favicon-", | |
| 332 | }); | |
| 333 | yield* fileSystem.writeFileString( | |
| 334 | path.join(projectDir, "favicon.svg"), | |
| 335 | "<svg>router-project-favicon</svg>", | |
| 336 | ); | |
| 337 | ||
| 338 | yield* buildAppUnderTest({ | |
| 339 | config: { devUrl: new URL("http://127.0.0.1:5173") }, | |
| 340 | }); | |
| 341 | ||
| 342 | const response = yield* HttpClient.get( | |
| 343 | `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, | |
| 344 | ); | |
| 345 | ||
| 346 | assert.equal(response.status, 200); | |
| 347 | assert.equal(yield* response.text, "<svg>router-project-favicon</svg>"); | |
| 348 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 349 | ); | |
| 350 | ||
| 351 | it.effect("serves the fallback project favicon when no icon exists", () => | |
| 352 | Effect.gen(function* () { | |
| 353 | const fileSystem = yield* FileSystem.FileSystem; | |
| 354 | const projectDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 355 | prefix: "t3-router-project-favicon-fallback-", | |
| 356 | }); | |
| 357 | ||
| 358 | yield* buildAppUnderTest({ | |
| 359 | config: { devUrl: new URL("http://127.0.0.1:5173") }, | |
| 360 | }); | |
| 361 | ||
| 362 | const response = yield* HttpClient.get( | |
| 363 | `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, | |
| 364 | ); | |
| 365 | ||
| 366 | assert.equal(response.status, 200); | |
| 367 | assert.include(yield* response.text, 'data-fallback="project-favicon"'); | |
| 368 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 369 | ); | |
| 370 | ||
| 371 | it.effect("serves attachment files from state dir", () => | |
| 372 | Effect.gen(function* () { | |
| 373 | const fileSystem = yield* FileSystem.FileSystem; | |
| 374 | const path = yield* Path.Path; | |
| 375 | const attachmentId = "thread-11111111-1111-4111-8111-111111111111"; | |
| 376 | ||
| 377 | const config = yield* buildAppUnderTest(); | |
| 378 | const attachmentPath = resolveAttachmentRelativePath({ | |
| 379 | attachmentsDir: config.attachmentsDir, | |
| 380 | relativePath: `${attachmentId}.bin`, | |
| 381 | }); | |
| 382 | assert.isNotNull(attachmentPath, "Attachment path should be resolvable"); | |
| 383 | ||
| 384 | yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); | |
| 385 | yield* fileSystem.writeFileString(attachmentPath, "attachment-ok"); | |
| 386 | ||
| 387 | const response = yield* HttpClient.get(`/attachments/${attachmentId}`); | |
| 388 | assert.equal(response.status, 200); | |
| 389 | assert.equal(yield* response.text, "attachment-ok"); | |
| 390 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 391 | ); | |
| 392 | ||
| 393 | it.effect("serves attachment files for URL-encoded paths", () => | |
| 394 | Effect.gen(function* () { | |
| 395 | const fileSystem = yield* FileSystem.FileSystem; | |
| 396 | const path = yield* Path.Path; | |
| 397 | ||
| 398 | const config = yield* buildAppUnderTest(); | |
| 399 | const attachmentPath = resolveAttachmentRelativePath({ | |
| 400 | attachmentsDir: config.attachmentsDir, | |
| 401 | relativePath: "thread%20folder/message%20folder/file%20name.png", | |
| 402 | }); | |
| 403 | assert.isNotNull(attachmentPath, "Attachment path should be resolvable"); | |
| 404 | ||
| 405 | yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); | |
| 406 | yield* fileSystem.writeFileString(attachmentPath, "attachment-encoded-ok"); | |
| 407 | ||
| 408 | const response = yield* HttpClient.get( | |
| 409 | "/attachments/thread%20folder/message%20folder/file%20name.png", | |
| 410 | ); | |
| 411 | assert.equal(response.status, 200); | |
| 412 | assert.equal(yield* response.text, "attachment-encoded-ok"); | |
| 413 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 414 | ); | |
| 415 | ||
| 416 | it.effect("returns 404 for missing attachment id lookups", () => | |
| 417 | Effect.gen(function* () { | |
| 418 | yield* buildAppUnderTest(); | |
| 419 | ||
| 420 | const response = yield* HttpClient.get( | |
| 421 | "/attachments/missing-11111111-1111-4111-8111-111111111111", | |
| 422 | ); | |
| 423 | assert.equal(response.status, 404); | |
| 424 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 425 | ); | |
| 426 | ||
| 427 | it.effect("routes websocket rpc server.upsertKeybinding", () => | |
| 428 | Effect.gen(function* () { | |
| 429 | const rule: KeybindingRule = { | |
| 430 | command: "terminal.toggle", | |
| 431 | key: "ctrl+k", | |
| 432 | }; | |
| 433 | const resolved: ResolvedKeybindingRule = { | |
| 434 | command: "terminal.toggle", | |
| 435 | shortcut: { | |
| 436 | key: "k", | |
| 437 | metaKey: false, | |
| 438 | ctrlKey: true, | |
| 439 | shiftKey: false, | |
| 440 | altKey: false, | |
| 441 | modKey: true, | |
| 442 | }, | |
| 443 | }; | |
| 444 | ||
| 445 | yield* buildAppUnderTest({ | |
| 446 | layers: { | |
| 447 | keybindings: { | |
| 448 | upsertKeybindingRule: () => Effect.succeed([resolved]), | |
| 449 | }, | |
| 450 | }, | |
| 451 | }); | |
| 452 | ||
| 453 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 454 | const response = yield* Effect.scoped( | |
| 455 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverUpsertKeybinding](rule)), | |
| 456 | ); | |
| 457 | ||
| 458 | assert.deepEqual(response.issues, []); | |
| 459 | assert.deepEqual(response.keybindings, [resolved]); | |
| 460 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 461 | ); | |
| 462 | ||
| 463 | it.effect("rejects websocket rpc handshake when auth token is missing", () => | |
| 464 | Effect.gen(function* () { | |
| 465 | const fs = yield* FileSystem.FileSystem; | |
| 466 | const path = yield* Path.Path; | |
| 467 | const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-auth-required-" }); | |
| 468 | yield* fs.writeFileString( | |
| 469 | path.join(workspaceDir, "needle-file.ts"), | |
| 470 | "export const needle = 1;", | |
| 471 | ); | |
| 472 | ||
| 473 | yield* buildAppUnderTest({ | |
| 474 | config: { | |
| 475 | authToken: "secret-token", | |
| 476 | }, | |
| 477 | }); | |
| 478 | ||
| 479 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 480 | const result = yield* Effect.scoped( | |
| 481 | withWsRpcClient(wsUrl, (client) => | |
| 482 | client[WS_METHODS.projectsSearchEntries]({ | |
| 483 | cwd: workspaceDir, | |
| 484 | query: "needle", | |
| 485 | limit: 10, | |
| 486 | }), | |
| 487 | ).pipe(Effect.result), | |
| 488 | ); | |
| 489 | ||
| 490 | assertTrue(result._tag === "Failure"); | |
| 491 | assertInclude(String(result.failure), "SocketOpenError"); | |
| 492 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 493 | ); | |
| 494 | ||
| 495 | it.effect("accepts websocket rpc handshake when auth token is provided", () => | |
| 496 | Effect.gen(function* () { | |
| 497 | const fs = yield* FileSystem.FileSystem; | |
| 498 | const path = yield* Path.Path; | |
| 499 | const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-auth-ok-" }); | |
| 500 | yield* fs.writeFileString( | |
| 501 | path.join(workspaceDir, "needle-file.ts"), | |
| 502 | "export const needle = 1;", | |
| 503 | ); | |
| 504 | ||
| 505 | yield* buildAppUnderTest({ | |
| 506 | config: { | |
| 507 | authToken: "secret-token", | |
| 508 | }, | |
| 509 | }); | |
| 510 | ||
| 511 | const wsUrl = yield* getWsServerUrl("/ws?token=secret-token"); | |
| 512 | const response = yield* Effect.scoped( | |
| 513 | withWsRpcClient(wsUrl, (client) => | |
| 514 | client[WS_METHODS.projectsSearchEntries]({ | |
| 515 | cwd: workspaceDir, | |
| 516 | query: "needle", | |
| 517 | limit: 10, | |
| 518 | }), | |
| 519 | ), | |
| 520 | ); | |
| 521 | ||
| 522 | assert.isAtLeast(response.entries.length, 1); | |
| 523 | assert.equal(response.truncated, false); | |
| 524 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 525 | ); | |
| 526 | ||
| 527 | it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => | |
| 528 | Effect.gen(function* () { | |
| 529 | const providers = [] as const; | |
| 530 | const changeEvent = { | |
| 531 | keybindings: [], | |
| 532 | issues: [], | |
| 533 | } as const; | |
| 534 | ||
| 535 | yield* buildAppUnderTest({ | |
| 536 | layers: { | |
| 537 | keybindings: { | |
| 538 | loadConfigState: Effect.succeed({ | |
| 539 | keybindings: [], | |
| 540 | issues: [], | |
| 541 | }), | |
| 542 | streamChanges: Stream.succeed(changeEvent), | |
| 543 | }, | |
| 544 | providerRegistry: { | |
| 545 | getProviders: Effect.succeed(providers), | |
| 546 | }, | |
| 547 | }, | |
| 548 | }); | |
| 549 | ||
| 550 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 551 | const events = yield* Effect.scoped( | |
| 552 | withWsRpcClient(wsUrl, (client) => | |
| 553 | client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), | |
| 554 | ), | |
| 555 | ); | |
| 556 | ||
| 557 | const [first, second] = Array.from(events); | |
| 558 | assert.equal(first?.type, "snapshot"); | |
| 559 | if (first?.type === "snapshot") { | |
| 560 | assert.equal(first.version, 1); | |
| 561 | assert.deepEqual(first.config.keybindings, []); | |
| 562 | assert.deepEqual(first.config.issues, []); | |
| 563 | assert.deepEqual(first.config.providers, providers); | |
| 564 | assert.deepEqual(first.config.settings, DEFAULT_SERVER_SETTINGS); | |
| 565 | } | |
| 566 | assert.deepEqual(second, { | |
| 567 | version: 1, | |
| 568 | type: "keybindingsUpdated", | |
| 569 | payload: { issues: [] }, | |
| 570 | }); | |
| 571 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 572 | ); | |
| 573 | ||
| 574 | it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => | |
| 575 | Effect.gen(function* () { | |
| 576 | const providers = [] as const; | |
| 577 | ||
| 578 | yield* buildAppUnderTest({ | |
| 579 | layers: { | |
| 580 | keybindings: { | |
| 581 | loadConfigState: Effect.succeed({ | |
| 582 | keybindings: [], | |
| 583 | issues: [], | |
| 584 | }), | |
| 585 | streamChanges: Stream.empty, | |
| 586 | }, | |
| 587 | providerRegistry: { | |
| 588 | getProviders: Effect.succeed([]), | |
| 589 | streamChanges: Stream.succeed(providers), | |
| 590 | }, | |
| 591 | }, | |
| 592 | }); | |
| 593 | ||
| 594 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 595 | const events = yield* Effect.scoped( | |
| 596 | withWsRpcClient(wsUrl, (client) => | |
| 597 | client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), | |
| 598 | ), | |
| 599 | ); | |
| 600 | ||
| 601 | const [first, second] = Array.from(events); | |
| 602 | assert.equal(first?.type, "snapshot"); | |
| 603 | assert.deepEqual(second, { | |
| 604 | version: 1, | |
| 605 | type: "providerStatuses", | |
| 606 | payload: { providers }, | |
| 607 | }); | |
| 608 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 609 | ); | |
| 610 | ||
| 611 | it.effect( | |
| 612 | "routes websocket rpc subscribeServerLifecycle replays snapshot and streams updates", | |
| 613 | () => | |
| 614 | Effect.gen(function* () { | |
| 615 | const lifecycleEvents = [ | |
| 616 | { | |
| 617 | version: 1 as const, | |
| 618 | sequence: 1, | |
| 619 | type: "welcome" as const, | |
| 620 | payload: { | |
| 621 | cwd: "/tmp/project", | |
| 622 | projectName: "project", | |
| 623 | }, | |
| 624 | }, | |
| 625 | ] as const; | |
| 626 | const liveEvents = Stream.make({ | |
| 627 | version: 1 as const, | |
| 628 | sequence: 2, | |
| 629 | type: "ready" as const, | |
| 630 | payload: { at: new Date().toISOString() }, | |
| 631 | }); | |
| 632 | ||
| 633 | yield* buildAppUnderTest({ | |
| 634 | layers: { | |
| 635 | serverLifecycleEvents: { | |
| 636 | snapshot: Effect.succeed({ | |
| 637 | sequence: 1, | |
| 638 | events: lifecycleEvents, | |
| 639 | }), | |
| 640 | stream: liveEvents, | |
| 641 | }, | |
| 642 | }, | |
| 643 | }); | |
| 644 | ||
| 645 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 646 | const events = yield* Effect.scoped( | |
| 647 | withWsRpcClient(wsUrl, (client) => | |
| 648 | client[WS_METHODS.subscribeServerLifecycle]({}).pipe(Stream.take(2), Stream.runCollect), | |
| 649 | ), | |
| 650 | ); | |
| 651 | ||
| 652 | const [first, second] = Array.from(events); | |
| 653 | assert.equal(first?.type, "welcome"); | |
| 654 | assert.equal(first?.sequence, 1); | |
| 655 | assert.equal(second?.type, "ready"); | |
| 656 | assert.equal(second?.sequence, 2); | |
| 657 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 658 | ); | |
| 659 | ||
| 660 | it.effect("routes websocket rpc projects.searchEntries", () => | |
| 661 | Effect.gen(function* () { | |
| 662 | const fs = yield* FileSystem.FileSystem; | |
| 663 | const path = yield* Path.Path; | |
| 664 | const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-search-" }); | |
| 665 | yield* fs.writeFileString( | |
| 666 | path.join(workspaceDir, "needle-file.ts"), | |
| 667 | "export const needle = 1;", | |
| 668 | ); | |
| 669 | ||
| 670 | yield* buildAppUnderTest(); | |
| 671 | ||
| 672 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 673 | const response = yield* Effect.scoped( | |
| 674 | withWsRpcClient(wsUrl, (client) => | |
| 675 | client[WS_METHODS.projectsSearchEntries]({ | |
| 676 | cwd: workspaceDir, | |
| 677 | query: "needle", | |
| 678 | limit: 10, | |
| 679 | }), | |
| 680 | ), | |
| 681 | ); | |
| 682 | ||
| 683 | assert.isAtLeast(response.entries.length, 1); | |
| 684 | assert.isTrue(response.entries.some((entry) => entry.path === "needle-file.ts")); | |
| 685 | assert.equal(response.truncated, false); | |
| 686 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 687 | ); | |
| 688 | ||
| 689 | it.effect("routes websocket rpc projects.searchEntries errors", () => | |
| 690 | Effect.gen(function* () { | |
| 691 | yield* buildAppUnderTest(); | |
| 692 | ||
| 693 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 694 | const result = yield* Effect.scoped( | |
| 695 | withWsRpcClient(wsUrl, (client) => | |
| 696 | client[WS_METHODS.projectsSearchEntries]({ | |
| 697 | cwd: "/definitely/not/a/real/workspace/path", | |
| 698 | query: "needle", | |
| 699 | limit: 10, | |
| 700 | }), | |
| 701 | ).pipe(Effect.result), | |
| 702 | ); | |
| 703 | ||
| 704 | assertTrue(result._tag === "Failure"); | |
| 705 | assertTrue(result.failure._tag === "ProjectSearchEntriesError"); | |
| 706 | assertInclude( | |
| 707 | result.failure.message, | |
| 708 | "Workspace root does not exist: /definitely/not/a/real/workspace/path", | |
| 709 | ); | |
| 710 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 711 | ); | |
| 712 | ||
| 713 | it.effect("routes websocket rpc projects.writeFile", () => | |
| 714 | Effect.gen(function* () { | |
| 715 | const fs = yield* FileSystem.FileSystem; | |
| 716 | const path = yield* Path.Path; | |
| 717 | const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-write-" }); | |
| 718 | ||
| 719 | yield* buildAppUnderTest(); | |
| 720 | ||
| 721 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 722 | const response = yield* Effect.scoped( | |
| 723 | withWsRpcClient(wsUrl, (client) => | |
| 724 | client[WS_METHODS.projectsWriteFile]({ | |
| 725 | cwd: workspaceDir, | |
| 726 | relativePath: "nested/created.txt", | |
| 727 | contents: "written-by-rpc", | |
| 728 | }), | |
| 729 | ), | |
| 730 | ); | |
| 731 | ||
| 732 | assert.equal(response.relativePath, "nested/created.txt"); | |
| 733 | const persisted = yield* fs.readFileString(path.join(workspaceDir, "nested", "created.txt")); | |
| 734 | assert.equal(persisted, "written-by-rpc"); | |
| 735 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 736 | ); | |
| 737 | ||
| 738 | it.effect("routes websocket rpc projects.writeFile errors", () => | |
| 739 | Effect.gen(function* () { | |
| 740 | const fs = yield* FileSystem.FileSystem; | |
| 741 | const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-write-" }); | |
| 742 | ||
| 743 | yield* buildAppUnderTest(); | |
| 744 | ||
| 745 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 746 | const result = yield* Effect.scoped( | |
| 747 | withWsRpcClient(wsUrl, (client) => | |
| 748 | client[WS_METHODS.projectsWriteFile]({ | |
| 749 | cwd: workspaceDir, | |
| 750 | relativePath: "../escape.txt", | |
| 751 | contents: "nope", | |
| 752 | }), | |
| 753 | ).pipe(Effect.result), | |
| 754 | ); | |
| 755 | ||
| 756 | assertTrue(result._tag === "Failure"); | |
| 757 | assertTrue(result.failure._tag === "ProjectWriteFileError"); | |
| 758 | assert.equal( | |
| 759 | result.failure.message, | |
| 760 | "Workspace file path must stay within the project root.", | |
| 761 | ); | |
| 762 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 763 | ); | |
| 764 | ||
| 765 | it.effect("routes websocket rpc shell.openInEditor", () => | |
| 766 | Effect.gen(function* () { | |
| 767 | let openedInput: { cwd: string; editor: EditorId } | null = null; | |
| 768 | yield* buildAppUnderTest({ | |
| 769 | layers: { | |
| 770 | open: { | |
| 771 | openInEditor: (input) => | |
| 772 | Effect.sync(() => { | |
| 773 | openedInput = input; | |
| 774 | }), | |
| 775 | }, | |
| 776 | }, | |
| 777 | }); | |
| 778 | ||
| 779 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 780 | yield* Effect.scoped( | |
| 781 | withWsRpcClient(wsUrl, (client) => | |
| 782 | client[WS_METHODS.shellOpenInEditor]({ | |
| 783 | cwd: "/tmp/project", | |
| 784 | editor: "cursor", | |
| 785 | }), | |
| 786 | ), | |
| 787 | ); | |
| 788 | ||
| 789 | assert.deepEqual(openedInput, { cwd: "/tmp/project", editor: "cursor" }); | |
| 790 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 791 | ); | |
| 792 | ||
| 793 | it.effect("routes websocket rpc shell.openInEditor errors", () => | |
| 794 | Effect.gen(function* () { | |
| 795 | const openError = new OpenError({ message: "Editor command not found: cursor" }); | |
| 796 | yield* buildAppUnderTest({ | |
| 797 | layers: { | |
| 798 | open: { | |
| 799 | openInEditor: () => Effect.fail(openError), | |
| 800 | }, | |
| 801 | }, | |
| 802 | }); | |
| 803 | ||
| 804 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 805 | const result = yield* Effect.scoped( | |
| 806 | withWsRpcClient(wsUrl, (client) => | |
| 807 | client[WS_METHODS.shellOpenInEditor]({ | |
| 808 | cwd: "/tmp/project", | |
| 809 | editor: "cursor", | |
| 810 | }), | |
| 811 | ).pipe(Effect.result), | |
| 812 | ); | |
| 813 | ||
| 814 | assertFailure(result, openError); | |
| 815 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 816 | ); | |
| 817 | ||
| 818 | it.effect("routes websocket rpc git methods", () => | |
| 819 | Effect.gen(function* () { | |
| 820 | yield* buildAppUnderTest({ | |
| 821 | layers: { | |
| 822 | gitManager: { | |
| 823 | status: () => | |
| 824 | Effect.succeed({ | |
| 825 | branch: "main", | |
| 826 | hasWorkingTreeChanges: false, | |
| 827 | workingTree: { files: [], insertions: 0, deletions: 0 }, | |
| 828 | hasUpstream: true, | |
| 829 | aheadCount: 0, | |
| 830 | behindCount: 0, | |
| 831 | pr: null, | |
| 832 | }), | |
| 833 | runStackedAction: (input, options) => | |
| 834 | Effect.gen(function* () { | |
| 835 | const result = { | |
| 836 | action: "commit" as const, | |
| 837 | branch: { status: "skipped_not_requested" as const }, | |
| 838 | commit: { | |
| 839 | status: "created" as const, | |
| 840 | commitSha: "abc123", | |
| 841 | subject: "feat: demo", | |
| 842 | }, | |
| 843 | push: { status: "skipped_not_requested" as const }, | |
| 844 | pr: { status: "skipped_not_requested" as const }, | |
| 845 | }; | |
| 846 | ||
| 847 | yield* ( | |
| 848 | options?.progressReporter?.publish({ | |
| 849 | actionId: options.actionId ?? input.actionId, | |
| 850 | cwd: input.cwd, | |
| 851 | action: input.action, | |
| 852 | kind: "phase_started", | |
| 853 | phase: "commit", | |
| 854 | label: "Committing...", | |
| 855 | }) ?? Effect.void | |
| 856 | ); | |
| 857 | ||
| 858 | yield* ( | |
| 859 | options?.progressReporter?.publish({ | |
| 860 | actionId: options.actionId ?? input.actionId, | |
| 861 | cwd: input.cwd, | |
| 862 | action: input.action, | |
| 863 | kind: "action_finished", | |
| 864 | result, | |
| 865 | }) ?? Effect.void | |
| 866 | ); | |
| 867 | ||
| 868 | return result; | |
| 869 | }), | |
| 870 | resolvePullRequest: () => | |
| 871 | Effect.succeed({ | |
| 872 | pullRequest: { | |
| 873 | number: 1, | |
| 874 | title: "Demo PR", | |
| 875 | url: "https://example.com/pr/1", | |
| 876 | baseBranch: "main", | |
| 877 | headBranch: "feature/demo", | |
| 878 | state: "open", | |
| 879 | }, | |
| 880 | }), | |
| 881 | preparePullRequestThread: () => | |
| 882 | Effect.succeed({ | |
| 883 | pullRequest: { | |
| 884 | number: 1, | |
| 885 | title: "Demo PR", | |
| 886 | url: "https://example.com/pr/1", | |
| 887 | baseBranch: "main", | |
| 888 | headBranch: "feature/demo", | |
| 889 | state: "open", | |
| 890 | }, | |
| 891 | branch: "feature/demo", | |
| 892 | worktreePath: null, | |
| 893 | }), | |
| 894 | }, | |
| 895 | gitCore: { | |
| 896 | pullCurrentBranch: () => | |
| 897 | Effect.succeed({ | |
| 898 | status: "pulled", | |
| 899 | branch: "main", | |
| 900 | upstreamBranch: "origin/main", | |
| 901 | }), | |
| 902 | listBranches: () => | |
| 903 | Effect.succeed({ | |
| 904 | branches: [ | |
| 905 | { | |
| 906 | name: "main", | |
| 907 | current: true, | |
| 908 | isDefault: true, | |
| 909 | worktreePath: null, | |
| 910 | }, | |
| 911 | ], | |
| 912 | isRepo: true, | |
| 913 | hasOriginRemote: true, | |
| 914 | }), | |
| 915 | createWorktree: () => | |
| 916 | Effect.succeed({ | |
| 917 | worktree: { path: "/tmp/wt", branch: "feature/demo" }, | |
| 918 | }), | |
| 919 | removeWorktree: () => Effect.void, | |
| 920 | createBranch: () => Effect.void, | |
| 921 | checkoutBranch: () => Effect.void, | |
| 922 | initRepo: () => Effect.void, | |
| 923 | }, | |
| 924 | }, | |
| 925 | }); | |
| 926 | ||
| 927 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 928 | ||
| 929 | const status = yield* Effect.scoped( | |
| 930 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitStatus]({ cwd: "/tmp/repo" })), | |
| 931 | ); | |
| 932 | assert.equal(status.branch, "main"); | |
| 933 | ||
| 934 | const pull = yield* Effect.scoped( | |
| 935 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), | |
| 936 | ); | |
| 937 | assert.equal(pull.status, "pulled"); | |
| 938 | ||
| 939 | const stackedEvents = yield* Effect.scoped( | |
| 940 | withWsRpcClient(wsUrl, (client) => | |
| 941 | client[WS_METHODS.gitRunStackedAction]({ | |
| 942 | actionId: "action-1", | |
| 943 | cwd: "/tmp/repo", | |
| 944 | action: "commit", | |
| 945 | }).pipe( | |
| 946 | Stream.runCollect, | |
| 947 | Effect.map((events) => Array.from(events)), | |
| 948 | ), | |
| 949 | ), | |
| 950 | ); | |
| 951 | const lastStackedEvent = stackedEvents.at(-1); | |
| 952 | assert.equal(lastStackedEvent?.kind, "action_finished"); | |
| 953 | if (lastStackedEvent?.kind === "action_finished") { | |
| 954 | assert.equal(lastStackedEvent.result.action, "commit"); | |
| 955 | } | |
| 956 | ||
| 957 | const resolvedPr = yield* Effect.scoped( | |
| 958 | withWsRpcClient(wsUrl, (client) => | |
| 959 | client[WS_METHODS.gitResolvePullRequest]({ | |
| 960 | cwd: "/tmp/repo", | |
| 961 | reference: "1", | |
| 962 | }), | |
| 963 | ), | |
| 964 | ); | |
| 965 | assert.equal(resolvedPr.pullRequest.number, 1); | |
| 966 | ||
| 967 | const prepared = yield* Effect.scoped( | |
| 968 | withWsRpcClient(wsUrl, (client) => | |
| 969 | client[WS_METHODS.gitPreparePullRequestThread]({ | |
| 970 | cwd: "/tmp/repo", | |
| 971 | reference: "1", | |
| 972 | mode: "local", | |
| 973 | }), | |
| 974 | ), | |
| 975 | ); | |
| 976 | assert.equal(prepared.branch, "feature/demo"); | |
| 977 | ||
| 978 | const branches = yield* Effect.scoped( | |
| 979 | withWsRpcClient(wsUrl, (client) => | |
| 980 | client[WS_METHODS.gitListBranches]({ cwd: "/tmp/repo" }), | |
| 981 | ), | |
| 982 | ); | |
| 983 | assert.equal(branches.branches[0]?.name, "main"); | |
| 984 | ||
| 985 | const worktree = yield* Effect.scoped( | |
| 986 | withWsRpcClient(wsUrl, (client) => | |
| 987 | client[WS_METHODS.gitCreateWorktree]({ | |
| 988 | cwd: "/tmp/repo", | |
| 989 | branch: "main", | |
| 990 | path: null, | |
| 991 | }), | |
| 992 | ), | |
| 993 | ); | |
| 994 | assert.equal(worktree.worktree.branch, "feature/demo"); | |
| 995 | ||
| 996 | yield* Effect.scoped( | |
| 997 | withWsRpcClient(wsUrl, (client) => | |
| 998 | client[WS_METHODS.gitRemoveWorktree]({ | |
| 999 | cwd: "/tmp/repo", | |
| 1000 | path: "/tmp/wt", | |
| 1001 | }), | |
| 1002 | ), | |
| 1003 | ); | |
| 1004 | ||
| 1005 | yield* Effect.scoped( | |
| 1006 | withWsRpcClient(wsUrl, (client) => | |
| 1007 | client[WS_METHODS.gitCreateBranch]({ | |
| 1008 | cwd: "/tmp/repo", | |
| 1009 | branch: "feature/new", | |
| 1010 | }), | |
| 1011 | ), | |
| 1012 | ); | |
| 1013 | ||
| 1014 | yield* Effect.scoped( | |
| 1015 | withWsRpcClient(wsUrl, (client) => | |
| 1016 | client[WS_METHODS.gitCheckout]({ | |
| 1017 | cwd: "/tmp/repo", | |
| 1018 | branch: "main", | |
| 1019 | }), | |
| 1020 | ), | |
| 1021 | ); | |
| 1022 | ||
| 1023 | yield* Effect.scoped( | |
| 1024 | withWsRpcClient(wsUrl, (client) => | |
| 1025 | client[WS_METHODS.gitInit]({ | |
| 1026 | cwd: "/tmp/repo", | |
| 1027 | }), | |
| 1028 | ), | |
| 1029 | ); | |
| 1030 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1031 | ); | |
| 1032 | ||
| 1033 | it.effect("routes websocket rpc git.pull errors", () => | |
| 1034 | Effect.gen(function* () { | |
| 1035 | const gitError = new GitCommandError({ | |
| 1036 | operation: "pull", | |
| 1037 | command: "git pull --ff-only", | |
| 1038 | cwd: "/tmp/repo", | |
| 1039 | detail: "upstream missing", | |
| 1040 | }); | |
| 1041 | yield* buildAppUnderTest({ | |
| 1042 | layers: { | |
| 1043 | gitCore: { | |
| 1044 | pullCurrentBranch: () => Effect.fail(gitError), | |
| 1045 | }, | |
| 1046 | }, | |
| 1047 | }); | |
| 1048 | ||
| 1049 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 1050 | const result = yield* Effect.scoped( | |
| 1051 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })).pipe( | |
| 1052 | Effect.result, | |
| 1053 | ), | |
| 1054 | ); | |
| 1055 | ||
| 1056 | assertFailure(result, gitError); | |
| 1057 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1058 | ); | |
| 1059 | ||
| 1060 | it.effect("routes websocket rpc orchestration methods", () => | |
| 1061 | Effect.gen(function* () { | |
| 1062 | const now = new Date().toISOString(); | |
| 1063 | const snapshot = { | |
| 1064 | snapshotSequence: 1, | |
| 1065 | updatedAt: now, | |
| 1066 | projects: [ | |
| 1067 | { | |
| 1068 | id: ProjectId.makeUnsafe("project-a"), | |
| 1069 | title: "Project A", | |
| 1070 | workspaceRoot: "/tmp/project-a", | |
| 1071 | defaultModelSelection, | |
| 1072 | scripts: [], | |
| 1073 | createdAt: now, | |
| 1074 | updatedAt: now, | |
| 1075 | deletedAt: null, | |
| 1076 | }, | |
| 1077 | ], | |
| 1078 | threads: [ | |
| 1079 | { | |
| 1080 | id: ThreadId.makeUnsafe("thread-1"), | |
| 1081 | projectId: ProjectId.makeUnsafe("project-a"), | |
| 1082 | title: "Thread A", | |
| 1083 | modelSelection: defaultModelSelection, | |
| 1084 | interactionMode: "default" as const, | |
| 1085 | runtimeMode: "full-access" as const, | |
| 1086 | branch: null, | |
| 1087 | worktreePath: null, | |
| 1088 | createdAt: now, | |
| 1089 | updatedAt: now, | |
| 1090 | archivedAt: null, | |
| 1091 | latestTurn: null, | |
| 1092 | messages: [], | |
| 1093 | session: null, | |
| 1094 | activities: [], | |
| 1095 | proposedPlans: [], | |
| 1096 | checkpoints: [], | |
| 1097 | deletedAt: null, | |
| 1098 | }, | |
| 1099 | ], | |
| 1100 | }; | |
| 1101 | ||
| 1102 | yield* buildAppUnderTest({ | |
| 1103 | layers: { | |
| 1104 | projectionSnapshotQuery: { | |
| 1105 | getSnapshot: () => Effect.succeed(snapshot), | |
| 1106 | }, | |
| 1107 | orchestrationEngine: { | |
| 1108 | dispatch: () => Effect.succeed({ sequence: 7 }), | |
| 1109 | readEvents: () => Stream.empty, | |
| 1110 | }, | |
| 1111 | checkpointDiffQuery: { | |
| 1112 | getTurnDiff: () => | |
| 1113 | Effect.succeed({ | |
| 1114 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 1115 | fromTurnCount: 0, | |
| 1116 | toTurnCount: 1, | |
| 1117 | diff: "turn-diff", | |
| 1118 | }), | |
| 1119 | getFullThreadDiff: () => | |
| 1120 | Effect.succeed({ | |
| 1121 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 1122 | fromTurnCount: 0, | |
| 1123 | toTurnCount: 1, | |
| 1124 | diff: "full-diff", | |
| 1125 | }), | |
| 1126 | }, | |
| 1127 | }, | |
| 1128 | }); | |
| 1129 | ||
| 1130 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 1131 | const snapshotResult = yield* Effect.scoped( | |
| 1132 | withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})), | |
| 1133 | ); | |
| 1134 | assert.equal(snapshotResult.snapshotSequence, 1); | |
| 1135 | ||
| 1136 | const dispatchResult = yield* Effect.scoped( | |
| 1137 | withWsRpcClient(wsUrl, (client) => | |
| 1138 | client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ | |
| 1139 | type: "thread.session.stop", | |
| 1140 | commandId: CommandId.makeUnsafe("cmd-1"), | |
| 1141 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 1142 | createdAt: now, | |
| 1143 | }), | |
| 1144 | ), | |
| 1145 | ); | |
| 1146 | assert.equal(dispatchResult.sequence, 7); | |
| 1147 | ||
| 1148 | const turnDiffResult = yield* Effect.scoped( | |
| 1149 | withWsRpcClient(wsUrl, (client) => | |
| 1150 | client[ORCHESTRATION_WS_METHODS.getTurnDiff]({ | |
| 1151 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 1152 | fromTurnCount: 0, | |
| 1153 | toTurnCount: 1, | |
| 1154 | }), | |
| 1155 | ), | |
| 1156 | ); | |
| 1157 | assert.equal(turnDiffResult.diff, "turn-diff"); | |
| 1158 | ||
| 1159 | const fullDiffResult = yield* Effect.scoped( | |
| 1160 | withWsRpcClient(wsUrl, (client) => | |
| 1161 | client[ORCHESTRATION_WS_METHODS.getFullThreadDiff]({ | |
| 1162 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 1163 | toTurnCount: 1, | |
| 1164 | }), | |
| 1165 | ), | |
| 1166 | ); | |
| 1167 | assert.equal(fullDiffResult.diff, "full-diff"); | |
| 1168 | ||
| 1169 | const replayResult = yield* Effect.scoped( | |
| 1170 | withWsRpcClient(wsUrl, (client) => | |
| 1171 | client[ORCHESTRATION_WS_METHODS.replayEvents]({ | |
| 1172 | fromSequenceExclusive: 0, | |
| 1173 | }), | |
| 1174 | ), | |
| 1175 | ); | |
| 1176 | assert.deepEqual(replayResult, []); | |
| 1177 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1178 | ); | |
| 1179 | ||
| 1180 | it.effect( | |
| 1181 | "routes websocket rpc subscribeOrchestrationDomainEvents with replay/live overlap resilience", | |
| 1182 | () => | |
| 1183 | Effect.gen(function* () { | |
| 1184 | const now = new Date().toISOString(); | |
| 1185 | const threadId = ThreadId.makeUnsafe("thread-1"); | |
| 1186 | let replayCursor: number | null = null; | |
| 1187 | const makeEvent = (sequence: number): OrchestrationEvent => | |
| 1188 | ({ | |
| 1189 | sequence, | |
| 1190 | eventId: `event-${sequence}`, | |
| 1191 | aggregateKind: "thread", | |
| 1192 | aggregateId: threadId, | |
| 1193 | occurredAt: now, | |
| 1194 | commandId: null, | |
| 1195 | causationEventId: null, | |
| 1196 | correlationId: null, | |
| 1197 | metadata: {}, | |
| 1198 | type: "thread.reverted", | |
| 1199 | payload: { | |
| 1200 | threadId, | |
| 1201 | turnCount: sequence, | |
| 1202 | }, | |
| 1203 | }) as OrchestrationEvent; | |
| 1204 | ||
| 1205 | yield* buildAppUnderTest({ | |
| 1206 | layers: { | |
| 1207 | orchestrationEngine: { | |
| 1208 | getReadModel: () => | |
| 1209 | Effect.succeed({ | |
| 1210 | ...makeDefaultOrchestrationReadModel(), | |
| 1211 | snapshotSequence: 1, | |
| 1212 | }), | |
| 1213 | readEvents: (fromSequenceExclusive) => { | |
| 1214 | replayCursor = fromSequenceExclusive; | |
| 1215 | return Stream.make(makeEvent(2), makeEvent(3)); | |
| 1216 | }, | |
| 1217 | streamDomainEvents: Stream.make(makeEvent(3), makeEvent(4)), | |
| 1218 | }, | |
| 1219 | }, | |
| 1220 | }); | |
| 1221 | ||
| 1222 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 1223 | const events = yield* Effect.scoped( | |
| 1224 | withWsRpcClient(wsUrl, (client) => | |
| 1225 | client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( | |
| 1226 | Stream.take(3), | |
| 1227 | Stream.runCollect, | |
| 1228 | ), | |
| 1229 | ), | |
| 1230 | ); | |
| 1231 | ||
| 1232 | assert.equal(replayCursor, 1); | |
| 1233 | assert.deepEqual( | |
| 1234 | Array.from(events).map((event) => event.sequence), | |
| 1235 | [2, 3, 4], | |
| 1236 | ); | |
| 1237 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1238 | ); | |
| 1239 | ||
| 1240 | it.effect("routes websocket rpc orchestration.getSnapshot errors", () => | |
| 1241 | Effect.gen(function* () { | |
| 1242 | yield* buildAppUnderTest({ | |
| 1243 | layers: { | |
| 1244 | projectionSnapshotQuery: { | |
| 1245 | getSnapshot: () => | |
| 1246 | Effect.fail( | |
| 1247 | new PersistenceSqlError({ | |
| 1248 | operation: "ProjectionSnapshotQuery.getSnapshot", | |
| 1249 | detail: "projection unavailable", | |
| 1250 | }), | |
| 1251 | ), | |
| 1252 | }, | |
| 1253 | }, | |
| 1254 | }); | |
| 1255 | ||
| 1256 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 1257 | const result = yield* Effect.scoped( | |
| 1258 | withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})).pipe( | |
| 1259 | Effect.result, | |
| 1260 | ), | |
| 1261 | ); | |
| 1262 | ||
| 1263 | assertTrue(result._tag === "Failure"); | |
| 1264 | assertTrue(result.failure._tag === "OrchestrationGetSnapshotError"); | |
| 1265 | assertInclude(result.failure.message, "Failed to load orchestration snapshot"); | |
| 1266 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1267 | ); | |
| 1268 | ||
| 1269 | it.effect("routes websocket rpc terminal methods", () => | |
| 1270 | Effect.gen(function* () { | |
| 1271 | const snapshot = { | |
| 1272 | threadId: "thread-1", | |
| 1273 | terminalId: "default", | |
| 1274 | cwd: "/tmp/project", | |
| 1275 | status: "running" as const, | |
| 1276 | pid: 1234, | |
| 1277 | history: "", | |
| 1278 | exitCode: null, | |
| 1279 | exitSignal: null, | |
| 1280 | updatedAt: new Date().toISOString(), | |
| 1281 | }; | |
| 1282 | ||
| 1283 | yield* buildAppUnderTest({ | |
| 1284 | layers: { | |
| 1285 | terminalManager: { | |
| 1286 | open: () => Effect.succeed(snapshot), | |
| 1287 | write: () => Effect.void, | |
| 1288 | resize: () => Effect.void, | |
| 1289 | clear: () => Effect.void, | |
| 1290 | restart: () => Effect.succeed(snapshot), | |
| 1291 | close: () => Effect.void, | |
| 1292 | }, | |
| 1293 | }, | |
| 1294 | }); | |
| 1295 | ||
| 1296 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 1297 | ||
| 1298 | const opened = yield* Effect.scoped( | |
| 1299 | withWsRpcClient(wsUrl, (client) => | |
| 1300 | client[WS_METHODS.terminalOpen]({ | |
| 1301 | threadId: "thread-1", | |
| 1302 | terminalId: "default", | |
| 1303 | cwd: "/tmp/project", | |
| 1304 | }), | |
| 1305 | ), | |
| 1306 | ); | |
| 1307 | assert.equal(opened.terminalId, "default"); | |
| 1308 | ||
| 1309 | yield* Effect.scoped( | |
| 1310 | withWsRpcClient(wsUrl, (client) => | |
| 1311 | client[WS_METHODS.terminalWrite]({ | |
| 1312 | threadId: "thread-1", | |
| 1313 | terminalId: "default", | |
| 1314 | data: "echo hi\n", | |
| 1315 | }), | |
| 1316 | ), | |
| 1317 | ); | |
| 1318 | ||
| 1319 | yield* Effect.scoped( | |
| 1320 | withWsRpcClient(wsUrl, (client) => | |
| 1321 | client[WS_METHODS.terminalResize]({ | |
| 1322 | threadId: "thread-1", | |
| 1323 | terminalId: "default", | |
| 1324 | cols: 120, | |
| 1325 | rows: 40, | |
| 1326 | }), | |
| 1327 | ), | |
| 1328 | ); | |
| 1329 | ||
| 1330 | yield* Effect.scoped( | |
| 1331 | withWsRpcClient(wsUrl, (client) => | |
| 1332 | client[WS_METHODS.terminalClear]({ | |
| 1333 | threadId: "thread-1", | |
| 1334 | terminalId: "default", | |
| 1335 | }), | |
| 1336 | ), | |
| 1337 | ); | |
| 1338 | ||
| 1339 | const restarted = yield* Effect.scoped( | |
| 1340 | withWsRpcClient(wsUrl, (client) => | |
| 1341 | client[WS_METHODS.terminalRestart]({ | |
| 1342 | threadId: "thread-1", | |
| 1343 | terminalId: "default", | |
| 1344 | cwd: "/tmp/project", | |
| 1345 | cols: 120, | |
| 1346 | rows: 40, | |
| 1347 | }), | |
| 1348 | ), | |
| 1349 | ); | |
| 1350 | assert.equal(restarted.terminalId, "default"); | |
| 1351 | ||
| 1352 | yield* Effect.scoped( | |
| 1353 | withWsRpcClient(wsUrl, (client) => | |
| 1354 | client[WS_METHODS.terminalClose]({ | |
| 1355 | threadId: "thread-1", | |
| 1356 | terminalId: "default", | |
| 1357 | }), | |
| 1358 | ), | |
| 1359 | ); | |
| 1360 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1361 | ); | |
| 1362 | ||
| 1363 | it.effect("routes websocket rpc terminal.write errors", () => | |
| 1364 | Effect.gen(function* () { | |
| 1365 | const terminalError = new TerminalNotRunningError({ | |
| 1366 | threadId: "thread-1", | |
| 1367 | terminalId: "default", | |
| 1368 | }); | |
| 1369 | yield* buildAppUnderTest({ | |
| 1370 | layers: { | |
| 1371 | terminalManager: { | |
| 1372 | write: () => Effect.fail(terminalError), | |
| 1373 | }, | |
| 1374 | }, | |
| 1375 | }); | |
| 1376 | ||
| 1377 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 1378 | const result = yield* Effect.scoped( | |
| 1379 | withWsRpcClient(wsUrl, (client) => | |
| 1380 | client[WS_METHODS.terminalWrite]({ | |
| 1381 | threadId: "thread-1", | |
| 1382 | terminalId: "default", | |
| 1383 | data: "echo fail\n", | |
| 1384 | }), | |
| 1385 | ).pipe(Effect.result), | |
| 1386 | ); | |
| 1387 | ||
| 1388 | assertFailure(result, terminalError); | |
| 1389 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1390 | ); | |
| 1391 | }); |
apps/server/src/server.ts created+234| ... | ... | @@ -0,0 +1,234 @@ |
| 1 | import { Effect, Layer } from "effect"; | |
| 2 | import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; | |
| 3 | ||
| 4 | import { ServerConfig } from "./config"; | |
| 5 | import { attachmentsRouteLayer, projectFaviconRouteLayer, staticAndDevRouteLayer } from "./http"; | |
| 6 | import { fixPath } from "./os-jank"; | |
| 7 | import { websocketRpcRouteLayer } from "./ws"; | |
| 8 | import { OpenLive } from "./open"; | |
| 9 | import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite"; | |
| 10 | import { ServerLifecycleEventsLive } from "./serverLifecycleEvents"; | |
| 11 | import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; | |
| 12 | import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; | |
| 13 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; | |
| 14 | import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; | |
| 15 | import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; | |
| 16 | import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; | |
| 17 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; | |
| 18 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; | |
| 19 | import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; | |
| 20 | import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; | |
| 21 | import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; | |
| 22 | import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; | |
| 23 | import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; | |
| 24 | import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; | |
| 25 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; | |
| 26 | import { GitCoreLive } from "./git/Layers/GitCore"; | |
| 27 | import { GitHubCliLive } from "./git/Layers/GitHubCli"; | |
| 28 | import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; | |
| 29 | import { TerminalManagerLive } from "./terminal/Layers/Manager"; | |
| 30 | import { GitManagerLive } from "./git/Layers/GitManager"; | |
| 31 | import { KeybindingsLive } from "./keybindings"; | |
| 32 | import { ServerLoggerLive } from "./serverLogger"; | |
| 33 | import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup"; | |
| 34 | import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor"; | |
| 35 | import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; | |
| 36 | import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; | |
| 37 | import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor"; | |
| 38 | import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"; | |
| 39 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry"; | |
| 40 | import { ServerSettingsLive } from "./serverSettings"; | |
| 41 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver"; | |
| 42 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries"; | |
| 43 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem"; | |
| 44 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; | |
| 45 | ||
| 46 | const PtyAdapterLive = Layer.unwrap( | |
| 47 | Effect.gen(function* () { | |
| 48 | if (typeof Bun !== "undefined") { | |
| 49 | const BunPTY = yield* Effect.promise(() => import("./terminal/Layers/BunPTY")); | |
| 50 | return BunPTY.layer; | |
| 51 | } else { | |
| 52 | const NodePTY = yield* Effect.promise(() => import("./terminal/Layers/NodePTY")); | |
| 53 | return NodePTY.layer; | |
| 54 | } | |
| 55 | }), | |
| 56 | ); | |
| 57 | ||
| 58 | const HttpServerLive = Layer.unwrap( | |
| 59 | Effect.gen(function* () { | |
| 60 | const config = yield* ServerConfig; | |
| 61 | if (typeof Bun !== "undefined") { | |
| 62 | const BunHttpServer = yield* Effect.promise( | |
| 63 | () => import("@effect/platform-bun/BunHttpServer"), | |
| 64 | ); | |
| 65 | return BunHttpServer.layer({ | |
| 66 | port: config.port, | |
| 67 | ...(config.host ? { hostname: config.host } : {}), | |
| 68 | }); | |
| 69 | } else { | |
| 70 | const [NodeHttpServer, NodeHttp] = yield* Effect.all([ | |
| 71 | Effect.promise(() => import("@effect/platform-node/NodeHttpServer")), | |
| 72 | Effect.promise(() => import("node:http")), | |
| 73 | ]); | |
| 74 | return NodeHttpServer.layer(NodeHttp.createServer, { | |
| 75 | host: config.host, | |
| 76 | port: config.port, | |
| 77 | }); | |
| 78 | } | |
| 79 | }), | |
| 80 | ); | |
| 81 | ||
| 82 | const PlatformServicesLive = Layer.unwrap( | |
| 83 | Effect.gen(function* () { | |
| 84 | if (typeof Bun !== "undefined") { | |
| 85 | const { layer } = yield* Effect.promise(() => import("@effect/platform-bun/BunServices")); | |
| 86 | return layer; | |
| 87 | } else { | |
| 88 | const { layer } = yield* Effect.promise(() => import("@effect/platform-node/NodeServices")); | |
| 89 | return layer; | |
| 90 | } | |
| 91 | }), | |
| 92 | ); | |
| 93 | ||
| 94 | const ReactorLayerLive = Layer.empty.pipe( | |
| 95 | Layer.provideMerge(OrchestrationReactorLive), | |
| 96 | Layer.provideMerge(ProviderRuntimeIngestionLive), | |
| 97 | Layer.provideMerge(ProviderCommandReactorLive), | |
| 98 | Layer.provideMerge(CheckpointReactorLive), | |
| 99 | Layer.provideMerge(RuntimeReceiptBusLive), | |
| 100 | ); | |
| 101 | ||
| 102 | const OrchestrationLayerLive = Layer.empty.pipe( | |
| 103 | Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), | |
| 104 | Layer.provideMerge(OrchestrationEngineLive), | |
| 105 | Layer.provideMerge(OrchestrationProjectionPipelineLive), | |
| 106 | Layer.provideMerge(OrchestrationEventStoreLive), | |
| 107 | Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), | |
| 108 | ); | |
| 109 | ||
| 110 | const CheckpointingLayerLive = Layer.empty.pipe( | |
| 111 | Layer.provideMerge(CheckpointDiffQueryLive), | |
| 112 | Layer.provideMerge(CheckpointStoreLive), | |
| 113 | ); | |
| 114 | ||
| 115 | const ProviderLayerLive = Layer.unwrap( | |
| 116 | Effect.gen(function* () { | |
| 117 | const { providerEventLogPath } = yield* ServerConfig; | |
| 118 | const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { | |
| 119 | stream: "native", | |
| 120 | }); | |
| 121 | const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { | |
| 122 | stream: "canonical", | |
| 123 | }); | |
| 124 | const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( | |
| 125 | Layer.provide(ProviderSessionRuntimeRepositoryLive), | |
| 126 | ); | |
| 127 | const codexAdapterLayer = makeCodexAdapterLive( | |
| 128 | nativeEventLogger ? { nativeEventLogger } : undefined, | |
| 129 | ); | |
| 130 | const claudeAdapterLayer = makeClaudeAdapterLive( | |
| 131 | nativeEventLogger ? { nativeEventLogger } : undefined, | |
| 132 | ); | |
| 133 | const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( | |
| 134 | Layer.provide(codexAdapterLayer), | |
| 135 | Layer.provide(claudeAdapterLayer), | |
| 136 | Layer.provideMerge(providerSessionDirectoryLayer), | |
| 137 | ); | |
| 138 | return makeProviderServiceLive( | |
| 139 | canonicalEventLogger ? { canonicalEventLogger } : undefined, | |
| 140 | ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); | |
| 141 | }), | |
| 142 | ); | |
| 143 | ||
| 144 | const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); | |
| 145 | ||
| 146 | const GitLayerLive = Layer.empty.pipe( | |
| 147 | Layer.provideMerge( | |
| 148 | GitManagerLive.pipe( | |
| 149 | Layer.provideMerge(GitCoreLive), | |
| 150 | Layer.provideMerge(GitHubCliLive), | |
| 151 | Layer.provideMerge(RoutingTextGenerationLive), | |
| 152 | ), | |
| 153 | ), | |
| 154 | Layer.provideMerge(GitCoreLive), | |
| 155 | ); | |
| 156 | ||
| 157 | const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); | |
| 158 | ||
| 159 | const WorkspaceLayerLive = Layer.mergeAll( | |
| 160 | WorkspacePathsLive, | |
| 161 | WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive)), | |
| 162 | WorkspaceFileSystemLive.pipe( | |
| 163 | Layer.provide(WorkspacePathsLive), | |
| 164 | Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), | |
| 165 | ), | |
| 166 | ); | |
| 167 | ||
| 168 | const RuntimeServicesLive = Layer.empty.pipe( | |
| 169 | Layer.provideMerge(ServerRuntimeStartupLive), | |
| 170 | Layer.provideMerge(ReactorLayerLive), | |
| 171 | ||
| 172 | // Core Services | |
| 173 | Layer.provideMerge(CheckpointingLayerLive), | |
| 174 | Layer.provideMerge(OrchestrationLayerLive), | |
| 175 | Layer.provideMerge(ProviderLayerLive), | |
| 176 | Layer.provideMerge(GitLayerLive), | |
| 177 | Layer.provideMerge(TerminalLayerLive), | |
| 178 | Layer.provideMerge(PersistenceLayerLive), | |
| 179 | Layer.provideMerge(KeybindingsLive), | |
| 180 | Layer.provideMerge(ProviderRegistryLive), | |
| 181 | Layer.provideMerge(ServerSettingsLive), | |
| 182 | Layer.provideMerge(WorkspaceLayerLive), | |
| 183 | Layer.provideMerge(ProjectFaviconResolverLive), | |
| 184 | ||
| 185 | // Misc. | |
| 186 | Layer.provideMerge(AnalyticsServiceLayerLive), | |
| 187 | Layer.provideMerge(OpenLive), | |
| 188 | Layer.provideMerge(ServerLifecycleEventsLive), | |
| 189 | ); | |
| 190 | ||
| 191 | export const makeRoutesLayer = Layer.mergeAll( | |
| 192 | attachmentsRouteLayer, | |
| 193 | projectFaviconRouteLayer, | |
| 194 | staticAndDevRouteLayer, | |
| 195 | websocketRpcRouteLayer, | |
| 196 | ); | |
| 197 | ||
| 198 | export const makeServerLayer = Layer.unwrap( | |
| 199 | Effect.gen(function* () { | |
| 200 | const config = yield* ServerConfig; | |
| 201 | ||
| 202 | fixPath(); | |
| 203 | ||
| 204 | const httpListeningLayer = Layer.effectDiscard( | |
| 205 | Effect.gen(function* () { | |
| 206 | yield* HttpServer.HttpServer; | |
| 207 | const startup = yield* ServerRuntimeStartup; | |
| 208 | yield* startup.markHttpListening; | |
| 209 | }), | |
| 210 | ); | |
| 211 | ||
| 212 | const serverApplicationLayer = Layer.mergeAll( | |
| 213 | HttpRouter.serve(makeRoutesLayer, { | |
| 214 | disableLogger: !config.logWebSocketEvents, | |
| 215 | }), | |
| 216 | httpListeningLayer, | |
| 217 | ); | |
| 218 | ||
| 219 | return serverApplicationLayer.pipe( | |
| 220 | Layer.provideMerge(RuntimeServicesLive), | |
| 221 | Layer.provideMerge(HttpServerLive), | |
| 222 | Layer.provide(ServerLoggerLive), | |
| 223 | Layer.provideMerge(FetchHttpClient.layer), | |
| 224 | Layer.provideMerge(PlatformServicesLive), | |
| 225 | ); | |
| 226 | }), | |
| 227 | ); | |
| 228 | ||
| 229 | // Important: Only `ServerConfig` should be provided by the CLI layer!!! Don't let other requirements leak into the launch layer. | |
| 230 | export const runServer = Layer.launch(makeServerLayer) satisfies Effect.Effect< | |
| 231 | never, | |
| 232 | any, | |
| 233 | ServerConfig | |
| 234 | >; |
apps/server/src/serverLayers.ts deleted-161| ... | ... | @@ -1,161 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { Effect, FileSystem, Layer, Path } from "effect"; | |
| 3 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 4 | ||
| 5 | import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; | |
| 6 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; | |
| 7 | import { ServerConfig } from "./config"; | |
| 8 | import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; | |
| 9 | import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; | |
| 10 | import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; | |
| 11 | import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; | |
| 12 | import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"; | |
| 13 | import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor"; | |
| 14 | import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor"; | |
| 15 | import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; | |
| 16 | import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; | |
| 17 | import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; | |
| 18 | import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; | |
| 19 | import { ProviderUnsupportedError } from "./provider/Errors"; | |
| 20 | import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; | |
| 21 | import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; | |
| 22 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; | |
| 23 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; | |
| 24 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; | |
| 25 | import { ProviderService } from "./provider/Services/ProviderService"; | |
| 26 | import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; | |
| 27 | import { ServerSettingsService } from "./serverSettings"; | |
| 28 | ||
| 29 | import { TerminalManagerLive } from "./terminal/Layers/Manager"; | |
| 30 | import { KeybindingsLive } from "./keybindings"; | |
| 31 | import { GitManagerLive } from "./git/Layers/GitManager"; | |
| 32 | import { GitCoreLive } from "./git/Layers/GitCore"; | |
| 33 | import { GitHubCliLive } from "./git/Layers/GitHubCli"; | |
| 34 | import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; | |
| 35 | import { PtyAdapter } from "./terminal/Services/PTY"; | |
| 36 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; | |
| 37 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; | |
| 38 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; | |
| 39 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; | |
| 40 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; | |
| 41 | ||
| 42 | type RuntimePtyAdapterLoader = { | |
| 43 | layer: Layer.Layer<PtyAdapter, never, FileSystem.FileSystem | Path.Path>; | |
| 44 | }; | |
| 45 | ||
| 46 | const runtimePtyAdapterLoaders = { | |
| 47 | bun: () => import("./terminal/Layers/BunPTY"), | |
| 48 | node: () => import("./terminal/Layers/NodePTY"), | |
| 49 | } satisfies Record<string, () => Promise<RuntimePtyAdapterLoader>>; | |
| 50 | ||
| 51 | const makeRuntimePtyAdapterLayer = () => | |
| 52 | Effect.gen(function* () { | |
| 53 | const runtime = process.versions.bun !== undefined ? "bun" : "node"; | |
| 54 | const loader = runtimePtyAdapterLoaders[runtime]; | |
| 55 | const ptyAdapterModule = yield* Effect.promise<RuntimePtyAdapterLoader>(loader); | |
| 56 | return ptyAdapterModule.layer; | |
| 57 | }).pipe(Layer.unwrap); | |
| 58 | ||
| 59 | export function makeServerProviderLayer(): Layer.Layer< | |
| 60 | ProviderService, | |
| 61 | ProviderUnsupportedError, | |
| 62 | | SqlClient.SqlClient | |
| 63 | | ServerConfig | |
| 64 | | ServerSettingsService | |
| 65 | | FileSystem.FileSystem | |
| 66 | | AnalyticsService | |
| 67 | > { | |
| 68 | return Effect.gen(function* () { | |
| 69 | const { providerEventLogPath } = yield* ServerConfig; | |
| 70 | const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { | |
| 71 | stream: "native", | |
| 72 | }); | |
| 73 | const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { | |
| 74 | stream: "canonical", | |
| 75 | }); | |
| 76 | const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( | |
| 77 | Layer.provide(ProviderSessionRuntimeRepositoryLive), | |
| 78 | ); | |
| 79 | const codexAdapterLayer = makeCodexAdapterLive( | |
| 80 | nativeEventLogger ? { nativeEventLogger } : undefined, | |
| 81 | ); | |
| 82 | const claudeAdapterLayer = makeClaudeAdapterLive( | |
| 83 | nativeEventLogger ? { nativeEventLogger } : undefined, | |
| 84 | ); | |
| 85 | const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( | |
| 86 | Layer.provide(codexAdapterLayer), | |
| 87 | Layer.provide(claudeAdapterLayer), | |
| 88 | Layer.provideMerge(providerSessionDirectoryLayer), | |
| 89 | ); | |
| 90 | return makeProviderServiceLive( | |
| 91 | canonicalEventLogger ? { canonicalEventLogger } : undefined, | |
| 92 | ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); | |
| 93 | }).pipe(Layer.unwrap); | |
| 94 | } | |
| 95 | ||
| 96 | export function makeServerRuntimeServicesLayer() { | |
| 97 | const textGenerationLayer = RoutingTextGenerationLive; | |
| 98 | const gitCoreLayer = GitCoreLive; | |
| 99 | const checkpointStoreLayer = CheckpointStoreLive; | |
| 100 | ||
| 101 | const orchestrationLayer = OrchestrationEngineLive.pipe( | |
| 102 | Layer.provide(OrchestrationProjectionPipelineLive), | |
| 103 | Layer.provide(OrchestrationEventStoreLive), | |
| 104 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | |
| 105 | ); | |
| 106 | ||
| 107 | const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe( | |
| 108 | Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), | |
| 109 | Layer.provideMerge(checkpointStoreLayer), | |
| 110 | ); | |
| 111 | ||
| 112 | const runtimeServicesLayer = Layer.mergeAll( | |
| 113 | orchestrationLayer, | |
| 114 | OrchestrationProjectionSnapshotQueryLive, | |
| 115 | checkpointStoreLayer, | |
| 116 | checkpointDiffQueryLayer, | |
| 117 | RuntimeReceiptBusLive, | |
| 118 | ); | |
| 119 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( | |
| 120 | Layer.provideMerge(runtimeServicesLayer), | |
| 121 | ); | |
| 122 | const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( | |
| 123 | Layer.provideMerge(runtimeServicesLayer), | |
| 124 | Layer.provideMerge(textGenerationLayer), | |
| 125 | ); | |
| 126 | const checkpointReactorLayer = CheckpointReactorLive.pipe( | |
| 127 | Layer.provideMerge(runtimeServicesLayer), | |
| 128 | Layer.provideMerge(WorkspaceEntriesLive), | |
| 129 | ); | |
| 130 | const orchestrationReactorLayer = OrchestrationReactorLive.pipe( | |
| 131 | Layer.provideMerge(runtimeIngestionLayer), | |
| 132 | Layer.provideMerge(providerCommandReactorLayer), | |
| 133 | Layer.provideMerge(checkpointReactorLayer), | |
| 134 | ); | |
| 135 | ||
| 136 | const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer())); | |
| 137 | ||
| 138 | const gitManagerLayer = GitManagerLive.pipe( | |
| 139 | Layer.provideMerge(GitHubCliLive), | |
| 140 | Layer.provideMerge(textGenerationLayer), | |
| 141 | ); | |
| 142 | ||
| 143 | const workspacePathsLayer = WorkspacePathsLive; | |
| 144 | const workspaceEntriesLayer = WorkspaceEntriesLive; | |
| 145 | const workspaceFileSystemLayer = WorkspaceFileSystemLive.pipe( | |
| 146 | Layer.provide(workspacePathsLayer), | |
| 147 | Layer.provide(workspaceEntriesLayer), | |
| 148 | ); | |
| 149 | const projectFaviconResolverLayer = ProjectFaviconResolverLive; | |
| 150 | ||
| 151 | return Layer.mergeAll( | |
| 152 | orchestrationReactorLayer, | |
| 153 | workspacePathsLayer, | |
| 154 | workspaceEntriesLayer, | |
| 155 | workspaceFileSystemLayer, | |
| 156 | projectFaviconResolverLayer, | |
| 157 | gitManagerLayer, | |
| 158 | terminalLayer, | |
| 159 | KeybindingsLive, | |
| 160 | ).pipe(Layer.provideMerge(gitCoreLayer), Layer.provideMerge(NodeServices.layer)); | |
| 161 | } |
apps/server/src/serverLifecycleEvents.test.ts created+42| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import { assertTrue } from "@effect/vitest/utils"; | |
| 3 | import { Effect, Option } from "effect"; | |
| 4 | ||
| 5 | import { ServerLifecycleEvents, ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts"; | |
| 6 | ||
| 7 | it.effect( | |
| 8 | "publishes lifecycle events without subscribers and snapshots the latest welcome/ready", | |
| 9 | () => | |
| 10 | Effect.gen(function* () { | |
| 11 | const lifecycleEvents = yield* ServerLifecycleEvents; | |
| 12 | ||
| 13 | const welcome = yield* lifecycleEvents | |
| 14 | .publish({ | |
| 15 | version: 1, | |
| 16 | type: "welcome", | |
| 17 | payload: { | |
| 18 | cwd: "/tmp/project", | |
| 19 | projectName: "project", | |
| 20 | }, | |
| 21 | }) | |
| 22 | .pipe(Effect.timeoutOption("50 millis")); | |
| 23 | assertTrue(Option.isSome(welcome)); | |
| 24 | assert.equal(welcome.value.sequence, 1); | |
| 25 | ||
| 26 | const ready = yield* lifecycleEvents | |
| 27 | .publish({ | |
| 28 | version: 1, | |
| 29 | type: "ready", | |
| 30 | payload: { | |
| 31 | at: new Date().toISOString(), | |
| 32 | }, | |
| 33 | }) | |
| 34 | .pipe(Effect.timeoutOption("50 millis")); | |
| 35 | assertTrue(Option.isSome(ready)); | |
| 36 | assert.equal(ready.value.sequence, 2); | |
| 37 | ||
| 38 | const snapshot = yield* lifecycleEvents.snapshot; | |
| 39 | assert.equal(snapshot.sequence, 2); | |
| 40 | assert.deepEqual(snapshot.events.map((event) => event.type).toSorted(), ["ready", "welcome"]); | |
| 41 | }).pipe(Effect.provide(ServerLifecycleEventsLive)), | |
| 42 | ); |
apps/server/src/serverLifecycleEvents.ts created+53| ... | ... | @@ -0,0 +1,53 @@ |
| 1 | import type { ServerLifecycleStreamEvent } from "@t3tools/contracts"; | |
| 2 | import { Effect, Layer, PubSub, Ref, ServiceMap, Stream } from "effect"; | |
| 3 | ||
| 4 | type LifecycleEventInput = | |
| 5 | | Omit<Extract<ServerLifecycleStreamEvent, { type: "welcome" }>, "sequence"> | |
| 6 | | Omit<Extract<ServerLifecycleStreamEvent, { type: "ready" }>, "sequence">; | |
| 7 | ||
| 8 | interface SnapshotState { | |
| 9 | readonly sequence: number; | |
| 10 | readonly events: ReadonlyArray<ServerLifecycleStreamEvent>; | |
| 11 | } | |
| 12 | ||
| 13 | export interface ServerLifecycleEventsShape { | |
| 14 | readonly publish: (event: LifecycleEventInput) => Effect.Effect<ServerLifecycleStreamEvent>; | |
| 15 | readonly snapshot: Effect.Effect<SnapshotState>; | |
| 16 | readonly stream: Stream.Stream<ServerLifecycleStreamEvent>; | |
| 17 | } | |
| 18 | ||
| 19 | export class ServerLifecycleEvents extends ServiceMap.Service< | |
| 20 | ServerLifecycleEvents, | |
| 21 | ServerLifecycleEventsShape | |
| 22 | >()("t3/serverLifecycleEvents") {} | |
| 23 | ||
| 24 | export const ServerLifecycleEventsLive = Layer.effect( | |
| 25 | ServerLifecycleEvents, | |
| 26 | Effect.gen(function* () { | |
| 27 | const pubsub = yield* PubSub.unbounded<ServerLifecycleStreamEvent>(); | |
| 28 | const state = yield* Ref.make<SnapshotState>({ | |
| 29 | sequence: 0, | |
| 30 | events: [], | |
| 31 | }); | |
| 32 | ||
| 33 | return { | |
| 34 | publish: (event) => | |
| 35 | Ref.modify(state, (current) => { | |
| 36 | const nextSequence = current.sequence + 1; | |
| 37 | const nextEvent = { | |
| 38 | ...event, | |
| 39 | sequence: nextSequence, | |
| 40 | } satisfies ServerLifecycleStreamEvent; | |
| 41 | const nextEvents = | |
| 42 | nextEvent.type === "welcome" | |
| 43 | ? [nextEvent, ...current.events.filter((entry) => entry.type !== "welcome")] | |
| 44 | : [nextEvent, ...current.events.filter((entry) => entry.type !== "ready")]; | |
| 45 | return [nextEvent, { sequence: nextSequence, events: nextEvents }] as const; | |
| 46 | }).pipe(Effect.tap((event) => PubSub.publish(pubsub, event))), | |
| 47 | snapshot: Ref.get(state), | |
| 48 | get stream() { | |
| 49 | return Stream.fromPubSub(pubsub); | |
| 50 | }, | |
| 51 | } satisfies ServerLifecycleEventsShape; | |
| 52 | }), | |
| 53 | ); |
apps/server/src/serverLogger.ts+7-11| ... | ... | @@ -1,20 +1,16 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | ||
| 3 | import { Effect, Logger } from "effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 1 | import { Effect, Logger, References, Layer } from "effect"; | |
| 5 | 2 | |
| 6 | 3 | import { ServerConfig } from "./config"; |
| 7 | 4 | |
| 8 | 5 | export const ServerLoggerLive = Effect.gen(function* () { |
| 9 | const { logsDir, serverLogPath } = yield* ServerConfig; | |
| 10 | ||
| 11 | yield* Effect.sync(() => { | |
| 12 | fs.mkdirSync(logsDir, { recursive: true }); | |
| 13 | }); | |
| 6 | const config = yield* ServerConfig; | |
| 7 | const { serverLogPath } = config; | |
| 14 | 8 | |
| 15 | 9 | const fileLogger = Logger.formatSimple.pipe(Logger.toFile(serverLogPath)); |
| 16 | ||
| 17 | return Logger.layer([Logger.defaultLogger, fileLogger], { | |
| 10 | const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel); | |
| 11 | const loggerLayer = Logger.layer([Logger.consolePretty(), fileLogger], { | |
| 18 | 12 | mergeWithExisting: false, |
| 19 | 13 | }); |
| 14 | ||
| 15 | return Layer.mergeAll(loggerLayer, minimumLogLevelLayer); | |
| 20 | 16 | }).pipe(Layer.unwrap); |
apps/server/src/serverRuntimeStartup.test.ts created+49| ... | ... | @@ -0,0 +1,49 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import { Deferred, Effect, Fiber, Ref } from "effect"; | |
| 3 | import { TestClock } from "effect/testing"; | |
| 4 | ||
| 5 | import { makeCommandGate, ServerRuntimeStartupError } from "./serverRuntimeStartup.ts"; | |
| 6 | ||
| 7 | it.effect("enqueueCommand waits for readiness and then drains queued work", () => | |
| 8 | Effect.scoped( | |
| 9 | Effect.gen(function* () { | |
| 10 | const executionCount = yield* Ref.make(0); | |
| 11 | const commandGate = yield* makeCommandGate; | |
| 12 | ||
| 13 | const queuedCommandFiber = yield* commandGate | |
| 14 | .enqueueCommand(Ref.updateAndGet(executionCount, (count) => count + 1)) | |
| 15 | .pipe(Effect.forkScoped); | |
| 16 | ||
| 17 | yield* TestClock.adjust("50 millis"); | |
| 18 | assert.equal(yield* Ref.get(executionCount), 0); | |
| 19 | ||
| 20 | yield* commandGate.signalCommandReady; | |
| 21 | ||
| 22 | const result = yield* Fiber.join(queuedCommandFiber); | |
| 23 | assert.equal(result, 1); | |
| 24 | assert.equal(yield* Ref.get(executionCount), 1); | |
| 25 | }), | |
| 26 | ), | |
| 27 | ); | |
| 28 | ||
| 29 | it.effect("enqueueCommand fails queued work when readiness fails", () => | |
| 30 | Effect.scoped( | |
| 31 | Effect.gen(function* () { | |
| 32 | const commandGate = yield* makeCommandGate; | |
| 33 | const failure = yield* Deferred.make<void, never>(); | |
| 34 | ||
| 35 | const queuedCommandFiber = yield* commandGate | |
| 36 | .enqueueCommand(Deferred.await(failure).pipe(Effect.as("should-not-run"))) | |
| 37 | .pipe(Effect.forkScoped); | |
| 38 | ||
| 39 | yield* commandGate.failCommandReady( | |
| 40 | new ServerRuntimeStartupError({ | |
| 41 | message: "startup failed", | |
| 42 | }), | |
| 43 | ); | |
| 44 | ||
| 45 | const error = yield* Effect.flip(Fiber.join(queuedCommandFiber)); | |
| 46 | assert.equal(error.message, "startup failed"); | |
| 47 | }), | |
| 48 | ), | |
| 49 | ); |
apps/server/src/serverRuntimeStartup.ts created+334| ... | ... | @@ -0,0 +1,334 @@ |
| 1 | import { | |
| 2 | CommandId, | |
| 3 | DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 4 | type ModelSelection, | |
| 5 | ProjectId, | |
| 6 | ThreadId, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { Data, Deferred, Effect, Exit, Layer, Path, Queue, Ref, Scope, ServiceMap } from "effect"; | |
| 9 | ||
| 10 | import { ServerConfig } from "./config"; | |
| 11 | import { Keybindings } from "./keybindings"; | |
| 12 | import { Open } from "./open"; | |
| 13 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; | |
| 14 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; | |
| 15 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; | |
| 16 | import { ServerLifecycleEvents } from "./serverLifecycleEvents"; | |
| 17 | import { ServerSettingsService } from "./serverSettings"; | |
| 18 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; | |
| 19 | ||
| 20 | const isWildcardHost = (host: string | undefined): boolean => | |
| 21 | host === "0.0.0.0" || host === "::" || host === "[::]"; | |
| 22 | ||
| 23 | const formatHostForUrl = (host: string): string => | |
| 24 | host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; | |
| 25 | ||
| 26 | export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{ | |
| 27 | readonly message: string; | |
| 28 | readonly cause?: unknown; | |
| 29 | }> {} | |
| 30 | ||
| 31 | export interface ServerRuntimeStartupShape { | |
| 32 | readonly awaitCommandReady: Effect.Effect<void, ServerRuntimeStartupError>; | |
| 33 | readonly markHttpListening: Effect.Effect<void>; | |
| 34 | readonly enqueueCommand: <A, E>( | |
| 35 | effect: Effect.Effect<A, E>, | |
| 36 | ) => Effect.Effect<A, E | ServerRuntimeStartupError>; | |
| 37 | } | |
| 38 | ||
| 39 | export class ServerRuntimeStartup extends ServiceMap.Service< | |
| 40 | ServerRuntimeStartup, | |
| 41 | ServerRuntimeStartupShape | |
| 42 | >()("t3/serverRuntimeStartup") {} | |
| 43 | ||
| 44 | interface QueuedCommand { | |
| 45 | readonly run: Effect.Effect<void, never>; | |
| 46 | } | |
| 47 | ||
| 48 | type CommandReadinessState = "pending" | "ready" | ServerRuntimeStartupError; | |
| 49 | ||
| 50 | interface CommandGate { | |
| 51 | readonly awaitCommandReady: Effect.Effect<void, ServerRuntimeStartupError>; | |
| 52 | readonly signalCommandReady: Effect.Effect<void>; | |
| 53 | readonly failCommandReady: (error: ServerRuntimeStartupError) => Effect.Effect<void>; | |
| 54 | readonly enqueueCommand: <A, E>( | |
| 55 | effect: Effect.Effect<A, E>, | |
| 56 | ) => Effect.Effect<A, E | ServerRuntimeStartupError>; | |
| 57 | } | |
| 58 | ||
| 59 | const settleQueuedCommand = <A, E>(deferred: Deferred.Deferred<A, E>, exit: Exit.Exit<A, E>) => | |
| 60 | Exit.isSuccess(exit) | |
| 61 | ? Deferred.succeed(deferred, exit.value) | |
| 62 | : Deferred.failCause(deferred, exit.cause); | |
| 63 | ||
| 64 | export const makeCommandGate = Effect.gen(function* () { | |
| 65 | const commandReady = yield* Deferred.make<void, ServerRuntimeStartupError>(); | |
| 66 | const commandQueue = yield* Queue.unbounded<QueuedCommand>(); | |
| 67 | const commandReadinessState = yield* Ref.make<CommandReadinessState>("pending"); | |
| 68 | ||
| 69 | const commandWorker = Effect.forever( | |
| 70 | Queue.take(commandQueue).pipe(Effect.flatMap((command) => command.run)), | |
| 71 | ); | |
| 72 | yield* Effect.forkScoped(commandWorker); | |
| 73 | ||
| 74 | return { | |
| 75 | awaitCommandReady: Deferred.await(commandReady), | |
| 76 | signalCommandReady: Effect.gen(function* () { | |
| 77 | yield* Ref.set(commandReadinessState, "ready"); | |
| 78 | yield* Deferred.succeed(commandReady, undefined).pipe(Effect.orDie); | |
| 79 | }), | |
| 80 | failCommandReady: (error) => | |
| 81 | Effect.gen(function* () { | |
| 82 | yield* Ref.set(commandReadinessState, error); | |
| 83 | yield* Deferred.fail(commandReady, error).pipe(Effect.orDie); | |
| 84 | }), | |
| 85 | enqueueCommand: <A, E>(effect: Effect.Effect<A, E>) => | |
| 86 | Effect.gen(function* () { | |
| 87 | const readinessState = yield* Ref.get(commandReadinessState); | |
| 88 | if (readinessState === "ready") { | |
| 89 | return yield* effect; | |
| 90 | } | |
| 91 | if (readinessState !== "pending") { | |
| 92 | return yield* readinessState; | |
| 93 | } | |
| 94 | ||
| 95 | const result = yield* Deferred.make<A, E | ServerRuntimeStartupError>(); | |
| 96 | yield* Queue.offer(commandQueue, { | |
| 97 | run: Deferred.await(commandReady).pipe( | |
| 98 | Effect.flatMap(() => effect), | |
| 99 | Effect.exit, | |
| 100 | Effect.flatMap((exit) => settleQueuedCommand(result, exit)), | |
| 101 | ), | |
| 102 | }); | |
| 103 | return yield* Deferred.await(result); | |
| 104 | }), | |
| 105 | } satisfies CommandGate; | |
| 106 | }); | |
| 107 | ||
| 108 | const recordStartupHeartbeat = Effect.gen(function* () { | |
| 109 | const analytics = yield* AnalyticsService; | |
| 110 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | |
| 111 | ||
| 112 | const { threadCount, projectCount } = yield* projectionSnapshotQuery.getSnapshot().pipe( | |
| 113 | Effect.map((snapshot) => ({ | |
| 114 | threadCount: snapshot.threads.length, | |
| 115 | projectCount: snapshot.projects.length, | |
| 116 | })), | |
| 117 | Effect.catch((cause) => | |
| 118 | Effect.logWarning("failed to gather startup snapshot for telemetry", { cause }).pipe( | |
| 119 | Effect.as({ | |
| 120 | threadCount: 0, | |
| 121 | projectCount: 0, | |
| 122 | }), | |
| 123 | ), | |
| 124 | ), | |
| 125 | ); | |
| 126 | ||
| 127 | yield* analytics.record("server.boot.heartbeat", { | |
| 128 | threadCount, | |
| 129 | projectCount, | |
| 130 | }); | |
| 131 | }); | |
| 132 | ||
| 133 | const autoBootstrapWelcome = Effect.gen(function* () { | |
| 134 | const serverConfig = yield* ServerConfig; | |
| 135 | const projectionReadModelQuery = yield* ProjectionSnapshotQuery; | |
| 136 | const orchestrationEngine = yield* OrchestrationEngineService; | |
| 137 | const path = yield* Path.Path; | |
| 138 | ||
| 139 | let bootstrapProjectId: ProjectId | undefined; | |
| 140 | let bootstrapThreadId: ThreadId | undefined; | |
| 141 | ||
| 142 | if (serverConfig.autoBootstrapProjectFromCwd) { | |
| 143 | yield* Effect.gen(function* () { | |
| 144 | const snapshot = yield* projectionReadModelQuery.getSnapshot(); | |
| 145 | const existingProject = snapshot.projects.find( | |
| 146 | (project) => project.workspaceRoot === serverConfig.cwd && project.deletedAt === null, | |
| 147 | ); | |
| 148 | let nextProjectId: ProjectId; | |
| 149 | let nextProjectDefaultModelSelection: ModelSelection; | |
| 150 | ||
| 151 | if (!existingProject) { | |
| 152 | const createdAt = new Date().toISOString(); | |
| 153 | nextProjectId = ProjectId.makeUnsafe(crypto.randomUUID()); | |
| 154 | const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project"; | |
| 155 | nextProjectDefaultModelSelection = { | |
| 156 | provider: "codex", | |
| 157 | model: "gpt-5-codex", | |
| 158 | }; | |
| 159 | yield* orchestrationEngine.dispatch({ | |
| 160 | type: "project.create", | |
| 161 | commandId: CommandId.makeUnsafe(crypto.randomUUID()), | |
| 162 | projectId: nextProjectId, | |
| 163 | title: bootstrapProjectTitle, | |
| 164 | workspaceRoot: serverConfig.cwd, | |
| 165 | defaultModelSelection: nextProjectDefaultModelSelection, | |
| 166 | createdAt, | |
| 167 | }); | |
| 168 | } else { | |
| 169 | nextProjectId = existingProject.id; | |
| 170 | nextProjectDefaultModelSelection = existingProject.defaultModelSelection ?? { | |
| 171 | provider: "codex", | |
| 172 | model: "gpt-5-codex", | |
| 173 | }; | |
| 174 | } | |
| 175 | ||
| 176 | const existingThread = snapshot.threads.find( | |
| 177 | (thread) => thread.projectId === nextProjectId && thread.deletedAt === null, | |
| 178 | ); | |
| 179 | if (!existingThread) { | |
| 180 | const createdAt = new Date().toISOString(); | |
| 181 | const createdThreadId = ThreadId.makeUnsafe(crypto.randomUUID()); | |
| 182 | yield* orchestrationEngine.dispatch({ | |
| 183 | type: "thread.create", | |
| 184 | commandId: CommandId.makeUnsafe(crypto.randomUUID()), | |
| 185 | threadId: createdThreadId, | |
| 186 | projectId: nextProjectId, | |
| 187 | title: "New thread", | |
| 188 | modelSelection: nextProjectDefaultModelSelection, | |
| 189 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 190 | runtimeMode: "full-access", | |
| 191 | branch: null, | |
| 192 | worktreePath: null, | |
| 193 | createdAt, | |
| 194 | }); | |
| 195 | bootstrapProjectId = nextProjectId; | |
| 196 | bootstrapThreadId = createdThreadId; | |
| 197 | } else { | |
| 198 | bootstrapProjectId = nextProjectId; | |
| 199 | bootstrapThreadId = existingThread.id; | |
| 200 | } | |
| 201 | }); | |
| 202 | } | |
| 203 | ||
| 204 | const segments = serverConfig.cwd.split(/[/\\]/).filter(Boolean); | |
| 205 | const projectName = segments[segments.length - 1] ?? "project"; | |
| 206 | ||
| 207 | return { | |
| 208 | cwd: serverConfig.cwd, | |
| 209 | projectName, | |
| 210 | ...(bootstrapProjectId ? { bootstrapProjectId } : {}), | |
| 211 | ...(bootstrapThreadId ? { bootstrapThreadId } : {}), | |
| 212 | } as const; | |
| 213 | }); | |
| 214 | ||
| 215 | const maybeOpenBrowser = Effect.gen(function* () { | |
| 216 | const serverConfig = yield* ServerConfig; | |
| 217 | if (serverConfig.noBrowser) { | |
| 218 | return; | |
| 219 | } | |
| 220 | const { openBrowser } = yield* Open; | |
| 221 | const localUrl = `http://localhost:${serverConfig.port}`; | |
| 222 | const bindUrl = | |
| 223 | serverConfig.host && !isWildcardHost(serverConfig.host) | |
| 224 | ? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}` | |
| 225 | : localUrl; | |
| 226 | const target = serverConfig.devUrl?.toString() ?? bindUrl; | |
| 227 | ||
| 228 | yield* openBrowser(target).pipe( | |
| 229 | Effect.catch(() => | |
| 230 | Effect.logInfo("browser auto-open unavailable", { | |
| 231 | hint: `Open ${target} in your browser.`, | |
| 232 | }), | |
| 233 | ), | |
| 234 | ); | |
| 235 | }); | |
| 236 | ||
| 237 | const makeServerRuntimeStartup = Effect.gen(function* () { | |
| 238 | const keybindings = yield* Keybindings; | |
| 239 | const orchestrationReactor = yield* OrchestrationReactor; | |
| 240 | const lifecycleEvents = yield* ServerLifecycleEvents; | |
| 241 | const serverSettings = yield* ServerSettingsService; | |
| 242 | ||
| 243 | const commandGate = yield* makeCommandGate; | |
| 244 | const httpListening = yield* Deferred.make<void>(); | |
| 245 | const reactorScope = yield* Scope.make("sequential"); | |
| 246 | ||
| 247 | yield* Effect.addFinalizer(() => Scope.close(reactorScope, Exit.void)); | |
| 248 | ||
| 249 | const startup = Effect.gen(function* () { | |
| 250 | yield* Effect.logDebug("startup phase: starting keybindings runtime"); | |
| 251 | yield* keybindings.start.pipe( | |
| 252 | Effect.catch((error) => | |
| 253 | Effect.logWarning("failed to start keybindings runtime", { | |
| 254 | path: error.configPath, | |
| 255 | detail: error.detail, | |
| 256 | cause: error.cause, | |
| 257 | }), | |
| 258 | ), | |
| 259 | Effect.forkScoped, | |
| 260 | ); | |
| 261 | ||
| 262 | yield* Effect.logDebug("startup phase: starting server settings runtime"); | |
| 263 | yield* serverSettings.start.pipe( | |
| 264 | Effect.catch((error) => | |
| 265 | Effect.logWarning("failed to start server settings runtime", { | |
| 266 | path: error.settingsPath, | |
| 267 | detail: error.detail, | |
| 268 | cause: error.cause, | |
| 269 | }), | |
| 270 | ), | |
| 271 | Effect.forkScoped, | |
| 272 | ); | |
| 273 | ||
| 274 | yield* Effect.logDebug("startup phase: starting orchestration reactors"); | |
| 275 | yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope)); | |
| 276 | ||
| 277 | yield* Effect.logDebug("startup phase: preparing welcome payload"); | |
| 278 | const welcome = yield* autoBootstrapWelcome; | |
| 279 | yield* Effect.logDebug("startup phase: publishing welcome event", { | |
| 280 | cwd: welcome.cwd, | |
| 281 | projectName: welcome.projectName, | |
| 282 | bootstrapProjectId: welcome.bootstrapProjectId, | |
| 283 | bootstrapThreadId: welcome.bootstrapThreadId, | |
| 284 | }); | |
| 285 | yield* lifecycleEvents.publish({ | |
| 286 | version: 1, | |
| 287 | type: "welcome", | |
| 288 | payload: welcome, | |
| 289 | }); | |
| 290 | }); | |
| 291 | ||
| 292 | yield* Effect.forkScoped( | |
| 293 | Effect.gen(function* () { | |
| 294 | const startupExit = yield* Effect.exit(startup); | |
| 295 | if (Exit.isFailure(startupExit)) { | |
| 296 | const error = new ServerRuntimeStartupError({ | |
| 297 | message: "Server runtime startup failed before command readiness.", | |
| 298 | cause: startupExit.cause, | |
| 299 | }); | |
| 300 | yield* Effect.logError("server runtime startup failed", { cause: startupExit.cause }); | |
| 301 | yield* commandGate.failCommandReady(error); | |
| 302 | return; | |
| 303 | } | |
| 304 | ||
| 305 | yield* Effect.logDebug("Accepting commands"); | |
| 306 | yield* commandGate.signalCommandReady; | |
| 307 | yield* Effect.logDebug("startup phase: waiting for http listener"); | |
| 308 | yield* Deferred.await(httpListening); | |
| 309 | yield* Effect.logDebug("startup phase: publishing ready event"); | |
| 310 | yield* lifecycleEvents.publish({ | |
| 311 | version: 1, | |
| 312 | type: "ready", | |
| 313 | payload: { at: new Date().toISOString() }, | |
| 314 | }); | |
| 315 | ||
| 316 | yield* Effect.logDebug("startup phase: recording startup heartbeat"); | |
| 317 | yield* recordStartupHeartbeat; | |
| 318 | yield* Effect.logDebug("startup phase: browser open check"); | |
| 319 | yield* maybeOpenBrowser; | |
| 320 | yield* Effect.logDebug("startup phase: complete"); | |
| 321 | }), | |
| 322 | ); | |
| 323 | ||
| 324 | return { | |
| 325 | awaitCommandReady: commandGate.awaitCommandReady, | |
| 326 | markHttpListening: Deferred.succeed(httpListening, undefined), | |
| 327 | enqueueCommand: commandGate.enqueueCommand, | |
| 328 | } satisfies ServerRuntimeStartupShape; | |
| 329 | }); | |
| 330 | ||
| 331 | export const ServerRuntimeStartupLive = Layer.effect( | |
| 332 | ServerRuntimeStartup, | |
| 333 | makeServerRuntimeStartup, | |
| 334 | ); |
apps/server/src/serverSettings.ts+3-13| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | type ModelSelection, |
| 17 | 17 | type ProviderKind, |
| 18 | 18 | ServerSettings, |
| 19 | ServerSettingsError, | |
| 19 | 20 | type ServerSettingsPatch, |
| 20 | 21 | } from "@t3tools/contracts"; |
| 21 | 22 | import { |
| ... | ... | @@ -35,25 +36,13 @@ import { |
| 35 | 36 | Scope, |
| 36 | 37 | ServiceMap, |
| 37 | 38 | Stream, |
| 39 | Cause, | |
| 38 | 40 | } from "effect"; |
| 39 | 41 | import * as Semaphore from "effect/Semaphore"; |
| 40 | 42 | import { ServerConfig } from "./config"; |
| 41 | 43 | import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; |
| 42 | 44 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 43 | 45 | |
| 44 | export class ServerSettingsError extends Schema.TaggedErrorClass<ServerSettingsError>()( | |
| 45 | "ServerSettingsError", | |
| 46 | { | |
| 47 | settingsPath: Schema.String, | |
| 48 | detail: Schema.String, | |
| 49 | cause: Schema.optional(Schema.Defect), | |
| 50 | }, | |
| 51 | ) { | |
| 52 | override get message(): string { | |
| 53 | return `Server settings error at ${this.settingsPath}: ${this.detail}`; | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | 46 | export interface ServerSettingsShape { |
| 58 | 47 | /** Start the settings runtime and attach file watching. */ |
| 59 | 48 | readonly start: Effect.Effect<void, ServerSettingsError>; |
| ... | ... | @@ -215,6 +204,7 @@ const makeServerSettings = Effect.gen(function* () { |
| 215 | 204 | if (decoded._tag === "Failure") { |
| 216 | 205 | yield* Effect.logWarning("failed to parse settings.json, using defaults", { |
| 217 | 206 | path: settingsPath, |
| 207 | issues: Cause.pretty(decoded.cause), | |
| 218 | 208 | }); |
| 219 | 209 | return DEFAULT_SERVER_SETTINGS; |
| 220 | 210 | } |
apps/server/src/terminal/Services/Manager.ts+39-65| ... | ... | @@ -10,85 +10,59 @@ import { |
| 10 | 10 | TerminalClearInput, |
| 11 | 11 | TerminalCloseInput, |
| 12 | 12 | TerminalEvent, |
| 13 | TerminalCwdError, | |
| 14 | TerminalError, | |
| 15 | TerminalHistoryError, | |
| 16 | TerminalNotRunningError, | |
| 13 | 17 | TerminalOpenInput, |
| 14 | 18 | TerminalResizeInput, |
| 15 | 19 | TerminalRestartInput, |
| 16 | 20 | TerminalSessionSnapshot, |
| 21 | TerminalSessionLookupError, | |
| 22 | TerminalSessionStatus, | |
| 17 | 23 | TerminalWriteInput, |
| 18 | 24 | } from "@t3tools/contracts"; |
| 19 | import { Effect, Schema, ServiceMap } from "effect"; | |
| 25 | import { PtyProcess } from "./PTY"; | |
| 26 | import { Effect, ServiceMap } from "effect"; | |
| 20 | 27 | |
| 21 | export class TerminalCwdError extends Schema.TaggedErrorClass<TerminalCwdError>()( | |
| 22 | "TerminalCwdError", | |
| 23 | { | |
| 24 | cwd: Schema.String, | |
| 25 | reason: Schema.Literals(["notFound", "notDirectory", "statFailed"]), | |
| 26 | cause: Schema.optional(Schema.Defect), | |
| 27 | }, | |
| 28 | ) { | |
| 29 | override get message() { | |
| 30 | if (this.reason === "notDirectory") { | |
| 31 | return `Terminal cwd is not a directory: ${this.cwd}`; | |
| 32 | } | |
| 33 | if (this.reason === "notFound") { | |
| 34 | return `Terminal cwd does not exist: ${this.cwd}`; | |
| 35 | } | |
| 36 | const causeMessage = | |
| 37 | this.cause && typeof this.cause === "object" && "message" in this.cause | |
| 38 | ? this.cause.message | |
| 39 | : undefined; | |
| 40 | return causeMessage | |
| 41 | ? `Failed to access terminal cwd: ${this.cwd} (${causeMessage})` | |
| 42 | : `Failed to access terminal cwd: ${this.cwd}`; | |
| 43 | } | |
| 44 | } | |
| 28 | export { | |
| 29 | TerminalCwdError, | |
| 30 | TerminalError, | |
| 31 | TerminalHistoryError, | |
| 32 | TerminalNotRunningError, | |
| 33 | TerminalSessionLookupError, | |
| 34 | }; | |
| 45 | 35 | |
| 46 | export class TerminalHistoryError extends Schema.TaggedErrorClass<TerminalHistoryError>()( | |
| 47 | "TerminalHistoryError", | |
| 48 | { | |
| 49 | operation: Schema.Literals(["read", "truncate", "migrate"]), | |
| 50 | threadId: Schema.String, | |
| 51 | terminalId: Schema.String, | |
| 52 | cause: Schema.optional(Schema.Defect), | |
| 53 | }, | |
| 54 | ) { | |
| 55 | override get message() { | |
| 56 | return `Failed to ${this.operation} terminal history for thread: ${this.threadId}, terminal: ${this.terminalId}`; | |
| 57 | } | |
| 36 | export interface TerminalSessionState { | |
| 37 | threadId: string; | |
| 38 | terminalId: string; | |
| 39 | cwd: string; | |
| 40 | status: TerminalSessionStatus; | |
| 41 | pid: number | null; | |
| 42 | history: string; | |
| 43 | pendingHistoryControlSequence: string; | |
| 44 | exitCode: number | null; | |
| 45 | exitSignal: number | null; | |
| 46 | updatedAt: string; | |
| 47 | cols: number; | |
| 48 | rows: number; | |
| 49 | process: PtyProcess | null; | |
| 50 | unsubscribeData: (() => void) | null; | |
| 51 | unsubscribeExit: (() => void) | null; | |
| 52 | hasRunningSubprocess: boolean; | |
| 53 | runtimeEnv: Record<string, string> | null; | |
| 58 | 54 | } |
| 59 | 55 | |
| 60 | export class TerminalSessionLookupError extends Schema.TaggedErrorClass<TerminalSessionLookupError>()( | |
| 61 | "TerminalSessionLookupError", | |
| 62 | { | |
| 63 | threadId: Schema.String, | |
| 64 | terminalId: Schema.String, | |
| 65 | }, | |
| 66 | ) { | |
| 67 | override get message() { | |
| 68 | return `Unknown terminal thread: ${this.threadId}, terminal: ${this.terminalId}`; | |
| 69 | } | |
| 56 | export interface ShellCandidate { | |
| 57 | shell: string; | |
| 58 | args?: string[]; | |
| 70 | 59 | } |
| 71 | 60 | |
| 72 | export class TerminalNotRunningError extends Schema.TaggedErrorClass<TerminalNotRunningError>()( | |
| 73 | "TerminalNotRunningError", | |
| 74 | { | |
| 75 | threadId: Schema.String, | |
| 76 | terminalId: Schema.String, | |
| 77 | }, | |
| 78 | ) { | |
| 79 | override get message() { | |
| 80 | return `Terminal is not running for thread: ${this.threadId}, terminal: ${this.terminalId}`; | |
| 81 | } | |
| 61 | export interface TerminalStartInput extends TerminalOpenInput { | |
| 62 | cols: number; | |
| 63 | rows: number; | |
| 82 | 64 | } |
| 83 | 65 | |
| 84 | export const TerminalError = Schema.Union([ | |
| 85 | TerminalCwdError, | |
| 86 | TerminalHistoryError, | |
| 87 | TerminalSessionLookupError, | |
| 88 | TerminalNotRunningError, | |
| 89 | ]); | |
| 90 | export type TerminalError = typeof TerminalError.Type; | |
| 91 | ||
| 92 | 66 | /** |
| 93 | 67 | * TerminalManagerShape - Service API for terminal session lifecycle operations. |
| 94 | 68 | */ |
apps/server/src/workspace/Layers/WorkspaceEntries.test.ts+3-1| ... | ... | @@ -9,9 +9,11 @@ import { GitCoreLive } from "../../git/Layers/GitCore.ts"; |
| 9 | 9 | import { GitCore } from "../../git/Services/GitCore.ts"; |
| 10 | 10 | import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; |
| 11 | 11 | import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; |
| 12 | import { WorkspacePathsLive } from "./WorkspacePaths.ts"; | |
| 12 | 13 | |
| 13 | 14 | const TestLayer = Layer.empty.pipe( |
| 14 | Layer.provideMerge(WorkspaceEntriesLive), | |
| 15 | Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), | |
| 16 | Layer.provideMerge(WorkspacePathsLive), | |
| 15 | 17 | Layer.provideMerge(GitCoreLive), |
| 16 | 18 | Layer.provide( |
| 17 | 19 | ServerConfig.layerTest(process.cwd(), { |
apps/server/src/workspace/Layers/WorkspaceEntries.ts+27-2| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | WorkspaceEntriesError, |
| 12 | 12 | type WorkspaceEntriesShape, |
| 13 | 13 | } from "../Services/WorkspaceEntries.ts"; |
| 14 | import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; | |
| 14 | 15 | |
| 15 | 16 | const WORKSPACE_CACHE_TTL_MS = 15_000; |
| 16 | 17 | const WORKSPACE_CACHE_MAX_KEYS = 4; |
| ... | ... | @@ -219,6 +220,7 @@ const processErrorDetail = (cause: unknown): string => |
| 219 | 220 | export const makeWorkspaceEntries = Effect.gen(function* () { |
| 220 | 221 | const path = yield* Path.Path; |
| 221 | 222 | const gitOption = yield* Effect.serviceOption(GitCore); |
| 223 | const workspacePaths = yield* WorkspacePaths; | |
| 222 | 224 | |
| 223 | 225 | const isInsideGitWorkTree = (cwd: string): Effect.Effect<boolean> => |
| 224 | 226 | Option.match(gitOption, { |
| ... | ... | @@ -435,15 +437,38 @@ export const makeWorkspaceEntries = Effect.gen(function* () { |
| 435 | 437 | Exit.isSuccess(exit) ? Duration.millis(WORKSPACE_CACHE_TTL_MS) : Duration.zero, |
| 436 | 438 | }); |
| 437 | 439 | |
| 440 | const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* ( | |
| 441 | cwd: string, | |
| 442 | ): Effect.fn.Return<string, WorkspaceEntriesError> { | |
| 443 | return yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( | |
| 444 | Effect.mapError( | |
| 445 | (cause) => | |
| 446 | new WorkspaceEntriesError({ | |
| 447 | cwd, | |
| 448 | operation: "workspaceEntries.normalizeWorkspaceRoot", | |
| 449 | detail: cause.message, | |
| 450 | cause, | |
| 451 | }), | |
| 452 | ), | |
| 453 | ); | |
| 454 | }); | |
| 455 | ||
| 438 | 456 | const invalidate: WorkspaceEntriesShape["invalidate"] = Effect.fn("WorkspaceEntries.invalidate")( |
| 439 | 457 | function* (cwd) { |
| 440 | return yield* Cache.invalidate(workspaceIndexCache, cwd); | |
| 458 | const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( | |
| 459 | Effect.catch(() => Effect.succeed(cwd)), | |
| 460 | ); | |
| 461 | yield* Cache.invalidate(workspaceIndexCache, cwd); | |
| 462 | if (normalizedCwd !== cwd) { | |
| 463 | yield* Cache.invalidate(workspaceIndexCache, normalizedCwd); | |
| 464 | } | |
| 441 | 465 | }, |
| 442 | 466 | ); |
| 443 | 467 | |
| 444 | 468 | const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")( |
| 445 | 469 | function* (input) { |
| 446 | return yield* Cache.get(workspaceIndexCache, input.cwd).pipe( | |
| 470 | const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); | |
| 471 | return yield* Cache.get(workspaceIndexCache, normalizedCwd).pipe( | |
| 447 | 472 | Effect.map((index) => { |
| 448 | 473 | const normalizedQuery = normalizeQuery(input.query); |
| 449 | 474 | const limit = Math.max(0, Math.floor(input.limit)); |
apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts+3-2| ... | ... | @@ -12,12 +12,13 @@ import { WorkspacePathsLive } from "./WorkspacePaths.ts"; |
| 12 | 12 | |
| 13 | 13 | const ProjectLayer = WorkspaceFileSystemLive.pipe( |
| 14 | 14 | Layer.provide(WorkspacePathsLive), |
| 15 | Layer.provide(WorkspaceEntriesLive), | |
| 15 | Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), | |
| 16 | 16 | ); |
| 17 | 17 | |
| 18 | 18 | const TestLayer = Layer.empty.pipe( |
| 19 | 19 | Layer.provideMerge(ProjectLayer), |
| 20 | Layer.provideMerge(WorkspaceEntriesLive), | |
| 20 | Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), | |
| 21 | Layer.provideMerge(WorkspacePathsLive), | |
| 21 | 22 | Layer.provideMerge(GitCoreLive), |
| 22 | 23 | Layer.provide( |
| 23 | 24 | ServerConfig.layerTest(process.cwd(), { |
apps/server/src/ws.ts created+343| ... | ... | @@ -0,0 +1,343 @@ |
| 1 | import { Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect"; | |
| 2 | import { | |
| 3 | type GitActionProgressEvent, | |
| 4 | type GitManagerServiceError, | |
| 5 | OrchestrationDispatchCommandError, | |
| 6 | type OrchestrationEvent, | |
| 7 | OrchestrationGetFullThreadDiffError, | |
| 8 | OrchestrationGetSnapshotError, | |
| 9 | OrchestrationGetTurnDiffError, | |
| 10 | ORCHESTRATION_WS_METHODS, | |
| 11 | ProjectSearchEntriesError, | |
| 12 | ProjectWriteFileError, | |
| 13 | OrchestrationReplayEventsError, | |
| 14 | type TerminalEvent, | |
| 15 | WS_METHODS, | |
| 16 | WsRpcGroup, | |
| 17 | } from "@t3tools/contracts"; | |
| 18 | import { clamp } from "effect/Number"; | |
| 19 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 20 | import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; | |
| 21 | ||
| 22 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; | |
| 23 | import { ServerConfig } from "./config"; | |
| 24 | import { GitCore } from "./git/Services/GitCore"; | |
| 25 | import { GitManager } from "./git/Services/GitManager"; | |
| 26 | import { Keybindings } from "./keybindings"; | |
| 27 | import { Open, resolveAvailableEditors } from "./open"; | |
| 28 | import { normalizeDispatchCommand } from "./orchestration/Normalizer"; | |
| 29 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; | |
| 30 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; | |
| 31 | import { ProviderRegistry } from "./provider/Services/ProviderRegistry"; | |
| 32 | import { ServerLifecycleEvents } from "./serverLifecycleEvents"; | |
| 33 | import { ServerRuntimeStartup } from "./serverRuntimeStartup"; | |
| 34 | import { ServerSettingsService } from "./serverSettings"; | |
| 35 | import { TerminalManager } from "./terminal/Services/Manager"; | |
| 36 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; | |
| 37 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; | |
| 38 | import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths"; | |
| 39 | ||
| 40 | const WsRpcLayer = WsRpcGroup.toLayer( | |
| 41 | Effect.gen(function* () { | |
| 42 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | |
| 43 | const orchestrationEngine = yield* OrchestrationEngineService; | |
| 44 | const checkpointDiffQuery = yield* CheckpointDiffQuery; | |
| 45 | const keybindings = yield* Keybindings; | |
| 46 | const open = yield* Open; | |
| 47 | const gitManager = yield* GitManager; | |
| 48 | const git = yield* GitCore; | |
| 49 | const terminalManager = yield* TerminalManager; | |
| 50 | const providerRegistry = yield* ProviderRegistry; | |
| 51 | const config = yield* ServerConfig; | |
| 52 | const lifecycleEvents = yield* ServerLifecycleEvents; | |
| 53 | const serverSettings = yield* ServerSettingsService; | |
| 54 | const startup = yield* ServerRuntimeStartup; | |
| 55 | const workspaceEntries = yield* WorkspaceEntries; | |
| 56 | const workspaceFileSystem = yield* WorkspaceFileSystem; | |
| 57 | ||
| 58 | const loadServerConfig = Effect.gen(function* () { | |
| 59 | const keybindingsConfig = yield* keybindings.loadConfigState; | |
| 60 | const providers = yield* providerRegistry.getProviders; | |
| 61 | const settings = yield* serverSettings.getSettings; | |
| 62 | ||
| 63 | return { | |
| 64 | cwd: config.cwd, | |
| 65 | keybindingsConfigPath: config.keybindingsConfigPath, | |
| 66 | keybindings: keybindingsConfig.keybindings, | |
| 67 | issues: keybindingsConfig.issues, | |
| 68 | providers, | |
| 69 | availableEditors: resolveAvailableEditors(), | |
| 70 | settings, | |
| 71 | }; | |
| 72 | }); | |
| 73 | ||
| 74 | return WsRpcGroup.of({ | |
| 75 | [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => | |
| 76 | projectionSnapshotQuery.getSnapshot().pipe( | |
| 77 | Effect.mapError( | |
| 78 | (cause) => | |
| 79 | new OrchestrationGetSnapshotError({ | |
| 80 | message: "Failed to load orchestration snapshot", | |
| 81 | cause, | |
| 82 | }), | |
| 83 | ), | |
| 84 | ), | |
| 85 | [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => | |
| 86 | Effect.gen(function* () { | |
| 87 | const normalizedCommand = yield* normalizeDispatchCommand(command); | |
| 88 | return yield* startup.enqueueCommand(orchestrationEngine.dispatch(normalizedCommand)); | |
| 89 | }).pipe( | |
| 90 | Effect.mapError((cause) => | |
| 91 | Schema.is(OrchestrationDispatchCommandError)(cause) | |
| 92 | ? cause | |
| 93 | : new OrchestrationDispatchCommandError({ | |
| 94 | message: "Failed to dispatch orchestration command", | |
| 95 | cause, | |
| 96 | }), | |
| 97 | ), | |
| 98 | ), | |
| 99 | [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => | |
| 100 | checkpointDiffQuery.getTurnDiff(input).pipe( | |
| 101 | Effect.mapError( | |
| 102 | (cause) => | |
| 103 | new OrchestrationGetTurnDiffError({ | |
| 104 | message: "Failed to load turn diff", | |
| 105 | cause, | |
| 106 | }), | |
| 107 | ), | |
| 108 | ), | |
| 109 | [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) => | |
| 110 | checkpointDiffQuery.getFullThreadDiff(input).pipe( | |
| 111 | Effect.mapError( | |
| 112 | (cause) => | |
| 113 | new OrchestrationGetFullThreadDiffError({ | |
| 114 | message: "Failed to load full thread diff", | |
| 115 | cause, | |
| 116 | }), | |
| 117 | ), | |
| 118 | ), | |
| 119 | [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => | |
| 120 | Stream.runCollect( | |
| 121 | orchestrationEngine.readEvents( | |
| 122 | clamp(input.fromSequenceExclusive, { maximum: Number.MAX_SAFE_INTEGER, minimum: 0 }), | |
| 123 | ), | |
| 124 | ).pipe( | |
| 125 | Effect.map((events) => Array.from(events)), | |
| 126 | Effect.mapError( | |
| 127 | (cause) => | |
| 128 | new OrchestrationReplayEventsError({ | |
| 129 | message: "Failed to replay orchestration events", | |
| 130 | cause, | |
| 131 | }), | |
| 132 | ), | |
| 133 | ), | |
| 134 | [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) => | |
| 135 | Stream.unwrap( | |
| 136 | Effect.gen(function* () { | |
| 137 | const snapshot = yield* orchestrationEngine.getReadModel(); | |
| 138 | const fromSequenceExclusive = snapshot.snapshotSequence; | |
| 139 | const replayEvents: Array<OrchestrationEvent> = yield* Stream.runCollect( | |
| 140 | orchestrationEngine.readEvents(fromSequenceExclusive), | |
| 141 | ).pipe( | |
| 142 | Effect.map((events) => Array.from(events)), | |
| 143 | Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)), | |
| 144 | ); | |
| 145 | const replayStream = Stream.fromIterable(replayEvents); | |
| 146 | const source = Stream.merge(replayStream, orchestrationEngine.streamDomainEvents); | |
| 147 | type SequenceState = { | |
| 148 | readonly nextSequence: number; | |
| 149 | readonly pendingBySequence: Map<number, OrchestrationEvent>; | |
| 150 | }; | |
| 151 | const state = yield* Ref.make<SequenceState>({ | |
| 152 | nextSequence: fromSequenceExclusive + 1, | |
| 153 | pendingBySequence: new Map<number, OrchestrationEvent>(), | |
| 154 | }); | |
| 155 | ||
| 156 | return source.pipe( | |
| 157 | Stream.mapEffect((event) => | |
| 158 | Ref.modify( | |
| 159 | state, | |
| 160 | ({ | |
| 161 | nextSequence, | |
| 162 | pendingBySequence, | |
| 163 | }): [Array<OrchestrationEvent>, SequenceState] => { | |
| 164 | if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { | |
| 165 | return [[], { nextSequence, pendingBySequence }]; | |
| 166 | } | |
| 167 | ||
| 168 | const updatedPending = new Map(pendingBySequence); | |
| 169 | updatedPending.set(event.sequence, event); | |
| 170 | ||
| 171 | const emit: Array<OrchestrationEvent> = []; | |
| 172 | let expected = nextSequence; | |
| 173 | for (;;) { | |
| 174 | const expectedEvent = updatedPending.get(expected); | |
| 175 | if (!expectedEvent) { | |
| 176 | break; | |
| 177 | } | |
| 178 | emit.push(expectedEvent); | |
| 179 | updatedPending.delete(expected); | |
| 180 | expected += 1; | |
| 181 | } | |
| 182 | ||
| 183 | return [emit, { nextSequence: expected, pendingBySequence: updatedPending }]; | |
| 184 | }, | |
| 185 | ), | |
| 186 | ), | |
| 187 | Stream.flatMap((events) => Stream.fromIterable(events)), | |
| 188 | ); | |
| 189 | }), | |
| 190 | ), | |
| 191 | [WS_METHODS.serverGetConfig]: (_input) => loadServerConfig, | |
| 192 | [WS_METHODS.serverRefreshProviders]: (_input) => | |
| 193 | providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))), | |
| 194 | [WS_METHODS.serverUpsertKeybinding]: (rule) => | |
| 195 | Effect.gen(function* () { | |
| 196 | const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); | |
| 197 | return { keybindings: keybindingsConfig, issues: [] }; | |
| 198 | }), | |
| 199 | [WS_METHODS.serverGetSettings]: (_input) => serverSettings.getSettings, | |
| 200 | [WS_METHODS.serverUpdateSettings]: ({ patch }) => serverSettings.updateSettings(patch), | |
| 201 | [WS_METHODS.projectsSearchEntries]: (input) => | |
| 202 | workspaceEntries.search(input).pipe( | |
| 203 | Effect.mapError( | |
| 204 | (cause) => | |
| 205 | new ProjectSearchEntriesError({ | |
| 206 | message: `Failed to search workspace entries: ${cause.detail}`, | |
| 207 | cause, | |
| 208 | }), | |
| 209 | ), | |
| 210 | ), | |
| 211 | [WS_METHODS.projectsWriteFile]: (input) => | |
| 212 | workspaceFileSystem.writeFile(input).pipe( | |
| 213 | Effect.mapError((cause) => { | |
| 214 | const message = Schema.is(WorkspacePathOutsideRootError)(cause) | |
| 215 | ? "Workspace file path must stay within the project root." | |
| 216 | : "Failed to write workspace file"; | |
| 217 | return new ProjectWriteFileError({ | |
| 218 | message, | |
| 219 | cause, | |
| 220 | }); | |
| 221 | }), | |
| 222 | ), | |
| 223 | [WS_METHODS.shellOpenInEditor]: (input) => open.openInEditor(input), | |
| 224 | [WS_METHODS.gitStatus]: (input) => gitManager.status(input), | |
| 225 | [WS_METHODS.gitPull]: (input) => git.pullCurrentBranch(input.cwd), | |
| 226 | [WS_METHODS.gitRunStackedAction]: (input) => | |
| 227 | Stream.callback<GitActionProgressEvent, GitManagerServiceError>((queue) => | |
| 228 | gitManager | |
| 229 | .runStackedAction(input, { | |
| 230 | actionId: input.actionId, | |
| 231 | progressReporter: { | |
| 232 | publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), | |
| 233 | }, | |
| 234 | }) | |
| 235 | .pipe( | |
| 236 | Effect.matchCauseEffect({ | |
| 237 | onFailure: (cause) => Queue.failCause(queue, cause), | |
| 238 | onSuccess: () => Queue.end(queue).pipe(Effect.asVoid), | |
| 239 | }), | |
| 240 | ), | |
| 241 | ), | |
| 242 | [WS_METHODS.gitResolvePullRequest]: (input) => gitManager.resolvePullRequest(input), | |
| 243 | [WS_METHODS.gitPreparePullRequestThread]: (input) => | |
| 244 | gitManager.preparePullRequestThread(input), | |
| 245 | [WS_METHODS.gitListBranches]: (input) => git.listBranches(input), | |
| 246 | [WS_METHODS.gitCreateWorktree]: (input) => git.createWorktree(input), | |
| 247 | [WS_METHODS.gitRemoveWorktree]: (input) => git.removeWorktree(input), | |
| 248 | [WS_METHODS.gitCreateBranch]: (input) => git.createBranch(input), | |
| 249 | [WS_METHODS.gitCheckout]: (input) => Effect.scoped(git.checkoutBranch(input)), | |
| 250 | [WS_METHODS.gitInit]: (input) => git.initRepo(input), | |
| 251 | [WS_METHODS.terminalOpen]: (input) => terminalManager.open(input), | |
| 252 | [WS_METHODS.terminalWrite]: (input) => terminalManager.write(input), | |
| 253 | [WS_METHODS.terminalResize]: (input) => terminalManager.resize(input), | |
| 254 | [WS_METHODS.terminalClear]: (input) => terminalManager.clear(input), | |
| 255 | [WS_METHODS.terminalRestart]: (input) => terminalManager.restart(input), | |
| 256 | [WS_METHODS.terminalClose]: (input) => terminalManager.close(input), | |
| 257 | [WS_METHODS.subscribeTerminalEvents]: (_input) => | |
| 258 | Stream.callback<TerminalEvent>((queue) => | |
| 259 | Effect.acquireRelease( | |
| 260 | terminalManager.subscribe((event) => Queue.offer(queue, event)), | |
| 261 | (unsubscribe) => Effect.sync(unsubscribe), | |
| 262 | ), | |
| 263 | ), | |
| 264 | [WS_METHODS.subscribeServerConfig]: (_input) => | |
| 265 | Stream.unwrap( | |
| 266 | Effect.gen(function* () { | |
| 267 | const keybindingsUpdates = keybindings.streamChanges.pipe( | |
| 268 | Stream.map((event) => ({ | |
| 269 | version: 1 as const, | |
| 270 | type: "keybindingsUpdated" as const, | |
| 271 | payload: { | |
| 272 | issues: event.issues, | |
| 273 | }, | |
| 274 | })), | |
| 275 | ); | |
| 276 | const providerStatuses = providerRegistry.streamChanges.pipe( | |
| 277 | Stream.map((providers) => ({ | |
| 278 | version: 1 as const, | |
| 279 | type: "providerStatuses" as const, | |
| 280 | payload: { providers }, | |
| 281 | })), | |
| 282 | ); | |
| 283 | const settingsUpdates = serverSettings.streamChanges.pipe( | |
| 284 | Stream.map((settings) => ({ | |
| 285 | version: 1 as const, | |
| 286 | type: "settingsUpdated" as const, | |
| 287 | payload: { settings }, | |
| 288 | })), | |
| 289 | ); | |
| 290 | ||
| 291 | return Stream.concat( | |
| 292 | Stream.make({ | |
| 293 | version: 1 as const, | |
| 294 | type: "snapshot" as const, | |
| 295 | config: yield* loadServerConfig, | |
| 296 | }), | |
| 297 | Stream.merge(keybindingsUpdates, Stream.merge(providerStatuses, settingsUpdates)), | |
| 298 | ); | |
| 299 | }), | |
| 300 | ), | |
| 301 | [WS_METHODS.subscribeServerLifecycle]: (_input) => | |
| 302 | Stream.unwrap( | |
| 303 | Effect.gen(function* () { | |
| 304 | const snapshot = yield* lifecycleEvents.snapshot; | |
| 305 | const snapshotEvents = Array.from(snapshot.events).toSorted( | |
| 306 | (left, right) => left.sequence - right.sequence, | |
| 307 | ); | |
| 308 | const liveEvents = lifecycleEvents.stream.pipe( | |
| 309 | Stream.filter((event) => event.sequence > snapshot.sequence), | |
| 310 | ); | |
| 311 | return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents); | |
| 312 | }), | |
| 313 | ), | |
| 314 | }); | |
| 315 | }), | |
| 316 | ); | |
| 317 | ||
| 318 | export const websocketRpcRouteLayer = Layer.unwrap( | |
| 319 | Effect.gen(function* () { | |
| 320 | const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup).pipe( | |
| 321 | Effect.provide(Layer.mergeAll(WsRpcLayer, RpcSerialization.layerJson)), | |
| 322 | ); | |
| 323 | return HttpRouter.add( | |
| 324 | "GET", | |
| 325 | "/ws", | |
| 326 | Effect.gen(function* () { | |
| 327 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 328 | const config = yield* ServerConfig; | |
| 329 | if (config.authToken) { | |
| 330 | const url = HttpServerRequest.toURL(request); | |
| 331 | if (Option.isNone(url)) { | |
| 332 | return HttpServerResponse.text("Invalid WebSocket URL", { status: 400 }); | |
| 333 | } | |
| 334 | const token = url.value.searchParams.get("token"); | |
| 335 | if (token !== config.authToken) { | |
| 336 | return HttpServerResponse.text("Unauthorized WebSocket connection", { status: 401 }); | |
| 337 | } | |
| 338 | } | |
| 339 | return yield* rpcWebSocketHttpEffect; | |
| 340 | }), | |
| 341 | ); | |
| 342 | }), | |
| 343 | ); |
apps/server/src/wsServer.test.ts deleted-2069| ... | ... | @@ -1,2069 +0,0 @@ |
| 1 | import * as Http from "node:http"; | |
| 2 | import fs from "node:fs"; | |
| 3 | import os from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | ||
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 7 | import { | |
| 8 | Effect, | |
| 9 | Exit, | |
| 10 | Fiber, | |
| 11 | Layer, | |
| 12 | ManagedRuntime, | |
| 13 | PlatformError, | |
| 14 | PubSub, | |
| 15 | Scope, | |
| 16 | Stream, | |
| 17 | } from "effect"; | |
| 18 | import { describe, expect, it, afterEach, vi } from "vitest"; | |
| 19 | import { createServer } from "./wsServer"; | |
| 20 | import WebSocket from "ws"; | |
| 21 | import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config"; | |
| 22 | import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; | |
| 23 | ||
| 24 | import { | |
| 25 | DEFAULT_TERMINAL_ID, | |
| 26 | DEFAULT_SERVER_SETTINGS, | |
| 27 | EDITORS, | |
| 28 | EventId, | |
| 29 | ORCHESTRATION_WS_CHANNELS, | |
| 30 | ORCHESTRATION_WS_METHODS, | |
| 31 | ProviderItemId, | |
| 32 | type ServerSettings, | |
| 33 | ThreadId, | |
| 34 | TurnId, | |
| 35 | WS_CHANNELS, | |
| 36 | WS_METHODS, | |
| 37 | type WebSocketResponse, | |
| 38 | type ProviderRuntimeEvent, | |
| 39 | type ServerProvider, | |
| 40 | type KeybindingsConfig, | |
| 41 | type ResolvedKeybindingsConfig, | |
| 42 | type WsPushChannel, | |
| 43 | type WsPushMessage, | |
| 44 | type WsPush, | |
| 45 | } from "@t3tools/contracts"; | |
| 46 | import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings"; | |
| 47 | import type { | |
| 48 | TerminalClearInput, | |
| 49 | TerminalCloseInput, | |
| 50 | TerminalEvent, | |
| 51 | TerminalOpenInput, | |
| 52 | TerminalResizeInput, | |
| 53 | TerminalSessionSnapshot, | |
| 54 | TerminalWriteInput, | |
| 55 | } from "@t3tools/contracts"; | |
| 56 | import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager"; | |
| 57 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; | |
| 58 | import { SqlClient, SqlError } from "effect/unstable/sql"; | |
| 59 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; | |
| 60 | import { ProviderRegistry, type ProviderRegistryShape } from "./provider/Services/ProviderRegistry"; | |
| 61 | import { Open, type OpenShape } from "./open"; | |
| 62 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; | |
| 63 | import type { GitCoreShape } from "./git/Services/GitCore.ts"; | |
| 64 | import { GitCore } from "./git/Services/GitCore.ts"; | |
| 65 | import { GitCommandError, GitManagerError } from "./git/Errors.ts"; | |
| 66 | import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; | |
| 67 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; | |
| 68 | import { ServerSettingsService } from "./serverSettings.ts"; | |
| 69 | ||
| 70 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | |
| 71 | const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); | |
| 72 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 73 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | |
| 74 | ||
| 75 | const defaultOpenService: OpenShape = { | |
| 76 | openBrowser: () => Effect.void, | |
| 77 | openInEditor: () => Effect.void, | |
| 78 | }; | |
| 79 | ||
| 80 | const defaultProviderStatuses: ReadonlyArray<ServerProvider> = [ | |
| 81 | { | |
| 82 | provider: "codex", | |
| 83 | enabled: true, | |
| 84 | installed: true, | |
| 85 | version: "0.116.0", | |
| 86 | status: "ready", | |
| 87 | auth: { status: "authenticated" }, | |
| 88 | checkedAt: "2026-01-01T00:00:00.000Z", | |
| 89 | models: [], | |
| 90 | }, | |
| 91 | ]; | |
| 92 | ||
| 93 | const defaultProviderRegistryService: ProviderRegistryShape = { | |
| 94 | getProviders: Effect.succeed(defaultProviderStatuses), | |
| 95 | refresh: () => Effect.succeed(defaultProviderStatuses), | |
| 96 | streamChanges: Stream.empty, | |
| 97 | }; | |
| 98 | ||
| 99 | const defaultServerSettings = DEFAULT_SERVER_SETTINGS; | |
| 100 | ||
| 101 | class MockTerminalManager implements TerminalManagerShape { | |
| 102 | private readonly sessions = new Map<string, TerminalSessionSnapshot>(); | |
| 103 | private readonly eventPubSub = Effect.runSync(PubSub.unbounded<TerminalEvent>()); | |
| 104 | private activeSubscriptions = 0; | |
| 105 | ||
| 106 | private key(threadId: string, terminalId: string): string { | |
| 107 | return `${threadId}\u0000${terminalId}`; | |
| 108 | } | |
| 109 | ||
| 110 | emitEvent(event: TerminalEvent): void { | |
| 111 | Effect.runSync(PubSub.publish(this.eventPubSub, event)); | |
| 112 | } | |
| 113 | ||
| 114 | subscriptionCount(): number { | |
| 115 | return this.activeSubscriptions; | |
| 116 | } | |
| 117 | ||
| 118 | readonly open: TerminalManagerShape["open"] = (input: TerminalOpenInput) => | |
| 119 | Effect.sync(() => { | |
| 120 | const now = new Date().toISOString(); | |
| 121 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 122 | const snapshot: TerminalSessionSnapshot = { | |
| 123 | threadId: input.threadId, | |
| 124 | terminalId, | |
| 125 | cwd: input.cwd, | |
| 126 | status: "running", | |
| 127 | pid: 4242, | |
| 128 | history: "", | |
| 129 | exitCode: null, | |
| 130 | exitSignal: null, | |
| 131 | updatedAt: now, | |
| 132 | }; | |
| 133 | this.sessions.set(this.key(input.threadId, terminalId), snapshot); | |
| 134 | queueMicrotask(() => { | |
| 135 | this.emitEvent({ | |
| 136 | type: "started", | |
| 137 | threadId: input.threadId, | |
| 138 | terminalId, | |
| 139 | createdAt: now, | |
| 140 | snapshot, | |
| 141 | }); | |
| 142 | }); | |
| 143 | return snapshot; | |
| 144 | }); | |
| 145 | ||
| 146 | readonly write: TerminalManagerShape["write"] = (input: TerminalWriteInput) => | |
| 147 | Effect.sync(() => { | |
| 148 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 149 | const existing = this.sessions.get(this.key(input.threadId, terminalId)); | |
| 150 | if (!existing) { | |
| 151 | throw new Error(`Unknown terminal thread: ${input.threadId}`); | |
| 152 | } | |
| 153 | queueMicrotask(() => { | |
| 154 | this.emitEvent({ | |
| 155 | type: "output", | |
| 156 | threadId: input.threadId, | |
| 157 | terminalId, | |
| 158 | createdAt: new Date().toISOString(), | |
| 159 | data: input.data, | |
| 160 | }); | |
| 161 | }); | |
| 162 | }); | |
| 163 | ||
| 164 | readonly resize: TerminalManagerShape["resize"] = (_input: TerminalResizeInput) => Effect.void; | |
| 165 | ||
| 166 | readonly clear: TerminalManagerShape["clear"] = (input: TerminalClearInput) => | |
| 167 | Effect.sync(() => { | |
| 168 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 169 | queueMicrotask(() => { | |
| 170 | this.emitEvent({ | |
| 171 | type: "cleared", | |
| 172 | threadId: input.threadId, | |
| 173 | terminalId, | |
| 174 | createdAt: new Date().toISOString(), | |
| 175 | }); | |
| 176 | }); | |
| 177 | }); | |
| 178 | ||
| 179 | readonly restart: TerminalManagerShape["restart"] = (input: TerminalOpenInput) => | |
| 180 | Effect.sync(() => { | |
| 181 | const now = new Date().toISOString(); | |
| 182 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 183 | const snapshot: TerminalSessionSnapshot = { | |
| 184 | threadId: input.threadId, | |
| 185 | terminalId, | |
| 186 | cwd: input.cwd, | |
| 187 | status: "running", | |
| 188 | pid: 5252, | |
| 189 | history: "", | |
| 190 | exitCode: null, | |
| 191 | exitSignal: null, | |
| 192 | updatedAt: now, | |
| 193 | }; | |
| 194 | this.sessions.set(this.key(input.threadId, terminalId), snapshot); | |
| 195 | queueMicrotask(() => { | |
| 196 | this.emitEvent({ | |
| 197 | type: "restarted", | |
| 198 | threadId: input.threadId, | |
| 199 | terminalId, | |
| 200 | createdAt: now, | |
| 201 | snapshot, | |
| 202 | }); | |
| 203 | }); | |
| 204 | return snapshot; | |
| 205 | }); | |
| 206 | ||
| 207 | readonly close: TerminalManagerShape["close"] = (input: TerminalCloseInput) => | |
| 208 | Effect.sync(() => { | |
| 209 | if (input.terminalId) { | |
| 210 | this.sessions.delete(this.key(input.threadId, input.terminalId)); | |
| 211 | return; | |
| 212 | } | |
| 213 | for (const key of this.sessions.keys()) { | |
| 214 | if (key.startsWith(`${input.threadId}\u0000`)) { | |
| 215 | this.sessions.delete(key); | |
| 216 | } | |
| 217 | } | |
| 218 | }); | |
| 219 | ||
| 220 | readonly subscribe: TerminalManagerShape["subscribe"] = (listener) => | |
| 221 | Effect.sync(() => { | |
| 222 | this.activeSubscriptions += 1; | |
| 223 | const fiber = Effect.runFork( | |
| 224 | Stream.runForEach(Stream.fromPubSub(this.eventPubSub), (event) => listener(event)), | |
| 225 | ); | |
| 226 | return () => { | |
| 227 | this.activeSubscriptions -= 1; | |
| 228 | Effect.runFork(Fiber.interrupt(fiber).pipe(Effect.ignore)); | |
| 229 | }; | |
| 230 | }); | |
| 231 | } | |
| 232 | ||
| 233 | // --------------------------------------------------------------------------- | |
| 234 | // WebSocket test harness | |
| 235 | // | |
| 236 | // Incoming messages are split into two channels: | |
| 237 | // - pushChannel: server push envelopes (type === "push") | |
| 238 | // - responseChannel: request/response envelopes (have an "id" field) | |
| 239 | // | |
| 240 | // This means sendRequest never has to skip push messages and waitForPush | |
| 241 | // never has to skip response messages, eliminating a class of ordering bugs. | |
| 242 | // --------------------------------------------------------------------------- | |
| 243 | ||
| 244 | interface MessageChannel<T> { | |
| 245 | queue: T[]; | |
| 246 | waiters: Array<{ | |
| 247 | resolve: (value: T) => void; | |
| 248 | reject: (error: Error) => void; | |
| 249 | timeoutId: ReturnType<typeof setTimeout> | null; | |
| 250 | }>; | |
| 251 | } | |
| 252 | ||
| 253 | interface SocketChannels { | |
| 254 | push: MessageChannel<WsPush>; | |
| 255 | response: MessageChannel<WebSocketResponse>; | |
| 256 | } | |
| 257 | ||
| 258 | const channelsBySocket = new WeakMap<WebSocket, SocketChannels>(); | |
| 259 | ||
| 260 | function enqueue<T>(channel: MessageChannel<T>, item: T) { | |
| 261 | const waiter = channel.waiters.shift(); | |
| 262 | if (waiter) { | |
| 263 | if (waiter.timeoutId !== null) clearTimeout(waiter.timeoutId); | |
| 264 | waiter.resolve(item); | |
| 265 | return; | |
| 266 | } | |
| 267 | channel.queue.push(item); | |
| 268 | } | |
| 269 | ||
| 270 | function dequeue<T>(channel: MessageChannel<T>, timeoutMs: number): Promise<T> { | |
| 271 | const queued = channel.queue.shift(); | |
| 272 | if (queued !== undefined) { | |
| 273 | return Promise.resolve(queued); | |
| 274 | } | |
| 275 | ||
| 276 | return new Promise((resolve, reject) => { | |
| 277 | const waiter = { | |
| 278 | resolve, | |
| 279 | reject, | |
| 280 | timeoutId: setTimeout(() => { | |
| 281 | const index = channel.waiters.indexOf(waiter); | |
| 282 | if (index >= 0) channel.waiters.splice(index, 1); | |
| 283 | reject(new Error(`Timed out waiting for WebSocket message after ${timeoutMs}ms`)); | |
| 284 | }, timeoutMs) as ReturnType<typeof setTimeout>, | |
| 285 | }; | |
| 286 | channel.waiters.push(waiter); | |
| 287 | }); | |
| 288 | } | |
| 289 | ||
| 290 | function isWsPushEnvelope(message: unknown): message is WsPush { | |
| 291 | if (typeof message !== "object" || message === null) return false; | |
| 292 | if (!("type" in message) || !("channel" in message)) return false; | |
| 293 | return (message as { type?: unknown }).type === "push"; | |
| 294 | } | |
| 295 | ||
| 296 | function asWebSocketResponse(message: unknown): WebSocketResponse | null { | |
| 297 | if (typeof message !== "object" || message === null) return null; | |
| 298 | if (!("id" in message)) return null; | |
| 299 | const id = (message as { id?: unknown }).id; | |
| 300 | if (typeof id !== "string") return null; | |
| 301 | return message as WebSocketResponse; | |
| 302 | } | |
| 303 | ||
| 304 | function connectWsOnce(port: number, token?: string): Promise<WebSocket> { | |
| 305 | return new Promise((resolve, reject) => { | |
| 306 | const query = token ? `?token=${encodeURIComponent(token)}` : ""; | |
| 307 | const ws = new WebSocket(`ws://127.0.0.1:${port}/${query}`); | |
| 308 | const channels: SocketChannels = { | |
| 309 | push: { queue: [], waiters: [] }, | |
| 310 | response: { queue: [], waiters: [] }, | |
| 311 | }; | |
| 312 | channelsBySocket.set(ws, channels); | |
| 313 | ||
| 314 | ws.on("message", (raw) => { | |
| 315 | const parsed = JSON.parse(String(raw)); | |
| 316 | if (isWsPushEnvelope(parsed)) { | |
| 317 | enqueue(channels.push, parsed); | |
| 318 | } else { | |
| 319 | const response = asWebSocketResponse(parsed); | |
| 320 | if (response) { | |
| 321 | enqueue(channels.response, response); | |
| 322 | } | |
| 323 | } | |
| 324 | }); | |
| 325 | ||
| 326 | ws.once("open", () => resolve(ws)); | |
| 327 | ws.once("error", () => reject(new Error("WebSocket connection failed"))); | |
| 328 | }); | |
| 329 | } | |
| 330 | ||
| 331 | async function connectWs(port: number, token?: string, attempts = 5): Promise<WebSocket> { | |
| 332 | let lastError: unknown = new Error("WebSocket connection failed"); | |
| 333 | ||
| 334 | for (let attempt = 0; attempt < attempts; attempt += 1) { | |
| 335 | try { | |
| 336 | return await connectWsOnce(port, token); | |
| 337 | } catch (error) { | |
| 338 | lastError = error; | |
| 339 | if (attempt < attempts - 1) { | |
| 340 | await new Promise((resolve) => setTimeout(resolve, 25)); | |
| 341 | } | |
| 342 | } | |
| 343 | } | |
| 344 | ||
| 345 | throw lastError; | |
| 346 | } | |
| 347 | ||
| 348 | /** Connect and wait for the server.welcome push. Returns [ws, welcomeData]. */ | |
| 349 | async function connectAndAwaitWelcome( | |
| 350 | port: number, | |
| 351 | token?: string, | |
| 352 | ): Promise<[WebSocket, WsPushMessage<typeof WS_CHANNELS.serverWelcome>]> { | |
| 353 | const ws = await connectWs(port, token); | |
| 354 | const welcome = await waitForPush(ws, WS_CHANNELS.serverWelcome); | |
| 355 | return [ws, welcome]; | |
| 356 | } | |
| 357 | ||
| 358 | async function sendRequest( | |
| 359 | ws: WebSocket, | |
| 360 | method: string, | |
| 361 | params?: unknown, | |
| 362 | ): Promise<WebSocketResponse> { | |
| 363 | const channels = channelsBySocket.get(ws); | |
| 364 | if (!channels) throw new Error("WebSocket not initialized"); | |
| 365 | ||
| 366 | const id = crypto.randomUUID(); | |
| 367 | const body = | |
| 368 | method === ORCHESTRATION_WS_METHODS.dispatchCommand | |
| 369 | ? { _tag: method, command: params } | |
| 370 | : params && typeof params === "object" && !Array.isArray(params) | |
| 371 | ? { _tag: method, ...(params as Record<string, unknown>) } | |
| 372 | : { _tag: method }; | |
| 373 | ws.send(JSON.stringify({ id, body })); | |
| 374 | ||
| 375 | // Response channel only contains responses — no push filtering needed | |
| 376 | while (true) { | |
| 377 | const response = await dequeue(channels.response, 60_000); | |
| 378 | if (response.id === id || response.id === "unknown") { | |
| 379 | return response; | |
| 380 | } | |
| 381 | } | |
| 382 | } | |
| 383 | ||
| 384 | async function waitForPush<C extends WsPushChannel>( | |
| 385 | ws: WebSocket, | |
| 386 | channel: C, | |
| 387 | predicate?: (push: WsPushMessage<C>) => boolean, | |
| 388 | maxMessages = 120, | |
| 389 | idleTimeoutMs = 5_000, | |
| 390 | ): Promise<WsPushMessage<C>> { | |
| 391 | const channels = channelsBySocket.get(ws); | |
| 392 | if (!channels) throw new Error("WebSocket not initialized"); | |
| 393 | ||
| 394 | for (let remaining = maxMessages; remaining > 0; remaining--) { | |
| 395 | const push = await dequeue(channels.push, idleTimeoutMs); | |
| 396 | if (push.channel !== channel) continue; | |
| 397 | const typed = push as WsPushMessage<C>; | |
| 398 | if (!predicate || predicate(typed)) return typed; | |
| 399 | } | |
| 400 | throw new Error(`Timed out waiting for push on ${channel}`); | |
| 401 | } | |
| 402 | ||
| 403 | async function rewriteKeybindingsAndWaitForPush( | |
| 404 | ws: WebSocket, | |
| 405 | keybindingsPath: string, | |
| 406 | contents: string, | |
| 407 | predicate: (push: WsPushMessage<typeof WS_CHANNELS.serverConfigUpdated>) => boolean, | |
| 408 | attempts = 3, | |
| 409 | ): Promise<WsPushMessage<typeof WS_CHANNELS.serverConfigUpdated>> { | |
| 410 | let lastError: unknown; | |
| 411 | for (let attempt = 0; attempt < attempts; attempt++) { | |
| 412 | fs.writeFileSync(keybindingsPath, contents, "utf8"); | |
| 413 | try { | |
| 414 | return await waitForPush(ws, WS_CHANNELS.serverConfigUpdated, predicate, 20, 3_000); | |
| 415 | } catch (error) { | |
| 416 | lastError = error; | |
| 417 | } | |
| 418 | } | |
| 419 | throw lastError; | |
| 420 | } | |
| 421 | ||
| 422 | async function requestPath( | |
| 423 | port: number, | |
| 424 | requestPath: string, | |
| 425 | ): Promise<{ statusCode: number; body: string }> { | |
| 426 | return new Promise((resolve, reject) => { | |
| 427 | const req = Http.request( | |
| 428 | { | |
| 429 | hostname: "127.0.0.1", | |
| 430 | port, | |
| 431 | path: requestPath, | |
| 432 | method: "GET", | |
| 433 | }, | |
| 434 | (res) => { | |
| 435 | const chunks: Buffer[] = []; | |
| 436 | res.on("data", (chunk) => { | |
| 437 | chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); | |
| 438 | }); | |
| 439 | res.on("end", () => { | |
| 440 | resolve({ | |
| 441 | statusCode: res.statusCode ?? 0, | |
| 442 | body: Buffer.concat(chunks).toString("utf8"), | |
| 443 | }); | |
| 444 | }); | |
| 445 | }, | |
| 446 | ); | |
| 447 | req.once("error", reject); | |
| 448 | req.end(); | |
| 449 | }); | |
| 450 | } | |
| 451 | ||
| 452 | function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { | |
| 453 | const resolved: Array<ResolvedKeybindingsConfig[number]> = []; | |
| 454 | for (const binding of bindings) { | |
| 455 | const compiled = compileResolvedKeybindingRule(binding); | |
| 456 | if (!compiled) { | |
| 457 | throw new Error(`Unexpected invalid keybinding in test setup: ${binding.command}`); | |
| 458 | } | |
| 459 | resolved.push(compiled); | |
| 460 | } | |
| 461 | return resolved; | |
| 462 | } | |
| 463 | ||
| 464 | const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]); | |
| 465 | const VALID_EDITOR_IDS = new Set(EDITORS.map((editor) => editor.id)); | |
| 466 | ||
| 467 | function expectAvailableEditors(value: unknown): void { | |
| 468 | expect(Array.isArray(value)).toBe(true); | |
| 469 | for (const editorId of value as unknown[]) { | |
| 470 | expect(typeof editorId).toBe("string"); | |
| 471 | expect(VALID_EDITOR_IDS.has(editorId as (typeof EDITORS)[number]["id"])).toBe(true); | |
| 472 | } | |
| 473 | } | |
| 474 | ||
| 475 | function ensureParentDir(filePath: string): void { | |
| 476 | fs.mkdirSync(path.dirname(filePath), { recursive: true }); | |
| 477 | } | |
| 478 | ||
| 479 | function deriveServerPathsSync(baseDir: string, devUrl: URL | undefined) { | |
| 480 | return Effect.runSync( | |
| 481 | deriveServerPaths(baseDir, devUrl).pipe(Effect.provide(NodeServices.layer)), | |
| 482 | ); | |
| 483 | } | |
| 484 | ||
| 485 | describe("WebSocket Server", () => { | |
| 486 | let server: Http.Server | null = null; | |
| 487 | let serverScope: Scope.Closeable | null = null; | |
| 488 | let disposeServerRuntime: (() => Promise<void>) | null = null; | |
| 489 | const connections: WebSocket[] = []; | |
| 490 | const tempDirs: string[] = []; | |
| 491 | ||
| 492 | function makeTempDir(prefix: string): string { | |
| 493 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 494 | tempDirs.push(dir); | |
| 495 | return dir; | |
| 496 | } | |
| 497 | ||
| 498 | async function createTestServer( | |
| 499 | options: { | |
| 500 | persistenceLayer?: Layer.Layer< | |
| 501 | SqlClient.SqlClient, | |
| 502 | SqlError.SqlError | MigrationError | PlatformError.PlatformError | |
| 503 | >; | |
| 504 | cwd?: string; | |
| 505 | autoBootstrapProjectFromCwd?: boolean; | |
| 506 | logWebSocketEvents?: boolean; | |
| 507 | devUrl?: string; | |
| 508 | authToken?: string; | |
| 509 | baseDir?: string; | |
| 510 | staticDir?: string; | |
| 511 | providerLayer?: Layer.Layer<ProviderService, never>; | |
| 512 | providerRegistry?: ProviderRegistryShape; | |
| 513 | open?: OpenShape; | |
| 514 | gitManager?: GitManagerShape; | |
| 515 | gitCore?: Pick<GitCoreShape, "listBranches" | "initRepo" | "pullCurrentBranch">; | |
| 516 | terminalManager?: TerminalManagerShape; | |
| 517 | serverSettings?: Partial<ServerSettings>; | |
| 518 | } = {}, | |
| 519 | ): Promise<Http.Server> { | |
| 520 | if (serverScope) { | |
| 521 | throw new Error("Test server is already running"); | |
| 522 | } | |
| 523 | ||
| 524 | const baseDir = options.baseDir ?? makeTempDir("t3code-ws-base-"); | |
| 525 | const devUrl = options.devUrl ? new URL(options.devUrl) : undefined; | |
| 526 | const derivedPaths = deriveServerPathsSync(baseDir, devUrl); | |
| 527 | const scope = await Effect.runPromise(Scope.make("sequential")); | |
| 528 | const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; | |
| 529 | const providerLayer = options.providerLayer ?? makeServerProviderLayer(); | |
| 530 | const providerRegistryLayer = Layer.succeed( | |
| 531 | ProviderRegistry, | |
| 532 | options.providerRegistry ?? defaultProviderRegistryService, | |
| 533 | ); | |
| 534 | const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService); | |
| 535 | const nodeServicesLayer = NodeServices.layer; | |
| 536 | const serverSettingsLayer = ServerSettingsService.layerTest(options.serverSettings); | |
| 537 | const serverSettingsRuntimeLayer = serverSettingsLayer.pipe( | |
| 538 | Layer.provideMerge(nodeServicesLayer), | |
| 539 | ); | |
| 540 | const analyticsLayer = AnalyticsService.layerTest; | |
| 541 | const serverConfigLayer = Layer.succeed(ServerConfig, { | |
| 542 | mode: "web", | |
| 543 | port: 0, | |
| 544 | host: undefined, | |
| 545 | cwd: options.cwd ?? "/test/project", | |
| 546 | baseDir, | |
| 547 | ...derivedPaths, | |
| 548 | staticDir: options.staticDir, | |
| 549 | devUrl, | |
| 550 | noBrowser: true, | |
| 551 | authToken: options.authToken, | |
| 552 | autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false, | |
| 553 | logWebSocketEvents: options.logWebSocketEvents ?? Boolean(options.devUrl), | |
| 554 | } satisfies ServerConfigShape); | |
| 555 | const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); | |
| 556 | const providerRuntimeLayer = infrastructureLayer.pipe( | |
| 557 | Layer.provideMerge(serverConfigLayer), | |
| 558 | Layer.provideMerge(serverSettingsRuntimeLayer), | |
| 559 | Layer.provideMerge(analyticsLayer), | |
| 560 | ); | |
| 561 | const runtimeOverrides = Layer.mergeAll( | |
| 562 | options.gitManager ? Layer.succeed(GitManager, options.gitManager) : Layer.empty, | |
| 563 | options.gitCore | |
| 564 | ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape) | |
| 565 | : Layer.empty, | |
| 566 | options.terminalManager | |
| 567 | ? Layer.succeed(TerminalManager, options.terminalManager) | |
| 568 | : Layer.empty, | |
| 569 | ); | |
| 570 | ||
| 571 | const runtimeLayer = Layer.merge( | |
| 572 | Layer.merge( | |
| 573 | makeServerRuntimeServicesLayer().pipe( | |
| 574 | Layer.provideMerge(providerRuntimeLayer), | |
| 575 | Layer.provideMerge(serverConfigLayer), | |
| 576 | Layer.provideMerge(serverSettingsRuntimeLayer), | |
| 577 | Layer.provideMerge(analyticsLayer), | |
| 578 | Layer.provideMerge(nodeServicesLayer), | |
| 579 | ), | |
| 580 | Layer.mergeAll(providerRuntimeLayer, serverSettingsRuntimeLayer, analyticsLayer), | |
| 581 | ), | |
| 582 | runtimeOverrides, | |
| 583 | ); | |
| 584 | const dependenciesLayer = Layer.mergeAll( | |
| 585 | runtimeLayer, | |
| 586 | providerRegistryLayer, | |
| 587 | openLayer, | |
| 588 | serverConfigLayer, | |
| 589 | nodeServicesLayer, | |
| 590 | ); | |
| 591 | const runtime = ManagedRuntime.make(dependenciesLayer); | |
| 592 | try { | |
| 593 | const httpServer = await runtime.runPromise(createServer().pipe(Scope.provide(scope))); | |
| 594 | disposeServerRuntime = () => runtime.dispose(); | |
| 595 | serverScope = scope; | |
| 596 | return httpServer; | |
| 597 | } catch (error) { | |
| 598 | await runtime.dispose(); | |
| 599 | await Effect.runPromise(Scope.close(scope, Exit.void)); | |
| 600 | throw error; | |
| 601 | } | |
| 602 | } | |
| 603 | ||
| 604 | async function closeTestServer() { | |
| 605 | if (!serverScope && !disposeServerRuntime) return; | |
| 606 | const scope = serverScope; | |
| 607 | const disposeRuntime = disposeServerRuntime; | |
| 608 | serverScope = null; | |
| 609 | disposeServerRuntime = null; | |
| 610 | if (scope) { | |
| 611 | await Effect.runPromise(Scope.close(scope, Exit.void)); | |
| 612 | } | |
| 613 | if (disposeRuntime) { | |
| 614 | await disposeRuntime(); | |
| 615 | } | |
| 616 | } | |
| 617 | ||
| 618 | afterEach(async () => { | |
| 619 | for (const ws of connections) { | |
| 620 | ws.close(); | |
| 621 | } | |
| 622 | connections.length = 0; | |
| 623 | await closeTestServer(); | |
| 624 | server = null; | |
| 625 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 626 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 627 | } | |
| 628 | vi.restoreAllMocks(); | |
| 629 | }); | |
| 630 | ||
| 631 | it("sends welcome message on connect", async () => { | |
| 632 | server = await createTestServer({ cwd: "/test/project" }); | |
| 633 | const addr = server.address(); | |
| 634 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 635 | expect(port).toBeGreaterThan(0); | |
| 636 | ||
| 637 | const [ws, welcome] = await connectAndAwaitWelcome(port); | |
| 638 | connections.push(ws); | |
| 639 | ||
| 640 | expect(welcome.type).toBe("push"); | |
| 641 | expect(welcome.data).toEqual({ | |
| 642 | cwd: "/test/project", | |
| 643 | projectName: "project", | |
| 644 | }); | |
| 645 | }); | |
| 646 | ||
| 647 | it("serves persisted attachments from stateDir", async () => { | |
| 648 | const baseDir = makeTempDir("t3code-state-attachments-"); | |
| 649 | const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined); | |
| 650 | const attachmentPath = path.join(attachmentsDir, "thread-a", "message-a", "0.png"); | |
| 651 | fs.mkdirSync(path.dirname(attachmentPath), { recursive: true }); | |
| 652 | fs.writeFileSync(attachmentPath, Buffer.from("hello-attachment")); | |
| 653 | ||
| 654 | server = await createTestServer({ cwd: "/test/project", baseDir }); | |
| 655 | const addr = server.address(); | |
| 656 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 657 | expect(port).toBeGreaterThan(0); | |
| 658 | ||
| 659 | const response = await fetch(`http://127.0.0.1:${port}/attachments/thread-a/message-a/0.png`); | |
| 660 | expect(response.status).toBe(200); | |
| 661 | expect(response.headers.get("content-type")).toContain("image/png"); | |
| 662 | const bytes = Buffer.from(await response.arrayBuffer()); | |
| 663 | expect(bytes).toEqual(Buffer.from("hello-attachment")); | |
| 664 | }); | |
| 665 | ||
| 666 | it("serves persisted attachments for URL-encoded paths", async () => { | |
| 667 | const baseDir = makeTempDir("t3code-state-attachments-encoded-"); | |
| 668 | const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined); | |
| 669 | const attachmentPath = path.join( | |
| 670 | attachmentsDir, | |
| 671 | "thread%20folder", | |
| 672 | "message%20folder", | |
| 673 | "file%20name.png", | |
| 674 | ); | |
| 675 | fs.mkdirSync(path.dirname(attachmentPath), { recursive: true }); | |
| 676 | fs.writeFileSync(attachmentPath, Buffer.from("hello-encoded-attachment")); | |
| 677 | ||
| 678 | server = await createTestServer({ cwd: "/test/project", baseDir }); | |
| 679 | const addr = server.address(); | |
| 680 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 681 | expect(port).toBeGreaterThan(0); | |
| 682 | ||
| 683 | const response = await fetch( | |
| 684 | `http://127.0.0.1:${port}/attachments/thread%20folder/message%20folder/file%20name.png`, | |
| 685 | ); | |
| 686 | expect(response.status).toBe(200); | |
| 687 | expect(response.headers.get("content-type")).toContain("image/png"); | |
| 688 | const bytes = Buffer.from(await response.arrayBuffer()); | |
| 689 | expect(bytes).toEqual(Buffer.from("hello-encoded-attachment")); | |
| 690 | }); | |
| 691 | ||
| 692 | it("serves static index for root path", async () => { | |
| 693 | const baseDir = makeTempDir("t3code-state-static-root-"); | |
| 694 | const staticDir = makeTempDir("t3code-static-root-"); | |
| 695 | fs.writeFileSync(path.join(staticDir, "index.html"), "<h1>static-root</h1>", "utf8"); | |
| 696 | ||
| 697 | server = await createTestServer({ cwd: "/test/project", baseDir, staticDir }); | |
| 698 | const addr = server.address(); | |
| 699 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 700 | expect(port).toBeGreaterThan(0); | |
| 701 | ||
| 702 | const response = await fetch(`http://127.0.0.1:${port}/`); | |
| 703 | expect(response.status).toBe(200); | |
| 704 | expect(await response.text()).toContain("static-root"); | |
| 705 | }); | |
| 706 | ||
| 707 | it("rejects static path traversal attempts", async () => { | |
| 708 | const baseDir = makeTempDir("t3code-state-static-traversal-"); | |
| 709 | const staticDir = makeTempDir("t3code-static-traversal-"); | |
| 710 | fs.writeFileSync(path.join(staticDir, "index.html"), "<h1>safe</h1>", "utf8"); | |
| 711 | ||
| 712 | server = await createTestServer({ cwd: "/test/project", baseDir, staticDir }); | |
| 713 | const addr = server.address(); | |
| 714 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 715 | expect(port).toBeGreaterThan(0); | |
| 716 | ||
| 717 | const response = await requestPath(port, "/..%2f..%2fetc/passwd"); | |
| 718 | expect(response.statusCode).toBe(400); | |
| 719 | expect(response.body).toBe("Invalid static file path"); | |
| 720 | }); | |
| 721 | ||
| 722 | it("bootstraps the cwd project on startup when enabled", async () => { | |
| 723 | server = await createTestServer({ | |
| 724 | cwd: "/test/bootstrap-workspace", | |
| 725 | autoBootstrapProjectFromCwd: true, | |
| 726 | }); | |
| 727 | const addr = server.address(); | |
| 728 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 729 | expect(port).toBeGreaterThan(0); | |
| 730 | ||
| 731 | const [ws, welcome] = await connectAndAwaitWelcome(port); | |
| 732 | connections.push(ws); | |
| 733 | expect(welcome.data).toEqual( | |
| 734 | expect.objectContaining({ | |
| 735 | cwd: "/test/bootstrap-workspace", | |
| 736 | projectName: "bootstrap-workspace", | |
| 737 | bootstrapProjectId: expect.any(String), | |
| 738 | bootstrapThreadId: expect.any(String), | |
| 739 | }), | |
| 740 | ); | |
| 741 | ||
| 742 | const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot); | |
| 743 | expect(snapshotResponse.error).toBeUndefined(); | |
| 744 | const snapshot = snapshotResponse.result as { | |
| 745 | projects: Array<{ | |
| 746 | id: string; | |
| 747 | workspaceRoot: string; | |
| 748 | title: string; | |
| 749 | defaultModelSelection: { | |
| 750 | provider: string; | |
| 751 | model: string; | |
| 752 | } | null; | |
| 753 | }>; | |
| 754 | threads: Array<{ | |
| 755 | id: string; | |
| 756 | projectId: string; | |
| 757 | title: string; | |
| 758 | modelSelection: { | |
| 759 | provider: string; | |
| 760 | model: string; | |
| 761 | }; | |
| 762 | branch: string | null; | |
| 763 | worktreePath: string | null; | |
| 764 | }>; | |
| 765 | }; | |
| 766 | const bootstrapProjectId = (welcome.data as { bootstrapProjectId?: string }).bootstrapProjectId; | |
| 767 | const bootstrapThreadId = (welcome.data as { bootstrapThreadId?: string }).bootstrapThreadId; | |
| 768 | expect(bootstrapProjectId).toBeDefined(); | |
| 769 | expect(bootstrapThreadId).toBeDefined(); | |
| 770 | ||
| 771 | expect(snapshot.projects).toEqual( | |
| 772 | expect.arrayContaining([ | |
| 773 | expect.objectContaining({ | |
| 774 | id: bootstrapProjectId, | |
| 775 | workspaceRoot: "/test/bootstrap-workspace", | |
| 776 | title: "bootstrap-workspace", | |
| 777 | defaultModelSelection: { | |
| 778 | provider: "codex", | |
| 779 | model: "gpt-5-codex", | |
| 780 | }, | |
| 781 | }), | |
| 782 | ]), | |
| 783 | ); | |
| 784 | expect(snapshot.threads).toEqual( | |
| 785 | expect.arrayContaining([ | |
| 786 | expect.objectContaining({ | |
| 787 | id: bootstrapThreadId, | |
| 788 | projectId: bootstrapProjectId, | |
| 789 | title: "New thread", | |
| 790 | modelSelection: { | |
| 791 | provider: "codex", | |
| 792 | model: "gpt-5-codex", | |
| 793 | }, | |
| 794 | branch: null, | |
| 795 | worktreePath: null, | |
| 796 | }), | |
| 797 | ]), | |
| 798 | ); | |
| 799 | }); | |
| 800 | ||
| 801 | it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => { | |
| 802 | const baseDir = makeTempDir("t3code-state-bootstrap-existing-"); | |
| 803 | const { dbPath } = deriveServerPathsSync(baseDir, undefined); | |
| 804 | const persistenceLayer = makeSqlitePersistenceLive(dbPath).pipe( | |
| 805 | Layer.provide(NodeServices.layer), | |
| 806 | ); | |
| 807 | const cwd = "/test/bootstrap-existing"; | |
| 808 | ||
| 809 | server = await createTestServer({ | |
| 810 | cwd, | |
| 811 | baseDir, | |
| 812 | persistenceLayer, | |
| 813 | autoBootstrapProjectFromCwd: true, | |
| 814 | }); | |
| 815 | let addr = server.address(); | |
| 816 | let port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 817 | expect(port).toBeGreaterThan(0); | |
| 818 | ||
| 819 | const [firstWs, firstWelcome] = await connectAndAwaitWelcome(port); | |
| 820 | connections.push(firstWs); | |
| 821 | const firstBootstrapProjectId = (firstWelcome.data as { bootstrapProjectId?: string }) | |
| 822 | .bootstrapProjectId; | |
| 823 | const firstBootstrapThreadId = (firstWelcome.data as { bootstrapThreadId?: string }) | |
| 824 | .bootstrapThreadId; | |
| 825 | expect(firstBootstrapProjectId).toBeDefined(); | |
| 826 | expect(firstBootstrapThreadId).toBeDefined(); | |
| 827 | ||
| 828 | firstWs.close(); | |
| 829 | await closeTestServer(); | |
| 830 | server = null; | |
| 831 | ||
| 832 | server = await createTestServer({ | |
| 833 | cwd, | |
| 834 | baseDir, | |
| 835 | persistenceLayer, | |
| 836 | autoBootstrapProjectFromCwd: true, | |
| 837 | }); | |
| 838 | addr = server.address(); | |
| 839 | port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 840 | expect(port).toBeGreaterThan(0); | |
| 841 | ||
| 842 | const [secondWs, secondWelcome] = await connectAndAwaitWelcome(port); | |
| 843 | connections.push(secondWs); | |
| 844 | expect(secondWelcome.data).toEqual( | |
| 845 | expect.objectContaining({ | |
| 846 | cwd, | |
| 847 | projectName: "bootstrap-existing", | |
| 848 | bootstrapProjectId: firstBootstrapProjectId, | |
| 849 | bootstrapThreadId: firstBootstrapThreadId, | |
| 850 | }), | |
| 851 | ); | |
| 852 | }); | |
| 853 | ||
| 854 | it("logs outbound websocket push events in dev mode", async () => { | |
| 855 | const logSpy = vi.spyOn(console, "log").mockImplementation(() => { | |
| 856 | // Keep test output clean while verifying websocket logs. | |
| 857 | }); | |
| 858 | ||
| 859 | server = await createTestServer({ | |
| 860 | cwd: "/test/project", | |
| 861 | devUrl: "http://localhost:5173", | |
| 862 | }); | |
| 863 | const addr = server.address(); | |
| 864 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 865 | expect(port).toBeGreaterThan(0); | |
| 866 | ||
| 867 | const [ws] = await connectAndAwaitWelcome(port); | |
| 868 | connections.push(ws); | |
| 869 | ||
| 870 | expect( | |
| 871 | logSpy.mock.calls.some(([message]) => { | |
| 872 | if (typeof message !== "string") return false; | |
| 873 | return ( | |
| 874 | message.includes("[ws]") && | |
| 875 | message.includes("outgoing push") && | |
| 876 | message.includes(`channel="${WS_CHANNELS.serverWelcome}"`) | |
| 877 | ); | |
| 878 | }), | |
| 879 | ).toBe(true); | |
| 880 | }); | |
| 881 | ||
| 882 | it("responds to server.getConfig", async () => { | |
| 883 | const baseDir = makeTempDir("t3code-state-get-config-"); | |
| 884 | const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); | |
| 885 | ensureParentDir(keybindingsPath); | |
| 886 | fs.writeFileSync(keybindingsPath, "[]", "utf8"); | |
| 887 | ||
| 888 | server = await createTestServer({ cwd: "/my/workspace", baseDir }); | |
| 889 | const addr = server.address(); | |
| 890 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 891 | ||
| 892 | const [ws] = await connectAndAwaitWelcome(port); | |
| 893 | connections.push(ws); | |
| 894 | ||
| 895 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 896 | expect(response.error).toBeUndefined(); | |
| 897 | expect(response.result).toEqual({ | |
| 898 | cwd: "/my/workspace", | |
| 899 | keybindingsConfigPath: keybindingsPath, | |
| 900 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, | |
| 901 | issues: [], | |
| 902 | providers: defaultProviderStatuses, | |
| 903 | availableEditors: expect.any(Array), | |
| 904 | settings: defaultServerSettings, | |
| 905 | }); | |
| 906 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); | |
| 907 | }); | |
| 908 | ||
| 909 | it("bootstraps default keybindings file when missing", async () => { | |
| 910 | const baseDir = makeTempDir("t3code-state-bootstrap-keybindings-"); | |
| 911 | const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); | |
| 912 | expect(fs.existsSync(keybindingsPath)).toBe(false); | |
| 913 | ||
| 914 | server = await createTestServer({ cwd: "/my/workspace", baseDir }); | |
| 915 | const addr = server.address(); | |
| 916 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 917 | ||
| 918 | const [ws] = await connectAndAwaitWelcome(port); | |
| 919 | connections.push(ws); | |
| 920 | ||
| 921 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 922 | expect(response.error).toBeUndefined(); | |
| 923 | expect(response.result).toEqual({ | |
| 924 | cwd: "/my/workspace", | |
| 925 | keybindingsConfigPath: keybindingsPath, | |
| 926 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, | |
| 927 | issues: [], | |
| 928 | providers: defaultProviderStatuses, | |
| 929 | availableEditors: expect.any(Array), | |
| 930 | settings: defaultServerSettings, | |
| 931 | }); | |
| 932 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); | |
| 933 | ||
| 934 | const persistedConfig = JSON.parse( | |
| 935 | fs.readFileSync(keybindingsPath, "utf8"), | |
| 936 | ) as KeybindingsConfig; | |
| 937 | expect(persistedConfig).toEqual(DEFAULT_KEYBINDINGS); | |
| 938 | }); | |
| 939 | ||
| 940 | it("falls back to defaults and reports malformed keybindings config issues", async () => { | |
| 941 | const baseDir = makeTempDir("t3code-state-malformed-keybindings-"); | |
| 942 | const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); | |
| 943 | ensureParentDir(keybindingsPath); | |
| 944 | fs.writeFileSync(keybindingsPath, "{ not-json", "utf8"); | |
| 945 | ||
| 946 | server = await createTestServer({ cwd: "/my/workspace", baseDir }); | |
| 947 | const addr = server.address(); | |
| 948 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 949 | ||
| 950 | const [ws] = await connectAndAwaitWelcome(port); | |
| 951 | connections.push(ws); | |
| 952 | ||
| 953 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 954 | expect(response.error).toBeUndefined(); | |
| 955 | expect(response.result).toEqual({ | |
| 956 | cwd: "/my/workspace", | |
| 957 | keybindingsConfigPath: keybindingsPath, | |
| 958 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, | |
| 959 | issues: [ | |
| 960 | { | |
| 961 | kind: "keybindings.malformed-config", | |
| 962 | message: expect.stringContaining("expected JSON array"), | |
| 963 | }, | |
| 964 | ], | |
| 965 | providers: defaultProviderStatuses, | |
| 966 | availableEditors: expect.any(Array), | |
| 967 | settings: defaultServerSettings, | |
| 968 | }); | |
| 969 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); | |
| 970 | expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json"); | |
| 971 | }); | |
| 972 | ||
| 973 | it("ignores invalid keybinding entries but keeps valid entries and reports issues", async () => { | |
| 974 | const baseDir = makeTempDir("t3code-state-partial-invalid-keybindings-"); | |
| 975 | const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); | |
| 976 | ensureParentDir(keybindingsPath); | |
| 977 | fs.writeFileSync( | |
| 978 | keybindingsPath, | |
| 979 | JSON.stringify([ | |
| 980 | { key: "mod+j", command: "terminal.toggle" }, | |
| 981 | { key: "mod+shift+d+o", command: "terminal.new" }, | |
| 982 | { key: "mod+x", command: "not-a-real-command" }, | |
| 983 | ]), | |
| 984 | "utf8", | |
| 985 | ); | |
| 986 | ||
| 987 | server = await createTestServer({ cwd: "/my/workspace", baseDir }); | |
| 988 | const addr = server.address(); | |
| 989 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 990 | ||
| 991 | const [ws] = await connectAndAwaitWelcome(port); | |
| 992 | connections.push(ws); | |
| 993 | ||
| 994 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 995 | expect(response.error).toBeUndefined(); | |
| 996 | const result = response.result as { | |
| 997 | cwd: string; | |
| 998 | keybindingsConfigPath: string; | |
| 999 | keybindings: ResolvedKeybindingsConfig; | |
| 1000 | issues: Array<{ kind: string; index?: number; message: string }>; | |
| 1001 | providers: ReadonlyArray<ServerProvider>; | |
| 1002 | availableEditors: unknown; | |
| 1003 | }; | |
| 1004 | expect(result.cwd).toBe("/my/workspace"); | |
| 1005 | expect(result.keybindingsConfigPath).toBe(keybindingsPath); | |
| 1006 | expect(result.issues).toEqual([ | |
| 1007 | { | |
| 1008 | kind: "keybindings.invalid-entry", | |
| 1009 | index: 1, | |
| 1010 | message: expect.any(String), | |
| 1011 | }, | |
| 1012 | { | |
| 1013 | kind: "keybindings.invalid-entry", | |
| 1014 | index: 2, | |
| 1015 | message: expect.any(String), | |
| 1016 | }, | |
| 1017 | ]); | |
| 1018 | expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length); | |
| 1019 | expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true); | |
| 1020 | expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true); | |
| 1021 | expect(result.providers).toEqual(defaultProviderStatuses); | |
| 1022 | expectAvailableEditors(result.availableEditors); | |
| 1023 | }); | |
| 1024 | ||
| 1025 | it("pushes server.configUpdated issues when keybindings file changes", async () => { | |
| 1026 | const baseDir = makeTempDir("t3code-state-keybindings-watch-"); | |
| 1027 | const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); | |
| 1028 | ensureParentDir(keybindingsPath); | |
| 1029 | fs.writeFileSync(keybindingsPath, "[]", "utf8"); | |
| 1030 | ||
| 1031 | server = await createTestServer({ cwd: "/my/workspace", baseDir }); | |
| 1032 | const addr = server.address(); | |
| 1033 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1034 | ||
| 1035 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1036 | connections.push(ws); | |
| 1037 | ||
| 1038 | const malformedPush = await rewriteKeybindingsAndWaitForPush( | |
| 1039 | ws, | |
| 1040 | keybindingsPath, | |
| 1041 | "{ not-json", | |
| 1042 | (push) => | |
| 1043 | Array.isArray(push.data.issues) && | |
| 1044 | Boolean(push.data.issues[0]) && | |
| 1045 | push.data.issues[0]!.kind === "keybindings.malformed-config", | |
| 1046 | ); | |
| 1047 | expect(malformedPush.data).toEqual({ | |
| 1048 | issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], | |
| 1049 | }); | |
| 1050 | ||
| 1051 | const successPush = await rewriteKeybindingsAndWaitForPush( | |
| 1052 | ws, | |
| 1053 | keybindingsPath, | |
| 1054 | "[]", | |
| 1055 | (push) => Array.isArray(push.data.issues) && push.data.issues.length === 0, | |
| 1056 | ); | |
| 1057 | expect(successPush.data).toEqual({ issues: [] }); | |
| 1058 | }); | |
| 1059 | ||
| 1060 | it("routes shell.openInEditor through the injected open service", async () => { | |
| 1061 | const openCalls: Array<{ cwd: string; editor: string }> = []; | |
| 1062 | const openService: OpenShape = { | |
| 1063 | openBrowser: () => Effect.void, | |
| 1064 | openInEditor: (input) => { | |
| 1065 | openCalls.push({ cwd: input.cwd, editor: input.editor }); | |
| 1066 | return Effect.void; | |
| 1067 | }, | |
| 1068 | }; | |
| 1069 | ||
| 1070 | server = await createTestServer({ cwd: "/my/workspace", open: openService }); | |
| 1071 | const addr = server.address(); | |
| 1072 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1073 | ||
| 1074 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1075 | connections.push(ws); | |
| 1076 | ||
| 1077 | const response = await sendRequest(ws, WS_METHODS.shellOpenInEditor, { | |
| 1078 | cwd: "/my/workspace", | |
| 1079 | editor: "cursor", | |
| 1080 | }); | |
| 1081 | expect(response.error).toBeUndefined(); | |
| 1082 | expect(openCalls).toEqual([{ cwd: "/my/workspace", editor: "cursor" }]); | |
| 1083 | }); | |
| 1084 | ||
| 1085 | it("reads keybindings from the configured state directory", async () => { | |
| 1086 | const baseDir = makeTempDir("t3code-state-keybindings-"); | |
| 1087 | const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); | |
| 1088 | ensureParentDir(keybindingsPath); | |
| 1089 | fs.writeFileSync( | |
| 1090 | keybindingsPath, | |
| 1091 | JSON.stringify([ | |
| 1092 | { key: "cmd+j", command: "terminal.toggle" }, | |
| 1093 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | |
| 1094 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, | |
| 1095 | ]), | |
| 1096 | "utf8", | |
| 1097 | ); | |
| 1098 | server = await createTestServer({ cwd: "/my/workspace", baseDir }); | |
| 1099 | const addr = server.address(); | |
| 1100 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1101 | ||
| 1102 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1103 | connections.push(ws); | |
| 1104 | ||
| 1105 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 1106 | expect(response.error).toBeUndefined(); | |
| 1107 | const persistedConfig = JSON.parse( | |
| 1108 | fs.readFileSync(keybindingsPath, "utf8"), | |
| 1109 | ) as KeybindingsConfig; | |
| 1110 | expect(response.result).toEqual({ | |
| 1111 | cwd: "/my/workspace", | |
| 1112 | keybindingsConfigPath: keybindingsPath, | |
| 1113 | keybindings: compileKeybindings(persistedConfig), | |
| 1114 | issues: [], | |
| 1115 | providers: defaultProviderStatuses, | |
| 1116 | availableEditors: expect.any(Array), | |
| 1117 | settings: defaultServerSettings, | |
| 1118 | }); | |
| 1119 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); | |
| 1120 | }); | |
| 1121 | ||
| 1122 | it("upserts keybinding rules and updates cached server config", async () => { | |
| 1123 | const baseDir = makeTempDir("t3code-state-upsert-keybinding-"); | |
| 1124 | const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); | |
| 1125 | ensureParentDir(keybindingsPath); | |
| 1126 | fs.writeFileSync( | |
| 1127 | keybindingsPath, | |
| 1128 | JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), | |
| 1129 | "utf8", | |
| 1130 | ); | |
| 1131 | ||
| 1132 | server = await createTestServer({ cwd: "/my/workspace", baseDir }); | |
| 1133 | const addr = server.address(); | |
| 1134 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1135 | ||
| 1136 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1137 | connections.push(ws); | |
| 1138 | ||
| 1139 | const upsertResponse = await sendRequest(ws, WS_METHODS.serverUpsertKeybinding, { | |
| 1140 | key: "mod+shift+r", | |
| 1141 | command: "script.run-tests.run", | |
| 1142 | }); | |
| 1143 | expect(upsertResponse.error).toBeUndefined(); | |
| 1144 | const persistedConfig = JSON.parse( | |
| 1145 | fs.readFileSync(keybindingsPath, "utf8"), | |
| 1146 | ) as KeybindingsConfig; | |
| 1147 | const persistedCommands = new Set(persistedConfig.map((entry) => entry.command)); | |
| 1148 | for (const defaultRule of DEFAULT_KEYBINDINGS) { | |
| 1149 | expect(persistedCommands.has(defaultRule.command)).toBe(true); | |
| 1150 | } | |
| 1151 | expect(persistedCommands.has("script.run-tests.run")).toBe(true); | |
| 1152 | expect(upsertResponse.result).toEqual({ | |
| 1153 | keybindings: compileKeybindings(persistedConfig), | |
| 1154 | issues: [], | |
| 1155 | }); | |
| 1156 | ||
| 1157 | const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 1158 | expect(configResponse.error).toBeUndefined(); | |
| 1159 | expect(configResponse.result).toEqual({ | |
| 1160 | cwd: "/my/workspace", | |
| 1161 | keybindingsConfigPath: keybindingsPath, | |
| 1162 | keybindings: compileKeybindings(persistedConfig), | |
| 1163 | issues: [], | |
| 1164 | providers: defaultProviderStatuses, | |
| 1165 | availableEditors: expect.any(Array), | |
| 1166 | settings: defaultServerSettings, | |
| 1167 | }); | |
| 1168 | expectAvailableEditors( | |
| 1169 | (configResponse.result as { availableEditors: unknown }).availableEditors, | |
| 1170 | ); | |
| 1171 | }); | |
| 1172 | ||
| 1173 | it("returns error for unknown methods", async () => { | |
| 1174 | server = await createTestServer({ cwd: "/test" }); | |
| 1175 | const addr = server.address(); | |
| 1176 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1177 | ||
| 1178 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1179 | connections.push(ws); | |
| 1180 | ||
| 1181 | const response = await sendRequest(ws, "nonexistent.method"); | |
| 1182 | expect(response.error).toBeDefined(); | |
| 1183 | expect(response.error!.message).toContain("Invalid request format"); | |
| 1184 | }); | |
| 1185 | ||
| 1186 | it("returns error when requesting turn diff for unknown thread", async () => { | |
| 1187 | server = await createTestServer({ cwd: "/test" }); | |
| 1188 | const addr = server.address(); | |
| 1189 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1190 | ||
| 1191 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1192 | connections.push(ws); | |
| 1193 | ||
| 1194 | const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { | |
| 1195 | threadId: "thread-missing", | |
| 1196 | fromTurnCount: 1, | |
| 1197 | toTurnCount: 2, | |
| 1198 | }); | |
| 1199 | expect(response.result).toBeUndefined(); | |
| 1200 | expect(response.error?.message).toContain("Thread 'thread-missing' not found."); | |
| 1201 | }); | |
| 1202 | ||
| 1203 | it("returns error when requesting turn diff with an inverted range", async () => { | |
| 1204 | server = await createTestServer({ cwd: "/test" }); | |
| 1205 | const addr = server.address(); | |
| 1206 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1207 | ||
| 1208 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1209 | connections.push(ws); | |
| 1210 | ||
| 1211 | const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { | |
| 1212 | threadId: "thread-any", | |
| 1213 | fromTurnCount: 2, | |
| 1214 | toTurnCount: 1, | |
| 1215 | }); | |
| 1216 | expect(response.result).toBeUndefined(); | |
| 1217 | expect(response.error?.message).toContain( | |
| 1218 | "fromTurnCount must be less than or equal to toTurnCount", | |
| 1219 | ); | |
| 1220 | }); | |
| 1221 | ||
| 1222 | it("returns error when requesting full thread diff for unknown thread", async () => { | |
| 1223 | server = await createTestServer({ cwd: "/test" }); | |
| 1224 | const addr = server.address(); | |
| 1225 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1226 | ||
| 1227 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1228 | connections.push(ws); | |
| 1229 | ||
| 1230 | const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getFullThreadDiff, { | |
| 1231 | threadId: "thread-missing", | |
| 1232 | toTurnCount: 2, | |
| 1233 | }); | |
| 1234 | expect(response.result).toBeUndefined(); | |
| 1235 | expect(response.error?.message).toContain("Thread 'thread-missing' not found."); | |
| 1236 | }); | |
| 1237 | ||
| 1238 | it("returns retryable error when requested turn exceeds current checkpoint turn count", async () => { | |
| 1239 | server = await createTestServer({ cwd: "/test" }); | |
| 1240 | const addr = server.address(); | |
| 1241 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1242 | ||
| 1243 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1244 | connections.push(ws); | |
| 1245 | ||
| 1246 | const workspaceRoot = makeTempDir("t3code-ws-diff-project-"); | |
| 1247 | const createdAt = new Date().toISOString(); | |
| 1248 | const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { | |
| 1249 | type: "project.create", | |
| 1250 | commandId: "cmd-diff-project-create", | |
| 1251 | projectId: "project-diff", | |
| 1252 | title: "Diff Project", | |
| 1253 | workspaceRoot, | |
| 1254 | defaultModelSelection: { | |
| 1255 | provider: "codex", | |
| 1256 | model: "gpt-5-codex", | |
| 1257 | }, | |
| 1258 | createdAt, | |
| 1259 | }); | |
| 1260 | expect(createProjectResponse.error).toBeUndefined(); | |
| 1261 | const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { | |
| 1262 | type: "thread.create", | |
| 1263 | commandId: "cmd-diff-thread-create", | |
| 1264 | threadId: "thread-diff", | |
| 1265 | projectId: "project-diff", | |
| 1266 | title: "Diff Thread", | |
| 1267 | modelSelection: { | |
| 1268 | provider: "codex", | |
| 1269 | model: "gpt-5-codex", | |
| 1270 | }, | |
| 1271 | runtimeMode: "full-access", | |
| 1272 | interactionMode: "default", | |
| 1273 | branch: null, | |
| 1274 | worktreePath: null, | |
| 1275 | createdAt, | |
| 1276 | }); | |
| 1277 | expect(createThreadResponse.error).toBeUndefined(); | |
| 1278 | ||
| 1279 | const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { | |
| 1280 | threadId: "thread-diff", | |
| 1281 | fromTurnCount: 0, | |
| 1282 | toTurnCount: 1, | |
| 1283 | }); | |
| 1284 | expect(response.result).toBeUndefined(); | |
| 1285 | expect(response.error?.message).toContain("exceeds current turn count"); | |
| 1286 | }); | |
| 1287 | ||
| 1288 | it("rejects project.create when the workspace root does not exist", async () => { | |
| 1289 | server = await createTestServer({ cwd: "/test" }); | |
| 1290 | const addr = server.address(); | |
| 1291 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1292 | ||
| 1293 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1294 | connections.push(ws); | |
| 1295 | ||
| 1296 | const missingWorkspaceRoot = path.join(makeTempDir("t3code-ws-project-missing-"), "missing"); | |
| 1297 | const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { | |
| 1298 | type: "project.create", | |
| 1299 | commandId: "cmd-ws-project-create-missing", | |
| 1300 | projectId: "project-missing", | |
| 1301 | title: "Missing Project", | |
| 1302 | workspaceRoot: missingWorkspaceRoot, | |
| 1303 | defaultModelSelection: { | |
| 1304 | provider: "codex", | |
| 1305 | model: "gpt-5-codex", | |
| 1306 | }, | |
| 1307 | createdAt: new Date().toISOString(), | |
| 1308 | }); | |
| 1309 | ||
| 1310 | expect(response.result).toBeUndefined(); | |
| 1311 | expect(response.error?.message).toContain("Workspace root does not exist:"); | |
| 1312 | }); | |
| 1313 | ||
| 1314 | it("keeps orchestration domain push behavior for provider runtime events", async () => { | |
| 1315 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); | |
| 1316 | const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { | |
| 1317 | Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); | |
| 1318 | }; | |
| 1319 | const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; | |
| 1320 | const providerService: ProviderServiceShape = { | |
| 1321 | startSession: (threadId) => | |
| 1322 | Effect.succeed({ | |
| 1323 | provider: "codex", | |
| 1324 | status: "ready", | |
| 1325 | runtimeMode: "full-access", | |
| 1326 | threadId, | |
| 1327 | createdAt: new Date().toISOString(), | |
| 1328 | updatedAt: new Date().toISOString(), | |
| 1329 | }), | |
| 1330 | sendTurn: ({ threadId }) => | |
| 1331 | Effect.succeed({ | |
| 1332 | threadId, | |
| 1333 | turnId: asTurnId("provider-turn-1"), | |
| 1334 | }), | |
| 1335 | interruptTurn: () => unsupported(), | |
| 1336 | respondToRequest: () => unsupported(), | |
| 1337 | respondToUserInput: () => unsupported(), | |
| 1338 | stopSession: () => unsupported(), | |
| 1339 | listSessions: () => Effect.succeed([]), | |
| 1340 | getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), | |
| 1341 | rollbackConversation: () => unsupported(), | |
| 1342 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), | |
| 1343 | }; | |
| 1344 | const providerLayer = Layer.succeed(ProviderService, providerService); | |
| 1345 | ||
| 1346 | server = await createTestServer({ | |
| 1347 | cwd: "/test", | |
| 1348 | providerLayer, | |
| 1349 | serverSettings: { enableAssistantStreaming: true }, | |
| 1350 | }); | |
| 1351 | const addr = server.address(); | |
| 1352 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1353 | ||
| 1354 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1355 | connections.push(ws); | |
| 1356 | ||
| 1357 | const workspaceRoot = makeTempDir("t3code-ws-project-"); | |
| 1358 | const createdAt = new Date().toISOString(); | |
| 1359 | const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { | |
| 1360 | type: "project.create", | |
| 1361 | commandId: "cmd-ws-project-create", | |
| 1362 | projectId: "project-1", | |
| 1363 | title: "WS Project", | |
| 1364 | workspaceRoot, | |
| 1365 | defaultModelSelection: { | |
| 1366 | provider: "codex", | |
| 1367 | model: "gpt-5-codex", | |
| 1368 | }, | |
| 1369 | createdAt, | |
| 1370 | }); | |
| 1371 | expect(createProjectResponse.error).toBeUndefined(); | |
| 1372 | const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { | |
| 1373 | type: "thread.create", | |
| 1374 | commandId: "cmd-ws-runtime-thread-create", | |
| 1375 | threadId: "thread-1", | |
| 1376 | projectId: "project-1", | |
| 1377 | title: "Thread 1", | |
| 1378 | modelSelection: { | |
| 1379 | provider: "codex", | |
| 1380 | model: "gpt-5-codex", | |
| 1381 | }, | |
| 1382 | runtimeMode: "full-access", | |
| 1383 | interactionMode: "default", | |
| 1384 | branch: null, | |
| 1385 | worktreePath: null, | |
| 1386 | createdAt, | |
| 1387 | }); | |
| 1388 | expect(createThreadResponse.error).toBeUndefined(); | |
| 1389 | ||
| 1390 | const startTurnResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { | |
| 1391 | type: "thread.turn.start", | |
| 1392 | commandId: "cmd-ws-runtime-turn-start", | |
| 1393 | threadId: "thread-1", | |
| 1394 | message: { | |
| 1395 | messageId: "msg-ws-runtime-1", | |
| 1396 | role: "user", | |
| 1397 | text: "hello", | |
| 1398 | attachments: [], | |
| 1399 | }, | |
| 1400 | runtimeMode: "approval-required", | |
| 1401 | interactionMode: "default", | |
| 1402 | createdAt, | |
| 1403 | }); | |
| 1404 | expect(startTurnResponse.error).toBeUndefined(); | |
| 1405 | ||
| 1406 | await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => { | |
| 1407 | const event = push.data as { type?: string }; | |
| 1408 | return event.type === "thread.session-set"; | |
| 1409 | }); | |
| 1410 | ||
| 1411 | emitRuntimeEvent({ | |
| 1412 | type: "content.delta", | |
| 1413 | eventId: asEventId("evt-ws-runtime-message-delta"), | |
| 1414 | provider: "codex", | |
| 1415 | threadId: asThreadId("thread-1"), | |
| 1416 | createdAt: new Date().toISOString(), | |
| 1417 | turnId: asTurnId("turn-1"), | |
| 1418 | itemId: asProviderItemId("item-1"), | |
| 1419 | payload: { | |
| 1420 | streamKind: "assistant_text", | |
| 1421 | delta: "hello from runtime", | |
| 1422 | }, | |
| 1423 | } as unknown as ProviderRuntimeEvent); | |
| 1424 | ||
| 1425 | const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => { | |
| 1426 | const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } }; | |
| 1427 | return ( | |
| 1428 | event.type === "thread.message-sent" && event.payload?.messageId === "assistant:item-1" | |
| 1429 | ); | |
| 1430 | }); | |
| 1431 | ||
| 1432 | const domainEvent = domainPush.data as { | |
| 1433 | type: string; | |
| 1434 | payload: { messageId: string; text: string }; | |
| 1435 | }; | |
| 1436 | expect(domainEvent.type).toBe("thread.message-sent"); | |
| 1437 | expect(domainEvent.payload.messageId).toBe("assistant:item-1"); | |
| 1438 | expect(domainEvent.payload.text).toBe("hello from runtime"); | |
| 1439 | }); | |
| 1440 | ||
| 1441 | it("routes terminal RPC methods and broadcasts terminal events", async () => { | |
| 1442 | const cwd = makeTempDir("t3code-ws-terminal-cwd-"); | |
| 1443 | const terminalManager = new MockTerminalManager(); | |
| 1444 | server = await createTestServer({ | |
| 1445 | cwd: "/test", | |
| 1446 | terminalManager, | |
| 1447 | }); | |
| 1448 | const addr = server.address(); | |
| 1449 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1450 | ||
| 1451 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1452 | connections.push(ws); | |
| 1453 | ||
| 1454 | const open = await sendRequest(ws, WS_METHODS.terminalOpen, { | |
| 1455 | threadId: "thread-1", | |
| 1456 | cwd, | |
| 1457 | cols: 100, | |
| 1458 | rows: 24, | |
| 1459 | }); | |
| 1460 | expect(open.error).toBeUndefined(); | |
| 1461 | expect((open.result as TerminalSessionSnapshot).threadId).toBe("thread-1"); | |
| 1462 | expect((open.result as TerminalSessionSnapshot).terminalId).toBe(DEFAULT_TERMINAL_ID); | |
| 1463 | ||
| 1464 | const write = await sendRequest(ws, WS_METHODS.terminalWrite, { | |
| 1465 | threadId: "thread-1", | |
| 1466 | data: "echo hello\n", | |
| 1467 | }); | |
| 1468 | expect(write.error).toBeUndefined(); | |
| 1469 | ||
| 1470 | const resize = await sendRequest(ws, WS_METHODS.terminalResize, { | |
| 1471 | threadId: "thread-1", | |
| 1472 | cols: 120, | |
| 1473 | rows: 30, | |
| 1474 | }); | |
| 1475 | expect(resize.error).toBeUndefined(); | |
| 1476 | ||
| 1477 | const clear = await sendRequest(ws, WS_METHODS.terminalClear, { | |
| 1478 | threadId: "thread-1", | |
| 1479 | }); | |
| 1480 | expect(clear.error).toBeUndefined(); | |
| 1481 | ||
| 1482 | const restart = await sendRequest(ws, WS_METHODS.terminalRestart, { | |
| 1483 | threadId: "thread-1", | |
| 1484 | cwd, | |
| 1485 | cols: 120, | |
| 1486 | rows: 30, | |
| 1487 | }); | |
| 1488 | expect(restart.error).toBeUndefined(); | |
| 1489 | ||
| 1490 | const close = await sendRequest(ws, WS_METHODS.terminalClose, { | |
| 1491 | threadId: "thread-1", | |
| 1492 | deleteHistory: true, | |
| 1493 | }); | |
| 1494 | expect(close.error).toBeUndefined(); | |
| 1495 | ||
| 1496 | const manualEvent: TerminalEvent = { | |
| 1497 | type: "output", | |
| 1498 | threadId: "thread-1", | |
| 1499 | terminalId: DEFAULT_TERMINAL_ID, | |
| 1500 | createdAt: new Date().toISOString(), | |
| 1501 | data: "manual test output\n", | |
| 1502 | }; | |
| 1503 | terminalManager.emitEvent(manualEvent); | |
| 1504 | ||
| 1505 | const push = await waitForPush( | |
| 1506 | ws, | |
| 1507 | WS_CHANNELS.terminalEvent, | |
| 1508 | (candidate) => (candidate.data as TerminalEvent).type === "output", | |
| 1509 | ); | |
| 1510 | expect(push.type).toBe("push"); | |
| 1511 | expect(push.channel).toBe(WS_CHANNELS.terminalEvent); | |
| 1512 | }); | |
| 1513 | ||
| 1514 | it("shuts down cleanly for injected terminal managers", async () => { | |
| 1515 | const terminalManager = new MockTerminalManager(); | |
| 1516 | server = await createTestServer({ | |
| 1517 | cwd: "/test", | |
| 1518 | terminalManager, | |
| 1519 | }); | |
| 1520 | ||
| 1521 | await closeTestServer(); | |
| 1522 | server = null; | |
| 1523 | ||
| 1524 | expect(() => | |
| 1525 | terminalManager.emitEvent({ | |
| 1526 | type: "output", | |
| 1527 | threadId: "thread-1", | |
| 1528 | terminalId: DEFAULT_TERMINAL_ID, | |
| 1529 | createdAt: new Date().toISOString(), | |
| 1530 | data: "after shutdown\n", | |
| 1531 | }), | |
| 1532 | ).not.toThrow(); | |
| 1533 | }); | |
| 1534 | ||
| 1535 | it("returns validation errors for invalid terminal open params", async () => { | |
| 1536 | server = await createTestServer({ cwd: "/test" }); | |
| 1537 | const addr = server.address(); | |
| 1538 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1539 | ||
| 1540 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1541 | connections.push(ws); | |
| 1542 | ||
| 1543 | const response = await sendRequest(ws, WS_METHODS.terminalOpen, { | |
| 1544 | threadId: "", | |
| 1545 | cwd: "", | |
| 1546 | cols: 1, | |
| 1547 | rows: 1, | |
| 1548 | }); | |
| 1549 | expect(response.error).toBeDefined(); | |
| 1550 | }); | |
| 1551 | ||
| 1552 | it("handles invalid JSON gracefully", async () => { | |
| 1553 | server = await createTestServer({ cwd: "/test" }); | |
| 1554 | const addr = server.address(); | |
| 1555 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1556 | ||
| 1557 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1558 | connections.push(ws); | |
| 1559 | ||
| 1560 | // Send garbage | |
| 1561 | ws.send("not json at all"); | |
| 1562 | ||
| 1563 | // Error response goes to the response channel | |
| 1564 | const channels = channelsBySocket.get(ws)!; | |
| 1565 | let response: WebSocketResponse | null = null; | |
| 1566 | for (let attempt = 0; attempt < 5; attempt += 1) { | |
| 1567 | const message = await dequeue(channels.response, 5_000); | |
| 1568 | if (message.id === "unknown") { | |
| 1569 | response = message; | |
| 1570 | break; | |
| 1571 | } | |
| 1572 | if (message.error) { | |
| 1573 | response = message; | |
| 1574 | break; | |
| 1575 | } | |
| 1576 | } | |
| 1577 | expect(response).toBeDefined(); | |
| 1578 | expect(response!.error).toBeDefined(); | |
| 1579 | expect(response!.error!.message).toContain("Invalid request format"); | |
| 1580 | }); | |
| 1581 | ||
| 1582 | it("catches websocket message handler rejections and keeps the socket usable", async () => { | |
| 1583 | const unhandledRejections: unknown[] = []; | |
| 1584 | const onUnhandledRejection = (reason: unknown) => { | |
| 1585 | unhandledRejections.push(reason); | |
| 1586 | }; | |
| 1587 | process.on("unhandledRejection", onUnhandledRejection); | |
| 1588 | ||
| 1589 | const brokenOpenService: OpenShape = { | |
| 1590 | openBrowser: () => Effect.void, | |
| 1591 | openInEditor: () => | |
| 1592 | Effect.sync(() => BigInt(1)).pipe(Effect.map((result) => result as unknown as void)), | |
| 1593 | }; | |
| 1594 | ||
| 1595 | try { | |
| 1596 | server = await createTestServer({ cwd: "/test", open: brokenOpenService }); | |
| 1597 | const addr = server.address(); | |
| 1598 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1599 | ||
| 1600 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1601 | connections.push(ws); | |
| 1602 | ||
| 1603 | ws.send( | |
| 1604 | JSON.stringify({ | |
| 1605 | id: "req-broken-open", | |
| 1606 | body: { | |
| 1607 | _tag: WS_METHODS.shellOpenInEditor, | |
| 1608 | cwd: "/tmp", | |
| 1609 | editor: "cursor", | |
| 1610 | }, | |
| 1611 | }), | |
| 1612 | ); | |
| 1613 | ||
| 1614 | await new Promise((resolve) => setTimeout(resolve, 50)); | |
| 1615 | expect(unhandledRejections).toHaveLength(0); | |
| 1616 | ||
| 1617 | const workspace = makeTempDir("t3code-ws-handler-still-usable-"); | |
| 1618 | fs.writeFileSync(path.join(workspace, "file.txt"), "ok\n", "utf8"); | |
| 1619 | const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, { | |
| 1620 | cwd: workspace, | |
| 1621 | query: "file", | |
| 1622 | limit: 5, | |
| 1623 | }); | |
| 1624 | expect(response.error).toBeUndefined(); | |
| 1625 | expect(response.result).toEqual( | |
| 1626 | expect.objectContaining({ | |
| 1627 | entries: expect.arrayContaining([ | |
| 1628 | expect.objectContaining({ | |
| 1629 | path: "file.txt", | |
| 1630 | kind: "file", | |
| 1631 | }), | |
| 1632 | ]), | |
| 1633 | }), | |
| 1634 | ); | |
| 1635 | } finally { | |
| 1636 | process.off("unhandledRejection", onUnhandledRejection); | |
| 1637 | } | |
| 1638 | }); | |
| 1639 | ||
| 1640 | it("returns errors for removed projects CRUD methods", async () => { | |
| 1641 | server = await createTestServer({ cwd: "/test" }); | |
| 1642 | const addr = server.address(); | |
| 1643 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1644 | ||
| 1645 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1646 | connections.push(ws); | |
| 1647 | ||
| 1648 | const listResponse = await sendRequest(ws, WS_METHODS.projectsList); | |
| 1649 | expect(listResponse.result).toBeUndefined(); | |
| 1650 | expect(listResponse.error?.message).toContain("Invalid request format"); | |
| 1651 | ||
| 1652 | const addResponse = await sendRequest(ws, WS_METHODS.projectsAdd, { | |
| 1653 | cwd: "/tmp/project-a", | |
| 1654 | }); | |
| 1655 | expect(addResponse.result).toBeUndefined(); | |
| 1656 | expect(addResponse.error?.message).toContain("Invalid request format"); | |
| 1657 | ||
| 1658 | const removeResponse = await sendRequest(ws, WS_METHODS.projectsRemove, { | |
| 1659 | id: "project-a", | |
| 1660 | }); | |
| 1661 | expect(removeResponse.result).toBeUndefined(); | |
| 1662 | expect(removeResponse.error?.message).toContain("Invalid request format"); | |
| 1663 | }); | |
| 1664 | ||
| 1665 | it("supports projects.searchEntries", async () => { | |
| 1666 | const workspace = makeTempDir("t3code-ws-workspace-entries-"); | |
| 1667 | fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true }); | |
| 1668 | fs.writeFileSync( | |
| 1669 | path.join(workspace, "src", "components", "Composer.tsx"), | |
| 1670 | "export {};", | |
| 1671 | "utf8", | |
| 1672 | ); | |
| 1673 | fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8"); | |
| 1674 | fs.mkdirSync(path.join(workspace, ".git"), { recursive: true }); | |
| 1675 | fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8"); | |
| 1676 | ||
| 1677 | server = await createTestServer({ cwd: "/test" }); | |
| 1678 | const addr = server.address(); | |
| 1679 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1680 | ||
| 1681 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1682 | connections.push(ws); | |
| 1683 | ||
| 1684 | const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, { | |
| 1685 | cwd: workspace, | |
| 1686 | query: "comp", | |
| 1687 | limit: 10, | |
| 1688 | }); | |
| 1689 | expect(response.error).toBeUndefined(); | |
| 1690 | expect(response.result).toEqual({ | |
| 1691 | entries: expect.arrayContaining([ | |
| 1692 | expect.objectContaining({ path: "src/components", kind: "directory" }), | |
| 1693 | expect.objectContaining({ path: "src/components/Composer.tsx", kind: "file" }), | |
| 1694 | ]), | |
| 1695 | truncated: false, | |
| 1696 | }); | |
| 1697 | }); | |
| 1698 | ||
| 1699 | it("supports projects.writeFile within the workspace root", async () => { | |
| 1700 | const workspace = makeTempDir("t3code-ws-write-file-"); | |
| 1701 | ||
| 1702 | server = await createTestServer({ cwd: "/test" }); | |
| 1703 | const addr = server.address(); | |
| 1704 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1705 | ||
| 1706 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1707 | connections.push(ws); | |
| 1708 | ||
| 1709 | const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, { | |
| 1710 | cwd: workspace, | |
| 1711 | relativePath: "plans/effect-rpc.md", | |
| 1712 | contents: "# Plan\n\n- step 1\n", | |
| 1713 | }); | |
| 1714 | ||
| 1715 | expect(response.error).toBeUndefined(); | |
| 1716 | expect(response.result).toEqual({ | |
| 1717 | relativePath: "plans/effect-rpc.md", | |
| 1718 | }); | |
| 1719 | expect(fs.readFileSync(path.join(workspace, "plans", "effect-rpc.md"), "utf8")).toBe( | |
| 1720 | "# Plan\n\n- step 1\n", | |
| 1721 | ); | |
| 1722 | }); | |
| 1723 | ||
| 1724 | it("invalidates workspace entry search cache after projects.writeFile", async () => { | |
| 1725 | const workspace = makeTempDir("t3code-ws-write-file-invalidate-"); | |
| 1726 | fs.mkdirSync(path.join(workspace, "src"), { recursive: true }); | |
| 1727 | fs.writeFileSync(path.join(workspace, "src", "existing.ts"), "export {};\n", "utf8"); | |
| 1728 | ||
| 1729 | server = await createTestServer({ cwd: "/test" }); | |
| 1730 | const addr = server.address(); | |
| 1731 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1732 | ||
| 1733 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1734 | connections.push(ws); | |
| 1735 | ||
| 1736 | const beforeWrite = await sendRequest(ws, WS_METHODS.projectsSearchEntries, { | |
| 1737 | cwd: workspace, | |
| 1738 | query: "rpc", | |
| 1739 | limit: 10, | |
| 1740 | }); | |
| 1741 | expect(beforeWrite.error).toBeUndefined(); | |
| 1742 | expect(beforeWrite.result).toEqual({ | |
| 1743 | entries: [], | |
| 1744 | truncated: false, | |
| 1745 | }); | |
| 1746 | ||
| 1747 | const writeResponse = await sendRequest(ws, WS_METHODS.projectsWriteFile, { | |
| 1748 | cwd: workspace, | |
| 1749 | relativePath: "plans/effect-rpc.md", | |
| 1750 | contents: "# Plan\n", | |
| 1751 | }); | |
| 1752 | expect(writeResponse.error).toBeUndefined(); | |
| 1753 | ||
| 1754 | const afterWrite = await sendRequest(ws, WS_METHODS.projectsSearchEntries, { | |
| 1755 | cwd: workspace, | |
| 1756 | query: "rpc", | |
| 1757 | limit: 10, | |
| 1758 | }); | |
| 1759 | expect(afterWrite.error).toBeUndefined(); | |
| 1760 | expect(afterWrite.result).toEqual({ | |
| 1761 | entries: expect.arrayContaining([ | |
| 1762 | expect.objectContaining({ path: "plans/effect-rpc.md", kind: "file" }), | |
| 1763 | ]), | |
| 1764 | truncated: false, | |
| 1765 | }); | |
| 1766 | }); | |
| 1767 | ||
| 1768 | it("rejects projects.writeFile paths outside the workspace root", async () => { | |
| 1769 | const workspace = makeTempDir("t3code-ws-write-file-reject-"); | |
| 1770 | ||
| 1771 | server = await createTestServer({ cwd: "/test" }); | |
| 1772 | const addr = server.address(); | |
| 1773 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1774 | ||
| 1775 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1776 | connections.push(ws); | |
| 1777 | ||
| 1778 | const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, { | |
| 1779 | cwd: workspace, | |
| 1780 | relativePath: "../escape.md", | |
| 1781 | contents: "# no\n", | |
| 1782 | }); | |
| 1783 | ||
| 1784 | expect(response.result).toBeUndefined(); | |
| 1785 | expect(response.error?.message).toContain( | |
| 1786 | "Workspace file path must be relative to the project root: ../escape.md", | |
| 1787 | ); | |
| 1788 | expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false); | |
| 1789 | }); | |
| 1790 | ||
| 1791 | it("routes git core methods over websocket", async () => { | |
| 1792 | const listBranches = vi.fn(() => | |
| 1793 | Effect.succeed({ | |
| 1794 | branches: [], | |
| 1795 | isRepo: false, | |
| 1796 | hasOriginRemote: false, | |
| 1797 | }), | |
| 1798 | ); | |
| 1799 | const initRepo = vi.fn(() => Effect.void); | |
| 1800 | const pullCurrentBranch = vi.fn(() => | |
| 1801 | Effect.fail( | |
| 1802 | new GitCommandError({ | |
| 1803 | operation: "GitCore.test.pullCurrentBranch", | |
| 1804 | detail: "No upstream configured", | |
| 1805 | command: "git pull", | |
| 1806 | cwd: "/repo/path", | |
| 1807 | }), | |
| 1808 | ), | |
| 1809 | ); | |
| 1810 | ||
| 1811 | server = await createTestServer({ | |
| 1812 | cwd: "/test", | |
| 1813 | gitCore: { | |
| 1814 | listBranches, | |
| 1815 | initRepo, | |
| 1816 | pullCurrentBranch, | |
| 1817 | }, | |
| 1818 | }); | |
| 1819 | const addr = server.address(); | |
| 1820 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1821 | ||
| 1822 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1823 | connections.push(ws); | |
| 1824 | ||
| 1825 | const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: "/repo/path" }); | |
| 1826 | expect(listResponse.error).toBeUndefined(); | |
| 1827 | expect(listResponse.result).toEqual({ branches: [], isRepo: false, hasOriginRemote: false }); | |
| 1828 | expect(listBranches).toHaveBeenCalledWith({ cwd: "/repo/path" }); | |
| 1829 | ||
| 1830 | const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: "/repo/path" }); | |
| 1831 | expect(initResponse.error).toBeUndefined(); | |
| 1832 | expect(initRepo).toHaveBeenCalledWith({ cwd: "/repo/path" }); | |
| 1833 | ||
| 1834 | const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { cwd: "/repo/path" }); | |
| 1835 | expect(pullResponse.result).toBeUndefined(); | |
| 1836 | expect(pullResponse.error?.message).toContain("No upstream configured"); | |
| 1837 | expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path"); | |
| 1838 | }); | |
| 1839 | ||
| 1840 | it("supports git.status over websocket", async () => { | |
| 1841 | const statusResult = { | |
| 1842 | branch: "feature/test", | |
| 1843 | hasWorkingTreeChanges: true, | |
| 1844 | workingTree: { | |
| 1845 | files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }], | |
| 1846 | insertions: 7, | |
| 1847 | deletions: 2, | |
| 1848 | }, | |
| 1849 | hasUpstream: false, | |
| 1850 | aheadCount: 0, | |
| 1851 | behindCount: 0, | |
| 1852 | pr: null, | |
| 1853 | }; | |
| 1854 | ||
| 1855 | const status = vi.fn(() => Effect.succeed(statusResult)); | |
| 1856 | const runStackedAction = vi.fn(() => Effect.void as any); | |
| 1857 | const resolvePullRequest = vi.fn(() => Effect.void as any); | |
| 1858 | const preparePullRequestThread = vi.fn(() => Effect.void as any); | |
| 1859 | const gitManager: GitManagerShape = { | |
| 1860 | status, | |
| 1861 | resolvePullRequest, | |
| 1862 | preparePullRequestThread, | |
| 1863 | runStackedAction, | |
| 1864 | }; | |
| 1865 | ||
| 1866 | server = await createTestServer({ cwd: "/test", gitManager }); | |
| 1867 | const addr = server.address(); | |
| 1868 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1869 | ||
| 1870 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1871 | connections.push(ws); | |
| 1872 | ||
| 1873 | const response = await sendRequest(ws, WS_METHODS.gitStatus, { | |
| 1874 | cwd: "/test", | |
| 1875 | }); | |
| 1876 | expect(response.error).toBeUndefined(); | |
| 1877 | expect(response.result).toEqual(statusResult); | |
| 1878 | expect(status).toHaveBeenCalledWith({ cwd: "/test" }); | |
| 1879 | }); | |
| 1880 | ||
| 1881 | it("supports git pull request routing over websocket", async () => { | |
| 1882 | const resolvePullRequestResult = { | |
| 1883 | pullRequest: { | |
| 1884 | number: 42, | |
| 1885 | title: "PR thread flow", | |
| 1886 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | |
| 1887 | baseBranch: "main", | |
| 1888 | headBranch: "feature/pr-threads", | |
| 1889 | state: "open" as const, | |
| 1890 | }, | |
| 1891 | }; | |
| 1892 | const preparePullRequestThreadResult = { | |
| 1893 | ...resolvePullRequestResult, | |
| 1894 | branch: "feature/pr-threads", | |
| 1895 | worktreePath: "/tmp/pr-threads", | |
| 1896 | }; | |
| 1897 | ||
| 1898 | const gitManager: GitManagerShape = { | |
| 1899 | status: vi.fn(() => Effect.void as any), | |
| 1900 | resolvePullRequest: vi.fn(() => Effect.succeed(resolvePullRequestResult)), | |
| 1901 | preparePullRequestThread: vi.fn(() => Effect.succeed(preparePullRequestThreadResult)), | |
| 1902 | runStackedAction: vi.fn(() => Effect.void as any), | |
| 1903 | }; | |
| 1904 | ||
| 1905 | server = await createTestServer({ cwd: "/test", gitManager }); | |
| 1906 | const addr = server.address(); | |
| 1907 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1908 | ||
| 1909 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1910 | connections.push(ws); | |
| 1911 | ||
| 1912 | const resolveResponse = await sendRequest(ws, WS_METHODS.gitResolvePullRequest, { | |
| 1913 | cwd: "/test", | |
| 1914 | reference: "#42", | |
| 1915 | }); | |
| 1916 | expect(resolveResponse.error).toBeUndefined(); | |
| 1917 | expect(resolveResponse.result).toEqual(resolvePullRequestResult); | |
| 1918 | ||
| 1919 | const prepareResponse = await sendRequest(ws, WS_METHODS.gitPreparePullRequestThread, { | |
| 1920 | cwd: "/test", | |
| 1921 | reference: "42", | |
| 1922 | mode: "worktree", | |
| 1923 | }); | |
| 1924 | expect(prepareResponse.error).toBeUndefined(); | |
| 1925 | expect(prepareResponse.result).toEqual(preparePullRequestThreadResult); | |
| 1926 | expect(gitManager.resolvePullRequest).toHaveBeenCalledWith({ | |
| 1927 | cwd: "/test", | |
| 1928 | reference: "#42", | |
| 1929 | }); | |
| 1930 | expect(gitManager.preparePullRequestThread).toHaveBeenCalledWith({ | |
| 1931 | cwd: "/test", | |
| 1932 | reference: "42", | |
| 1933 | mode: "worktree", | |
| 1934 | }); | |
| 1935 | }); | |
| 1936 | ||
| 1937 | it("returns errors from git.runStackedAction", async () => { | |
| 1938 | const runStackedAction = vi.fn(() => | |
| 1939 | Effect.fail( | |
| 1940 | new GitManagerError({ | |
| 1941 | operation: "GitManager.test.runStackedAction", | |
| 1942 | detail: "Cannot push from detached HEAD.", | |
| 1943 | }), | |
| 1944 | ), | |
| 1945 | ); | |
| 1946 | const gitManager: GitManagerShape = { | |
| 1947 | status: vi.fn(() => Effect.void as any), | |
| 1948 | resolvePullRequest: vi.fn(() => Effect.void as any), | |
| 1949 | preparePullRequestThread: vi.fn(() => Effect.void as any), | |
| 1950 | runStackedAction, | |
| 1951 | }; | |
| 1952 | ||
| 1953 | server = await createTestServer({ cwd: "/test", gitManager }); | |
| 1954 | const addr = server.address(); | |
| 1955 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1956 | ||
| 1957 | const [ws] = await connectAndAwaitWelcome(port); | |
| 1958 | connections.push(ws); | |
| 1959 | ||
| 1960 | const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, { | |
| 1961 | actionId: "client-action-1", | |
| 1962 | cwd: "/test", | |
| 1963 | action: "commit_push", | |
| 1964 | modelSelection: { | |
| 1965 | provider: "codex", | |
| 1966 | model: "gpt-5.4-mini", | |
| 1967 | }, | |
| 1968 | }); | |
| 1969 | expect(response.result).toBeUndefined(); | |
| 1970 | expect(response.error?.message).toContain("detached HEAD"); | |
| 1971 | expect(runStackedAction).toHaveBeenCalledWith( | |
| 1972 | { | |
| 1973 | actionId: "client-action-1", | |
| 1974 | cwd: "/test", | |
| 1975 | action: "commit_push", | |
| 1976 | }, | |
| 1977 | expect.objectContaining({ | |
| 1978 | actionId: "client-action-1", | |
| 1979 | progressReporter: expect.any(Object), | |
| 1980 | }), | |
| 1981 | ); | |
| 1982 | }); | |
| 1983 | ||
| 1984 | it("publishes git action progress only to the initiating websocket", async () => { | |
| 1985 | const runStackedAction = vi.fn( | |
| 1986 | (_input, options) => | |
| 1987 | options?.progressReporter | |
| 1988 | ?.publish({ | |
| 1989 | actionId: options.actionId ?? "action-1", | |
| 1990 | cwd: "/test", | |
| 1991 | action: "commit", | |
| 1992 | kind: "phase_started", | |
| 1993 | phase: "commit", | |
| 1994 | label: "Committing...", | |
| 1995 | }) | |
| 1996 | .pipe( | |
| 1997 | Effect.flatMap(() => | |
| 1998 | Effect.succeed({ | |
| 1999 | action: "commit" as const, | |
| 2000 | branch: { status: "skipped_not_requested" as const }, | |
| 2001 | commit: { | |
| 2002 | status: "created" as const, | |
| 2003 | commitSha: "abc1234", | |
| 2004 | subject: "Test commit", | |
| 2005 | }, | |
| 2006 | push: { status: "skipped_not_requested" as const }, | |
| 2007 | pr: { status: "skipped_not_requested" as const }, | |
| 2008 | }), | |
| 2009 | ), | |
| 2010 | ) ?? Effect.void, | |
| 2011 | ); | |
| 2012 | const gitManager: GitManagerShape = { | |
| 2013 | status: vi.fn(() => Effect.void as any), | |
| 2014 | resolvePullRequest: vi.fn(() => Effect.void as any), | |
| 2015 | preparePullRequestThread: vi.fn(() => Effect.void as any), | |
| 2016 | runStackedAction, | |
| 2017 | }; | |
| 2018 | ||
| 2019 | server = await createTestServer({ cwd: "/test", gitManager }); | |
| 2020 | const addr = server.address(); | |
| 2021 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 2022 | ||
| 2023 | const [initiatingWs] = await connectAndAwaitWelcome(port); | |
| 2024 | const [otherWs] = await connectAndAwaitWelcome(port); | |
| 2025 | connections.push(initiatingWs, otherWs); | |
| 2026 | ||
| 2027 | const responsePromise = sendRequest(initiatingWs, WS_METHODS.gitRunStackedAction, { | |
| 2028 | actionId: "client-action-2", | |
| 2029 | cwd: "/test", | |
| 2030 | action: "commit", | |
| 2031 | modelSelection: { | |
| 2032 | provider: "codex", | |
| 2033 | model: "gpt-5.4-mini", | |
| 2034 | }, | |
| 2035 | }); | |
| 2036 | const progressPush = await waitForPush(initiatingWs, WS_CHANNELS.gitActionProgress); | |
| 2037 | ||
| 2038 | expect(progressPush.data).toEqual({ | |
| 2039 | actionId: "client-action-2", | |
| 2040 | cwd: "/test", | |
| 2041 | action: "commit", | |
| 2042 | kind: "phase_started", | |
| 2043 | phase: "commit", | |
| 2044 | label: "Committing...", | |
| 2045 | }); | |
| 2046 | ||
| 2047 | await expect( | |
| 2048 | waitForPush(otherWs, WS_CHANNELS.gitActionProgress, undefined, 10, 100), | |
| 2049 | ).rejects.toThrow("Timed out waiting for WebSocket message after 100ms"); | |
| 2050 | await expect(responsePromise).resolves.toEqual( | |
| 2051 | expect.objectContaining({ | |
| 2052 | result: expect.objectContaining({ | |
| 2053 | action: "commit", | |
| 2054 | }), | |
| 2055 | }), | |
| 2056 | ); | |
| 2057 | }); | |
| 2058 | ||
| 2059 | it("rejects websocket connections without a valid auth token", async () => { | |
| 2060 | server = await createTestServer({ cwd: "/test", authToken: "secret-token" }); | |
| 2061 | const addr = server.address(); | |
| 2062 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 2063 | ||
| 2064 | await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed"); | |
| 2065 | ||
| 2066 | const [authorizedWs] = await connectAndAwaitWelcome(port, "secret-token"); | |
| 2067 | connections.push(authorizedWs); | |
| 2068 | }); | |
| 2069 | }); |
apps/server/src/wsServer.ts deleted-998| ... | ... | @@ -1,998 +0,0 @@ |
| 1 | /** | |
| 2 | * Server - HTTP/WebSocket server service interface. | |
| 3 | * | |
| 4 | * Owns startup and shutdown lifecycle of the HTTP server, static asset serving, | |
| 5 | * and WebSocket request routing. | |
| 6 | * | |
| 7 | * @module Server | |
| 8 | */ | |
| 9 | import http from "node:http"; | |
| 10 | import type { Duplex } from "node:stream"; | |
| 11 | ||
| 12 | import Mime from "@effect/platform-node/Mime"; | |
| 13 | import { | |
| 14 | CommandId, | |
| 15 | DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 16 | type ClientOrchestrationCommand, | |
| 17 | type OrchestrationCommand, | |
| 18 | ORCHESTRATION_WS_CHANNELS, | |
| 19 | ORCHESTRATION_WS_METHODS, | |
| 20 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | |
| 21 | ProjectId, | |
| 22 | ThreadId, | |
| 23 | WS_CHANNELS, | |
| 24 | WS_METHODS, | |
| 25 | WebSocketRequest, | |
| 26 | type WsResponse as WsResponseMessage, | |
| 27 | WsResponse, | |
| 28 | type WsPushEnvelopeBase, | |
| 29 | } from "@t3tools/contracts"; | |
| 30 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | |
| 31 | import { | |
| 32 | Cause, | |
| 33 | Effect, | |
| 34 | Exit, | |
| 35 | FileSystem, | |
| 36 | Layer, | |
| 37 | Path, | |
| 38 | Ref, | |
| 39 | Result, | |
| 40 | Schema, | |
| 41 | Scope, | |
| 42 | ServiceMap, | |
| 43 | Stream, | |
| 44 | Struct, | |
| 45 | } from "effect"; | |
| 46 | import { WebSocketServer, type WebSocket } from "ws"; | |
| 47 | ||
| 48 | import { createLogger } from "./logger"; | |
| 49 | import { GitManager } from "./git/Services/GitManager.ts"; | |
| 50 | import { TerminalManager } from "./terminal/Services/Manager.ts"; | |
| 51 | import { Keybindings } from "./keybindings"; | |
| 52 | import { ServerSettingsService } from "./serverSettings"; | |
| 53 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; | |
| 54 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; | |
| 55 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; | |
| 56 | import { ProviderService } from "./provider/Services/ProviderService"; | |
| 57 | import { ProviderRegistry } from "./provider/Services/ProviderRegistry"; | |
| 58 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; | |
| 59 | import { clamp } from "effect/Number"; | |
| 60 | import { Open, resolveAvailableEditors } from "./open"; | |
| 61 | import { ServerConfig } from "./config"; | |
| 62 | import { GitCore } from "./git/Services/GitCore.ts"; | |
| 63 | import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; | |
| 64 | import { | |
| 65 | ATTACHMENTS_ROUTE_PREFIX, | |
| 66 | normalizeAttachmentRelativePath, | |
| 67 | resolveAttachmentRelativePath, | |
| 68 | } from "./attachmentPaths"; | |
| 69 | ||
| 70 | import { | |
| 71 | createAttachmentId, | |
| 72 | resolveAttachmentPath, | |
| 73 | resolveAttachmentPathById, | |
| 74 | } from "./attachmentStore.ts"; | |
| 75 | import { parseBase64DataUrl } from "./imageMime.ts"; | |
| 76 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; | |
| 77 | import { makeServerPushBus } from "./wsServer/pushBus.ts"; | |
| 78 | import { makeServerReadiness } from "./wsServer/readiness.ts"; | |
| 79 | import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; | |
| 80 | import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; | |
| 81 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; | |
| 82 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; | |
| 83 | import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts"; | |
| 84 | ||
| 85 | /** | |
| 86 | * ServerShape - Service API for server lifecycle control. | |
| 87 | */ | |
| 88 | export interface ServerShape { | |
| 89 | /** | |
| 90 | * Start HTTP and WebSocket listeners. | |
| 91 | */ | |
| 92 | readonly start: Effect.Effect< | |
| 93 | http.Server, | |
| 94 | ServerLifecycleError, | |
| 95 | Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path | |
| 96 | >; | |
| 97 | ||
| 98 | /** | |
| 99 | * Wait for process shutdown signals. | |
| 100 | */ | |
| 101 | readonly stopSignal: Effect.Effect<void, never>; | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * Server - Service tag for HTTP/WebSocket lifecycle management. | |
| 106 | */ | |
| 107 | export class Server extends ServiceMap.Service<Server, ServerShape>()("t3/wsServer/Server") {} | |
| 108 | ||
| 109 | const isServerNotRunningError = (error: Error): boolean => { | |
| 110 | const maybeCode = (error as NodeJS.ErrnoException).code; | |
| 111 | return ( | |
| 112 | maybeCode === "ERR_SERVER_NOT_RUNNING" || error.message.toLowerCase().includes("not running") | |
| 113 | ); | |
| 114 | }; | |
| 115 | ||
| 116 | function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void { | |
| 117 | socket.end( | |
| 118 | `HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` + | |
| 119 | "Connection: close\r\n" + | |
| 120 | "Content-Type: text/plain\r\n" + | |
| 121 | `Content-Length: ${Buffer.byteLength(message)}\r\n` + | |
| 122 | "\r\n" + | |
| 123 | message, | |
| 124 | ); | |
| 125 | } | |
| 126 | ||
| 127 | function websocketRawToString(raw: unknown): string | null { | |
| 128 | if (typeof raw === "string") { | |
| 129 | return raw; | |
| 130 | } | |
| 131 | if (raw instanceof Uint8Array) { | |
| 132 | return Buffer.from(raw).toString("utf8"); | |
| 133 | } | |
| 134 | if (raw instanceof ArrayBuffer) { | |
| 135 | return Buffer.from(new Uint8Array(raw)).toString("utf8"); | |
| 136 | } | |
| 137 | if (Array.isArray(raw)) { | |
| 138 | const chunks: string[] = []; | |
| 139 | for (const chunk of raw) { | |
| 140 | if (typeof chunk === "string") { | |
| 141 | chunks.push(chunk); | |
| 142 | continue; | |
| 143 | } | |
| 144 | if (chunk instanceof Uint8Array) { | |
| 145 | chunks.push(Buffer.from(chunk).toString("utf8")); | |
| 146 | continue; | |
| 147 | } | |
| 148 | if (chunk instanceof ArrayBuffer) { | |
| 149 | chunks.push(Buffer.from(new Uint8Array(chunk)).toString("utf8")); | |
| 150 | continue; | |
| 151 | } | |
| 152 | return null; | |
| 153 | } | |
| 154 | return chunks.join(""); | |
| 155 | } | |
| 156 | return null; | |
| 157 | } | |
| 158 | ||
| 159 | function stripRequestTag<T extends { _tag: string }>(body: T) { | |
| 160 | return Struct.omit(body, ["_tag"]); | |
| 161 | } | |
| 162 | ||
| 163 | const encodeWsResponse = Schema.encodeEffect(Schema.fromJsonString(WsResponse)); | |
| 164 | const decodeWebSocketRequest = decodeJsonResult(WebSocketRequest); | |
| 165 | ||
| 166 | export type ServerCoreRuntimeServices = | |
| 167 | | OrchestrationEngineService | |
| 168 | | ProjectionSnapshotQuery | |
| 169 | | CheckpointDiffQuery | |
| 170 | | OrchestrationReactor | |
| 171 | | ProviderService | |
| 172 | | ProviderRegistry; | |
| 173 | ||
| 174 | export type ServerRuntimeServices = | |
| 175 | | ServerCoreRuntimeServices | |
| 176 | | GitManager | |
| 177 | | GitCore | |
| 178 | | TerminalManager | |
| 179 | | Keybindings | |
| 180 | | ServerSettingsService | |
| 181 | | ProjectFaviconResolver | |
| 182 | | WorkspaceEntries | |
| 183 | | WorkspaceFileSystem | |
| 184 | | WorkspacePaths | |
| 185 | | Open | |
| 186 | | AnalyticsService; | |
| 187 | ||
| 188 | export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()( | |
| 189 | "ServerLifecycleError", | |
| 190 | { | |
| 191 | operation: Schema.String, | |
| 192 | cause: Schema.optional(Schema.Defect), | |
| 193 | }, | |
| 194 | ) {} | |
| 195 | ||
| 196 | class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("RouteRequestError", { | |
| 197 | message: Schema.String, | |
| 198 | }) {} | |
| 199 | ||
| 200 | export const createServer = Effect.fn(function* (): Effect.fn.Return< | |
| 201 | http.Server, | |
| 202 | ServerLifecycleError, | |
| 203 | Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path | |
| 204 | > { | |
| 205 | const serverConfig = yield* ServerConfig; | |
| 206 | const { | |
| 207 | port, | |
| 208 | cwd, | |
| 209 | keybindingsConfigPath, | |
| 210 | staticDir, | |
| 211 | devUrl, | |
| 212 | authToken, | |
| 213 | host, | |
| 214 | logWebSocketEvents, | |
| 215 | autoBootstrapProjectFromCwd, | |
| 216 | } = serverConfig; | |
| 217 | const availableEditors = resolveAvailableEditors(); | |
| 218 | ||
| 219 | const runtimeServices = yield* Effect.services< | |
| 220 | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path | |
| 221 | >(); | |
| 222 | const runPromise = Effect.runPromiseWith(runtimeServices); | |
| 223 | ||
| 224 | const gitManager = yield* GitManager; | |
| 225 | const terminalManager = yield* TerminalManager; | |
| 226 | const keybindingsManager = yield* Keybindings; | |
| 227 | const serverSettingsManager = yield* ServerSettingsService; | |
| 228 | const providerRegistry = yield* ProviderRegistry; | |
| 229 | const git = yield* GitCore; | |
| 230 | const workspaceEntries = yield* WorkspaceEntries; | |
| 231 | const workspaceFileSystem = yield* WorkspaceFileSystem; | |
| 232 | const workspacePaths = yield* WorkspacePaths; | |
| 233 | const fileSystem = yield* FileSystem.FileSystem; | |
| 234 | const path = yield* Path.Path; | |
| 235 | ||
| 236 | yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe( | |
| 237 | Effect.catch((error) => | |
| 238 | Effect.logWarning("failed to sync keybindings defaults on startup", { | |
| 239 | path: error.configPath, | |
| 240 | detail: error.detail, | |
| 241 | cause: error.cause, | |
| 242 | }), | |
| 243 | ), | |
| 244 | ); | |
| 245 | ||
| 246 | const providersRef = yield* Ref.make(yield* providerRegistry.getProviders); | |
| 247 | ||
| 248 | const clients = yield* Ref.make(new Set<WebSocket>()); | |
| 249 | const logger = createLogger("ws"); | |
| 250 | const readiness = yield* makeServerReadiness; | |
| 251 | ||
| 252 | function logOutgoingPush(push: WsPushEnvelopeBase, recipients: number) { | |
| 253 | if (!logWebSocketEvents) return; | |
| 254 | logger.event("outgoing push", { | |
| 255 | channel: push.channel, | |
| 256 | sequence: push.sequence, | |
| 257 | recipients, | |
| 258 | payload: push.data, | |
| 259 | }); | |
| 260 | } | |
| 261 | ||
| 262 | const pushBus = yield* makeServerPushBus({ | |
| 263 | clients, | |
| 264 | logOutgoingPush, | |
| 265 | }); | |
| 266 | yield* readiness.markPushBusReady; | |
| 267 | yield* keybindingsManager.start.pipe( | |
| 268 | Effect.mapError( | |
| 269 | (cause) => new ServerLifecycleError({ operation: "keybindingsRuntimeStart", cause }), | |
| 270 | ), | |
| 271 | ); | |
| 272 | yield* readiness.markKeybindingsReady; | |
| 273 | yield* serverSettingsManager.start.pipe( | |
| 274 | Effect.mapError( | |
| 275 | (cause) => new ServerLifecycleError({ operation: "serverSettingsRuntimeStart", cause }), | |
| 276 | ), | |
| 277 | ); | |
| 278 | ||
| 279 | const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { | |
| 280 | readonly command: ClientOrchestrationCommand; | |
| 281 | }) { | |
| 282 | if (input.command.type === "project.create") { | |
| 283 | return { | |
| 284 | ...input.command, | |
| 285 | workspaceRoot: yield* workspacePaths | |
| 286 | .normalizeWorkspaceRoot(input.command.workspaceRoot) | |
| 287 | .pipe(Effect.mapError((cause) => new RouteRequestError({ message: cause.message }))), | |
| 288 | } satisfies OrchestrationCommand; | |
| 289 | } | |
| 290 | ||
| 291 | if (input.command.type === "project.meta.update" && input.command.workspaceRoot !== undefined) { | |
| 292 | return { | |
| 293 | ...input.command, | |
| 294 | workspaceRoot: yield* workspacePaths | |
| 295 | .normalizeWorkspaceRoot(input.command.workspaceRoot) | |
| 296 | .pipe(Effect.mapError((cause) => new RouteRequestError({ message: cause.message }))), | |
| 297 | } satisfies OrchestrationCommand; | |
| 298 | } | |
| 299 | ||
| 300 | if (input.command.type !== "thread.turn.start") { | |
| 301 | return input.command as OrchestrationCommand; | |
| 302 | } | |
| 303 | const turnStartCommand = input.command; | |
| 304 | ||
| 305 | const normalizedAttachments = yield* Effect.forEach( | |
| 306 | turnStartCommand.message.attachments, | |
| 307 | (attachment) => | |
| 308 | Effect.gen(function* () { | |
| 309 | const parsed = parseBase64DataUrl(attachment.dataUrl); | |
| 310 | if (!parsed || !parsed.mimeType.startsWith("image/")) { | |
| 311 | return yield* new RouteRequestError({ | |
| 312 | message: `Invalid image attachment payload for '${attachment.name}'.`, | |
| 313 | }); | |
| 314 | } | |
| 315 | ||
| 316 | const bytes = Buffer.from(parsed.base64, "base64"); | |
| 317 | if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { | |
| 318 | return yield* new RouteRequestError({ | |
| 319 | message: `Image attachment '${attachment.name}' is empty or too large.`, | |
| 320 | }); | |
| 321 | } | |
| 322 | ||
| 323 | const attachmentId = createAttachmentId(turnStartCommand.threadId); | |
| 324 | if (!attachmentId) { | |
| 325 | return yield* new RouteRequestError({ | |
| 326 | message: "Failed to create a safe attachment id.", | |
| 327 | }); | |
| 328 | } | |
| 329 | ||
| 330 | const persistedAttachment = { | |
| 331 | type: "image" as const, | |
| 332 | id: attachmentId, | |
| 333 | name: attachment.name, | |
| 334 | mimeType: parsed.mimeType.toLowerCase(), | |
| 335 | sizeBytes: bytes.byteLength, | |
| 336 | }; | |
| 337 | ||
| 338 | const attachmentPath = resolveAttachmentPath({ | |
| 339 | attachmentsDir: serverConfig.attachmentsDir, | |
| 340 | attachment: persistedAttachment, | |
| 341 | }); | |
| 342 | if (!attachmentPath) { | |
| 343 | return yield* new RouteRequestError({ | |
| 344 | message: `Failed to resolve persisted path for '${attachment.name}'.`, | |
| 345 | }); | |
| 346 | } | |
| 347 | ||
| 348 | yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe( | |
| 349 | Effect.mapError( | |
| 350 | () => | |
| 351 | new RouteRequestError({ | |
| 352 | message: `Failed to create attachment directory for '${attachment.name}'.`, | |
| 353 | }), | |
| 354 | ), | |
| 355 | ); | |
| 356 | yield* fileSystem.writeFile(attachmentPath, bytes).pipe( | |
| 357 | Effect.mapError( | |
| 358 | () => | |
| 359 | new RouteRequestError({ | |
| 360 | message: `Failed to persist attachment '${attachment.name}'.`, | |
| 361 | }), | |
| 362 | ), | |
| 363 | ); | |
| 364 | ||
| 365 | return persistedAttachment; | |
| 366 | }), | |
| 367 | { concurrency: 1 }, | |
| 368 | ); | |
| 369 | ||
| 370 | return { | |
| 371 | ...turnStartCommand, | |
| 372 | message: { | |
| 373 | ...turnStartCommand.message, | |
| 374 | attachments: normalizedAttachments, | |
| 375 | }, | |
| 376 | } satisfies OrchestrationCommand; | |
| 377 | }); | |
| 378 | ||
| 379 | // HTTP server — serves static files or redirects to Vite dev server | |
| 380 | const httpServer = http.createServer((req, res) => { | |
| 381 | const respond = ( | |
| 382 | statusCode: number, | |
| 383 | headers: Record<string, string>, | |
| 384 | body?: string | Uint8Array, | |
| 385 | ) => { | |
| 386 | res.writeHead(statusCode, headers); | |
| 387 | res.end(body); | |
| 388 | }; | |
| 389 | ||
| 390 | void runPromise( | |
| 391 | Effect.gen(function* () { | |
| 392 | const url = new URL(req.url ?? "/", `http://localhost:${port}`); | |
| 393 | if (yield* tryHandleProjectFaviconRequest(url, res)) { | |
| 394 | return; | |
| 395 | } | |
| 396 | ||
| 397 | if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { | |
| 398 | const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); | |
| 399 | const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); | |
| 400 | if (!normalizedRelativePath) { | |
| 401 | respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); | |
| 402 | return; | |
| 403 | } | |
| 404 | ||
| 405 | const isIdLookup = | |
| 406 | !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes("."); | |
| 407 | const filePath = isIdLookup | |
| 408 | ? resolveAttachmentPathById({ | |
| 409 | attachmentsDir: serverConfig.attachmentsDir, | |
| 410 | attachmentId: normalizedRelativePath, | |
| 411 | }) | |
| 412 | : resolveAttachmentRelativePath({ | |
| 413 | attachmentsDir: serverConfig.attachmentsDir, | |
| 414 | relativePath: normalizedRelativePath, | |
| 415 | }); | |
| 416 | if (!filePath) { | |
| 417 | respond( | |
| 418 | isIdLookup ? 404 : 400, | |
| 419 | { "Content-Type": "text/plain" }, | |
| 420 | isIdLookup ? "Not Found" : "Invalid attachment path", | |
| 421 | ); | |
| 422 | return; | |
| 423 | } | |
| 424 | ||
| 425 | const fileInfo = yield* fileSystem | |
| 426 | .stat(filePath) | |
| 427 | .pipe(Effect.catch(() => Effect.succeed(null))); | |
| 428 | if (!fileInfo || fileInfo.type !== "File") { | |
| 429 | respond(404, { "Content-Type": "text/plain" }, "Not Found"); | |
| 430 | return; | |
| 431 | } | |
| 432 | ||
| 433 | const contentType = Mime.getType(filePath) ?? "application/octet-stream"; | |
| 434 | res.writeHead(200, { | |
| 435 | "Content-Type": contentType, | |
| 436 | "Cache-Control": "public, max-age=31536000, immutable", | |
| 437 | }); | |
| 438 | const streamExit = yield* Stream.runForEach(fileSystem.stream(filePath), (chunk) => | |
| 439 | Effect.sync(() => { | |
| 440 | if (!res.destroyed) { | |
| 441 | res.write(chunk); | |
| 442 | } | |
| 443 | }), | |
| 444 | ).pipe(Effect.exit); | |
| 445 | if (Exit.isFailure(streamExit)) { | |
| 446 | if (!res.destroyed) { | |
| 447 | res.destroy(); | |
| 448 | } | |
| 449 | return; | |
| 450 | } | |
| 451 | if (!res.writableEnded) { | |
| 452 | res.end(); | |
| 453 | } | |
| 454 | return; | |
| 455 | } | |
| 456 | ||
| 457 | // In dev mode, redirect to Vite dev server | |
| 458 | if (devUrl) { | |
| 459 | respond(302, { Location: devUrl.href }); | |
| 460 | return; | |
| 461 | } | |
| 462 | ||
| 463 | // Serve static files from the web app build | |
| 464 | if (!staticDir) { | |
| 465 | respond( | |
| 466 | 503, | |
| 467 | { "Content-Type": "text/plain" }, | |
| 468 | "No static directory configured and no dev URL set.", | |
| 469 | ); | |
| 470 | return; | |
| 471 | } | |
| 472 | ||
| 473 | const staticRoot = path.resolve(staticDir); | |
| 474 | const staticRequestPath = url.pathname === "/" ? "/index.html" : url.pathname; | |
| 475 | const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); | |
| 476 | const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); | |
| 477 | const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, ""); | |
| 478 | const hasPathTraversalSegment = staticRelativePath.startsWith(".."); | |
| 479 | if ( | |
| 480 | staticRelativePath.length === 0 || | |
| 481 | hasRawLeadingParentSegment || | |
| 482 | hasPathTraversalSegment || | |
| 483 | staticRelativePath.includes("\0") | |
| 484 | ) { | |
| 485 | respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); | |
| 486 | return; | |
| 487 | } | |
| 488 | ||
| 489 | const isWithinStaticRoot = (candidate: string) => | |
| 490 | candidate === staticRoot || | |
| 491 | candidate.startsWith( | |
| 492 | staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`, | |
| 493 | ); | |
| 494 | ||
| 495 | let filePath = path.resolve(staticRoot, staticRelativePath); | |
| 496 | if (!isWithinStaticRoot(filePath)) { | |
| 497 | respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); | |
| 498 | return; | |
| 499 | } | |
| 500 | ||
| 501 | const ext = path.extname(filePath); | |
| 502 | if (!ext) { | |
| 503 | filePath = path.resolve(filePath, "index.html"); | |
| 504 | if (!isWithinStaticRoot(filePath)) { | |
| 505 | respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); | |
| 506 | return; | |
| 507 | } | |
| 508 | } | |
| 509 | ||
| 510 | const fileInfo = yield* fileSystem | |
| 511 | .stat(filePath) | |
| 512 | .pipe(Effect.catch(() => Effect.succeed(null))); | |
| 513 | if (!fileInfo || fileInfo.type !== "File") { | |
| 514 | const indexPath = path.resolve(staticRoot, "index.html"); | |
| 515 | const indexData = yield* fileSystem | |
| 516 | .readFile(indexPath) | |
| 517 | .pipe(Effect.catch(() => Effect.succeed(null))); | |
| 518 | if (!indexData) { | |
| 519 | respond(404, { "Content-Type": "text/plain" }, "Not Found"); | |
| 520 | return; | |
| 521 | } | |
| 522 | respond(200, { "Content-Type": "text/html; charset=utf-8" }, indexData); | |
| 523 | return; | |
| 524 | } | |
| 525 | ||
| 526 | const contentType = Mime.getType(filePath) ?? "application/octet-stream"; | |
| 527 | const data = yield* fileSystem | |
| 528 | .readFile(filePath) | |
| 529 | .pipe(Effect.catch(() => Effect.succeed(null))); | |
| 530 | if (!data) { | |
| 531 | respond(500, { "Content-Type": "text/plain" }, "Internal Server Error"); | |
| 532 | return; | |
| 533 | } | |
| 534 | respond(200, { "Content-Type": contentType }, data); | |
| 535 | }), | |
| 536 | ).catch(() => { | |
| 537 | if (!res.headersSent) { | |
| 538 | respond(500, { "Content-Type": "text/plain" }, "Internal Server Error"); | |
| 539 | } | |
| 540 | }); | |
| 541 | }); | |
| 542 | ||
| 543 | // WebSocket server — upgrades from the HTTP server | |
| 544 | const wss = new WebSocketServer({ noServer: true }); | |
| 545 | ||
| 546 | const closeWebSocketServer = Effect.callback<void, ServerLifecycleError>((resume) => { | |
| 547 | wss.close((error) => { | |
| 548 | if (error && !isServerNotRunningError(error)) { | |
| 549 | resume( | |
| 550 | Effect.fail( | |
| 551 | new ServerLifecycleError({ operation: "closeWebSocketServer", cause: error }), | |
| 552 | ), | |
| 553 | ); | |
| 554 | } else { | |
| 555 | resume(Effect.void); | |
| 556 | } | |
| 557 | }); | |
| 558 | }); | |
| 559 | ||
| 560 | const closeAllClients = Ref.get(clients).pipe( | |
| 561 | Effect.flatMap(Effect.forEach((client) => Effect.sync(() => client.close()))), | |
| 562 | Effect.flatMap(() => Ref.set(clients, new Set())), | |
| 563 | ); | |
| 564 | ||
| 565 | const listenOptions = host ? { host, port } : { port }; | |
| 566 | ||
| 567 | const orchestrationEngine = yield* OrchestrationEngineService; | |
| 568 | const projectionReadModelQuery = yield* ProjectionSnapshotQuery; | |
| 569 | const checkpointDiffQuery = yield* CheckpointDiffQuery; | |
| 570 | const orchestrationReactor = yield* OrchestrationReactor; | |
| 571 | const { openInEditor } = yield* Open; | |
| 572 | ||
| 573 | const subscriptionsScope = yield* Scope.make("sequential"); | |
| 574 | yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); | |
| 575 | ||
| 576 | yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => | |
| 577 | pushBus.publishAll(ORCHESTRATION_WS_CHANNELS.domainEvent, event), | |
| 578 | ).pipe(Effect.forkIn(subscriptionsScope)); | |
| 579 | ||
| 580 | yield* Stream.runForEach(keybindingsManager.streamChanges, (event) => | |
| 581 | pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { | |
| 582 | issues: event.issues, | |
| 583 | }), | |
| 584 | ).pipe(Effect.forkIn(subscriptionsScope)); | |
| 585 | ||
| 586 | yield* Stream.runForEach(serverSettingsManager.streamChanges, (settings) => | |
| 587 | pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { | |
| 588 | issues: [], | |
| 589 | settings, | |
| 590 | }), | |
| 591 | ).pipe(Effect.forkIn(subscriptionsScope)); | |
| 592 | ||
| 593 | yield* Stream.runForEach(providerRegistry.streamChanges, (providers) => | |
| 594 | Effect.gen(function* () { | |
| 595 | yield* Ref.set(providersRef, providers); | |
| 596 | yield* pushBus.publishAll(WS_CHANNELS.serverProvidersUpdated, { | |
| 597 | providers, | |
| 598 | }); | |
| 599 | }), | |
| 600 | ).pipe(Effect.forkIn(subscriptionsScope)); | |
| 601 | ||
| 602 | yield* Scope.provide(orchestrationReactor.start(), subscriptionsScope); | |
| 603 | yield* readiness.markOrchestrationSubscriptionsReady; | |
| 604 | ||
| 605 | let welcomeBootstrapProjectId: ProjectId | undefined; | |
| 606 | let welcomeBootstrapThreadId: ThreadId | undefined; | |
| 607 | ||
| 608 | if (autoBootstrapProjectFromCwd) { | |
| 609 | yield* Effect.gen(function* () { | |
| 610 | const snapshot = yield* projectionReadModelQuery.getSnapshot(); | |
| 611 | const existingProject = snapshot.projects.find( | |
| 612 | (project) => project.workspaceRoot === cwd && project.deletedAt === null, | |
| 613 | ); | |
| 614 | let bootstrapProjectId: ProjectId; | |
| 615 | let bootstrapProjectDefaultModelSelection; | |
| 616 | ||
| 617 | if (!existingProject) { | |
| 618 | const createdAt = new Date().toISOString(); | |
| 619 | bootstrapProjectId = ProjectId.makeUnsafe(crypto.randomUUID()); | |
| 620 | const bootstrapProjectTitle = path.basename(cwd) || "project"; | |
| 621 | bootstrapProjectDefaultModelSelection = { | |
| 622 | provider: "codex" as const, | |
| 623 | model: "gpt-5-codex", | |
| 624 | }; | |
| 625 | yield* orchestrationEngine.dispatch({ | |
| 626 | type: "project.create", | |
| 627 | commandId: CommandId.makeUnsafe(crypto.randomUUID()), | |
| 628 | projectId: bootstrapProjectId, | |
| 629 | title: bootstrapProjectTitle, | |
| 630 | workspaceRoot: cwd, | |
| 631 | defaultModelSelection: bootstrapProjectDefaultModelSelection, | |
| 632 | createdAt, | |
| 633 | }); | |
| 634 | } else { | |
| 635 | bootstrapProjectId = existingProject.id; | |
| 636 | bootstrapProjectDefaultModelSelection = existingProject.defaultModelSelection ?? { | |
| 637 | provider: "codex" as const, | |
| 638 | model: "gpt-5-codex", | |
| 639 | }; | |
| 640 | } | |
| 641 | ||
| 642 | const existingThread = snapshot.threads.find( | |
| 643 | (thread) => thread.projectId === bootstrapProjectId && thread.deletedAt === null, | |
| 644 | ); | |
| 645 | if (!existingThread) { | |
| 646 | const createdAt = new Date().toISOString(); | |
| 647 | const threadId = ThreadId.makeUnsafe(crypto.randomUUID()); | |
| 648 | yield* orchestrationEngine.dispatch({ | |
| 649 | type: "thread.create", | |
| 650 | commandId: CommandId.makeUnsafe(crypto.randomUUID()), | |
| 651 | threadId, | |
| 652 | projectId: bootstrapProjectId, | |
| 653 | title: "New thread", | |
| 654 | modelSelection: bootstrapProjectDefaultModelSelection, | |
| 655 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 656 | runtimeMode: "full-access", | |
| 657 | branch: null, | |
| 658 | worktreePath: null, | |
| 659 | createdAt, | |
| 660 | }); | |
| 661 | welcomeBootstrapProjectId = bootstrapProjectId; | |
| 662 | welcomeBootstrapThreadId = threadId; | |
| 663 | } else { | |
| 664 | welcomeBootstrapProjectId = bootstrapProjectId; | |
| 665 | welcomeBootstrapThreadId = existingThread.id; | |
| 666 | } | |
| 667 | }).pipe( | |
| 668 | Effect.mapError( | |
| 669 | (cause) => new ServerLifecycleError({ operation: "autoBootstrapProject", cause }), | |
| 670 | ), | |
| 671 | ); | |
| 672 | } | |
| 673 | ||
| 674 | const unsubscribeTerminalEvents = yield* terminalManager.subscribe((event) => | |
| 675 | pushBus.publishAll(WS_CHANNELS.terminalEvent, event), | |
| 676 | ); | |
| 677 | yield* Scope.addFinalizer(subscriptionsScope, Effect.sync(unsubscribeTerminalEvents)); | |
| 678 | yield* readiness.markTerminalSubscriptionsReady; | |
| 679 | ||
| 680 | yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe( | |
| 681 | Effect.mapError((cause) => new ServerLifecycleError({ operation: "httpServerListen", cause })), | |
| 682 | ); | |
| 683 | yield* readiness.markHttpListening; | |
| 684 | ||
| 685 | yield* Effect.addFinalizer(() => | |
| 686 | Effect.all([closeAllClients, closeWebSocketServer.pipe(Effect.ignoreCause({ log: true }))]), | |
| 687 | ); | |
| 688 | ||
| 689 | const routeRequest = Effect.fnUntraced(function* (ws: WebSocket, request: WebSocketRequest) { | |
| 690 | switch (request.body._tag) { | |
| 691 | case ORCHESTRATION_WS_METHODS.getSnapshot: | |
| 692 | return yield* projectionReadModelQuery.getSnapshot(); | |
| 693 | ||
| 694 | case ORCHESTRATION_WS_METHODS.dispatchCommand: { | |
| 695 | const { command } = request.body; | |
| 696 | const normalizedCommand = yield* normalizeDispatchCommand({ command }); | |
| 697 | return yield* orchestrationEngine.dispatch(normalizedCommand); | |
| 698 | } | |
| 699 | ||
| 700 | case ORCHESTRATION_WS_METHODS.getTurnDiff: { | |
| 701 | const body = stripRequestTag(request.body); | |
| 702 | return yield* checkpointDiffQuery.getTurnDiff(body); | |
| 703 | } | |
| 704 | ||
| 705 | case ORCHESTRATION_WS_METHODS.getFullThreadDiff: { | |
| 706 | const body = stripRequestTag(request.body); | |
| 707 | return yield* checkpointDiffQuery.getFullThreadDiff(body); | |
| 708 | } | |
| 709 | ||
| 710 | case ORCHESTRATION_WS_METHODS.replayEvents: { | |
| 711 | const { fromSequenceExclusive } = request.body; | |
| 712 | return yield* Stream.runCollect( | |
| 713 | orchestrationEngine.readEvents( | |
| 714 | clamp(fromSequenceExclusive, { | |
| 715 | maximum: Number.MAX_SAFE_INTEGER, | |
| 716 | minimum: 0, | |
| 717 | }), | |
| 718 | ), | |
| 719 | ).pipe(Effect.map((events) => Array.from(events))); | |
| 720 | } | |
| 721 | ||
| 722 | case WS_METHODS.projectsSearchEntries: { | |
| 723 | const body = stripRequestTag(request.body); | |
| 724 | return yield* workspaceEntries.search(body).pipe( | |
| 725 | Effect.mapError( | |
| 726 | (cause) => | |
| 727 | new RouteRequestError({ | |
| 728 | message: `Failed to search workspace entries: ${cause.detail}`, | |
| 729 | }), | |
| 730 | ), | |
| 731 | ); | |
| 732 | } | |
| 733 | ||
| 734 | case WS_METHODS.projectsWriteFile: { | |
| 735 | const body = stripRequestTag(request.body); | |
| 736 | return yield* workspaceFileSystem.writeFile(body).pipe( | |
| 737 | Effect.mapError( | |
| 738 | (cause) => | |
| 739 | new RouteRequestError({ | |
| 740 | message: `Failed to write workspace file: ${cause.message}`, | |
| 741 | }), | |
| 742 | ), | |
| 743 | ); | |
| 744 | } | |
| 745 | ||
| 746 | case WS_METHODS.shellOpenInEditor: { | |
| 747 | const body = stripRequestTag(request.body); | |
| 748 | return yield* openInEditor(body); | |
| 749 | } | |
| 750 | ||
| 751 | case WS_METHODS.gitStatus: { | |
| 752 | const body = stripRequestTag(request.body); | |
| 753 | return yield* gitManager.status(body); | |
| 754 | } | |
| 755 | ||
| 756 | case WS_METHODS.gitPull: { | |
| 757 | const body = stripRequestTag(request.body); | |
| 758 | return yield* git.pullCurrentBranch(body.cwd); | |
| 759 | } | |
| 760 | ||
| 761 | case WS_METHODS.gitRunStackedAction: { | |
| 762 | const body = stripRequestTag(request.body); | |
| 763 | return yield* gitManager.runStackedAction(body, { | |
| 764 | actionId: body.actionId, | |
| 765 | progressReporter: { | |
| 766 | publish: (event) => | |
| 767 | pushBus.publishClient(ws, WS_CHANNELS.gitActionProgress, event).pipe(Effect.asVoid), | |
| 768 | }, | |
| 769 | }); | |
| 770 | } | |
| 771 | ||
| 772 | case WS_METHODS.gitResolvePullRequest: { | |
| 773 | const body = stripRequestTag(request.body); | |
| 774 | return yield* gitManager.resolvePullRequest(body); | |
| 775 | } | |
| 776 | ||
| 777 | case WS_METHODS.gitPreparePullRequestThread: { | |
| 778 | const body = stripRequestTag(request.body); | |
| 779 | return yield* gitManager.preparePullRequestThread(body); | |
| 780 | } | |
| 781 | ||
| 782 | case WS_METHODS.gitListBranches: { | |
| 783 | const body = stripRequestTag(request.body); | |
| 784 | return yield* git.listBranches(body); | |
| 785 | } | |
| 786 | ||
| 787 | case WS_METHODS.gitCreateWorktree: { | |
| 788 | const body = stripRequestTag(request.body); | |
| 789 | return yield* git.createWorktree(body); | |
| 790 | } | |
| 791 | ||
| 792 | case WS_METHODS.gitRemoveWorktree: { | |
| 793 | const body = stripRequestTag(request.body); | |
| 794 | return yield* git.removeWorktree(body); | |
| 795 | } | |
| 796 | ||
| 797 | case WS_METHODS.gitCreateBranch: { | |
| 798 | const body = stripRequestTag(request.body); | |
| 799 | return yield* git.createBranch(body); | |
| 800 | } | |
| 801 | ||
| 802 | case WS_METHODS.gitCheckout: { | |
| 803 | const body = stripRequestTag(request.body); | |
| 804 | return yield* Effect.scoped(git.checkoutBranch(body)); | |
| 805 | } | |
| 806 | ||
| 807 | case WS_METHODS.gitInit: { | |
| 808 | const body = stripRequestTag(request.body); | |
| 809 | return yield* git.initRepo(body); | |
| 810 | } | |
| 811 | ||
| 812 | case WS_METHODS.terminalOpen: { | |
| 813 | const body = stripRequestTag(request.body); | |
| 814 | return yield* terminalManager.open(body); | |
| 815 | } | |
| 816 | ||
| 817 | case WS_METHODS.terminalWrite: { | |
| 818 | const body = stripRequestTag(request.body); | |
| 819 | return yield* terminalManager.write(body); | |
| 820 | } | |
| 821 | ||
| 822 | case WS_METHODS.terminalResize: { | |
| 823 | const body = stripRequestTag(request.body); | |
| 824 | return yield* terminalManager.resize(body); | |
| 825 | } | |
| 826 | ||
| 827 | case WS_METHODS.terminalClear: { | |
| 828 | const body = stripRequestTag(request.body); | |
| 829 | return yield* terminalManager.clear(body); | |
| 830 | } | |
| 831 | ||
| 832 | case WS_METHODS.terminalRestart: { | |
| 833 | const body = stripRequestTag(request.body); | |
| 834 | return yield* terminalManager.restart(body); | |
| 835 | } | |
| 836 | ||
| 837 | case WS_METHODS.terminalClose: { | |
| 838 | const body = stripRequestTag(request.body); | |
| 839 | return yield* terminalManager.close(body); | |
| 840 | } | |
| 841 | ||
| 842 | case WS_METHODS.serverGetConfig: { | |
| 843 | const keybindingsConfig = yield* keybindingsManager.loadConfigState; | |
| 844 | const settings = yield* serverSettingsManager.getSettings; | |
| 845 | const providers = yield* Ref.get(providersRef); | |
| 846 | return { | |
| 847 | cwd, | |
| 848 | keybindingsConfigPath, | |
| 849 | keybindings: keybindingsConfig.keybindings, | |
| 850 | issues: keybindingsConfig.issues, | |
| 851 | providers, | |
| 852 | availableEditors, | |
| 853 | settings, | |
| 854 | }; | |
| 855 | } | |
| 856 | ||
| 857 | case WS_METHODS.serverRefreshProviders: { | |
| 858 | const providers = yield* providerRegistry.refresh(); | |
| 859 | yield* Ref.set(providersRef, providers); | |
| 860 | return { providers }; | |
| 861 | } | |
| 862 | ||
| 863 | case WS_METHODS.serverUpsertKeybinding: { | |
| 864 | const body = stripRequestTag(request.body); | |
| 865 | const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body); | |
| 866 | return { keybindings: keybindingsConfig, issues: [] }; | |
| 867 | } | |
| 868 | ||
| 869 | case WS_METHODS.serverGetSettings: { | |
| 870 | return yield* serverSettingsManager.getSettings; | |
| 871 | } | |
| 872 | ||
| 873 | case WS_METHODS.serverUpdateSettings: { | |
| 874 | const body = stripRequestTag(request.body); | |
| 875 | return yield* serverSettingsManager.updateSettings(body.patch); | |
| 876 | } | |
| 877 | ||
| 878 | default: { | |
| 879 | const _exhaustiveCheck: never = request.body; | |
| 880 | return yield* new RouteRequestError({ | |
| 881 | message: `Unknown method: ${String(_exhaustiveCheck)}`, | |
| 882 | }); | |
| 883 | } | |
| 884 | } | |
| 885 | }); | |
| 886 | ||
| 887 | const handleMessage = Effect.fnUntraced(function* (ws: WebSocket, raw: unknown) { | |
| 888 | const sendWsResponse = (response: WsResponseMessage) => | |
| 889 | encodeWsResponse(response).pipe( | |
| 890 | Effect.tap((encodedResponse) => Effect.sync(() => ws.send(encodedResponse))), | |
| 891 | Effect.asVoid, | |
| 892 | ); | |
| 893 | ||
| 894 | const messageText = websocketRawToString(raw); | |
| 895 | if (messageText === null) { | |
| 896 | return yield* sendWsResponse({ | |
| 897 | id: "unknown", | |
| 898 | error: { message: "Invalid request format: Failed to read message" }, | |
| 899 | }); | |
| 900 | } | |
| 901 | ||
| 902 | const request = decodeWebSocketRequest(messageText); | |
| 903 | if (Result.isFailure(request)) { | |
| 904 | return yield* sendWsResponse({ | |
| 905 | id: "unknown", | |
| 906 | error: { message: `Invalid request format: ${formatSchemaError(request.failure)}` }, | |
| 907 | }); | |
| 908 | } | |
| 909 | ||
| 910 | const result = yield* Effect.exit(routeRequest(ws, request.success)); | |
| 911 | if (Exit.isFailure(result)) { | |
| 912 | return yield* sendWsResponse({ | |
| 913 | id: request.success.id, | |
| 914 | error: { message: Cause.pretty(result.cause) }, | |
| 915 | }); | |
| 916 | } | |
| 917 | ||
| 918 | return yield* sendWsResponse({ | |
| 919 | id: request.success.id, | |
| 920 | result: result.value, | |
| 921 | }); | |
| 922 | }); | |
| 923 | ||
| 924 | httpServer.on("upgrade", (request, socket, head) => { | |
| 925 | socket.on("error", () => {}); // Prevent unhandled `EPIPE`/`ECONNRESET` from crashing the process if the client disconnects mid-handshake | |
| 926 | ||
| 927 | if (authToken) { | |
| 928 | let providedToken: string | null = null; | |
| 929 | try { | |
| 930 | const url = new URL(request.url ?? "/", `http://localhost:${port}`); | |
| 931 | providedToken = url.searchParams.get("token"); | |
| 932 | } catch { | |
| 933 | rejectUpgrade(socket, 400, "Invalid WebSocket URL"); | |
| 934 | return; | |
| 935 | } | |
| 936 | ||
| 937 | if (providedToken !== authToken) { | |
| 938 | rejectUpgrade(socket, 401, "Unauthorized WebSocket connection"); | |
| 939 | return; | |
| 940 | } | |
| 941 | } | |
| 942 | ||
| 943 | wss.handleUpgrade(request, socket, head, (ws) => { | |
| 944 | wss.emit("connection", ws, request); | |
| 945 | }); | |
| 946 | }); | |
| 947 | ||
| 948 | wss.on("connection", (ws) => { | |
| 949 | const segments = cwd.split(/[/\\]/).filter(Boolean); | |
| 950 | const projectName = segments[segments.length - 1] ?? "project"; | |
| 951 | ||
| 952 | const welcomeData = { | |
| 953 | cwd, | |
| 954 | projectName, | |
| 955 | ...(welcomeBootstrapProjectId ? { bootstrapProjectId: welcomeBootstrapProjectId } : {}), | |
| 956 | ...(welcomeBootstrapThreadId ? { bootstrapThreadId: welcomeBootstrapThreadId } : {}), | |
| 957 | }; | |
| 958 | // Send welcome before adding to broadcast set so publishAll calls | |
| 959 | // cannot reach this client before the welcome arrives. | |
| 960 | void runPromise( | |
| 961 | readiness.awaitServerReady.pipe( | |
| 962 | Effect.flatMap(() => pushBus.publishClient(ws, WS_CHANNELS.serverWelcome, welcomeData)), | |
| 963 | Effect.flatMap((delivered) => | |
| 964 | delivered ? Ref.update(clients, (clients) => clients.add(ws)) : Effect.void, | |
| 965 | ), | |
| 966 | ), | |
| 967 | ); | |
| 968 | ||
| 969 | ws.on("message", (raw) => { | |
| 970 | void runPromise(handleMessage(ws, raw).pipe(Effect.ignoreCause({ log: true }))); | |
| 971 | }); | |
| 972 | ||
| 973 | ws.on("close", () => { | |
| 974 | void runPromise( | |
| 975 | Ref.update(clients, (clients) => { | |
| 976 | clients.delete(ws); | |
| 977 | return clients; | |
| 978 | }), | |
| 979 | ); | |
| 980 | }); | |
| 981 | ||
| 982 | ws.on("error", () => { | |
| 983 | void runPromise( | |
| 984 | Ref.update(clients, (clients) => { | |
| 985 | clients.delete(ws); | |
| 986 | return clients; | |
| 987 | }), | |
| 988 | ); | |
| 989 | }); | |
| 990 | }); | |
| 991 | ||
| 992 | return httpServer; | |
| 993 | }); | |
| 994 | ||
| 995 | export const ServerLive = Layer.succeed(Server, { | |
| 996 | start: createServer(), | |
| 997 | stopSignal: Effect.never, | |
| 998 | } satisfies ServerShape); |
apps/server/src/wsServer/pushBus.test.ts deleted-101| ... | ... | @@ -1,101 +0,0 @@ |
| 1 | import type { WebSocket } from "ws"; | |
| 2 | import { it } from "@effect/vitest"; | |
| 3 | import { describe, expect } from "vitest"; | |
| 4 | import { Effect, Ref } from "effect"; | |
| 5 | import { WS_CHANNELS } from "@t3tools/contracts"; | |
| 6 | ||
| 7 | import { makeServerPushBus } from "./pushBus"; | |
| 8 | ||
| 9 | class MockWebSocket { | |
| 10 | static readonly OPEN = 1; | |
| 11 | ||
| 12 | readonly OPEN = MockWebSocket.OPEN; | |
| 13 | readyState = MockWebSocket.OPEN; | |
| 14 | readonly sent: string[] = []; | |
| 15 | private readonly waiters = new Set<() => void>(); | |
| 16 | ||
| 17 | send(message: string) { | |
| 18 | this.sent.push(message); | |
| 19 | for (const waiter of this.waiters) { | |
| 20 | waiter(); | |
| 21 | } | |
| 22 | } | |
| 23 | ||
| 24 | waitForSentCount(count: number): Promise<void> { | |
| 25 | if (this.sent.length >= count) { | |
| 26 | return Promise.resolve(); | |
| 27 | } | |
| 28 | ||
| 29 | return new Promise((resolve) => { | |
| 30 | const check = () => { | |
| 31 | if (this.sent.length < count) { | |
| 32 | return; | |
| 33 | } | |
| 34 | this.waiters.delete(check); | |
| 35 | resolve(); | |
| 36 | }; | |
| 37 | ||
| 38 | this.waiters.add(check); | |
| 39 | }); | |
| 40 | } | |
| 41 | } | |
| 42 | ||
| 43 | describe("makeServerPushBus", () => { | |
| 44 | it.live("waits for the welcome push before a new client joins broadcast delivery", () => | |
| 45 | Effect.scoped( | |
| 46 | Effect.gen(function* () { | |
| 47 | const client = new MockWebSocket(); | |
| 48 | const clients = yield* Ref.make(new Set<WebSocket>()); | |
| 49 | const pushBus = yield* makeServerPushBus({ | |
| 50 | clients, | |
| 51 | logOutgoingPush: () => {}, | |
| 52 | }); | |
| 53 | ||
| 54 | yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { | |
| 55 | issues: [{ kind: "keybindings.malformed-config", message: "queued-before-connect" }], | |
| 56 | }); | |
| 57 | ||
| 58 | const delivered = yield* pushBus.publishClient( | |
| 59 | client as unknown as WebSocket, | |
| 60 | WS_CHANNELS.serverWelcome, | |
| 61 | { | |
| 62 | cwd: "/tmp/project", | |
| 63 | projectName: "project", | |
| 64 | }, | |
| 65 | ); | |
| 66 | expect(delivered).toBe(true); | |
| 67 | ||
| 68 | yield* Ref.update(clients, (current) => current.add(client as unknown as WebSocket)); | |
| 69 | ||
| 70 | yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { | |
| 71 | issues: [], | |
| 72 | }); | |
| 73 | ||
| 74 | yield* Effect.promise(() => client.waitForSentCount(2)); | |
| 75 | ||
| 76 | const messages = client.sent.map( | |
| 77 | (message) => JSON.parse(message) as { channel: string; data: unknown }, | |
| 78 | ); | |
| 79 | ||
| 80 | expect(messages).toHaveLength(2); | |
| 81 | expect(messages[0]).toEqual({ | |
| 82 | type: "push", | |
| 83 | sequence: 2, | |
| 84 | channel: WS_CHANNELS.serverWelcome, | |
| 85 | data: { | |
| 86 | cwd: "/tmp/project", | |
| 87 | projectName: "project", | |
| 88 | }, | |
| 89 | }); | |
| 90 | expect(messages[1]).toEqual({ | |
| 91 | type: "push", | |
| 92 | sequence: 3, | |
| 93 | channel: WS_CHANNELS.serverConfigUpdated, | |
| 94 | data: { | |
| 95 | issues: [], | |
| 96 | }, | |
| 97 | }); | |
| 98 | }), | |
| 99 | ), | |
| 100 | ); | |
| 101 | }); |
apps/server/src/wsServer/pushBus.ts deleted-114| ... | ... | @@ -1,114 +0,0 @@ |
| 1 | import { | |
| 2 | WsPush, | |
| 3 | type WsPushChannel, | |
| 4 | type WsPushData, | |
| 5 | type WsPushEnvelopeBase, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { Deferred, Effect, Queue, Ref, Schema } from "effect"; | |
| 8 | import type { Scope } from "effect"; | |
| 9 | import type { WebSocket } from "ws"; | |
| 10 | ||
| 11 | type PushTarget = | |
| 12 | | { readonly kind: "all" } | |
| 13 | | { readonly kind: "client"; readonly client: WebSocket }; | |
| 14 | ||
| 15 | interface PushJob<C extends WsPushChannel = WsPushChannel> { | |
| 16 | readonly channel: C; | |
| 17 | readonly data: WsPushData<C>; | |
| 18 | readonly target: PushTarget; | |
| 19 | readonly delivered: Deferred.Deferred<boolean> | null; | |
| 20 | } | |
| 21 | ||
| 22 | export interface ServerPushBus { | |
| 23 | readonly publishAll: <C extends WsPushChannel>( | |
| 24 | channel: C, | |
| 25 | data: WsPushData<C>, | |
| 26 | ) => Effect.Effect<void>; | |
| 27 | readonly publishClient: <C extends WsPushChannel>( | |
| 28 | client: WebSocket, | |
| 29 | channel: C, | |
| 30 | data: WsPushData<C>, | |
| 31 | ) => Effect.Effect<boolean>; | |
| 32 | } | |
| 33 | ||
| 34 | export const makeServerPushBus = (input: { | |
| 35 | readonly clients: Ref.Ref<Set<WebSocket>>; | |
| 36 | readonly logOutgoingPush: (push: WsPushEnvelopeBase, recipients: number) => void; | |
| 37 | }): Effect.Effect<ServerPushBus, never, Scope.Scope> => | |
| 38 | Effect.gen(function* () { | |
| 39 | const nextSequence = yield* Ref.make(0); | |
| 40 | const queue = yield* Queue.unbounded<PushJob>(); | |
| 41 | const encodePush = Schema.encodeUnknownEffect(Schema.fromJsonString(WsPush)); | |
| 42 | ||
| 43 | const settleDelivery = (job: PushJob, delivered: boolean) => | |
| 44 | job.delivered === null | |
| 45 | ? Effect.void | |
| 46 | : Deferred.succeed(job.delivered, delivered).pipe(Effect.orDie); | |
| 47 | ||
| 48 | const send = Effect.fnUntraced(function* (job: PushJob) { | |
| 49 | const sequence = yield* Ref.updateAndGet(nextSequence, (current) => current + 1); | |
| 50 | const push: WsPushEnvelopeBase = { | |
| 51 | type: "push", | |
| 52 | sequence, | |
| 53 | channel: job.channel, | |
| 54 | data: job.data, | |
| 55 | }; | |
| 56 | const recipients = | |
| 57 | job.target.kind === "all" ? yield* Ref.get(input.clients) : new Set([job.target.client]); | |
| 58 | ||
| 59 | return yield* encodePush(push).pipe( | |
| 60 | Effect.map((message) => { | |
| 61 | let recipientCount = 0; | |
| 62 | for (const client of recipients) { | |
| 63 | if (client.readyState !== client.OPEN) { | |
| 64 | continue; | |
| 65 | } | |
| 66 | client.send(message); | |
| 67 | recipientCount += 1; | |
| 68 | } | |
| 69 | ||
| 70 | input.logOutgoingPush(push, recipientCount); | |
| 71 | return recipientCount > 0; | |
| 72 | }), | |
| 73 | ); | |
| 74 | }); | |
| 75 | ||
| 76 | yield* Effect.forkScoped( | |
| 77 | Effect.forever( | |
| 78 | Queue.take(queue).pipe( | |
| 79 | Effect.flatMap((job) => | |
| 80 | send(job).pipe( | |
| 81 | Effect.tap((delivered) => settleDelivery(job, delivered)), | |
| 82 | Effect.tapCause(() => settleDelivery(job, false)), | |
| 83 | Effect.ignoreCause({ log: true }), | |
| 84 | ), | |
| 85 | ), | |
| 86 | ), | |
| 87 | ), | |
| 88 | ); | |
| 89 | ||
| 90 | const publish = | |
| 91 | (target: PushTarget) => | |
| 92 | <C extends WsPushChannel>(channel: C, data: WsPushData<C>) => | |
| 93 | Queue.offer(queue, { | |
| 94 | channel, | |
| 95 | data, | |
| 96 | target, | |
| 97 | delivered: null, | |
| 98 | }).pipe(Effect.asVoid); | |
| 99 | ||
| 100 | return { | |
| 101 | publishAll: publish({ kind: "all" }), | |
| 102 | publishClient: (client, channel, data) => | |
| 103 | Effect.gen(function* () { | |
| 104 | const delivered = yield* Deferred.make<boolean>(); | |
| 105 | yield* Queue.offer(queue, { | |
| 106 | channel, | |
| 107 | data, | |
| 108 | target: { kind: "client", client }, | |
| 109 | delivered, | |
| 110 | }).pipe(Effect.asVoid); | |
| 111 | return yield* Deferred.await(delivered); | |
| 112 | }), | |
| 113 | } satisfies ServerPushBus; | |
| 114 | }); |
apps/server/src/wsServer/readiness.ts deleted-36| ... | ... | @@ -1,36 +0,0 @@ |
| 1 | import { Deferred, Effect } from "effect"; | |
| 2 | ||
| 3 | export interface ServerReadiness { | |
| 4 | readonly awaitServerReady: Effect.Effect<void>; | |
| 5 | readonly markHttpListening: Effect.Effect<void>; | |
| 6 | readonly markPushBusReady: Effect.Effect<void>; | |
| 7 | readonly markKeybindingsReady: Effect.Effect<void>; | |
| 8 | readonly markTerminalSubscriptionsReady: Effect.Effect<void>; | |
| 9 | readonly markOrchestrationSubscriptionsReady: Effect.Effect<void>; | |
| 10 | } | |
| 11 | ||
| 12 | export const makeServerReadiness = Effect.gen(function* () { | |
| 13 | const httpListening = yield* Deferred.make<void>(); | |
| 14 | const pushBusReady = yield* Deferred.make<void>(); | |
| 15 | const keybindingsReady = yield* Deferred.make<void>(); | |
| 16 | const terminalSubscriptionsReady = yield* Deferred.make<void>(); | |
| 17 | const orchestrationSubscriptionsReady = yield* Deferred.make<void>(); | |
| 18 | ||
| 19 | const complete = (deferred: Deferred.Deferred<void>) => | |
| 20 | Deferred.succeed(deferred, undefined).pipe(Effect.orDie); | |
| 21 | ||
| 22 | return { | |
| 23 | awaitServerReady: Effect.all([ | |
| 24 | Deferred.await(httpListening), | |
| 25 | Deferred.await(pushBusReady), | |
| 26 | Deferred.await(keybindingsReady), | |
| 27 | Deferred.await(terminalSubscriptionsReady), | |
| 28 | Deferred.await(orchestrationSubscriptionsReady), | |
| 29 | ]).pipe(Effect.asVoid), | |
| 30 | markHttpListening: complete(httpListening), | |
| 31 | markPushBusReady: complete(pushBusReady), | |
| 32 | markKeybindingsReady: complete(keybindingsReady), | |
| 33 | markTerminalSubscriptionsReady: complete(terminalSubscriptionsReady), | |
| 34 | markOrchestrationSubscriptionsReady: complete(orchestrationSubscriptionsReady), | |
| 35 | } satisfies ServerReadiness; | |
| 36 | }); |
apps/server/tsdown.config.ts+1-1| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { defineConfig } from "tsdown"; |
| 2 | 2 | |
| 3 | 3 | export default defineConfig({ |
| 4 | entry: ["src/index.ts"], | |
| 4 | entry: ["src/bin.ts"], | |
| 5 | 5 | format: ["esm", "cjs"], |
| 6 | 6 | checks: { |
| 7 | 7 | legacyCjs: false, |
apps/server/vitest.config.ts+7-2| ... | ... | @@ -6,8 +6,13 @@ export default mergeConfig( |
| 6 | 6 | baseConfig, |
| 7 | 7 | defineConfig({ |
| 8 | 8 | test: { |
| 9 | testTimeout: 15_000, | |
| 10 | hookTimeout: 15_000, | |
| 9 | // The server suite exercises sqlite, git, temp worktrees, and orchestration | |
| 10 | // runtimes heavily. Running files in parallel introduces load-sensitive flakes. | |
| 11 | fileParallelism: false, | |
| 12 | // Server integration tests exercise sqlite, git, and orchestration together. | |
| 13 | // Under package-wide parallel runs they regularly exceed the default 15s budget. | |
| 14 | testTimeout: 60_000, | |
| 15 | hookTimeout: 60_000, | |
| 11 | 16 | }, |
| 12 | 17 | }), |
| 13 | 18 | ); |
apps/web/package.json+1| ... | ... | @@ -19,6 +19,7 @@ |
| 19 | 19 | "@dnd-kit/modifiers": "^9.0.0", |
| 20 | 20 | "@dnd-kit/sortable": "^10.0.0", |
| 21 | 21 | "@dnd-kit/utilities": "^3.2.2", |
| 22 | "@effect/atom-react": "catalog:", | |
| 22 | 23 | "@formkit/auto-animate": "^0.9.0", |
| 23 | 24 | "@lexical/react": "^0.41.0", |
| 24 | 25 | "@pierre/diffs": "^1.1.0-beta.16", |
apps/web/src/components/ChatView.browser.tsx+65-64| ... | ... | @@ -4,16 +4,14 @@ import "../index.css"; |
| 4 | 4 | import { |
| 5 | 5 | EventId, |
| 6 | 6 | ORCHESTRATION_WS_METHODS, |
| 7 | ORCHESTRATION_WS_CHANNELS, | |
| 8 | 7 | type MessageId, |
| 9 | 8 | type OrchestrationEvent, |
| 10 | 9 | type OrchestrationReadModel, |
| 11 | 10 | type ProjectId, |
| 12 | 11 | type ServerConfig, |
| 12 | type ServerLifecycleWelcomePayload, | |
| 13 | 13 | type ThreadId, |
| 14 | 14 | type TurnId, |
| 15 | type WsWelcomePayload, | |
| 16 | WS_CHANNELS, | |
| 17 | 15 | WS_METHODS, |
| 18 | 16 | OrchestrationSessionStatus, |
| 19 | 17 | DEFAULT_SERVER_SETTINGS, |
| ... | ... | @@ -32,8 +30,10 @@ import { |
| 32 | 30 | removeInlineTerminalContextPlaceholder, |
| 33 | 31 | } from "../lib/terminalContext"; |
| 34 | 32 | import { isMacPlatform } from "../lib/utils"; |
| 33 | import { __resetNativeApiForTests } from "../nativeApi"; | |
| 35 | 34 | import { getRouter } from "../router"; |
| 36 | 35 | import { useStore } from "../store"; |
| 36 | import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; | |
| 37 | 37 | import { estimateTimelineMessageHeight } from "./timelineHeight"; |
| 38 | 38 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; |
| 39 | 39 | |
| ... | ... | @@ -44,25 +44,16 @@ const NOW_ISO = "2026-03-04T12:00:00.000Z"; |
| 44 | 44 | const BASE_TIME_MS = Date.parse(NOW_ISO); |
| 45 | 45 | const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='300'></svg>"; |
| 46 | 46 | |
| 47 | interface WsRequestEnvelope { | |
| 48 | id: string; | |
| 49 | body: { | |
| 50 | _tag: string; | |
| 51 | [key: string]: unknown; | |
| 52 | }; | |
| 53 | } | |
| 54 | ||
| 55 | 47 | interface TestFixture { |
| 56 | 48 | snapshot: OrchestrationReadModel; |
| 57 | 49 | serverConfig: ServerConfig; |
| 58 | welcome: WsWelcomePayload; | |
| 50 | welcome: ServerLifecycleWelcomePayload; | |
| 59 | 51 | } |
| 60 | 52 | |
| 61 | 53 | let fixture: TestFixture; |
| 62 | const wsRequests: WsRequestEnvelope["body"][] = []; | |
| 63 | let customWsRpcResolver: ((body: WsRequestEnvelope["body"]) => unknown | undefined) | null = null; | |
| 64 | let wsClient: { send: (message: string) => void } | null = null; | |
| 65 | let pushSequence = 1; | |
| 54 | const rpcHarness = new BrowserWsRpcHarness(); | |
| 55 | const wsRequests = rpcHarness.requests; | |
| 56 | let customWsRpcResolver: ((body: NormalizedWsRpcRequestBody) => unknown | undefined) | null = null; | |
| 66 | 57 | const wsLink = ws.link(/ws(s)?:\/\/.*/); |
| 67 | 58 | |
| 68 | 59 | interface ViewportSpec { |
| ... | ... | @@ -388,32 +379,20 @@ function createThreadCreatedEvent(threadId: ThreadId, sequence: number): Orchest |
| 388 | 379 | } |
| 389 | 380 | |
| 390 | 381 | function sendOrchestrationDomainEvent(event: OrchestrationEvent): void { |
| 391 | if (!wsClient) { | |
| 392 | throw new Error("WebSocket client not connected"); | |
| 393 | } | |
| 394 | wsClient.send( | |
| 395 | JSON.stringify({ | |
| 396 | type: "push", | |
| 397 | sequence: pushSequence++, | |
| 398 | channel: ORCHESTRATION_WS_CHANNELS.domainEvent, | |
| 399 | data: event, | |
| 400 | }), | |
| 401 | ); | |
| 382 | rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event); | |
| 402 | 383 | } |
| 403 | 384 | |
| 404 | async function waitForWsClient(): Promise<{ send: (message: string) => void }> { | |
| 405 | let client: { send: (message: string) => void } | null = null; | |
| 385 | async function waitForWsClient(): Promise<void> { | |
| 406 | 386 | await vi.waitFor( |
| 407 | 387 | () => { |
| 408 | client = wsClient; | |
| 409 | expect(client).toBeTruthy(); | |
| 388 | expect( | |
| 389 | wsRequests.some( | |
| 390 | (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents, | |
| 391 | ), | |
| 392 | ).toBe(true); | |
| 410 | 393 | }, |
| 411 | 394 | { timeout: 8_000, interval: 16 }, |
| 412 | 395 | ); |
| 413 | if (!client) { | |
| 414 | throw new Error("WebSocket client not connected"); | |
| 415 | } | |
| 416 | return client; | |
| 417 | 396 | } |
| 418 | 397 | |
| 419 | 398 | async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> { |
| ... | ... | @@ -637,7 +616,7 @@ function createSnapshotWithPlanFollowUpPrompt(): OrchestrationReadModel { |
| 637 | 616 | }; |
| 638 | 617 | } |
| 639 | 618 | |
| 640 | function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { | |
| 619 | function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { | |
| 641 | 620 | const customResult = customWsRpcResolver?.(body); |
| 642 | 621 | if (customResult !== undefined) { |
| 643 | 622 | return customResult; |
| ... | ... | @@ -684,6 +663,9 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { |
| 684 | 663 | truncated: false, |
| 685 | 664 | }; |
| 686 | 665 | } |
| 666 | if (tag === WS_METHODS.shellOpenInEditor) { | |
| 667 | return null; | |
| 668 | } | |
| 687 | 669 | if (tag === WS_METHODS.terminalOpen) { |
| 688 | 670 | return { |
| 689 | 671 | threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID, |
| ... | ... | @@ -702,34 +684,11 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { |
| 702 | 684 | |
| 703 | 685 | const worker = setupWorker( |
| 704 | 686 | wsLink.addEventListener("connection", ({ client }) => { |
| 705 | wsClient = client; | |
| 706 | pushSequence = 1; | |
| 707 | client.send( | |
| 708 | JSON.stringify({ | |
| 709 | type: "push", | |
| 710 | sequence: pushSequence++, | |
| 711 | channel: WS_CHANNELS.serverWelcome, | |
| 712 | data: fixture.welcome, | |
| 713 | }), | |
| 714 | ); | |
| 687 | void rpcHarness.connect(client); | |
| 715 | 688 | client.addEventListener("message", (event) => { |
| 716 | 689 | const rawData = event.data; |
| 717 | 690 | if (typeof rawData !== "string") return; |
| 718 | let request: WsRequestEnvelope; | |
| 719 | try { | |
| 720 | request = JSON.parse(rawData) as WsRequestEnvelope; | |
| 721 | } catch { | |
| 722 | return; | |
| 723 | } | |
| 724 | const method = request.body?._tag; | |
| 725 | if (typeof method !== "string") return; | |
| 726 | wsRequests.push(request.body); | |
| 727 | client.send( | |
| 728 | JSON.stringify({ | |
| 729 | id: request.id, | |
| 730 | result: resolveWsRpc(request.body), | |
| 731 | }), | |
| 732 | ); | |
| 691 | void rpcHarness.onMessage(rawData); | |
| 733 | 692 | }); |
| 734 | 693 | }), |
| 735 | 694 | http.get("*/attachments/:attachmentId", () => |
| ... | ... | @@ -903,7 +862,9 @@ async function waitForInteractionModeButton( |
| 903 | 862 | async function waitForServerConfigToApply(): Promise<void> { |
| 904 | 863 | await vi.waitFor( |
| 905 | 864 | () => { |
| 906 | expect(wsRequests.some((request) => request._tag === WS_METHODS.serverGetConfig)).toBe(true); | |
| 865 | expect(wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerConfig)).toBe( | |
| 866 | true, | |
| 867 | ); | |
| 907 | 868 | }, |
| 908 | 869 | { timeout: 8_000, interval: 16 }, |
| 909 | 870 | ); |
| ... | ... | @@ -1041,7 +1002,7 @@ async function mountChatView(options: { |
| 1041 | 1002 | viewport: ViewportSpec; |
| 1042 | 1003 | snapshot: OrchestrationReadModel; |
| 1043 | 1004 | configureFixture?: (fixture: TestFixture) => void; |
| 1044 | resolveRpc?: (body: WsRequestEnvelope["body"]) => unknown | undefined; | |
| 1005 | resolveRpc?: (body: NormalizedWsRpcRequestBody) => unknown | undefined; | |
| 1045 | 1006 | }): Promise<MountedChatView> { |
| 1046 | 1007 | fixture = buildFixture(options.snapshot); |
| 1047 | 1008 | options.configureFixture?.(fixture); |
| ... | ... | @@ -1129,10 +1090,37 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1129 | 1090 | }); |
| 1130 | 1091 | |
| 1131 | 1092 | afterAll(async () => { |
| 1093 | await rpcHarness.disconnect(); | |
| 1132 | 1094 | await worker.stop(); |
| 1133 | 1095 | }); |
| 1134 | 1096 | |
| 1135 | 1097 | beforeEach(async () => { |
| 1098 | await rpcHarness.reset({ | |
| 1099 | resolveUnary: resolveWsRpc, | |
| 1100 | getInitialStreamValues: (request) => { | |
| 1101 | if (request._tag === WS_METHODS.subscribeServerLifecycle) { | |
| 1102 | return [ | |
| 1103 | { | |
| 1104 | version: 1, | |
| 1105 | sequence: 1, | |
| 1106 | type: "welcome", | |
| 1107 | payload: fixture.welcome, | |
| 1108 | }, | |
| 1109 | ]; | |
| 1110 | } | |
| 1111 | if (request._tag === WS_METHODS.subscribeServerConfig) { | |
| 1112 | return [ | |
| 1113 | { | |
| 1114 | version: 1, | |
| 1115 | type: "snapshot", | |
| 1116 | config: fixture.serverConfig, | |
| 1117 | }, | |
| 1118 | ]; | |
| 1119 | } | |
| 1120 | return []; | |
| 1121 | }, | |
| 1122 | }); | |
| 1123 | __resetNativeApiForTests(); | |
| 1136 | 1124 | await setViewport(DEFAULT_VIEWPORT); |
| 1137 | 1125 | localStorage.clear(); |
| 1138 | 1126 | document.body.innerHTML = ""; |
| ... | ... | @@ -1342,6 +1330,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1342 | 1330 | }); |
| 1343 | 1331 | |
| 1344 | 1332 | try { |
| 1333 | await waitForServerConfigToApply(); | |
| 1345 | 1334 | const openButton = await waitForElement( |
| 1346 | 1335 | () => |
| 1347 | 1336 | Array.from(document.querySelectorAll("button")).find( |
| ... | ... | @@ -1349,6 +1338,9 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1349 | 1338 | ) as HTMLButtonElement | null, |
| 1350 | 1339 | "Unable to find Open button.", |
| 1351 | 1340 | ); |
| 1341 | await vi.waitFor(() => { | |
| 1342 | expect(openButton.disabled).toBe(false); | |
| 1343 | }); | |
| 1352 | 1344 | openButton.click(); |
| 1353 | 1345 | |
| 1354 | 1346 | await vi.waitFor( |
| ... | ... | @@ -1384,6 +1376,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1384 | 1376 | }); |
| 1385 | 1377 | |
| 1386 | 1378 | try { |
| 1379 | await waitForServerConfigToApply(); | |
| 1387 | 1380 | const openButton = await waitForElement( |
| 1388 | 1381 | () => |
| 1389 | 1382 | Array.from(document.querySelectorAll("button")).find( |
| ... | ... | @@ -1391,6 +1384,9 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1391 | 1384 | ) as HTMLButtonElement | null, |
| 1392 | 1385 | "Unable to find Open button.", |
| 1393 | 1386 | ); |
| 1387 | await vi.waitFor(() => { | |
| 1388 | expect(openButton.disabled).toBe(false); | |
| 1389 | }); | |
| 1394 | 1390 | openButton.click(); |
| 1395 | 1391 | |
| 1396 | 1392 | await vi.waitFor( |
| ... | ... | @@ -1426,6 +1422,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1426 | 1422 | }); |
| 1427 | 1423 | |
| 1428 | 1424 | try { |
| 1425 | await waitForServerConfigToApply(); | |
| 1429 | 1426 | const menuButton = await waitForElement( |
| 1430 | 1427 | () => document.querySelector('button[aria-label="Copy options"]'), |
| 1431 | 1428 | "Unable to find Open picker button.", |
| ... | ... | @@ -1474,7 +1471,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1474 | 1471 | }); |
| 1475 | 1472 | |
| 1476 | 1473 | it("falls back to the first installed editor when the stored favorite is unavailable", async () => { |
| 1477 | localStorage.setItem("t3code:last-editor", "vscodium"); | |
| 1474 | localStorage.setItem("t3code:last-editor", JSON.stringify("vscodium")); | |
| 1478 | 1475 | setDraftThreadWithoutWorktree(); |
| 1479 | 1476 | |
| 1480 | 1477 | const mounted = await mountChatView({ |
| ... | ... | @@ -1489,6 +1486,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1489 | 1486 | }); |
| 1490 | 1487 | |
| 1491 | 1488 | try { |
| 1489 | await waitForServerConfigToApply(); | |
| 1492 | 1490 | const openButton = await waitForElement( |
| 1493 | 1491 | () => |
| 1494 | 1492 | Array.from(document.querySelectorAll("button")).find( |
| ... | ... | @@ -1496,6 +1494,9 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1496 | 1494 | ) as HTMLButtonElement | null, |
| 1497 | 1495 | "Unable to find Open button.", |
| 1498 | 1496 | ); |
| 1497 | await vi.waitFor(() => { | |
| 1498 | expect(openButton.disabled).toBe(false); | |
| 1499 | }); | |
| 1499 | 1500 | openButton.click(); |
| 1500 | 1501 | |
| 1501 | 1502 | await vi.waitFor( |
apps/web/src/components/ChatView.tsx+10-11| ... | ... | @@ -11,11 +11,9 @@ import { |
| 11 | 11 | type ProviderApprovalDecision, |
| 12 | 12 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 13 | 13 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 14 | type ResolvedKeybindingsConfig, | |
| 15 | 14 | type ServerProvider, |
| 16 | 15 | type ThreadId, |
| 17 | 16 | type TurnId, |
| 18 | type EditorId, | |
| 19 | 17 | type KeybindingCommand, |
| 20 | 18 | OrchestrationThreadActivity, |
| 21 | 19 | ProviderInteractionMode, |
| ... | ... | @@ -30,7 +28,6 @@ import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 30 | 28 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 31 | 29 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; |
| 32 | 30 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 33 | import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; | |
| 34 | 31 | import { isElectron } from "../env"; |
| 35 | 32 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 36 | 33 | import { |
| ... | ... | @@ -195,15 +192,18 @@ import { |
| 195 | 192 | waitForStartedServerThread, |
| 196 | 193 | } from "./ChatView.logic"; |
| 197 | 194 | import { useLocalStorage } from "~/hooks/useLocalStorage"; |
| 195 | import { | |
| 196 | useServerAvailableEditors, | |
| 197 | useServerConfig, | |
| 198 | useServerKeybindings, | |
| 199 | } from "~/rpc/serverState"; | |
| 198 | 200 | |
| 199 | 201 | const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; |
| 200 | 202 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; |
| 201 | 203 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 202 | 204 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; |
| 203 | 205 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; |
| 204 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | |
| 205 | 206 | const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; |
| 206 | const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; | |
| 207 | 207 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 208 | 208 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; |
| 209 | 209 | |
| ... | ... | @@ -794,8 +794,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 794 | 794 | const lockedProvider: ProviderKind | null = hasThreadStarted |
| 795 | 795 | ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null) |
| 796 | 796 | : null; |
| 797 | const serverConfigQuery = useQuery(serverConfigQueryOptions()); | |
| 798 | const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDERS; | |
| 797 | const serverConfig = useServerConfig(); | |
| 798 | const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; | |
| 799 | 799 | const unlockedSelectedProvider = resolveSelectableProvider( |
| 800 | 800 | providerStatuses, |
| 801 | 801 | selectedProviderByThreadId ?? threadProvider ?? "codex", |
| ... | ... | @@ -1202,8 +1202,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1202 | 1202 | ); |
| 1203 | 1203 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; |
| 1204 | 1204 | const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); |
| 1205 | const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; | |
| 1206 | const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; | |
| 1205 | const keybindings = useServerKeybindings(); | |
| 1206 | const availableEditors = useServerAvailableEditors(); | |
| 1207 | 1207 | const modelOptionsByProvider = useMemo( |
| 1208 | 1208 | () => ({ |
| 1209 | 1209 | codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], |
| ... | ... | @@ -1698,10 +1698,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1698 | 1698 | |
| 1699 | 1699 | if (isElectron && keybindingRule) { |
| 1700 | 1700 | await api.server.upsertKeybinding(keybindingRule); |
| 1701 | await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); | |
| 1702 | 1701 | } |
| 1703 | 1702 | }, |
| 1704 | [queryClient], | |
| 1703 | [], | |
| 1705 | 1704 | ); |
| 1706 | 1705 | const saveProjectScript = useCallback( |
| 1707 | 1706 | async (input: NewProjectScriptInput) => { |
apps/web/src/components/GitActionsControl.tsx+57-68| ... | ... | @@ -294,74 +294,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 294 | 294 | }) |
| 295 | 295 | : null; |
| 296 | 296 | |
| 297 | useEffect(() => { | |
| 298 | const api = readNativeApi(); | |
| 299 | if (!api) { | |
| 300 | return; | |
| 301 | } | |
| 302 | ||
| 303 | const applyProgressEvent = (event: GitActionProgressEvent) => { | |
| 304 | const progress = activeGitActionProgressRef.current; | |
| 305 | if (!progress) { | |
| 306 | return; | |
| 307 | } | |
| 308 | if (gitCwd && event.cwd !== gitCwd) { | |
| 309 | return; | |
| 310 | } | |
| 311 | if (progress.actionId !== event.actionId) { | |
| 312 | return; | |
| 313 | } | |
| 314 | ||
| 315 | const now = Date.now(); | |
| 316 | switch (event.kind) { | |
| 317 | case "action_started": | |
| 318 | progress.phaseStartedAtMs = now; | |
| 319 | progress.hookStartedAtMs = null; | |
| 320 | progress.hookName = null; | |
| 321 | progress.lastOutputLine = null; | |
| 322 | break; | |
| 323 | case "phase_started": | |
| 324 | progress.title = event.label; | |
| 325 | progress.currentPhaseLabel = event.label; | |
| 326 | progress.phaseStartedAtMs = now; | |
| 327 | progress.hookStartedAtMs = null; | |
| 328 | progress.hookName = null; | |
| 329 | progress.lastOutputLine = null; | |
| 330 | break; | |
| 331 | case "hook_started": | |
| 332 | progress.title = `Running ${event.hookName}...`; | |
| 333 | progress.hookName = event.hookName; | |
| 334 | progress.hookStartedAtMs = now; | |
| 335 | progress.lastOutputLine = null; | |
| 336 | break; | |
| 337 | case "hook_output": | |
| 338 | progress.lastOutputLine = event.text; | |
| 339 | break; | |
| 340 | case "hook_finished": | |
| 341 | progress.title = progress.currentPhaseLabel ?? "Committing..."; | |
| 342 | progress.hookName = null; | |
| 343 | progress.hookStartedAtMs = null; | |
| 344 | progress.lastOutputLine = null; | |
| 345 | break; | |
| 346 | case "action_finished": | |
| 347 | // Don't clear timestamps here — the HTTP response handler (line 496) | |
| 348 | // sets activeGitActionProgressRef to null and shows the success toast. | |
| 349 | // Clearing timestamps early causes the "Running for Xs" description | |
| 350 | // to disappear before the success state renders, leaving a bare | |
| 351 | // "Pushing..." toast in the gap between the WS event and HTTP response. | |
| 352 | return; | |
| 353 | case "action_failed": | |
| 354 | // Same reasoning as action_finished — let the HTTP error handler | |
| 355 | // manage the final toast state to avoid a flash of bare title. | |
| 356 | return; | |
| 357 | } | |
| 358 | ||
| 359 | updateActiveProgressToast(); | |
| 360 | }; | |
| 361 | ||
| 362 | return api.git.onActionProgress(applyProgressEvent); | |
| 363 | }, [gitCwd, updateActiveProgressToast]); | |
| 364 | ||
| 365 | 297 | useEffect(() => { |
| 366 | 298 | const interval = window.setInterval(() => { |
| 367 | 299 | if (!activeGitActionProgressRef.current) { |
| ... | ... | @@ -483,12 +415,69 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 483 | 415 | }); |
| 484 | 416 | } |
| 485 | 417 | |
| 418 | const applyProgressEvent = (event: GitActionProgressEvent) => { | |
| 419 | const progress = activeGitActionProgressRef.current; | |
| 420 | if (!progress) { | |
| 421 | return; | |
| 422 | } | |
| 423 | if (gitCwd && event.cwd !== gitCwd) { | |
| 424 | return; | |
| 425 | } | |
| 426 | if (progress.actionId !== event.actionId) { | |
| 427 | return; | |
| 428 | } | |
| 429 | ||
| 430 | const now = Date.now(); | |
| 431 | switch (event.kind) { | |
| 432 | case "action_started": | |
| 433 | progress.phaseStartedAtMs = now; | |
| 434 | progress.hookStartedAtMs = null; | |
| 435 | progress.hookName = null; | |
| 436 | progress.lastOutputLine = null; | |
| 437 | break; | |
| 438 | case "phase_started": | |
| 439 | progress.title = event.label; | |
| 440 | progress.currentPhaseLabel = event.label; | |
| 441 | progress.phaseStartedAtMs = now; | |
| 442 | progress.hookStartedAtMs = null; | |
| 443 | progress.hookName = null; | |
| 444 | progress.lastOutputLine = null; | |
| 445 | break; | |
| 446 | case "hook_started": | |
| 447 | progress.title = `Running ${event.hookName}...`; | |
| 448 | progress.hookName = event.hookName; | |
| 449 | progress.hookStartedAtMs = now; | |
| 450 | progress.lastOutputLine = null; | |
| 451 | break; | |
| 452 | case "hook_output": | |
| 453 | progress.lastOutputLine = event.text; | |
| 454 | break; | |
| 455 | case "hook_finished": | |
| 456 | progress.title = progress.currentPhaseLabel ?? "Committing..."; | |
| 457 | progress.hookName = null; | |
| 458 | progress.hookStartedAtMs = null; | |
| 459 | progress.lastOutputLine = null; | |
| 460 | break; | |
| 461 | case "action_finished": | |
| 462 | // Let the resolved mutation update the toast so we keep the | |
| 463 | // elapsed description visible until the final success state renders. | |
| 464 | return; | |
| 465 | case "action_failed": | |
| 466 | // Let the rejected mutation publish the error toast to avoid a | |
| 467 | // transient intermediate state before the final failure message. | |
| 468 | return; | |
| 469 | } | |
| 470 | ||
| 471 | updateActiveProgressToast(); | |
| 472 | }; | |
| 473 | ||
| 486 | 474 | const promise = runImmediateGitActionMutation.mutateAsync({ |
| 487 | 475 | actionId, |
| 488 | 476 | action, |
| 489 | 477 | ...(commitMessage ? { commitMessage } : {}), |
| 490 | 478 | ...(featureBranch ? { featureBranch } : {}), |
| 491 | 479 | ...(filePaths ? { filePaths } : {}), |
| 480 | onProgress: applyProgressEvent, | |
| 492 | 481 | }); |
| 493 | 482 | |
| 494 | 483 | try { |
apps/web/src/components/KeybindingsToast.browser.tsx+41-44| ... | ... | @@ -6,9 +6,8 @@ import { |
| 6 | 6 | type OrchestrationReadModel, |
| 7 | 7 | type ProjectId, |
| 8 | 8 | type ServerConfig, |
| 9 | type ServerLifecycleWelcomePayload, | |
| 9 | 10 | type ThreadId, |
| 10 | type WsWelcomePayload, | |
| 11 | WS_CHANNELS, | |
| 12 | 11 | WS_METHODS, |
| 13 | 12 | } from "@t3tools/contracts"; |
| 14 | 13 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; |
| ... | ... | @@ -18,8 +17,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } |
| 18 | 17 | import { render } from "vitest-browser-react"; |
| 19 | 18 | |
| 20 | 19 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 20 | import { __resetNativeApiForTests } from "../nativeApi"; | |
| 21 | 21 | import { getRouter } from "../router"; |
| 22 | 22 | import { useStore } from "../store"; |
| 23 | import { BrowserWsRpcHarness } from "../../test/wsRpcHarness"; | |
| 23 | 24 | |
| 24 | 25 | const THREAD_ID = "thread-kb-toast-test" as ThreadId; |
| 25 | 26 | const PROJECT_ID = "project-1" as ProjectId; |
| ... | ... | @@ -28,12 +29,11 @@ const NOW_ISO = "2026-03-04T12:00:00.000Z"; |
| 28 | 29 | interface TestFixture { |
| 29 | 30 | snapshot: OrchestrationReadModel; |
| 30 | 31 | serverConfig: ServerConfig; |
| 31 | welcome: WsWelcomePayload; | |
| 32 | welcome: ServerLifecycleWelcomePayload; | |
| 32 | 33 | } |
| 33 | 34 | |
| 34 | 35 | let fixture: TestFixture; |
| 35 | let wsClient: { send: (data: string) => void } | null = null; | |
| 36 | let pushSequence = 1; | |
| 36 | const rpcHarness = new BrowserWsRpcHarness(); | |
| 37 | 37 | |
| 38 | 38 | const wsLink = ws.link(/ws(s)?:\/\/.*/); |
| 39 | 39 | |
| ... | ... | @@ -179,52 +179,23 @@ function resolveWsRpc(tag: string): unknown { |
| 179 | 179 | |
| 180 | 180 | const worker = setupWorker( |
| 181 | 181 | wsLink.addEventListener("connection", ({ client }) => { |
| 182 | wsClient = client; | |
| 183 | pushSequence = 1; | |
| 184 | client.send( | |
| 185 | JSON.stringify({ | |
| 186 | type: "push", | |
| 187 | sequence: pushSequence++, | |
| 188 | channel: WS_CHANNELS.serverWelcome, | |
| 189 | data: fixture.welcome, | |
| 190 | }), | |
| 191 | ); | |
| 182 | void rpcHarness.connect(client); | |
| 192 | 183 | client.addEventListener("message", (event) => { |
| 193 | 184 | const rawData = event.data; |
| 194 | 185 | if (typeof rawData !== "string") return; |
| 195 | let request: { id: string; body: { _tag: string; [key: string]: unknown } }; | |
| 196 | try { | |
| 197 | request = JSON.parse(rawData); | |
| 198 | } catch { | |
| 199 | return; | |
| 200 | } | |
| 201 | const method = request.body?._tag; | |
| 202 | if (typeof method !== "string") return; | |
| 203 | client.send( | |
| 204 | JSON.stringify({ | |
| 205 | id: request.id, | |
| 206 | result: resolveWsRpc(method), | |
| 207 | }), | |
| 208 | ); | |
| 186 | void rpcHarness.onMessage(rawData); | |
| 209 | 187 | }); |
| 210 | 188 | }), |
| 211 | 189 | http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), |
| 212 | 190 | http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), |
| 213 | 191 | ); |
| 214 | 192 | |
| 215 | function sendServerConfigUpdatedPush(issues: Array<{ kind: string; message: string }>) { | |
| 216 | if (!wsClient) throw new Error("WebSocket client not connected"); | |
| 217 | wsClient.send( | |
| 218 | JSON.stringify({ | |
| 219 | type: "push", | |
| 220 | sequence: pushSequence++, | |
| 221 | channel: WS_CHANNELS.serverConfigUpdated, | |
| 222 | data: { | |
| 223 | issues, | |
| 224 | providers: fixture.serverConfig.providers, | |
| 225 | }, | |
| 226 | }), | |
| 227 | ); | |
| 193 | function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) { | |
| 194 | rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { | |
| 195 | version: 1, | |
| 196 | type: "keybindingsUpdated", | |
| 197 | payload: { issues }, | |
| 198 | }); | |
| 228 | 199 | } |
| 229 | 200 | |
| 230 | 201 | function queryToastTitles(): string[] { |
| ... | ... | @@ -308,13 +279,39 @@ describe("Keybindings update toast", () => { |
| 308 | 279 | }); |
| 309 | 280 | |
| 310 | 281 | afterAll(async () => { |
| 282 | await rpcHarness.disconnect(); | |
| 311 | 283 | await worker.stop(); |
| 312 | 284 | }); |
| 313 | 285 | |
| 314 | beforeEach(() => { | |
| 286 | beforeEach(async () => { | |
| 287 | await rpcHarness.reset({ | |
| 288 | resolveUnary: (request) => resolveWsRpc(request._tag), | |
| 289 | getInitialStreamValues: (request) => { | |
| 290 | if (request._tag === WS_METHODS.subscribeServerLifecycle) { | |
| 291 | return [ | |
| 292 | { | |
| 293 | version: 1, | |
| 294 | sequence: 1, | |
| 295 | type: "welcome", | |
| 296 | payload: fixture.welcome, | |
| 297 | }, | |
| 298 | ]; | |
| 299 | } | |
| 300 | if (request._tag === WS_METHODS.subscribeServerConfig) { | |
| 301 | return [ | |
| 302 | { | |
| 303 | version: 1, | |
| 304 | type: "snapshot", | |
| 305 | config: fixture.serverConfig, | |
| 306 | }, | |
| 307 | ]; | |
| 308 | } | |
| 309 | return []; | |
| 310 | }, | |
| 311 | }); | |
| 312 | __resetNativeApiForTests(); | |
| 315 | 313 | localStorage.clear(); |
| 316 | 314 | document.body.innerHTML = ""; |
| 317 | pushSequence = 1; | |
| 318 | 315 | useComposerDraftStore.setState({ |
| 319 | 316 | draftsByThreadId: {}, |
| 320 | 317 | draftThreadsByThreadId: {}, |
apps/web/src/components/ProjectFavicon.tsx+6-21| ... | ... | @@ -1,30 +1,15 @@ |
| 1 | 1 | import { FolderIcon } from "lucide-react"; |
| 2 | 2 | import { useState } from "react"; |
| 3 | ||
| 4 | function getServerHttpOrigin(): string { | |
| 5 | const bridgeUrl = window.desktopBridge?.getWsUrl(); | |
| 6 | const envUrl = import.meta.env.VITE_WS_URL as string | undefined; | |
| 7 | const wsUrl = | |
| 8 | bridgeUrl && bridgeUrl.length > 0 | |
| 9 | ? bridgeUrl | |
| 10 | : envUrl && envUrl.length > 0 | |
| 11 | ? envUrl | |
| 12 | : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`; | |
| 13 | // Parse to extract just the origin, dropping path/query (e.g. ?token=…) | |
| 14 | const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:"); | |
| 15 | try { | |
| 16 | return new URL(httpUrl).origin; | |
| 17 | } catch { | |
| 18 | return httpUrl; | |
| 19 | } | |
| 20 | } | |
| 21 | ||
| 22 | const serverHttpOrigin = getServerHttpOrigin(); | |
| 3 | import { resolveServerUrl } from "~/lib/utils"; | |
| 23 | 4 | |
| 24 | 5 | const loadedProjectFaviconSrcs = new Set<string>(); |
| 25 | 6 | |
| 26 | 7 | export function ProjectFavicon({ cwd, className }: { cwd: string; className?: string }) { |
| 27 | const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(cwd)}`; | |
| 8 | const src = resolveServerUrl({ | |
| 9 | protocol: "http", | |
| 10 | pathname: "/api/project-favicon", | |
| 11 | searchParams: { cwd }, | |
| 12 | }); | |
| 28 | 13 | const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => |
| 29 | 14 | loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", |
| 30 | 15 | ); |
apps/web/src/components/Sidebar.tsx+3-9| ... | ... | @@ -43,9 +43,8 @@ import { |
| 43 | 43 | ProjectId, |
| 44 | 44 | ThreadId, |
| 45 | 45 | type GitStatusResult, |
| 46 | type ResolvedKeybindingsConfig, | |
| 47 | 46 | } from "@t3tools/contracts"; |
| 48 | import { useQueries, useQuery } from "@tanstack/react-query"; | |
| 47 | import { useQueries } from "@tanstack/react-query"; | |
| 49 | 48 | import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; |
| 50 | 49 | import { |
| 51 | 50 | type SidebarProjectSortOrder, |
| ... | ... | @@ -67,7 +66,6 @@ import { |
| 67 | 66 | } from "../keybindings"; |
| 68 | 67 | import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; |
| 69 | 68 | import { gitStatusQueryOptions } from "../lib/gitReactQuery"; |
| 70 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | |
| 71 | 69 | import { readNativeApi } from "../nativeApi"; |
| 72 | 70 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 73 | 71 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| ... | ... | @@ -125,9 +123,8 @@ import { |
| 125 | 123 | import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; |
| 126 | 124 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 127 | 125 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; |
| 126 | import { useServerKeybindings } from "../rpc/serverState"; | |
| 128 | 127 | import type { Project, Thread } from "../types"; |
| 129 | ||
| 130 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | |
| 131 | 128 | const THREAD_PREVIEW_LIMIT = 6; |
| 132 | 129 | const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { |
| 133 | 130 | updated_at: "Last user message", |
| ... | ... | @@ -469,10 +466,7 @@ export default function Sidebar() { |
| 469 | 466 | strict: false, |
| 470 | 467 | select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), |
| 471 | 468 | }); |
| 472 | const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ | |
| 473 | ...serverConfigQueryOptions(), | |
| 474 | select: (config) => config.keybindings, | |
| 475 | }); | |
| 469 | const keybindings = useServerKeybindings(); | |
| 476 | 470 | const [addingProject, setAddingProject] = useState(false); |
| 477 | 471 | const [newCwd, setNewCwd] = useState(""); |
| 478 | 472 | const [isPickingFolder, setIsPickingFolder] = useState(false); |
apps/web/src/components/settings/SettingsPanels.tsx+10-11| ... | ... | @@ -9,7 +9,7 @@ import { |
| 9 | 9 | Undo2Icon, |
| 10 | 10 | XIcon, |
| 11 | 11 | } from "lucide-react"; |
| 12 | import { useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 12 | import { useQueryClient } from "@tanstack/react-query"; | |
| 13 | 13 | import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 14 | 14 | import { |
| 15 | 15 | PROVIDER_DISPLAY_NAMES, |
| ... | ... | @@ -40,7 +40,6 @@ import { |
| 40 | 40 | setDesktopUpdateStateQueryData, |
| 41 | 41 | useDesktopUpdateState, |
| 42 | 42 | } from "../../lib/desktopUpdateReactQuery"; |
| 43 | import { serverConfigQueryOptions, serverQueryKeys } from "../../lib/serverReactQuery"; | |
| 44 | 43 | import { |
| 45 | 44 | MAX_CUSTOM_MODEL_LENGTH, |
| 46 | 45 | getCustomModelOptionsByProvider, |
| ... | ... | @@ -59,6 +58,11 @@ import { Switch } from "../ui/switch"; |
| 59 | 58 | import { toastManager } from "../ui/toast"; |
| 60 | 59 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 61 | 60 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 61 | import { | |
| 62 | useServerAvailableEditors, | |
| 63 | useServerKeybindingsConfigPath, | |
| 64 | useServerProviders, | |
| 65 | } from "../../rpc/serverState"; | |
| 62 | 66 | |
| 63 | 67 | const THEME_OPTIONS = [ |
| 64 | 68 | { |
| ... | ... | @@ -81,8 +85,6 @@ const TIMESTAMP_FORMAT_LABELS = { |
| 81 | 85 | "24-hour": "24-hour", |
| 82 | 86 | } as const; |
| 83 | 87 | |
| 84 | const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = []; | |
| 85 | ||
| 86 | 88 | type InstallProviderSettings = { |
| 87 | 89 | provider: ProviderKind; |
| 88 | 90 | title: string; |
| ... | ... | @@ -516,7 +518,6 @@ export function GeneralSettingsPanel() { |
| 516 | 518 | const { theme, setTheme } = useTheme(); |
| 517 | 519 | const settings = useSettings(); |
| 518 | 520 | const { updateSettings } = useUpdateSettings(); |
| 519 | const serverConfigQuery = useQuery(serverConfigQueryOptions()); | |
| 520 | 521 | const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); |
| 521 | 522 | const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null); |
| 522 | 523 | const [openProviderDetails, setOpenProviderDetails] = useState<Record<ProviderKind, boolean>>({ |
| ... | ... | @@ -542,7 +543,6 @@ export function GeneralSettingsPanel() { |
| 542 | 543 | >({}); |
| 543 | 544 | const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); |
| 544 | 545 | const refreshingRef = useRef(false); |
| 545 | const queryClient = useQueryClient(); | |
| 546 | 546 | const modelListRefs = useRef<Partial<Record<ProviderKind, HTMLDivElement | null>>>({}); |
| 547 | 547 | const refreshProviders = useCallback(() => { |
| 548 | 548 | if (refreshingRef.current) return; |
| ... | ... | @@ -550,7 +550,6 @@ export function GeneralSettingsPanel() { |
| 550 | 550 | setIsRefreshingProviders(true); |
| 551 | 551 | void ensureNativeApi() |
| 552 | 552 | .server.refreshProviders() |
| 553 | .then(() => queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() })) | |
| 554 | 553 | .catch((error: unknown) => { |
| 555 | 554 | console.warn("Failed to refresh providers", error); |
| 556 | 555 | }) |
| ... | ... | @@ -558,11 +557,11 @@ export function GeneralSettingsPanel() { |
| 558 | 557 | refreshingRef.current = false; |
| 559 | 558 | setIsRefreshingProviders(false); |
| 560 | 559 | }); |
| 561 | }, [queryClient]); | |
| 560 | }, []); | |
| 562 | 561 | |
| 563 | const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; | |
| 564 | const availableEditors = serverConfigQuery.data?.availableEditors; | |
| 565 | const serverProviders = serverConfigQuery.data?.providers ?? EMPTY_SERVER_PROVIDERS; | |
| 562 | const keybindingsConfigPath = useServerKeybindingsConfigPath(); | |
| 563 | const availableEditors = useServerAvailableEditors(); | |
| 564 | const serverProviders = useServerProviders(); | |
| 566 | 565 | const codexHomePath = settings.providers.codex.homePath; |
| 567 | 566 | |
| 568 | 567 | const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); |
apps/web/src/hooks/useSettings.ts+10-18| ... | ... | @@ -10,15 +10,12 @@ |
| 10 | 10 | * store. |
| 11 | 11 | */ |
| 12 | 12 | import { useCallback, useMemo } from "react"; |
| 13 | import { useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 14 | 13 | import { |
| 15 | 14 | ServerSettings, |
| 16 | 15 | ServerSettingsPatch, |
| 17 | ServerConfig, | |
| 18 | 16 | ModelSelection, |
| 19 | 17 | ThreadEnvMode, |
| 20 | 18 | } from "@t3tools/contracts"; |
| 21 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; | |
| 22 | 19 | import { |
| 23 | 20 | type ClientSettings, |
| 24 | 21 | ClientSettingsSchema, |
| ... | ... | @@ -29,13 +26,13 @@ import { |
| 29 | 26 | TimestampFormat, |
| 30 | 27 | UnifiedSettings, |
| 31 | 28 | } from "@t3tools/contracts/settings"; |
| 32 | import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; | |
| 33 | 29 | import { ensureNativeApi } from "~/nativeApi"; |
| 34 | 30 | import { useLocalStorage } from "./useLocalStorage"; |
| 35 | 31 | import { normalizeCustomModelSlugs } from "~/modelSelection"; |
| 36 | 32 | import { Predicate, Schema, Struct } from "effect"; |
| 37 | 33 | import { DeepMutable } from "effect/Types"; |
| 38 | 34 | import { deepMerge } from "@t3tools/shared/Struct"; |
| 35 | import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState"; | |
| 39 | 36 | |
| 40 | 37 | const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; |
| 41 | 38 | const OLD_SETTINGS_KEY = "t3code:app-settings:v1"; |
| ... | ... | @@ -73,7 +70,7 @@ function splitPatch(patch: Partial<UnifiedSettings>): { |
| 73 | 70 | export function useSettings<T extends UnifiedSettings = UnifiedSettings>( |
| 74 | 71 | selector?: (s: UnifiedSettings) => T, |
| 75 | 72 | ): T { |
| 76 | const { data: serverConfig } = useQuery(serverConfigQueryOptions()); | |
| 73 | const serverSettings = useServerSettings(); | |
| 77 | 74 | const [clientSettings] = useLocalStorage( |
| 78 | 75 | CLIENT_SETTINGS_STORAGE_KEY, |
| 79 | 76 | DEFAULT_CLIENT_SETTINGS, |
| ... | ... | @@ -82,10 +79,10 @@ export function useSettings<T extends UnifiedSettings = UnifiedSettings>( |
| 82 | 79 | |
| 83 | 80 | const merged = useMemo<UnifiedSettings>( |
| 84 | 81 | () => ({ |
| 85 | ...(serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS), | |
| 82 | ...serverSettings, | |
| 86 | 83 | ...clientSettings, |
| 87 | 84 | }), |
| 88 | [serverConfig?.settings, clientSettings], | |
| 85 | [clientSettings, serverSettings], | |
| 89 | 86 | ); |
| 90 | 87 | |
| 91 | 88 | return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]); |
| ... | ... | @@ -94,11 +91,10 @@ export function useSettings<T extends UnifiedSettings = UnifiedSettings>( |
| 94 | 91 | /** |
| 95 | 92 | * Returns an updater that routes each key to the correct backing store. |
| 96 | 93 | * |
| 97 | * Server keys are optimistically patched in the React Query cache, then | |
| 94 | * Server keys are optimistically patched in atom-backed server state, then | |
| 98 | 95 | * persisted via RPC. Client keys go straight to localStorage. |
| 99 | 96 | */ |
| 100 | 97 | export function useUpdateSettings() { |
| 101 | const queryClient = useQueryClient(); | |
| 102 | 98 | const [, setClientSettings] = useLocalStorage( |
| 103 | 99 | CLIENT_SETTINGS_STORAGE_KEY, |
| 104 | 100 | DEFAULT_CLIENT_SETTINGS, |
| ... | ... | @@ -110,14 +106,10 @@ export function useUpdateSettings() { |
| 110 | 106 | const { serverPatch, clientPatch } = splitPatch(patch); |
| 111 | 107 | |
| 112 | 108 | if (Object.keys(serverPatch).length > 0) { |
| 113 | // Optimistic update of the React Query cache | |
| 114 | queryClient.setQueryData<ServerConfig>(serverQueryKeys.config(), (old) => { | |
| 115 | if (!old) return old; | |
| 116 | return { | |
| 117 | ...old, | |
| 118 | settings: deepMerge(old.settings, serverPatch), | |
| 119 | }; | |
| 120 | }); | |
| 109 | const currentServerConfig = getServerConfig(); | |
| 110 | if (currentServerConfig) { | |
| 111 | applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch)); | |
| 112 | } | |
| 121 | 113 | // Fire-and-forget RPC — push will reconcile on success |
| 122 | 114 | void ensureNativeApi().server.updateSettings(serverPatch); |
| 123 | 115 | } |
| ... | ... | @@ -126,7 +118,7 @@ export function useUpdateSettings() { |
| 126 | 118 | setClientSettings((prev) => ({ ...prev, ...clientPatch })); |
| 127 | 119 | } |
| 128 | 120 | }, |
| 129 | [queryClient, setClientSettings], | |
| 121 | [setClientSettings], | |
| 130 | 122 | ); |
| 131 | 123 | |
| 132 | 124 | const resetSettings = useCallback(() => { |
apps/web/src/lib/gitReactQuery.ts+15-10| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | import { type GitStackedAction } from "@t3tools/contracts"; | |
| 1 | import { type GitActionProgressEvent, type GitStackedAction } from "@t3tools/contracts"; | |
| 2 | 2 | import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; |
| 3 | 3 | import { ensureNativeApi } from "../nativeApi"; |
| 4 | import { getWsRpcClient } from "../wsRpcClient"; | |
| 4 | 5 | |
| 5 | 6 | const GIT_STATUS_STALE_TIME_MS = 5_000; |
| 6 | 7 | const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000; |
| ... | ... | @@ -121,23 +122,27 @@ export function gitRunStackedActionMutationOptions(input: { |
| 121 | 122 | commitMessage, |
| 122 | 123 | featureBranch, |
| 123 | 124 | filePaths, |
| 125 | onProgress, | |
| 124 | 126 | }: { |
| 125 | 127 | actionId: string; |
| 126 | 128 | action: GitStackedAction; |
| 127 | 129 | commitMessage?: string; |
| 128 | 130 | featureBranch?: boolean; |
| 129 | 131 | filePaths?: string[]; |
| 132 | onProgress?: (event: GitActionProgressEvent) => void; | |
| 130 | 133 | }) => { |
| 131 | const api = ensureNativeApi(); | |
| 132 | 134 | if (!input.cwd) throw new Error("Git action is unavailable."); |
| 133 | return api.git.runStackedAction({ | |
| 134 | actionId, | |
| 135 | cwd: input.cwd, | |
| 136 | action, | |
| 137 | ...(commitMessage ? { commitMessage } : {}), | |
| 138 | ...(featureBranch ? { featureBranch } : {}), | |
| 139 | ...(filePaths ? { filePaths } : {}), | |
| 140 | }); | |
| 135 | return getWsRpcClient().git.runStackedAction( | |
| 136 | { | |
| 137 | actionId, | |
| 138 | cwd: input.cwd, | |
| 139 | action, | |
| 140 | ...(commitMessage ? { commitMessage } : {}), | |
| 141 | ...(featureBranch ? { featureBranch } : {}), | |
| 142 | ...(filePaths ? { filePaths } : {}), | |
| 143 | }, | |
| 144 | ...(onProgress ? [{ onProgress }] : []), | |
| 145 | ); | |
| 141 | 146 | }, |
| 142 | 147 | onSettled: async () => { |
| 143 | 148 | await invalidateGitQueries(input.queryClient); |
apps/web/src/lib/serverReactQuery.ts deleted-24| ... | ... | @@ -1,24 +0,0 @@ |
| 1 | import { queryOptions } from "@tanstack/react-query"; | |
| 2 | import { ensureNativeApi } from "~/nativeApi"; | |
| 3 | ||
| 4 | export const serverQueryKeys = { | |
| 5 | all: ["server"] as const, | |
| 6 | config: () => ["server", "config"] as const, | |
| 7 | }; | |
| 8 | ||
| 9 | /** | |
| 10 | * Server config query options. | |
| 11 | * | |
| 12 | * `staleTime` is kept short so that push-driven `invalidateQueries` calls in | |
| 13 | * the EventRouter always trigger a refetch, and so the query re-fetches when | |
| 14 | * the component re-mounts (e.g. navigating away from settings and back). | |
| 15 | */ | |
| 16 | export function serverConfigQueryOptions() { | |
| 17 | return queryOptions({ | |
| 18 | queryKey: serverQueryKeys.config(), | |
| 19 | queryFn: async () => { | |
| 20 | const api = ensureNativeApi(); | |
| 21 | return api.server.getConfig(); | |
| 22 | }, | |
| 23 | }); | |
| 24 | } |
apps/web/src/lib/utils.ts+39| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { CommandId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { String, Predicate } from "effect"; | |
| 2 | 3 | import { type CxOptions, cx } from "class-variance-authority"; |
| 3 | 4 | import { twMerge } from "tailwind-merge"; |
| 4 | 5 | import * as Random from "effect/Random"; |
| ... | ... | @@ -34,3 +35,41 @@ export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(randomUUID()); |
| 34 | 35 | export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID()); |
| 35 | 36 | |
| 36 | 37 | export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID()); |
| 38 | ||
| 39 | const isNonEmptyString = Predicate.compose(Predicate.isString, String.isNonEmpty); | |
| 40 | const firstNonEmptyString = (...values: unknown[]): string => { | |
| 41 | for (const value of values) { | |
| 42 | if (isNonEmptyString(value)) { | |
| 43 | return value; | |
| 44 | } | |
| 45 | } | |
| 46 | throw new Error("No non-empty string provided"); | |
| 47 | }; | |
| 48 | ||
| 49 | export const resolveServerUrl = (options?: { | |
| 50 | url?: string | undefined; | |
| 51 | protocol?: "http" | "https" | "ws" | "wss" | undefined; | |
| 52 | pathname?: string | undefined; | |
| 53 | searchParams?: Record<string, string> | undefined; | |
| 54 | }): string => { | |
| 55 | const rawUrl = firstNonEmptyString( | |
| 56 | options?.url, | |
| 57 | window.desktopBridge?.getWsUrl(), | |
| 58 | import.meta.env.VITE_WS_URL, | |
| 59 | window.location.origin, | |
| 60 | ); | |
| 61 | ||
| 62 | const parsedUrl = new URL(rawUrl); | |
| 63 | if (options?.protocol) { | |
| 64 | parsedUrl.protocol = options.protocol; | |
| 65 | } | |
| 66 | if (options?.pathname) { | |
| 67 | parsedUrl.pathname = options.pathname; | |
| 68 | } else { | |
| 69 | parsedUrl.pathname = "/"; | |
| 70 | } | |
| 71 | if (options?.searchParams) { | |
| 72 | parsedUrl.search = new URLSearchParams(options.searchParams).toString(); | |
| 73 | } | |
| 74 | return parsedUrl.toString(); | |
| 75 | }; |
apps/web/src/nativeApi.ts+6-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import type { NativeApi } from "@t3tools/contracts"; |
| 2 | 2 | |
| 3 | import { createWsNativeApi } from "./wsNativeApi"; | |
| 3 | import { __resetWsNativeApiForTests, createWsNativeApi } from "./wsNativeApi"; | |
| 4 | 4 | |
| 5 | 5 | let cachedApi: NativeApi | undefined; |
| 6 | 6 | |
| ... | ... | @@ -24,3 +24,8 @@ export function ensureNativeApi(): NativeApi { |
| 24 | 24 | } |
| 25 | 25 | return api; |
| 26 | 26 | } |
| 27 | ||
| 28 | export function __resetNativeApiForTests() { | |
| 29 | cachedApi = undefined; | |
| 30 | __resetWsNativeApiForTests(); | |
| 31 | } |
apps/web/src/router.ts+7-1| ... | ... | @@ -2,6 +2,7 @@ import { createElement } from "react"; |
| 2 | 2 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
| 3 | 3 | import { createRouter, RouterHistory } from "@tanstack/react-router"; |
| 4 | 4 | |
| 5 | import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; | |
| 5 | 6 | import { routeTree } from "./routeTree.gen"; |
| 6 | 7 | |
| 7 | 8 | export function getRouter(history: RouterHistory) { |
| ... | ... | @@ -13,7 +14,12 @@ export function getRouter(history: RouterHistory) { |
| 13 | 14 | context: { |
| 14 | 15 | queryClient, |
| 15 | 16 | }, |
| 16 | Wrap: ({ children }) => createElement(QueryClientProvider, { client: queryClient }, children), | |
| 17 | Wrap: ({ children }) => | |
| 18 | createElement( | |
| 19 | QueryClientProvider, | |
| 20 | { client: queryClient }, | |
| 21 | createElement(AppAtomRegistryProvider, undefined, children), | |
| 22 | ), | |
| 17 | 23 | }); |
| 18 | 24 | } |
| 19 | 25 |
apps/web/src/routes/__root.tsx+123-102| ... | ... | @@ -1,12 +1,16 @@ |
| 1 | import { OrchestrationEvent, ThreadId } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | OrchestrationEvent, | |
| 3 | ThreadId, | |
| 4 | type ServerLifecycleWelcomePayload, | |
| 5 | } from "@t3tools/contracts"; | |
| 2 | 6 | import { |
| 3 | 7 | Outlet, |
| 4 | 8 | createRootRouteWithContext, |
| 5 | 9 | type ErrorComponentProps, |
| 6 | 10 | useNavigate, |
| 7 | useRouterState, | |
| 11 | useLocation, | |
| 8 | 12 | } from "@tanstack/react-router"; |
| 9 | import { useEffect, useRef } from "react"; | |
| 13 | import { useEffect, useEffectEvent, useRef } from "react"; | |
| 10 | 14 | import { QueryClient, useQueryClient } from "@tanstack/react-query"; |
| 11 | 15 | import { Throttler } from "@tanstack/react-pacer"; |
| 12 | 16 | |
| ... | ... | @@ -15,8 +19,14 @@ import { AppSidebarLayout } from "../components/AppSidebarLayout"; |
| 15 | 19 | import { Button } from "../components/ui/button"; |
| 16 | 20 | import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; |
| 17 | 21 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; |
| 18 | import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; | |
| 19 | 22 | import { readNativeApi } from "../nativeApi"; |
| 23 | import { | |
| 24 | type ServerConfigUpdateSource, | |
| 25 | useServerConfig, | |
| 26 | useServerConfigUpdatedSubscription, | |
| 27 | useServerWelcomeSubscription, | |
| 28 | } from "../rpc/serverState"; | |
| 29 | import { ServerStateBootstrap } from "../rpc/serverStateBootstrap"; | |
| 20 | 30 | import { |
| 21 | 31 | clearPromotedDraftThread, |
| 22 | 32 | clearPromotedDraftThreads, |
| ... | ... | @@ -26,7 +36,6 @@ import { useStore } from "../store"; |
| 26 | 36 | import { useUiStateStore } from "../uiStateStore"; |
| 27 | 37 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 28 | 38 | import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; |
| 29 | import { onServerConfigUpdated, onServerProvidersUpdated, onServerWelcome } from "../wsNativeApi"; | |
| 30 | 39 | import { migrateLocalSettingsToServer } from "../hooks/useSettings"; |
| 31 | 40 | import { providerQueryKeys } from "../lib/providerReactQuery"; |
| 32 | 41 | import { projectQueryKeys } from "../lib/projectReactQuery"; |
| ... | ... | @@ -58,15 +67,18 @@ function RootRouteView() { |
| 58 | 67 | } |
| 59 | 68 | |
| 60 | 69 | return ( |
| 61 | <ToastProvider> | |
| 62 | <AnchoredToastProvider> | |
| 63 | <EventRouter /> | |
| 64 | <DesktopProjectBootstrap /> | |
| 65 | <AppSidebarLayout> | |
| 66 | <Outlet /> | |
| 67 | </AppSidebarLayout> | |
| 68 | </AnchoredToastProvider> | |
| 69 | </ToastProvider> | |
| 70 | <> | |
| 71 | <ServerStateBootstrap /> | |
| 72 | <ToastProvider> | |
| 73 | <AnchoredToastProvider> | |
| 74 | <EventRouter /> | |
| 75 | <DesktopProjectBootstrap /> | |
| 76 | <AppSidebarLayout> | |
| 77 | <Outlet /> | |
| 78 | </AppSidebarLayout> | |
| 79 | </AnchoredToastProvider> | |
| 80 | </ToastProvider> | |
| 81 | </> | |
| 70 | 82 | ); |
| 71 | 83 | } |
| 72 | 84 | |
| ... | ... | @@ -154,16 +166,107 @@ function EventRouter() { |
| 154 | 166 | ); |
| 155 | 167 | const queryClient = useQueryClient(); |
| 156 | 168 | const navigate = useNavigate(); |
| 157 | const pathname = useRouterState({ select: (state) => state.location.pathname }); | |
| 169 | const pathname = useLocation({ select: (loc) => loc.pathname }); | |
| 158 | 170 | const pathnameRef = useRef(pathname); |
| 159 | 171 | const handledBootstrapThreadIdRef = useRef<string | null>(null); |
| 172 | const handledConfigReplayRef = useRef(false); | |
| 173 | const disposedRef = useRef(false); | |
| 174 | const bootstrapFromSnapshotRef = useRef<() => Promise<void>>(async () => undefined); | |
| 175 | const serverConfig = useServerConfig(); | |
| 160 | 176 | |
| 161 | 177 | pathnameRef.current = pathname; |
| 162 | 178 | |
| 179 | const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload) => { | |
| 180 | migrateLocalSettingsToServer(); | |
| 181 | void (async () => { | |
| 182 | await bootstrapFromSnapshotRef.current(); | |
| 183 | if (disposedRef.current) { | |
| 184 | return; | |
| 185 | } | |
| 186 | ||
| 187 | if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { | |
| 188 | return; | |
| 189 | } | |
| 190 | setProjectExpanded(payload.bootstrapProjectId, true); | |
| 191 | ||
| 192 | if (pathnameRef.current !== "/") { | |
| 193 | return; | |
| 194 | } | |
| 195 | if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) { | |
| 196 | return; | |
| 197 | } | |
| 198 | await navigate({ | |
| 199 | to: "/$threadId", | |
| 200 | params: { threadId: payload.bootstrapThreadId }, | |
| 201 | replace: true, | |
| 202 | }); | |
| 203 | handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; | |
| 204 | })().catch(() => undefined); | |
| 205 | }); | |
| 206 | ||
| 207 | const handleServerConfigUpdated = useEffectEvent( | |
| 208 | ({ | |
| 209 | payload, | |
| 210 | source, | |
| 211 | }: { | |
| 212 | readonly payload: import("@t3tools/contracts").ServerConfigUpdatedPayload; | |
| 213 | readonly source: ServerConfigUpdateSource; | |
| 214 | }) => { | |
| 215 | const isReplay = !handledConfigReplayRef.current; | |
| 216 | handledConfigReplayRef.current = true; | |
| 217 | if (isReplay || source !== "keybindingsUpdated") { | |
| 218 | return; | |
| 219 | } | |
| 220 | ||
| 221 | const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); | |
| 222 | if (!issue) { | |
| 223 | toastManager.add({ | |
| 224 | type: "success", | |
| 225 | title: "Keybindings updated", | |
| 226 | description: "Keybindings configuration reloaded successfully.", | |
| 227 | }); | |
| 228 | return; | |
| 229 | } | |
| 230 | ||
| 231 | toastManager.add({ | |
| 232 | type: "warning", | |
| 233 | title: "Invalid keybindings configuration", | |
| 234 | description: issue.message, | |
| 235 | actionProps: { | |
| 236 | children: "Open keybindings.json", | |
| 237 | onClick: () => { | |
| 238 | const api = readNativeApi(); | |
| 239 | if (!api) { | |
| 240 | return; | |
| 241 | } | |
| 242 | ||
| 243 | void Promise.resolve(serverConfig ?? api.server.getConfig()) | |
| 244 | .then((config) => { | |
| 245 | const editor = resolveAndPersistPreferredEditor(config.availableEditors); | |
| 246 | if (!editor) { | |
| 247 | throw new Error("No available editors found."); | |
| 248 | } | |
| 249 | return api.shell.openInEditor(config.keybindingsConfigPath, editor); | |
| 250 | }) | |
| 251 | .catch((error) => { | |
| 252 | toastManager.add({ | |
| 253 | type: "error", | |
| 254 | title: "Unable to open keybindings file", | |
| 255 | description: | |
| 256 | error instanceof Error ? error.message : "Unknown error opening file.", | |
| 257 | }); | |
| 258 | }); | |
| 259 | }, | |
| 260 | }, | |
| 261 | }); | |
| 262 | }, | |
| 263 | ); | |
| 264 | ||
| 163 | 265 | useEffect(() => { |
| 164 | 266 | const api = readNativeApi(); |
| 165 | 267 | if (!api) return; |
| 166 | 268 | let disposed = false; |
| 269 | disposedRef.current = false; | |
| 167 | 270 | const recovery = createOrchestrationRecoveryCoordinator(); |
| 168 | 271 | let needsProviderInvalidation = false; |
| 169 | 272 | |
| ... | ... | @@ -299,11 +402,11 @@ function EventRouter() { |
| 299 | 402 | const bootstrapFromSnapshot = async (): Promise<void> => { |
| 300 | 403 | await runSnapshotRecovery("bootstrap"); |
| 301 | 404 | }; |
| 405 | bootstrapFromSnapshotRef.current = bootstrapFromSnapshot; | |
| 302 | 406 | |
| 303 | 407 | const fallbackToSnapshotRecovery = async (): Promise<void> => { |
| 304 | 408 | await runSnapshotRecovery("replay-failed"); |
| 305 | 409 | }; |
| 306 | ||
| 307 | 410 | const unsubDomainEvent = api.orchestration.onDomainEvent((event) => { |
| 308 | 411 | const action = recovery.classifyDomainEvent(event.sequence); |
| 309 | 412 | if (action === "apply") { |
| ... | ... | @@ -327,98 +430,13 @@ function EventRouter() { |
| 327 | 430 | hasRunningSubprocess, |
| 328 | 431 | ); |
| 329 | 432 | }); |
| 330 | const unsubWelcome = onServerWelcome((payload) => { | |
| 331 | // Migrate old localStorage settings to server on first connect | |
| 332 | migrateLocalSettingsToServer(); | |
| 333 | void (async () => { | |
| 334 | await bootstrapFromSnapshot(); | |
| 335 | if (disposed) { | |
| 336 | return; | |
| 337 | } | |
| 338 | ||
| 339 | if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { | |
| 340 | return; | |
| 341 | } | |
| 342 | setProjectExpanded(payload.bootstrapProjectId, true); | |
| 343 | ||
| 344 | if (pathnameRef.current !== "/") { | |
| 345 | return; | |
| 346 | } | |
| 347 | if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) { | |
| 348 | return; | |
| 349 | } | |
| 350 | await navigate({ | |
| 351 | to: "/$threadId", | |
| 352 | params: { threadId: payload.bootstrapThreadId }, | |
| 353 | replace: true, | |
| 354 | }); | |
| 355 | handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; | |
| 356 | })().catch(() => undefined); | |
| 357 | }); | |
| 358 | // onServerConfigUpdated replays the latest cached value synchronously | |
| 359 | // during subscribe. Skip the toast for that replay so effect re-runs | |
| 360 | // don't produce duplicate toasts. | |
| 361 | let subscribed = false; | |
| 362 | const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { | |
| 363 | // Invalidate the config query so active observers refetch fresh data. | |
| 364 | void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); | |
| 365 | ||
| 366 | if (!subscribed) return; | |
| 367 | ||
| 368 | // Only show keybindings toasts for keybindings changes (no settings in payload) | |
| 369 | if (payload.settings) return; | |
| 370 | ||
| 371 | const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); | |
| 372 | if (!issue) { | |
| 373 | toastManager.add({ | |
| 374 | type: "success", | |
| 375 | title: "Keybindings updated", | |
| 376 | description: "Keybindings configuration reloaded successfully.", | |
| 377 | }); | |
| 378 | return; | |
| 379 | } | |
| 380 | ||
| 381 | toastManager.add({ | |
| 382 | type: "warning", | |
| 383 | title: "Invalid keybindings configuration", | |
| 384 | description: issue.message, | |
| 385 | actionProps: { | |
| 386 | children: "Open keybindings.json", | |
| 387 | onClick: () => { | |
| 388 | void queryClient | |
| 389 | .ensureQueryData(serverConfigQueryOptions()) | |
| 390 | .then((config) => { | |
| 391 | const editor = resolveAndPersistPreferredEditor(config.availableEditors); | |
| 392 | if (!editor) { | |
| 393 | throw new Error("No available editors found."); | |
| 394 | } | |
| 395 | return api.shell.openInEditor(config.keybindingsConfigPath, editor); | |
| 396 | }) | |
| 397 | .catch((error) => { | |
| 398 | toastManager.add({ | |
| 399 | type: "error", | |
| 400 | title: "Unable to open keybindings file", | |
| 401 | description: | |
| 402 | error instanceof Error ? error.message : "Unknown error opening file.", | |
| 403 | }); | |
| 404 | }); | |
| 405 | }, | |
| 406 | }, | |
| 407 | }); | |
| 408 | }); | |
| 409 | const unsubProvidersUpdated = onServerProvidersUpdated(() => { | |
| 410 | void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); | |
| 411 | }); | |
| 412 | subscribed = true; | |
| 413 | 433 | return () => { |
| 414 | 434 | disposed = true; |
| 435 | disposedRef.current = true; | |
| 415 | 436 | needsProviderInvalidation = false; |
| 416 | 437 | queryInvalidationThrottler.cancel(); |
| 417 | 438 | unsubDomainEvent(); |
| 418 | 439 | unsubTerminalEvent(); |
| 419 | unsubWelcome(); | |
| 420 | unsubServerConfigUpdated(); | |
| 421 | unsubProvidersUpdated(); | |
| 422 | 440 | }; |
| 423 | 441 | }, [ |
| 424 | 442 | applyOrchestrationEvents, |
| ... | ... | @@ -433,6 +451,9 @@ function EventRouter() { |
| 433 | 451 | syncThreads, |
| 434 | 452 | ]); |
| 435 | 453 | |
| 454 | useServerWelcomeSubscription(handleWelcome); | |
| 455 | useServerConfigUpdatedSubscription(handleServerConfigUpdated); | |
| 456 | ||
| 436 | 457 | return null; |
| 437 | 458 | } |
| 438 | 459 |
apps/web/src/routes/_chat.tsx+2-7| ... | ... | @@ -1,26 +1,21 @@ |
| 1 | import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; | |
| 2 | import { useQuery } from "@tanstack/react-query"; | |
| 3 | 1 | import { Outlet, createFileRoute } from "@tanstack/react-router"; |
| 4 | 2 | import { useEffect } from "react"; |
| 5 | 3 | |
| 6 | 4 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 7 | 5 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 8 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | |
| 9 | 6 | import { resolveShortcutCommand } from "../keybindings"; |
| 10 | 7 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 11 | 8 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 12 | 9 | import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; |
| 13 | 10 | import { useSettings } from "~/hooks/useSettings"; |
| 14 | ||
| 15 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | |
| 11 | import { useServerKeybindings } from "~/rpc/serverState"; | |
| 16 | 12 | |
| 17 | 13 | function ChatRouteGlobalShortcuts() { |
| 18 | 14 | const clearSelection = useThreadSelectionStore((state) => state.clearSelection); |
| 19 | 15 | const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size); |
| 20 | 16 | const { activeDraftThread, activeThread, defaultProjectId, handleNewThread, routeThreadId } = |
| 21 | 17 | useHandleNewThread(); |
| 22 | const serverConfigQuery = useQuery(serverConfigQueryOptions()); | |
| 23 | const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; | |
| 18 | const keybindings = useServerKeybindings(); | |
| 24 | 19 | const terminalOpen = useTerminalStateStore((state) => |
| 25 | 20 | routeThreadId |
| 26 | 21 | ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen |
apps/web/src/rpc/atomRegistry.tsx created+14| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | import { RegistryContext } from "@effect/atom-react"; | |
| 2 | import { AtomRegistry } from "effect/unstable/reactivity"; | |
| 3 | import type { ReactNode } from "react"; | |
| 4 | ||
| 5 | export let appAtomRegistry = AtomRegistry.make(); | |
| 6 | ||
| 7 | export function AppAtomRegistryProvider({ children }: { readonly children: ReactNode }) { | |
| 8 | return <RegistryContext.Provider value={appAtomRegistry}>{children}</RegistryContext.Provider>; | |
| 9 | } | |
| 10 | ||
| 11 | export function resetAppAtomRegistryForTests() { | |
| 12 | appAtomRegistry.dispose(); | |
| 13 | appAtomRegistry = AtomRegistry.make(); | |
| 14 | } |
apps/web/src/rpc/client.test.ts created+231| ... | ... | @@ -0,0 +1,231 @@ |
| 1 | import { DEFAULT_SERVER_SETTINGS, WS_METHODS } from "@t3tools/contracts"; | |
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 3 | import { AsyncResult, AtomRegistry } from "effect/unstable/reactivity"; | |
| 4 | ||
| 5 | import { __resetWsRpcAtomClientForTests, runRpc, WsRpcAtomClient } from "./client"; | |
| 6 | ||
| 7 | type WsEventType = "open" | "message" | "close" | "error"; | |
| 8 | type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; | |
| 9 | type WsListener = (event?: WsEvent) => void; | |
| 10 | ||
| 11 | const sockets: MockWebSocket[] = []; | |
| 12 | ||
| 13 | class MockWebSocket { | |
| 14 | static readonly CONNECTING = 0; | |
| 15 | static readonly OPEN = 1; | |
| 16 | static readonly CLOSING = 2; | |
| 17 | static readonly CLOSED = 3; | |
| 18 | ||
| 19 | readyState = MockWebSocket.CONNECTING; | |
| 20 | readonly sent: string[] = []; | |
| 21 | readonly url: string; | |
| 22 | private readonly listeners = new Map<WsEventType, Set<WsListener>>(); | |
| 23 | ||
| 24 | constructor(url: string) { | |
| 25 | this.url = url; | |
| 26 | sockets.push(this); | |
| 27 | } | |
| 28 | ||
| 29 | addEventListener(type: WsEventType, listener: WsListener) { | |
| 30 | const listeners = this.listeners.get(type) ?? new Set<WsListener>(); | |
| 31 | listeners.add(listener); | |
| 32 | this.listeners.set(type, listeners); | |
| 33 | } | |
| 34 | ||
| 35 | removeEventListener(type: WsEventType, listener: WsListener) { | |
| 36 | this.listeners.get(type)?.delete(listener); | |
| 37 | } | |
| 38 | ||
| 39 | send(data: string) { | |
| 40 | this.sent.push(data); | |
| 41 | } | |
| 42 | ||
| 43 | close(code = 1000, reason = "") { | |
| 44 | this.readyState = MockWebSocket.CLOSED; | |
| 45 | this.emit("close", { code, reason, type: "close" }); | |
| 46 | } | |
| 47 | ||
| 48 | open() { | |
| 49 | this.readyState = MockWebSocket.OPEN; | |
| 50 | this.emit("open", { type: "open" }); | |
| 51 | } | |
| 52 | ||
| 53 | serverMessage(data: unknown) { | |
| 54 | this.emit("message", { data, type: "message" }); | |
| 55 | } | |
| 56 | ||
| 57 | private emit(type: WsEventType, event?: WsEvent) { | |
| 58 | const listeners = this.listeners.get(type); | |
| 59 | if (!listeners) { | |
| 60 | return; | |
| 61 | } | |
| 62 | for (const listener of listeners) { | |
| 63 | listener(event); | |
| 64 | } | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | const originalWebSocket = globalThis.WebSocket; | |
| 69 | ||
| 70 | function getSocket(): MockWebSocket { | |
| 71 | const socket = sockets.at(-1); | |
| 72 | if (!socket) { | |
| 73 | throw new Error("Expected a websocket instance"); | |
| 74 | } | |
| 75 | return socket; | |
| 76 | } | |
| 77 | ||
| 78 | async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise<void> { | |
| 79 | const startedAt = Date.now(); | |
| 80 | for (;;) { | |
| 81 | try { | |
| 82 | assertion(); | |
| 83 | return; | |
| 84 | } catch (error) { | |
| 85 | if (Date.now() - startedAt >= timeoutMs) { | |
| 86 | throw error; | |
| 87 | } | |
| 88 | await new Promise((resolve) => setTimeout(resolve, 10)); | |
| 89 | } | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | beforeEach(() => { | |
| 94 | sockets.length = 0; | |
| 95 | ||
| 96 | Object.defineProperty(globalThis, "window", { | |
| 97 | configurable: true, | |
| 98 | value: { | |
| 99 | location: { | |
| 100 | hostname: "localhost", | |
| 101 | origin: "http://localhost:3020", | |
| 102 | port: "3020", | |
| 103 | protocol: "ws:", | |
| 104 | }, | |
| 105 | desktopBridge: undefined, | |
| 106 | }, | |
| 107 | }); | |
| 108 | ||
| 109 | globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; | |
| 110 | }); | |
| 111 | ||
| 112 | afterEach(() => { | |
| 113 | __resetWsRpcAtomClientForTests(); | |
| 114 | globalThis.WebSocket = originalWebSocket; | |
| 115 | vi.restoreAllMocks(); | |
| 116 | }); | |
| 117 | ||
| 118 | describe("WsRpcAtomClient", () => { | |
| 119 | it("runs unary requests through the AtomRpc service", async () => { | |
| 120 | const expectedSettings = { | |
| 121 | ...DEFAULT_SERVER_SETTINGS, | |
| 122 | enableAssistantStreaming: true, | |
| 123 | defaultThreadEnvMode: "worktree" as const, | |
| 124 | textGenerationModelSelection: { | |
| 125 | provider: "codex" as const, | |
| 126 | model: "gpt-5.4", | |
| 127 | }, | |
| 128 | providers: { | |
| 129 | codex: { | |
| 130 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | |
| 131 | homePath: "/tmp/codex-home", | |
| 132 | }, | |
| 133 | claudeAgent: { | |
| 134 | ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, | |
| 135 | enabled: false, | |
| 136 | }, | |
| 137 | }, | |
| 138 | }; | |
| 139 | const requestPromise = runRpc((client) => client(WS_METHODS.serverGetSettings, {})); | |
| 140 | ||
| 141 | await waitFor(() => { | |
| 142 | expect(sockets).toHaveLength(1); | |
| 143 | }); | |
| 144 | ||
| 145 | const socket = getSocket(); | |
| 146 | socket.open(); | |
| 147 | ||
| 148 | await waitFor(() => { | |
| 149 | expect(socket.sent).toHaveLength(1); | |
| 150 | }); | |
| 151 | ||
| 152 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string }; | |
| 153 | expect(requestMessage.tag).toBe(WS_METHODS.serverGetSettings); | |
| 154 | ||
| 155 | socket.serverMessage( | |
| 156 | JSON.stringify({ | |
| 157 | _tag: "Exit", | |
| 158 | requestId: requestMessage.id, | |
| 159 | exit: { | |
| 160 | _tag: "Success", | |
| 161 | value: expectedSettings, | |
| 162 | }, | |
| 163 | }), | |
| 164 | ); | |
| 165 | ||
| 166 | await expect(requestPromise).resolves.toEqual(expectedSettings); | |
| 167 | }); | |
| 168 | ||
| 169 | it("exposes atom-backed query state for unary RPC methods", async () => { | |
| 170 | const expectedSettings = { | |
| 171 | ...DEFAULT_SERVER_SETTINGS, | |
| 172 | enableAssistantStreaming: true, | |
| 173 | defaultThreadEnvMode: "worktree" as const, | |
| 174 | textGenerationModelSelection: { | |
| 175 | provider: "codex" as const, | |
| 176 | model: "gpt-5.4", | |
| 177 | }, | |
| 178 | providers: { | |
| 179 | codex: { | |
| 180 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | |
| 181 | homePath: "/tmp/codex-home", | |
| 182 | }, | |
| 183 | claudeAgent: { | |
| 184 | ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, | |
| 185 | enabled: false, | |
| 186 | }, | |
| 187 | }, | |
| 188 | }; | |
| 189 | const registry = AtomRegistry.make(); | |
| 190 | const query = WsRpcAtomClient.query(WS_METHODS.serverGetSettings, {}); | |
| 191 | const release = registry.mount(query); | |
| 192 | ||
| 193 | await waitFor(() => { | |
| 194 | expect(sockets).toHaveLength(1); | |
| 195 | }); | |
| 196 | ||
| 197 | const socket = getSocket(); | |
| 198 | socket.open(); | |
| 199 | ||
| 200 | await waitFor(() => { | |
| 201 | expect(socket.sent).toHaveLength(1); | |
| 202 | }); | |
| 203 | ||
| 204 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string }; | |
| 205 | expect(requestMessage.tag).toBe(WS_METHODS.serverGetSettings); | |
| 206 | expect(registry.get(query)._tag).toBe("Initial"); | |
| 207 | ||
| 208 | socket.serverMessage( | |
| 209 | JSON.stringify({ | |
| 210 | _tag: "Exit", | |
| 211 | requestId: requestMessage.id, | |
| 212 | exit: { | |
| 213 | _tag: "Success", | |
| 214 | value: expectedSettings, | |
| 215 | }, | |
| 216 | }), | |
| 217 | ); | |
| 218 | ||
| 219 | await waitFor(() => { | |
| 220 | const result = registry.get(query); | |
| 221 | expect(AsyncResult.isSuccess(result)).toBe(true); | |
| 222 | if (!AsyncResult.isSuccess(result)) { | |
| 223 | return; | |
| 224 | } | |
| 225 | expect(result.value).toEqual(expectedSettings); | |
| 226 | }); | |
| 227 | ||
| 228 | release(); | |
| 229 | registry.dispose(); | |
| 230 | }); | |
| 231 | }); |
apps/web/src/rpc/client.ts created+33| ... | ... | @@ -0,0 +1,33 @@ |
| 1 | import { WsRpcGroup } from "@t3tools/contracts"; | |
| 2 | import { Effect, Layer, ManagedRuntime } from "effect"; | |
| 3 | import { AtomRpc } from "effect/unstable/reactivity"; | |
| 4 | ||
| 5 | import { createWsRpcProtocolLayer } from "./protocol"; | |
| 6 | ||
| 7 | export class WsRpcAtomClient extends AtomRpc.Service<WsRpcAtomClient>()("WsRpcAtomClient", { | |
| 8 | group: WsRpcGroup, | |
| 9 | protocol: Layer.suspend(() => createWsRpcProtocolLayer()), | |
| 10 | }) {} | |
| 11 | ||
| 12 | let sharedRuntime: ManagedRuntime.ManagedRuntime<WsRpcAtomClient, never> | null = null; | |
| 13 | ||
| 14 | function getRuntime() { | |
| 15 | if (sharedRuntime !== null) { | |
| 16 | return sharedRuntime; | |
| 17 | } | |
| 18 | ||
| 19 | sharedRuntime = ManagedRuntime.make(WsRpcAtomClient.layer); | |
| 20 | return sharedRuntime; | |
| 21 | } | |
| 22 | ||
| 23 | export function runRpc<TSuccess, TError = never>( | |
| 24 | execute: (client: typeof WsRpcAtomClient.Service) => Effect.Effect<TSuccess, TError, never>, | |
| 25 | ): Promise<TSuccess> { | |
| 26 | return getRuntime().runPromise(WsRpcAtomClient.use(execute)); | |
| 27 | } | |
| 28 | ||
| 29 | export async function __resetWsRpcAtomClientForTests() { | |
| 30 | const runtime = sharedRuntime; | |
| 31 | sharedRuntime = null; | |
| 32 | await runtime?.dispose(); | |
| 33 | } |
apps/web/src/rpc/protocol.ts created+27| ... | ... | @@ -0,0 +1,27 @@ |
| 1 | import { WsRpcGroup } from "@t3tools/contracts"; | |
| 2 | import { Effect, Layer } from "effect"; | |
| 3 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; | |
| 4 | import * as Socket from "effect/unstable/socket/Socket"; | |
| 5 | ||
| 6 | import { resolveServerUrl } from "../lib/utils"; | |
| 7 | ||
| 8 | export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup); | |
| 9 | ||
| 10 | type RpcClientFactory = typeof makeWsRpcProtocolClient; | |
| 11 | export type WsRpcProtocolClient = | |
| 12 | RpcClientFactory extends Effect.Effect<infer Client, any, any> ? Client : never; | |
| 13 | ||
| 14 | export function createWsRpcProtocolLayer(url?: string) { | |
| 15 | const resolvedUrl = resolveServerUrl({ | |
| 16 | url, | |
| 17 | protocol: window.location.protocol === "https:" ? "wss" : "ws", | |
| 18 | pathname: "/ws", | |
| 19 | }); | |
| 20 | const socketLayer = Socket.layerWebSocket(resolvedUrl).pipe( | |
| 21 | Layer.provide(Socket.layerWebSocketConstructorGlobal), | |
| 22 | ); | |
| 23 | ||
| 24 | return RpcClient.layerProtocolSocket({ retryTransientErrors: true }).pipe( | |
| 25 | Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson)), | |
| 26 | ); | |
| 27 | } |
apps/web/src/rpc/serverState.test.ts created+311| ... | ... | @@ -0,0 +1,311 @@ |
| 1 | import { | |
| 2 | DEFAULT_SERVER_SETTINGS, | |
| 3 | ProjectId, | |
| 4 | ThreadId, | |
| 5 | type ServerConfig, | |
| 6 | type ServerConfigStreamEvent, | |
| 7 | type ServerLifecycleStreamEvent, | |
| 8 | type ServerProvider, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 11 | ||
| 12 | import { | |
| 13 | getServerConfig, | |
| 14 | onProvidersUpdated, | |
| 15 | onServerConfigUpdated, | |
| 16 | onWelcome, | |
| 17 | resetServerStateForTests, | |
| 18 | startServerStateSync, | |
| 19 | } from "./serverState"; | |
| 20 | ||
| 21 | function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) { | |
| 22 | listeners.add(listener); | |
| 23 | return () => { | |
| 24 | listeners.delete(listener); | |
| 25 | }; | |
| 26 | } | |
| 27 | ||
| 28 | function createDeferredPromise<T>() { | |
| 29 | let resolve!: (value: T) => void; | |
| 30 | const promise = new Promise<T>((nextResolve) => { | |
| 31 | resolve = nextResolve; | |
| 32 | }); | |
| 33 | ||
| 34 | return { promise, resolve }; | |
| 35 | } | |
| 36 | ||
| 37 | const lifecycleListeners = new Set<(event: ServerLifecycleStreamEvent) => void>(); | |
| 38 | const configListeners = new Set<(event: ServerConfigStreamEvent) => void>(); | |
| 39 | ||
| 40 | const defaultProviders: ReadonlyArray<ServerProvider> = [ | |
| 41 | { | |
| 42 | provider: "codex", | |
| 43 | enabled: true, | |
| 44 | installed: true, | |
| 45 | version: "0.116.0", | |
| 46 | status: "ready", | |
| 47 | auth: { status: "authenticated" }, | |
| 48 | checkedAt: "2026-01-01T00:00:00.000Z", | |
| 49 | models: [], | |
| 50 | }, | |
| 51 | ]; | |
| 52 | ||
| 53 | const baseServerConfig: ServerConfig = { | |
| 54 | cwd: "/tmp/workspace", | |
| 55 | keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", | |
| 56 | keybindings: [], | |
| 57 | issues: [], | |
| 58 | providers: defaultProviders, | |
| 59 | availableEditors: ["cursor"], | |
| 60 | settings: DEFAULT_SERVER_SETTINGS, | |
| 61 | }; | |
| 62 | ||
| 63 | const serverApi = { | |
| 64 | getConfig: vi.fn<() => Promise<ServerConfig>>(), | |
| 65 | subscribeConfig: vi.fn((listener: (event: ServerConfigStreamEvent) => void) => | |
| 66 | registerListener(configListeners, listener), | |
| 67 | ), | |
| 68 | subscribeLifecycle: vi.fn((listener: (event: ServerLifecycleStreamEvent) => void) => | |
| 69 | registerListener(lifecycleListeners, listener), | |
| 70 | ), | |
| 71 | }; | |
| 72 | ||
| 73 | function emitLifecycleEvent(event: ServerLifecycleStreamEvent) { | |
| 74 | for (const listener of lifecycleListeners) { | |
| 75 | listener(event); | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | function emitServerConfigEvent(event: ServerConfigStreamEvent) { | |
| 80 | for (const listener of configListeners) { | |
| 81 | listener(event); | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise<void> { | |
| 86 | const startedAt = Date.now(); | |
| 87 | for (;;) { | |
| 88 | try { | |
| 89 | assertion(); | |
| 90 | return; | |
| 91 | } catch (error) { | |
| 92 | if (Date.now() - startedAt >= timeoutMs) { | |
| 93 | throw error; | |
| 94 | } | |
| 95 | await new Promise((resolve) => setTimeout(resolve, 10)); | |
| 96 | } | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | beforeEach(() => { | |
| 101 | vi.clearAllMocks(); | |
| 102 | lifecycleListeners.clear(); | |
| 103 | configListeners.clear(); | |
| 104 | resetServerStateForTests(); | |
| 105 | }); | |
| 106 | ||
| 107 | afterEach(() => { | |
| 108 | resetServerStateForTests(); | |
| 109 | }); | |
| 110 | ||
| 111 | describe("serverState", () => { | |
| 112 | it("bootstraps the server config snapshot and replays it to late subscribers", async () => { | |
| 113 | serverApi.getConfig.mockResolvedValueOnce(baseServerConfig); | |
| 114 | ||
| 115 | const configListener = vi.fn(); | |
| 116 | const stop = startServerStateSync(serverApi); | |
| 117 | const unsubscribe = onServerConfigUpdated(configListener); | |
| 118 | ||
| 119 | await waitFor(() => { | |
| 120 | expect(getServerConfig()).toEqual(baseServerConfig); | |
| 121 | }); | |
| 122 | ||
| 123 | expect(serverApi.subscribeConfig).toHaveBeenCalledOnce(); | |
| 124 | expect(serverApi.subscribeLifecycle).toHaveBeenCalledOnce(); | |
| 125 | expect(serverApi.getConfig).toHaveBeenCalledOnce(); | |
| 126 | expect(configListener).toHaveBeenCalledWith( | |
| 127 | { | |
| 128 | issues: [], | |
| 129 | providers: defaultProviders, | |
| 130 | settings: DEFAULT_SERVER_SETTINGS, | |
| 131 | }, | |
| 132 | "snapshot", | |
| 133 | ); | |
| 134 | ||
| 135 | const lateListener = vi.fn(); | |
| 136 | const unsubscribeLate = onServerConfigUpdated(lateListener); | |
| 137 | expect(lateListener).toHaveBeenCalledWith( | |
| 138 | { | |
| 139 | issues: [], | |
| 140 | providers: defaultProviders, | |
| 141 | settings: DEFAULT_SERVER_SETTINGS, | |
| 142 | }, | |
| 143 | "snapshot", | |
| 144 | ); | |
| 145 | ||
| 146 | unsubscribeLate(); | |
| 147 | unsubscribe(); | |
| 148 | stop(); | |
| 149 | }); | |
| 150 | ||
| 151 | it("keeps the streamed snapshot when it arrives before the fallback fetch resolves", async () => { | |
| 152 | const deferred = createDeferredPromise<ServerConfig>(); | |
| 153 | serverApi.getConfig.mockReturnValueOnce(deferred.promise); | |
| 154 | const stop = startServerStateSync(serverApi); | |
| 155 | ||
| 156 | const streamedConfig: ServerConfig = { | |
| 157 | ...baseServerConfig, | |
| 158 | cwd: "/tmp/from-stream", | |
| 159 | }; | |
| 160 | ||
| 161 | emitServerConfigEvent({ | |
| 162 | version: 1, | |
| 163 | type: "snapshot", | |
| 164 | config: streamedConfig, | |
| 165 | }); | |
| 166 | ||
| 167 | await waitFor(() => { | |
| 168 | expect(getServerConfig()).toEqual(streamedConfig); | |
| 169 | }); | |
| 170 | ||
| 171 | deferred.resolve(baseServerConfig); | |
| 172 | await new Promise((resolve) => setTimeout(resolve, 0)); | |
| 173 | ||
| 174 | expect(getServerConfig()).toEqual(streamedConfig); | |
| 175 | stop(); | |
| 176 | }); | |
| 177 | ||
| 178 | it("replays welcome events to late subscribers", async () => { | |
| 179 | serverApi.getConfig.mockResolvedValueOnce(baseServerConfig); | |
| 180 | const stop = startServerStateSync(serverApi); | |
| 181 | ||
| 182 | const listener = vi.fn(); | |
| 183 | const unsubscribe = onWelcome(listener); | |
| 184 | ||
| 185 | emitLifecycleEvent({ | |
| 186 | version: 1, | |
| 187 | sequence: 1, | |
| 188 | type: "welcome", | |
| 189 | payload: { | |
| 190 | cwd: "/tmp/workspace", | |
| 191 | projectName: "t3-code", | |
| 192 | bootstrapProjectId: ProjectId.makeUnsafe("project-1"), | |
| 193 | bootstrapThreadId: ThreadId.makeUnsafe("thread-1"), | |
| 194 | }, | |
| 195 | }); | |
| 196 | ||
| 197 | expect(listener).toHaveBeenCalledWith({ | |
| 198 | cwd: "/tmp/workspace", | |
| 199 | projectName: "t3-code", | |
| 200 | bootstrapProjectId: ProjectId.makeUnsafe("project-1"), | |
| 201 | bootstrapThreadId: ThreadId.makeUnsafe("thread-1"), | |
| 202 | }); | |
| 203 | ||
| 204 | const lateListener = vi.fn(); | |
| 205 | const unsubscribeLate = onWelcome(lateListener); | |
| 206 | expect(lateListener).toHaveBeenCalledWith({ | |
| 207 | cwd: "/tmp/workspace", | |
| 208 | projectName: "t3-code", | |
| 209 | bootstrapProjectId: ProjectId.makeUnsafe("project-1"), | |
| 210 | bootstrapThreadId: ThreadId.makeUnsafe("thread-1"), | |
| 211 | }); | |
| 212 | ||
| 213 | unsubscribeLate(); | |
| 214 | unsubscribe(); | |
| 215 | stop(); | |
| 216 | }); | |
| 217 | ||
| 218 | it("merges provider, settings, and keybinding updates into the cached config", async () => { | |
| 219 | serverApi.getConfig.mockResolvedValueOnce(baseServerConfig); | |
| 220 | const configListener = vi.fn(); | |
| 221 | const providersListener = vi.fn(); | |
| 222 | const stop = startServerStateSync(serverApi); | |
| 223 | const unsubscribeConfig = onServerConfigUpdated(configListener); | |
| 224 | const unsubscribeProviders = onProvidersUpdated(providersListener); | |
| 225 | ||
| 226 | await waitFor(() => { | |
| 227 | expect(getServerConfig()).toEqual(baseServerConfig); | |
| 228 | }); | |
| 229 | ||
| 230 | const nextProviders: ReadonlyArray<ServerProvider> = [ | |
| 231 | { | |
| 232 | ...defaultProviders[0]!, | |
| 233 | status: "warning", | |
| 234 | checkedAt: "2026-01-02T00:00:00.000Z", | |
| 235 | message: "rate limited", | |
| 236 | }, | |
| 237 | ]; | |
| 238 | ||
| 239 | emitServerConfigEvent({ | |
| 240 | version: 1, | |
| 241 | type: "keybindingsUpdated", | |
| 242 | payload: { | |
| 243 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], | |
| 244 | }, | |
| 245 | }); | |
| 246 | emitServerConfigEvent({ | |
| 247 | version: 1, | |
| 248 | type: "providerStatuses", | |
| 249 | payload: { | |
| 250 | providers: nextProviders, | |
| 251 | }, | |
| 252 | }); | |
| 253 | emitServerConfigEvent({ | |
| 254 | version: 1, | |
| 255 | type: "settingsUpdated", | |
| 256 | payload: { | |
| 257 | settings: { | |
| 258 | ...DEFAULT_SERVER_SETTINGS, | |
| 259 | enableAssistantStreaming: true, | |
| 260 | }, | |
| 261 | }, | |
| 262 | }); | |
| 263 | ||
| 264 | await waitFor(() => { | |
| 265 | expect(getServerConfig()).toEqual({ | |
| 266 | ...baseServerConfig, | |
| 267 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], | |
| 268 | providers: nextProviders, | |
| 269 | settings: { | |
| 270 | ...DEFAULT_SERVER_SETTINGS, | |
| 271 | enableAssistantStreaming: true, | |
| 272 | }, | |
| 273 | }); | |
| 274 | }); | |
| 275 | ||
| 276 | expect(providersListener).toHaveBeenLastCalledWith({ providers: nextProviders }); | |
| 277 | expect(configListener).toHaveBeenNthCalledWith( | |
| 278 | 2, | |
| 279 | { | |
| 280 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], | |
| 281 | providers: defaultProviders, | |
| 282 | settings: DEFAULT_SERVER_SETTINGS, | |
| 283 | }, | |
| 284 | "keybindingsUpdated", | |
| 285 | ); | |
| 286 | expect(configListener).toHaveBeenNthCalledWith( | |
| 287 | 3, | |
| 288 | { | |
| 289 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], | |
| 290 | providers: nextProviders, | |
| 291 | settings: DEFAULT_SERVER_SETTINGS, | |
| 292 | }, | |
| 293 | "providerStatuses", | |
| 294 | ); | |
| 295 | expect(configListener).toHaveBeenLastCalledWith( | |
| 296 | { | |
| 297 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], | |
| 298 | providers: nextProviders, | |
| 299 | settings: { | |
| 300 | ...DEFAULT_SERVER_SETTINGS, | |
| 301 | enableAssistantStreaming: true, | |
| 302 | }, | |
| 303 | }, | |
| 304 | "settingsUpdated", | |
| 305 | ); | |
| 306 | ||
| 307 | unsubscribeProviders(); | |
| 308 | unsubscribeConfig(); | |
| 309 | stop(); | |
| 310 | }); | |
| 311 | }); |
apps/web/src/rpc/serverState.ts created+280| ... | ... | @@ -0,0 +1,280 @@ |
| 1 | import { useAtomSubscribe, useAtomValue } from "@effect/atom-react"; | |
| 2 | import { | |
| 3 | DEFAULT_SERVER_SETTINGS, | |
| 4 | type EditorId, | |
| 5 | type ServerConfig, | |
| 6 | type ServerConfigStreamEvent, | |
| 7 | type ServerConfigUpdatedPayload, | |
| 8 | type ServerLifecycleWelcomePayload, | |
| 9 | type ServerProvider, | |
| 10 | type ServerProviderUpdatedPayload, | |
| 11 | type ServerSettings, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | import { Atom } from "effect/unstable/reactivity"; | |
| 14 | ||
| 15 | import type { WsRpcClient } from "../wsRpcClient"; | |
| 16 | import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry"; | |
| 17 | ||
| 18 | export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"]; | |
| 19 | ||
| 20 | export interface ServerConfigUpdatedNotification { | |
| 21 | readonly payload: ServerConfigUpdatedPayload; | |
| 22 | readonly source: ServerConfigUpdateSource; | |
| 23 | } | |
| 24 | ||
| 25 | type ServerStateClient = Pick< | |
| 26 | WsRpcClient["server"], | |
| 27 | "getConfig" | "subscribeConfig" | "subscribeLifecycle" | |
| 28 | >; | |
| 29 | ||
| 30 | function makeStateAtom<A>(label: string, initialValue: A) { | |
| 31 | return Atom.make(initialValue).pipe(Atom.keepAlive, Atom.withLabel(label)); | |
| 32 | } | |
| 33 | ||
| 34 | function toServerConfigUpdatedPayload(config: ServerConfig): ServerConfigUpdatedPayload { | |
| 35 | return { | |
| 36 | issues: config.issues, | |
| 37 | providers: config.providers, | |
| 38 | settings: config.settings, | |
| 39 | }; | |
| 40 | } | |
| 41 | ||
| 42 | const EMPTY_AVAILABLE_EDITORS: ReadonlyArray<EditorId> = []; | |
| 43 | const EMPTY_KEYBINDINGS: ServerConfig["keybindings"] = []; | |
| 44 | const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = []; | |
| 45 | ||
| 46 | const selectAvailableEditors = (config: ServerConfig | null): ReadonlyArray<EditorId> => | |
| 47 | config?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; | |
| 48 | const selectKeybindings = (config: ServerConfig | null) => config?.keybindings ?? EMPTY_KEYBINDINGS; | |
| 49 | const selectKeybindingsConfigPath = (config: ServerConfig | null) => | |
| 50 | config?.keybindingsConfigPath ?? null; | |
| 51 | const selectProviders = (config: ServerConfig | null) => | |
| 52 | config?.providers ?? EMPTY_SERVER_PROVIDERS; | |
| 53 | const selectSettings = (config: ServerConfig | null): ServerSettings => | |
| 54 | config?.settings ?? DEFAULT_SERVER_SETTINGS; | |
| 55 | ||
| 56 | export const welcomeAtom = makeStateAtom<ServerLifecycleWelcomePayload | null>( | |
| 57 | "server-welcome", | |
| 58 | null, | |
| 59 | ); | |
| 60 | export const serverConfigAtom = makeStateAtom<ServerConfig | null>("server-config", null); | |
| 61 | export const serverConfigUpdatedAtom = makeStateAtom<ServerConfigUpdatedNotification | null>( | |
| 62 | "server-config-updated", | |
| 63 | null, | |
| 64 | ); | |
| 65 | export const providersUpdatedAtom = makeStateAtom<ServerProviderUpdatedPayload | null>( | |
| 66 | "server-providers-updated", | |
| 67 | null, | |
| 68 | ); | |
| 69 | ||
| 70 | export function getServerConfig(): ServerConfig | null { | |
| 71 | return appAtomRegistry.get(serverConfigAtom); | |
| 72 | } | |
| 73 | ||
| 74 | export function setServerConfigSnapshot(config: ServerConfig): void { | |
| 75 | resolveServerConfig(config); | |
| 76 | emitProvidersUpdated({ providers: config.providers }); | |
| 77 | emitServerConfigUpdated(toServerConfigUpdatedPayload(config), "snapshot"); | |
| 78 | } | |
| 79 | ||
| 80 | export function applyServerConfigEvent(event: ServerConfigStreamEvent): void { | |
| 81 | switch (event.type) { | |
| 82 | case "snapshot": { | |
| 83 | setServerConfigSnapshot(event.config); | |
| 84 | return; | |
| 85 | } | |
| 86 | case "keybindingsUpdated": { | |
| 87 | const latestServerConfig = getServerConfig(); | |
| 88 | if (!latestServerConfig) { | |
| 89 | return; | |
| 90 | } | |
| 91 | const nextConfig = { | |
| 92 | ...latestServerConfig, | |
| 93 | issues: event.payload.issues, | |
| 94 | } satisfies ServerConfig; | |
| 95 | resolveServerConfig(nextConfig); | |
| 96 | emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), event.type); | |
| 97 | return; | |
| 98 | } | |
| 99 | case "providerStatuses": { | |
| 100 | applyProvidersUpdated(event.payload); | |
| 101 | return; | |
| 102 | } | |
| 103 | case "settingsUpdated": { | |
| 104 | applySettingsUpdated(event.payload.settings); | |
| 105 | return; | |
| 106 | } | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | export function applyProvidersUpdated(payload: ServerProviderUpdatedPayload): void { | |
| 111 | const latestServerConfig = getServerConfig(); | |
| 112 | emitProvidersUpdated(payload); | |
| 113 | ||
| 114 | if (!latestServerConfig) { | |
| 115 | return; | |
| 116 | } | |
| 117 | ||
| 118 | const nextConfig = { | |
| 119 | ...latestServerConfig, | |
| 120 | providers: payload.providers, | |
| 121 | } satisfies ServerConfig; | |
| 122 | resolveServerConfig(nextConfig); | |
| 123 | emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), "providerStatuses"); | |
| 124 | } | |
| 125 | ||
| 126 | export function applySettingsUpdated(settings: ServerSettings): void { | |
| 127 | const latestServerConfig = getServerConfig(); | |
| 128 | if (!latestServerConfig) { | |
| 129 | return; | |
| 130 | } | |
| 131 | ||
| 132 | const nextConfig = { | |
| 133 | ...latestServerConfig, | |
| 134 | settings, | |
| 135 | } satisfies ServerConfig; | |
| 136 | resolveServerConfig(nextConfig); | |
| 137 | emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), "settingsUpdated"); | |
| 138 | } | |
| 139 | ||
| 140 | export function emitWelcome(payload: ServerLifecycleWelcomePayload): void { | |
| 141 | appAtomRegistry.set(welcomeAtom, payload); | |
| 142 | } | |
| 143 | ||
| 144 | export function onWelcome(listener: (payload: ServerLifecycleWelcomePayload) => void): () => void { | |
| 145 | return subscribeLatest(welcomeAtom, listener); | |
| 146 | } | |
| 147 | ||
| 148 | export function onServerConfigUpdated( | |
| 149 | listener: (payload: ServerConfigUpdatedPayload, source: ServerConfigUpdateSource) => void, | |
| 150 | ): () => void { | |
| 151 | return subscribeLatest(serverConfigUpdatedAtom, (notification) => { | |
| 152 | listener(notification.payload, notification.source); | |
| 153 | }); | |
| 154 | } | |
| 155 | ||
| 156 | export function onProvidersUpdated( | |
| 157 | listener: (payload: ServerProviderUpdatedPayload) => void, | |
| 158 | ): () => void { | |
| 159 | return subscribeLatest(providersUpdatedAtom, listener); | |
| 160 | } | |
| 161 | ||
| 162 | export function startServerStateSync(client: ServerStateClient): () => void { | |
| 163 | let disposed = false; | |
| 164 | const cleanups = [ | |
| 165 | client.subscribeLifecycle((event) => { | |
| 166 | if (event.type === "welcome") { | |
| 167 | emitWelcome(event.payload); | |
| 168 | } | |
| 169 | }), | |
| 170 | client.subscribeConfig((event) => { | |
| 171 | applyServerConfigEvent(event); | |
| 172 | }), | |
| 173 | ]; | |
| 174 | ||
| 175 | if (getServerConfig() === null) { | |
| 176 | void client | |
| 177 | .getConfig() | |
| 178 | .then((config) => { | |
| 179 | if (disposed || getServerConfig() !== null) { | |
| 180 | return; | |
| 181 | } | |
| 182 | setServerConfigSnapshot(config); | |
| 183 | }) | |
| 184 | .catch(() => undefined); | |
| 185 | } | |
| 186 | ||
| 187 | return () => { | |
| 188 | disposed = true; | |
| 189 | for (const cleanup of cleanups) { | |
| 190 | cleanup(); | |
| 191 | } | |
| 192 | }; | |
| 193 | } | |
| 194 | ||
| 195 | export function resetServerStateForTests() { | |
| 196 | resetAppAtomRegistryForTests(); | |
| 197 | } | |
| 198 | ||
| 199 | function resolveServerConfig(config: ServerConfig): void { | |
| 200 | appAtomRegistry.set(serverConfigAtom, config); | |
| 201 | } | |
| 202 | ||
| 203 | function emitProvidersUpdated(payload: ServerProviderUpdatedPayload): void { | |
| 204 | appAtomRegistry.set(providersUpdatedAtom, payload); | |
| 205 | } | |
| 206 | ||
| 207 | function emitServerConfigUpdated( | |
| 208 | payload: ServerConfigUpdatedPayload, | |
| 209 | source: ServerConfigUpdateSource, | |
| 210 | ): void { | |
| 211 | appAtomRegistry.set(serverConfigUpdatedAtom, { payload, source }); | |
| 212 | } | |
| 213 | ||
| 214 | function subscribeLatest<A>( | |
| 215 | atom: Atom.Atom<A | null>, | |
| 216 | listener: (value: NonNullable<A>) => void, | |
| 217 | ): () => void { | |
| 218 | return appAtomRegistry.subscribe( | |
| 219 | atom, | |
| 220 | (value) => { | |
| 221 | if (value === null) { | |
| 222 | return; | |
| 223 | } | |
| 224 | listener(value as NonNullable<A>); | |
| 225 | }, | |
| 226 | { immediate: true }, | |
| 227 | ); | |
| 228 | } | |
| 229 | ||
| 230 | function useLatestAtomSubscription<A>( | |
| 231 | atom: Atom.Atom<A | null>, | |
| 232 | listener: (value: NonNullable<A>) => void, | |
| 233 | ) { | |
| 234 | useAtomSubscribe( | |
| 235 | atom, | |
| 236 | (value) => { | |
| 237 | if (value === null) { | |
| 238 | return; | |
| 239 | } | |
| 240 | listener(value as NonNullable<A>); | |
| 241 | }, | |
| 242 | { immediate: true }, | |
| 243 | ); | |
| 244 | } | |
| 245 | ||
| 246 | export function useServerConfig(): ServerConfig | null { | |
| 247 | return useAtomValue(serverConfigAtom); | |
| 248 | } | |
| 249 | ||
| 250 | export function useServerSettings(): ServerSettings { | |
| 251 | return useAtomValue(serverConfigAtom, selectSettings); | |
| 252 | } | |
| 253 | ||
| 254 | export function useServerProviders(): ReadonlyArray<ServerProvider> { | |
| 255 | return useAtomValue(serverConfigAtom, selectProviders); | |
| 256 | } | |
| 257 | ||
| 258 | export function useServerKeybindings(): ServerConfig["keybindings"] { | |
| 259 | return useAtomValue(serverConfigAtom, selectKeybindings); | |
| 260 | } | |
| 261 | ||
| 262 | export function useServerAvailableEditors(): ReadonlyArray<EditorId> { | |
| 263 | return useAtomValue(serverConfigAtom, selectAvailableEditors); | |
| 264 | } | |
| 265 | ||
| 266 | export function useServerKeybindingsConfigPath(): string | null { | |
| 267 | return useAtomValue(serverConfigAtom, selectKeybindingsConfigPath); | |
| 268 | } | |
| 269 | ||
| 270 | export function useServerWelcomeSubscription( | |
| 271 | listener: (payload: ServerLifecycleWelcomePayload) => void, | |
| 272 | ): void { | |
| 273 | useLatestAtomSubscription(welcomeAtom, listener); | |
| 274 | } | |
| 275 | ||
| 276 | export function useServerConfigUpdatedSubscription( | |
| 277 | listener: (notification: ServerConfigUpdatedNotification) => void, | |
| 278 | ): void { | |
| 279 | useLatestAtomSubscription(serverConfigUpdatedAtom, listener); | |
| 280 | } |
apps/web/src/rpc/serverStateBootstrap.tsx created+10| ... | ... | @@ -0,0 +1,10 @@ |
| 1 | import { useEffect } from "react"; | |
| 2 | ||
| 3 | import { getWsRpcClient } from "../wsRpcClient"; | |
| 4 | import { startServerStateSync } from "./serverState"; | |
| 5 | ||
| 6 | export function ServerStateBootstrap() { | |
| 7 | useEffect(() => startServerStateSync(getWsRpcClient().server), []); | |
| 8 | ||
| 9 | return null; | |
| 10 | } |
apps/web/src/wsNativeApi.test.ts+178-280| ... | ... | @@ -1,23 +1,19 @@ |
| 1 | 1 | import { |
| 2 | 2 | CommandId, |
| 3 | type ContextMenuItem, | |
| 3 | DEFAULT_SERVER_SETTINGS, | |
| 4 | type DesktopBridge, | |
| 4 | 5 | EventId, |
| 5 | ORCHESTRATION_WS_CHANNELS, | |
| 6 | ORCHESTRATION_WS_METHODS, | |
| 7 | type OrchestrationEvent, | |
| 8 | 6 | ProjectId, |
| 9 | ThreadId, | |
| 10 | type WsPushChannel, | |
| 11 | type WsPushData, | |
| 12 | type WsPushMessage, | |
| 13 | WS_CHANNELS, | |
| 14 | WS_METHODS, | |
| 15 | type WsPush, | |
| 7 | type OrchestrationEvent, | |
| 8 | type ServerConfig, | |
| 16 | 9 | type ServerProvider, |
| 10 | type TerminalEvent, | |
| 11 | ThreadId, | |
| 17 | 12 | } from "@t3tools/contracts"; |
| 18 | 13 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 19 | 14 | |
| 20 | const requestMock = vi.fn<(...args: Array<unknown>) => Promise<unknown>>(); | |
| 15 | import type { ContextMenuItem } from "@t3tools/contracts"; | |
| 16 | ||
| 21 | 17 | const showContextMenuFallbackMock = |
| 22 | 18 | vi.fn< |
| 23 | 19 | <T extends string>( |
| ... | ... | @@ -25,39 +21,75 @@ const showContextMenuFallbackMock = |
| 25 | 21 | position?: { x: number; y: number }, |
| 26 | 22 | ) => Promise<T | null> |
| 27 | 23 | >(); |
| 28 | const channelListeners = new Map<string, Set<(message: WsPush) => void>>(); | |
| 29 | const latestPushByChannel = new Map<string, WsPush>(); | |
| 30 | const subscribeMock = vi.fn< | |
| 31 | ( | |
| 32 | channel: string, | |
| 33 | listener: (message: WsPush) => void, | |
| 34 | options?: { replayLatest?: boolean }, | |
| 35 | ) => () => void | |
| 36 | >((channel, listener, options) => { | |
| 37 | const listeners = channelListeners.get(channel) ?? new Set<(message: WsPush) => void>(); | |
| 24 | ||
| 25 | function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) { | |
| 38 | 26 | listeners.add(listener); |
| 39 | channelListeners.set(channel, listeners); | |
| 40 | const latest = latestPushByChannel.get(channel); | |
| 41 | if (latest && options?.replayLatest) { | |
| 42 | listener(latest); | |
| 43 | } | |
| 44 | 27 | return () => { |
| 45 | 28 | listeners.delete(listener); |
| 46 | if (listeners.size === 0) { | |
| 47 | channelListeners.delete(channel); | |
| 48 | } | |
| 49 | 29 | }; |
| 50 | }); | |
| 30 | } | |
| 51 | 31 | |
| 52 | vi.mock("./wsTransport", () => { | |
| 32 | const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); | |
| 33 | const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>(); | |
| 34 | ||
| 35 | const rpcClientMock = { | |
| 36 | dispose: vi.fn(), | |
| 37 | terminal: { | |
| 38 | open: vi.fn(), | |
| 39 | write: vi.fn(), | |
| 40 | resize: vi.fn(), | |
| 41 | clear: vi.fn(), | |
| 42 | restart: vi.fn(), | |
| 43 | close: vi.fn(), | |
| 44 | onEvent: vi.fn((listener: (event: TerminalEvent) => void) => | |
| 45 | registerListener(terminalEventListeners, listener), | |
| 46 | ), | |
| 47 | }, | |
| 48 | projects: { | |
| 49 | searchEntries: vi.fn(), | |
| 50 | writeFile: vi.fn(), | |
| 51 | }, | |
| 52 | shell: { | |
| 53 | openInEditor: vi.fn(), | |
| 54 | }, | |
| 55 | git: { | |
| 56 | pull: vi.fn(), | |
| 57 | status: vi.fn(), | |
| 58 | runStackedAction: vi.fn(), | |
| 59 | listBranches: vi.fn(), | |
| 60 | createWorktree: vi.fn(), | |
| 61 | removeWorktree: vi.fn(), | |
| 62 | createBranch: vi.fn(), | |
| 63 | checkout: vi.fn(), | |
| 64 | init: vi.fn(), | |
| 65 | resolvePullRequest: vi.fn(), | |
| 66 | preparePullRequestThread: vi.fn(), | |
| 67 | }, | |
| 68 | server: { | |
| 69 | getConfig: vi.fn(), | |
| 70 | refreshProviders: vi.fn(), | |
| 71 | upsertKeybinding: vi.fn(), | |
| 72 | getSettings: vi.fn(), | |
| 73 | updateSettings: vi.fn(), | |
| 74 | subscribeConfig: vi.fn(), | |
| 75 | subscribeLifecycle: vi.fn(), | |
| 76 | }, | |
| 77 | orchestration: { | |
| 78 | getSnapshot: vi.fn(), | |
| 79 | dispatchCommand: vi.fn(), | |
| 80 | getTurnDiff: vi.fn(), | |
| 81 | getFullThreadDiff: vi.fn(), | |
| 82 | replayEvents: vi.fn(), | |
| 83 | onDomainEvent: vi.fn((listener: (event: OrchestrationEvent) => void) => | |
| 84 | registerListener(orchestrationEventListeners, listener), | |
| 85 | ), | |
| 86 | }, | |
| 87 | }; | |
| 88 | ||
| 89 | vi.mock("./wsRpcClient", () => { | |
| 53 | 90 | return { |
| 54 | WsTransport: class MockWsTransport { | |
| 55 | request = requestMock; | |
| 56 | subscribe = subscribeMock; | |
| 57 | getLatestPush(channel: string) { | |
| 58 | return latestPushByChannel.get(channel) ?? null; | |
| 59 | } | |
| 60 | }, | |
| 91 | getWsRpcClient: () => rpcClientMock, | |
| 92 | __resetWsRpcClientForTests: vi.fn(), | |
| 61 | 93 | }; |
| 62 | 94 | }); |
| 63 | 95 | |
| ... | ... | @@ -65,20 +97,9 @@ vi.mock("./contextMenuFallback", () => ({ |
| 65 | 97 | showContextMenuFallback: showContextMenuFallbackMock, |
| 66 | 98 | })); |
| 67 | 99 | |
| 68 | let nextPushSequence = 1; | |
| 69 | ||
| 70 | function emitPush<C extends WsPushChannel>(channel: C, data: WsPushData<C>): void { | |
| 71 | const listeners = channelListeners.get(channel); | |
| 72 | const message = { | |
| 73 | type: "push" as const, | |
| 74 | sequence: nextPushSequence++, | |
| 75 | channel, | |
| 76 | data, | |
| 77 | } as WsPushMessage<C>; | |
| 78 | latestPushByChannel.set(channel, message); | |
| 79 | if (!listeners) return; | |
| 100 | function emitEvent<T>(listeners: Set<(event: T) => void>, event: T) { | |
| 80 | 101 | for (const listener of listeners) { |
| 81 | listener(message); | |
| 102 | listener(event); | |
| 82 | 103 | } |
| 83 | 104 | } |
| 84 | 105 | |
| ... | ... | @@ -92,6 +113,32 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn |
| 92 | 113 | return testGlobal.window; |
| 93 | 114 | } |
| 94 | 115 | |
| 116 | function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge { | |
| 117 | return { | |
| 118 | getWsUrl: () => null, | |
| 119 | pickFolder: async () => null, | |
| 120 | confirm: async () => true, | |
| 121 | setTheme: async () => undefined, | |
| 122 | showContextMenu: async () => null, | |
| 123 | openExternal: async () => true, | |
| 124 | onMenuAction: () => () => undefined, | |
| 125 | getUpdateState: async () => { | |
| 126 | throw new Error("getUpdateState not implemented in test"); | |
| 127 | }, | |
| 128 | checkForUpdate: async () => { | |
| 129 | throw new Error("checkForUpdate not implemented in test"); | |
| 130 | }, | |
| 131 | downloadUpdate: async () => { | |
| 132 | throw new Error("downloadUpdate not implemented in test"); | |
| 133 | }, | |
| 134 | installUpdate: async () => { | |
| 135 | throw new Error("installUpdate not implemented in test"); | |
| 136 | }, | |
| 137 | onUpdateState: () => () => undefined, | |
| 138 | ...overrides, | |
| 139 | }; | |
| 140 | } | |
| 141 | ||
| 95 | 142 | const defaultProviders: ReadonlyArray<ServerProvider> = [ |
| 96 | 143 | { |
| 97 | 144 | provider: "codex", |
| ... | ... | @@ -105,14 +152,22 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [ |
| 105 | 152 | }, |
| 106 | 153 | ]; |
| 107 | 154 | |
| 155 | const baseServerConfig: ServerConfig = { | |
| 156 | cwd: "/tmp/workspace", | |
| 157 | keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", | |
| 158 | keybindings: [], | |
| 159 | issues: [], | |
| 160 | providers: defaultProviders, | |
| 161 | availableEditors: ["cursor"], | |
| 162 | settings: DEFAULT_SERVER_SETTINGS, | |
| 163 | }; | |
| 164 | ||
| 108 | 165 | beforeEach(() => { |
| 109 | 166 | vi.resetModules(); |
| 110 | requestMock.mockReset(); | |
| 167 | vi.clearAllMocks(); | |
| 111 | 168 | showContextMenuFallbackMock.mockReset(); |
| 112 | subscribeMock.mockClear(); | |
| 113 | channelListeners.clear(); | |
| 114 | latestPushByChannel.clear(); | |
| 115 | nextPushSequence = 1; | |
| 169 | terminalEventListeners.clear(); | |
| 170 | orchestrationEventListeners.clear(); | |
| 116 | 171 | Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); |
| 117 | 172 | }); |
| 118 | 173 | |
| ... | ... | @@ -121,149 +176,27 @@ afterEach(() => { |
| 121 | 176 | }); |
| 122 | 177 | |
| 123 | 178 | describe("wsNativeApi", () => { |
| 124 | it("delivers and caches valid server.welcome payloads", async () => { | |
| 125 | const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); | |
| 126 | ||
| 127 | createWsNativeApi(); | |
| 128 | const listener = vi.fn(); | |
| 129 | onServerWelcome(listener); | |
| 130 | ||
| 131 | const payload = { cwd: "/tmp/workspace", projectName: "t3-code" }; | |
| 132 | emitPush(WS_CHANNELS.serverWelcome, payload); | |
| 133 | ||
| 134 | expect(listener).toHaveBeenCalledTimes(1); | |
| 135 | expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload)); | |
| 136 | ||
| 137 | const lateListener = vi.fn(); | |
| 138 | onServerWelcome(lateListener); | |
| 139 | ||
| 140 | expect(lateListener).toHaveBeenCalledTimes(1); | |
| 141 | expect(lateListener).toHaveBeenCalledWith(expect.objectContaining(payload)); | |
| 142 | }); | |
| 143 | ||
| 144 | it("preserves bootstrap ids from server.welcome payloads", async () => { | |
| 145 | const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); | |
| 146 | ||
| 147 | createWsNativeApi(); | |
| 148 | const listener = vi.fn(); | |
| 149 | onServerWelcome(listener); | |
| 150 | ||
| 151 | emitPush(WS_CHANNELS.serverWelcome, { | |
| 152 | cwd: "/tmp/workspace", | |
| 153 | projectName: "t3-code", | |
| 154 | bootstrapProjectId: ProjectId.makeUnsafe("project-1"), | |
| 155 | bootstrapThreadId: ThreadId.makeUnsafe("thread-1"), | |
| 156 | }); | |
| 157 | ||
| 158 | expect(listener).toHaveBeenCalledTimes(1); | |
| 159 | expect(listener).toHaveBeenCalledWith( | |
| 160 | expect.objectContaining({ | |
| 161 | cwd: "/tmp/workspace", | |
| 162 | projectName: "t3-code", | |
| 163 | bootstrapProjectId: "project-1", | |
| 164 | bootstrapThreadId: "thread-1", | |
| 165 | }), | |
| 166 | ); | |
| 167 | }); | |
| 168 | ||
| 169 | it("delivers successive server.welcome payloads to active listeners", async () => { | |
| 170 | const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); | |
| 171 | ||
| 172 | createWsNativeApi(); | |
| 173 | const listener = vi.fn(); | |
| 174 | onServerWelcome(listener); | |
| 175 | ||
| 176 | emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/one", projectName: "one" }); | |
| 177 | emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/workspace", projectName: "t3-code" }); | |
| 178 | ||
| 179 | expect(listener).toHaveBeenCalledTimes(2); | |
| 180 | expect(listener).toHaveBeenLastCalledWith( | |
| 181 | expect.objectContaining({ | |
| 182 | cwd: "/tmp/workspace", | |
| 183 | projectName: "t3-code", | |
| 184 | }), | |
| 185 | ); | |
| 186 | }); | |
| 187 | ||
| 188 | it("delivers and caches valid server.configUpdated payloads", async () => { | |
| 189 | const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi"); | |
| 190 | ||
| 191 | createWsNativeApi(); | |
| 192 | const listener = vi.fn(); | |
| 193 | onServerConfigUpdated(listener); | |
| 194 | ||
| 195 | const payload = { | |
| 196 | issues: [ | |
| 197 | { | |
| 198 | kind: "keybindings.invalid-entry", | |
| 199 | index: 1, | |
| 200 | message: "Entry at index 1 is invalid.", | |
| 201 | }, | |
| 202 | ], | |
| 203 | } as const; | |
| 204 | emitPush(WS_CHANNELS.serverConfigUpdated, payload); | |
| 205 | ||
| 206 | expect(listener).toHaveBeenCalledTimes(1); | |
| 207 | expect(listener).toHaveBeenCalledWith(payload); | |
| 208 | ||
| 209 | const lateListener = vi.fn(); | |
| 210 | onServerConfigUpdated(lateListener); | |
| 211 | expect(lateListener).toHaveBeenCalledTimes(1); | |
| 212 | expect(lateListener).toHaveBeenCalledWith(payload); | |
| 213 | }); | |
| 214 | ||
| 215 | it("delivers successive server.configUpdated payloads to active listeners", async () => { | |
| 216 | const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi"); | |
| 217 | ||
| 218 | createWsNativeApi(); | |
| 219 | const listener = vi.fn(); | |
| 220 | onServerConfigUpdated(listener); | |
| 221 | ||
| 222 | emitPush(WS_CHANNELS.serverConfigUpdated, { | |
| 223 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], | |
| 224 | }); | |
| 225 | emitPush(WS_CHANNELS.serverConfigUpdated, { | |
| 226 | issues: [], | |
| 227 | }); | |
| 228 | ||
| 229 | expect(listener).toHaveBeenCalledTimes(2); | |
| 230 | expect(listener).toHaveBeenLastCalledWith({ | |
| 231 | issues: [], | |
| 232 | }); | |
| 233 | }); | |
| 234 | ||
| 235 | it("delivers and caches valid server.providersUpdated payloads", async () => { | |
| 236 | const { createWsNativeApi, onServerProvidersUpdated } = await import("./wsNativeApi"); | |
| 237 | ||
| 238 | createWsNativeApi(); | |
| 239 | const listener = vi.fn(); | |
| 240 | onServerProvidersUpdated(listener); | |
| 241 | ||
| 242 | const payload = { | |
| 243 | providers: defaultProviders, | |
| 244 | } as const; | |
| 245 | emitPush(WS_CHANNELS.serverProvidersUpdated, payload); | |
| 179 | it("forwards server config fetches directly to the RPC client", async () => { | |
| 180 | rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig); | |
| 181 | const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 246 | 182 | |
| 247 | expect(listener).toHaveBeenCalledTimes(1); | |
| 248 | expect(listener).toHaveBeenCalledWith(payload); | |
| 183 | const api = createWsNativeApi(); | |
| 249 | 184 | |
| 250 | const lateListener = vi.fn(); | |
| 251 | onServerProvidersUpdated(lateListener); | |
| 252 | expect(lateListener).toHaveBeenCalledTimes(1); | |
| 253 | expect(lateListener).toHaveBeenCalledWith(payload); | |
| 185 | await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig); | |
| 186 | expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith(); | |
| 187 | expect(rpcClientMock.server.subscribeConfig).not.toHaveBeenCalled(); | |
| 188 | expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled(); | |
| 254 | 189 | }); |
| 255 | 190 | |
| 256 | it("forwards valid terminal and orchestration events", async () => { | |
| 191 | it("forwards terminal and orchestration stream events", async () => { | |
| 257 | 192 | const { createWsNativeApi } = await import("./wsNativeApi"); |
| 258 | 193 | |
| 259 | 194 | const api = createWsNativeApi(); |
| 260 | 195 | const onTerminalEvent = vi.fn(); |
| 261 | 196 | const onDomainEvent = vi.fn(); |
| 262 | const onActionProgress = vi.fn(); | |
| 263 | 197 | |
| 264 | 198 | api.terminal.onEvent(onTerminalEvent); |
| 265 | 199 | api.orchestration.onDomainEvent(onDomainEvent); |
| 266 | api.git.onActionProgress(onActionProgress); | |
| 267 | 200 | |
| 268 | 201 | const terminalEvent = { |
| 269 | 202 | threadId: "thread-1", |
| ... | ... | @@ -272,7 +205,7 @@ describe("wsNativeApi", () => { |
| 272 | 205 | type: "output", |
| 273 | 206 | data: "hello", |
| 274 | 207 | } as const; |
| 275 | emitPush(WS_CHANNELS.terminalEvent, terminalEvent); | |
| 208 | emitEvent(terminalEventListeners, terminalEvent); | |
| 276 | 209 | |
| 277 | 210 | const orchestrationEvent = { |
| 278 | 211 | sequence: 1, |
| ... | ... | @@ -289,39 +222,23 @@ describe("wsNativeApi", () => { |
| 289 | 222 | projectId: ProjectId.makeUnsafe("project-1"), |
| 290 | 223 | title: "Project", |
| 291 | 224 | workspaceRoot: "/tmp/workspace", |
| 292 | defaultModelSelection: null, | |
| 225 | defaultModelSelection: { | |
| 226 | provider: "codex", | |
| 227 | model: "gpt-5-codex", | |
| 228 | }, | |
| 293 | 229 | scripts: [], |
| 294 | 230 | createdAt: "2026-02-24T00:00:00.000Z", |
| 295 | 231 | updatedAt: "2026-02-24T00:00:00.000Z", |
| 296 | 232 | }, |
| 297 | 233 | } satisfies Extract<OrchestrationEvent, { type: "project.created" }>; |
| 298 | emitPush(ORCHESTRATION_WS_CHANNELS.domainEvent, orchestrationEvent); | |
| 299 | emitPush(WS_CHANNELS.gitActionProgress, { | |
| 300 | actionId: "action-1", | |
| 301 | cwd: "/repo", | |
| 302 | action: "commit", | |
| 303 | kind: "phase_started", | |
| 304 | phase: "commit", | |
| 305 | label: "Committing...", | |
| 306 | }); | |
| 234 | emitEvent(orchestrationEventListeners, orchestrationEvent); | |
| 307 | 235 | |
| 308 | expect(onTerminalEvent).toHaveBeenCalledTimes(1); | |
| 309 | 236 | expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent); |
| 310 | expect(onDomainEvent).toHaveBeenCalledTimes(1); | |
| 311 | 237 | expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent); |
| 312 | expect(onActionProgress).toHaveBeenCalledTimes(1); | |
| 313 | expect(onActionProgress).toHaveBeenCalledWith({ | |
| 314 | actionId: "action-1", | |
| 315 | cwd: "/repo", | |
| 316 | action: "commit", | |
| 317 | kind: "phase_started", | |
| 318 | phase: "commit", | |
| 319 | label: "Committing...", | |
| 320 | }); | |
| 321 | 238 | }); |
| 322 | 239 | |
| 323 | it("wraps orchestration dispatch commands in the command envelope", async () => { | |
| 324 | requestMock.mockResolvedValue(undefined); | |
| 240 | it("sends orchestration dispatch commands as the direct RPC payload", async () => { | |
| 241 | rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 }); | |
| 325 | 242 | const { createWsNativeApi } = await import("./wsNativeApi"); |
| 326 | 243 | |
| 327 | 244 | const api = createWsNativeApi(); |
| ... | ... | @@ -339,13 +256,11 @@ describe("wsNativeApi", () => { |
| 339 | 256 | } as const; |
| 340 | 257 | await api.orchestration.dispatchCommand(command); |
| 341 | 258 | |
| 342 | expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.dispatchCommand, { | |
| 343 | command, | |
| 344 | }); | |
| 259 | expect(rpcClientMock.orchestration.dispatchCommand).toHaveBeenCalledWith(command); | |
| 345 | 260 | }); |
| 346 | 261 | |
| 347 | it("forwards workspace file writes to the websocket project method", async () => { | |
| 348 | requestMock.mockResolvedValue({ relativePath: "plan.md" }); | |
| 262 | it("forwards workspace file writes to the project RPC", async () => { | |
| 263 | rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" }); | |
| 349 | 264 | const { createWsNativeApi } = await import("./wsNativeApi"); |
| 350 | 265 | |
| 351 | 266 | const api = createWsNativeApi(); |
| ... | ... | @@ -355,100 +270,83 @@ describe("wsNativeApi", () => { |
| 355 | 270 | contents: "# Plan\n", |
| 356 | 271 | }); |
| 357 | 272 | |
| 358 | expect(requestMock).toHaveBeenCalledWith(WS_METHODS.projectsWriteFile, { | |
| 273 | expect(rpcClientMock.projects.writeFile).toHaveBeenCalledWith({ | |
| 359 | 274 | cwd: "/tmp/project", |
| 360 | 275 | relativePath: "plan.md", |
| 361 | 276 | contents: "# Plan\n", |
| 362 | 277 | }); |
| 363 | 278 | }); |
| 364 | 279 | |
| 365 | it("uses no client timeout for git.runStackedAction", async () => { | |
| 366 | requestMock.mockResolvedValue({ | |
| 367 | action: "commit", | |
| 368 | branch: { status: "skipped_not_requested" }, | |
| 369 | commit: { status: "created", commitSha: "abc1234", subject: "Test" }, | |
| 370 | push: { status: "skipped_not_requested" }, | |
| 371 | pr: { status: "skipped_not_requested" }, | |
| 372 | }); | |
| 280 | it("forwards full-thread diff requests to the orchestration RPC", async () => { | |
| 281 | rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" }); | |
| 373 | 282 | const { createWsNativeApi } = await import("./wsNativeApi"); |
| 374 | 283 | |
| 375 | 284 | const api = createWsNativeApi(); |
| 376 | await api.git.runStackedAction({ | |
| 377 | actionId: "action-1", | |
| 378 | cwd: "/repo", | |
| 379 | action: "commit", | |
| 285 | await api.orchestration.getFullThreadDiff({ | |
| 286 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 287 | toTurnCount: 1, | |
| 380 | 288 | }); |
| 381 | 289 | |
| 382 | expect(requestMock).toHaveBeenCalledWith( | |
| 383 | WS_METHODS.gitRunStackedAction, | |
| 290 | expect(rpcClientMock.orchestration.getFullThreadDiff).toHaveBeenCalledWith({ | |
| 291 | threadId: "thread-1", | |
| 292 | toTurnCount: 1, | |
| 293 | }); | |
| 294 | }); | |
| 295 | ||
| 296 | it("forwards provider refreshes directly to the RPC client", async () => { | |
| 297 | const nextProviders: ReadonlyArray<ServerProvider> = [ | |
| 384 | 298 | { |
| 385 | actionId: "action-1", | |
| 386 | cwd: "/repo", | |
| 387 | action: "commit", | |
| 299 | ...defaultProviders[0]!, | |
| 300 | checkedAt: "2026-01-03T00:00:00.000Z", | |
| 388 | 301 | }, |
| 389 | { timeoutMs: null }, | |
| 390 | ); | |
| 302 | ]; | |
| 303 | rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders }); | |
| 304 | const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 305 | ||
| 306 | const api = createWsNativeApi(); | |
| 307 | ||
| 308 | await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders }); | |
| 309 | expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith(); | |
| 391 | 310 | }); |
| 392 | 311 | |
| 393 | it("forwards full-thread diff requests to the orchestration websocket method", async () => { | |
| 394 | requestMock.mockResolvedValue({ diff: "patch" }); | |
| 312 | it("forwards server settings updates directly to the RPC client", async () => { | |
| 313 | const nextSettings = { | |
| 314 | ...DEFAULT_SERVER_SETTINGS, | |
| 315 | enableAssistantStreaming: true, | |
| 316 | }; | |
| 317 | rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings); | |
| 395 | 318 | const { createWsNativeApi } = await import("./wsNativeApi"); |
| 396 | 319 | |
| 397 | 320 | const api = createWsNativeApi(); |
| 398 | await api.orchestration.getFullThreadDiff({ | |
| 399 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 400 | toTurnCount: 1, | |
| 401 | }); | |
| 402 | 321 | |
| 403 | expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.getFullThreadDiff, { | |
| 404 | threadId: "thread-1", | |
| 405 | toTurnCount: 1, | |
| 322 | await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual( | |
| 323 | nextSettings, | |
| 324 | ); | |
| 325 | expect(rpcClientMock.server.updateSettings).toHaveBeenCalledWith({ | |
| 326 | enableAssistantStreaming: true, | |
| 406 | 327 | }); |
| 407 | 328 | }); |
| 408 | 329 | |
| 409 | it("forwards context menu metadata to desktop bridge", async () => { | |
| 330 | it("forwards context menu metadata to the desktop bridge", async () => { | |
| 410 | 331 | const showContextMenu = vi.fn().mockResolvedValue("delete"); |
| 411 | Object.defineProperty(getWindowForTest(), "desktopBridge", { | |
| 412 | configurable: true, | |
| 413 | writable: true, | |
| 414 | value: { | |
| 415 | showContextMenu, | |
| 416 | }, | |
| 417 | }); | |
| 332 | getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu }); | |
| 418 | 333 | |
| 419 | 334 | const { createWsNativeApi } = await import("./wsNativeApi"); |
| 420 | 335 | const api = createWsNativeApi(); |
| 421 | await api.contextMenu.show( | |
| 422 | [ | |
| 423 | { id: "rename", label: "Rename thread" }, | |
| 424 | { id: "delete", label: "Delete", destructive: true }, | |
| 425 | ], | |
| 426 | { x: 200, y: 300 }, | |
| 427 | ); | |
| 336 | const items = [{ id: "delete", label: "Delete" }] as const; | |
| 428 | 337 | |
| 429 | expect(showContextMenu).toHaveBeenCalledWith( | |
| 430 | [ | |
| 431 | { id: "rename", label: "Rename thread" }, | |
| 432 | { id: "delete", label: "Delete", destructive: true }, | |
| 433 | ], | |
| 434 | { x: 200, y: 300 }, | |
| 435 | ); | |
| 338 | await expect(api.contextMenu.show(items)).resolves.toBe("delete"); | |
| 339 | expect(showContextMenu).toHaveBeenCalledWith(items, undefined); | |
| 436 | 340 | }); |
| 437 | 341 | |
| 438 | it("uses fallback context menu when desktop bridge is unavailable", async () => { | |
| 439 | showContextMenuFallbackMock.mockResolvedValue("delete"); | |
| 440 | Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); | |
| 441 | ||
| 342 | it("falls back to the browser context menu helper when the desktop bridge is missing", async () => { | |
| 343 | showContextMenuFallbackMock.mockResolvedValue("rename"); | |
| 442 | 344 | const { createWsNativeApi } = await import("./wsNativeApi"); |
| 345 | ||
| 443 | 346 | const api = createWsNativeApi(); |
| 444 | await api.contextMenu.show([{ id: "delete", label: "Delete", destructive: true }], { | |
| 445 | x: 20, | |
| 446 | y: 30, | |
| 447 | }); | |
| 347 | const items = [{ id: "rename", label: "Rename" }] as const; | |
| 448 | 348 | |
| 449 | expect(showContextMenuFallbackMock).toHaveBeenCalledWith( | |
| 450 | [{ id: "delete", label: "Delete", destructive: true }], | |
| 451 | { x: 20, y: 30 }, | |
| 452 | ); | |
| 349 | await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); | |
| 350 | expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }); | |
| 453 | 351 | }); |
| 454 | 352 | }); |
apps/web/src/wsNativeApi.ts+46-175| ... | ... | @@ -1,136 +1,23 @@ |
| 1 | import { | |
| 2 | type GitActionProgressEvent, | |
| 3 | ORCHESTRATION_WS_CHANNELS, | |
| 4 | ORCHESTRATION_WS_METHODS, | |
| 5 | type ContextMenuItem, | |
| 6 | type NativeApi, | |
| 7 | ServerConfigUpdatedPayload, | |
| 8 | ServerProviderUpdatedPayload, | |
| 9 | WS_CHANNELS, | |
| 10 | WS_METHODS, | |
| 11 | type WsWelcomePayload, | |
| 12 | } from "@t3tools/contracts"; | |
| 1 | import { type ContextMenuItem, type NativeApi } from "@t3tools/contracts"; | |
| 13 | 2 | |
| 14 | 3 | import { showContextMenuFallback } from "./contextMenuFallback"; |
| 15 | import { WsTransport } from "./wsTransport"; | |
| 4 | import { resetServerStateForTests } from "./rpc/serverState"; | |
| 5 | import { __resetWsRpcClientForTests, getWsRpcClient } from "./wsRpcClient"; | |
| 16 | 6 | |
| 17 | let instance: { api: NativeApi; transport: WsTransport } | null = null; | |
| 18 | const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>(); | |
| 19 | const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>(); | |
| 20 | const providersUpdatedListeners = new Set<(payload: ServerProviderUpdatedPayload) => void>(); | |
| 21 | const gitActionProgressListeners = new Set<(payload: GitActionProgressEvent) => void>(); | |
| 7 | let instance: { api: NativeApi } | null = null; | |
| 22 | 8 | |
| 23 | /** | |
| 24 | * Subscribe to the server welcome message. If a welcome was already received | |
| 25 | * before this call, the listener fires synchronously with the cached payload. | |
| 26 | * This avoids the race between WebSocket connect and React effect registration. | |
| 27 | */ | |
| 28 | export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): () => void { | |
| 29 | welcomeListeners.add(listener); | |
| 30 | ||
| 31 | const latestWelcome = instance?.transport.getLatestPush(WS_CHANNELS.serverWelcome)?.data ?? null; | |
| 32 | if (latestWelcome) { | |
| 33 | try { | |
| 34 | listener(latestWelcome); | |
| 35 | } catch { | |
| 36 | // Swallow listener errors | |
| 37 | } | |
| 38 | } | |
| 39 | ||
| 40 | return () => { | |
| 41 | welcomeListeners.delete(listener); | |
| 42 | }; | |
| 43 | } | |
| 44 | ||
| 45 | /** | |
| 46 | * Subscribe to server config update events. Replays the latest update for | |
| 47 | * late subscribers to avoid missing config validation feedback. | |
| 48 | */ | |
| 49 | export function onServerConfigUpdated( | |
| 50 | listener: (payload: ServerConfigUpdatedPayload) => void, | |
| 51 | ): () => void { | |
| 52 | serverConfigUpdatedListeners.add(listener); | |
| 53 | ||
| 54 | const latestConfig = | |
| 55 | instance?.transport.getLatestPush(WS_CHANNELS.serverConfigUpdated)?.data ?? null; | |
| 56 | if (latestConfig) { | |
| 57 | try { | |
| 58 | listener(latestConfig); | |
| 59 | } catch { | |
| 60 | // Swallow listener errors | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | return () => { | |
| 65 | serverConfigUpdatedListeners.delete(listener); | |
| 66 | }; | |
| 67 | } | |
| 68 | ||
| 69 | export function onServerProvidersUpdated( | |
| 70 | listener: (payload: ServerProviderUpdatedPayload) => void, | |
| 71 | ): () => void { | |
| 72 | providersUpdatedListeners.add(listener); | |
| 73 | ||
| 74 | const latestProviders = | |
| 75 | instance?.transport.getLatestPush(WS_CHANNELS.serverProvidersUpdated)?.data ?? null; | |
| 76 | if (latestProviders) { | |
| 77 | try { | |
| 78 | listener(latestProviders); | |
| 79 | } catch { | |
| 80 | // Swallow listener errors | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | return () => { | |
| 85 | providersUpdatedListeners.delete(listener); | |
| 86 | }; | |
| 9 | export function __resetWsNativeApiForTests() { | |
| 10 | instance = null; | |
| 11 | __resetWsRpcClientForTests(); | |
| 12 | resetServerStateForTests(); | |
| 87 | 13 | } |
| 88 | 14 | |
| 89 | 15 | export function createWsNativeApi(): NativeApi { |
| 90 | if (instance) return instance.api; | |
| 91 | ||
| 92 | const transport = new WsTransport(); | |
| 16 | if (instance) { | |
| 17 | return instance.api; | |
| 18 | } | |
| 93 | 19 | |
| 94 | transport.subscribe(WS_CHANNELS.serverWelcome, (message) => { | |
| 95 | const payload = message.data; | |
| 96 | for (const listener of welcomeListeners) { | |
| 97 | try { | |
| 98 | listener(payload); | |
| 99 | } catch { | |
| 100 | // Swallow listener errors | |
| 101 | } | |
| 102 | } | |
| 103 | }); | |
| 104 | transport.subscribe(WS_CHANNELS.serverConfigUpdated, (message) => { | |
| 105 | const payload = message.data; | |
| 106 | for (const listener of serverConfigUpdatedListeners) { | |
| 107 | try { | |
| 108 | listener(payload); | |
| 109 | } catch { | |
| 110 | // Swallow listener errors | |
| 111 | } | |
| 112 | } | |
| 113 | }); | |
| 114 | transport.subscribe(WS_CHANNELS.serverProvidersUpdated, (message) => { | |
| 115 | const payload = message.data; | |
| 116 | for (const listener of providersUpdatedListeners) { | |
| 117 | try { | |
| 118 | listener(payload); | |
| 119 | } catch { | |
| 120 | // Swallow listener errors | |
| 121 | } | |
| 122 | } | |
| 123 | }); | |
| 124 | transport.subscribe(WS_CHANNELS.gitActionProgress, (message) => { | |
| 125 | const payload = message.data; | |
| 126 | for (const listener of gitActionProgressListeners) { | |
| 127 | try { | |
| 128 | listener(payload); | |
| 129 | } catch { | |
| 130 | // Swallow listener errors | |
| 131 | } | |
| 132 | } | |
| 133 | }); | |
| 20 | const rpcClient = getWsRpcClient(); | |
| 134 | 21 | |
| 135 | 22 | const api: NativeApi = { |
| 136 | 23 | dialogs: { |
| ... | ... | @@ -146,22 +33,20 @@ export function createWsNativeApi(): NativeApi { |
| 146 | 33 | }, |
| 147 | 34 | }, |
| 148 | 35 | terminal: { |
| 149 | open: (input) => transport.request(WS_METHODS.terminalOpen, input), | |
| 150 | write: (input) => transport.request(WS_METHODS.terminalWrite, input), | |
| 151 | resize: (input) => transport.request(WS_METHODS.terminalResize, input), | |
| 152 | clear: (input) => transport.request(WS_METHODS.terminalClear, input), | |
| 153 | restart: (input) => transport.request(WS_METHODS.terminalRestart, input), | |
| 154 | close: (input) => transport.request(WS_METHODS.terminalClose, input), | |
| 155 | onEvent: (callback) => | |
| 156 | transport.subscribe(WS_CHANNELS.terminalEvent, (message) => callback(message.data)), | |
| 36 | open: (input) => rpcClient.terminal.open(input as never), | |
| 37 | write: (input) => rpcClient.terminal.write(input as never), | |
| 38 | resize: (input) => rpcClient.terminal.resize(input as never), | |
| 39 | clear: (input) => rpcClient.terminal.clear(input as never), | |
| 40 | restart: (input) => rpcClient.terminal.restart(input as never), | |
| 41 | close: (input) => rpcClient.terminal.close(input as never), | |
| 42 | onEvent: (callback) => rpcClient.terminal.onEvent(callback), | |
| 157 | 43 | }, |
| 158 | 44 | projects: { |
| 159 | searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), | |
| 160 | writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input), | |
| 45 | searchEntries: rpcClient.projects.searchEntries, | |
| 46 | writeFile: rpcClient.projects.writeFile, | |
| 161 | 47 | }, |
| 162 | 48 | shell: { |
| 163 | openInEditor: (cwd, editor) => | |
| 164 | transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }), | |
| 49 | openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }), | |
| 165 | 50 | openExternal: async (url) => { |
| 166 | 51 | if (window.desktopBridge) { |
| 167 | 52 | const opened = await window.desktopBridge.openExternal(url); |
| ... | ... | @@ -171,31 +56,20 @@ export function createWsNativeApi(): NativeApi { |
| 171 | 56 | return; |
| 172 | 57 | } |
| 173 | 58 | |
| 174 | // Some mobile browsers can return null here even when the tab opens. | |
| 175 | // Avoid false negatives and let the browser handle popup policy. | |
| 176 | 59 | window.open(url, "_blank", "noopener,noreferrer"); |
| 177 | 60 | }, |
| 178 | 61 | }, |
| 179 | 62 | git: { |
| 180 | pull: (input) => transport.request(WS_METHODS.gitPull, input), | |
| 181 | status: (input) => transport.request(WS_METHODS.gitStatus, input), | |
| 182 | runStackedAction: (input) => | |
| 183 | transport.request(WS_METHODS.gitRunStackedAction, input, { timeoutMs: null }), | |
| 184 | listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), | |
| 185 | createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input), | |
| 186 | removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input), | |
| 187 | createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input), | |
| 188 | checkout: (input) => transport.request(WS_METHODS.gitCheckout, input), | |
| 189 | init: (input) => transport.request(WS_METHODS.gitInit, input), | |
| 190 | resolvePullRequest: (input) => transport.request(WS_METHODS.gitResolvePullRequest, input), | |
| 191 | preparePullRequestThread: (input) => | |
| 192 | transport.request(WS_METHODS.gitPreparePullRequestThread, input), | |
| 193 | onActionProgress: (callback) => { | |
| 194 | gitActionProgressListeners.add(callback); | |
| 195 | return () => { | |
| 196 | gitActionProgressListeners.delete(callback); | |
| 197 | }; | |
| 198 | }, | |
| 63 | pull: rpcClient.git.pull, | |
| 64 | status: rpcClient.git.status, | |
| 65 | listBranches: rpcClient.git.listBranches, | |
| 66 | createWorktree: rpcClient.git.createWorktree, | |
| 67 | removeWorktree: rpcClient.git.removeWorktree, | |
| 68 | createBranch: rpcClient.git.createBranch, | |
| 69 | checkout: rpcClient.git.checkout, | |
| 70 | init: rpcClient.git.init, | |
| 71 | resolvePullRequest: rpcClient.git.resolvePullRequest, | |
| 72 | preparePullRequestThread: rpcClient.git.preparePullRequestThread, | |
| 199 | 73 | }, |
| 200 | 74 | contextMenu: { |
| 201 | 75 | show: async <T extends string>( |
| ... | ... | @@ -209,28 +83,25 @@ export function createWsNativeApi(): NativeApi { |
| 209 | 83 | }, |
| 210 | 84 | }, |
| 211 | 85 | server: { |
| 212 | getConfig: () => transport.request(WS_METHODS.serverGetConfig), | |
| 213 | refreshProviders: () => transport.request(WS_METHODS.serverRefreshProviders), | |
| 214 | upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), | |
| 215 | getSettings: () => transport.request(WS_METHODS.serverGetSettings), | |
| 216 | updateSettings: (patch) => transport.request(WS_METHODS.serverUpdateSettings, { patch }), | |
| 86 | getConfig: rpcClient.server.getConfig, | |
| 87 | refreshProviders: rpcClient.server.refreshProviders, | |
| 88 | upsertKeybinding: rpcClient.server.upsertKeybinding, | |
| 89 | getSettings: rpcClient.server.getSettings, | |
| 90 | updateSettings: rpcClient.server.updateSettings, | |
| 217 | 91 | }, |
| 218 | 92 | orchestration: { |
| 219 | getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), | |
| 220 | dispatchCommand: (command) => | |
| 221 | transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, { command }), | |
| 222 | getTurnDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getTurnDiff, input), | |
| 223 | getFullThreadDiff: (input) => | |
| 224 | transport.request(ORCHESTRATION_WS_METHODS.getFullThreadDiff, input), | |
| 93 | getSnapshot: rpcClient.orchestration.getSnapshot, | |
| 94 | dispatchCommand: rpcClient.orchestration.dispatchCommand, | |
| 95 | getTurnDiff: rpcClient.orchestration.getTurnDiff, | |
| 96 | getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, | |
| 225 | 97 | replayEvents: (fromSequenceExclusive) => |
| 226 | transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }), | |
| 227 | onDomainEvent: (callback) => | |
| 228 | transport.subscribe(ORCHESTRATION_WS_CHANNELS.domainEvent, (message) => | |
| 229 | callback(message.data), | |
| 230 | ), | |
| 98 | rpcClient.orchestration | |
| 99 | .replayEvents({ fromSequenceExclusive }) | |
| 100 | .then((events) => [...events]), | |
| 101 | onDomainEvent: (callback) => rpcClient.orchestration.onDomainEvent(callback), | |
| 231 | 102 | }, |
| 232 | 103 | }; |
| 233 | 104 | |
| 234 | instance = { api, transport }; | |
| 105 | instance = { api }; | |
| 235 | 106 | return api; |
| 236 | 107 | } |
apps/web/src/wsRpcClient.ts created+207| ... | ... | @@ -0,0 +1,207 @@ |
| 1 | import { | |
| 2 | type GitActionProgressEvent, | |
| 3 | type GitRunStackedActionInput, | |
| 4 | type GitRunStackedActionResult, | |
| 5 | type NativeApi, | |
| 6 | ORCHESTRATION_WS_METHODS, | |
| 7 | type ServerSettingsPatch, | |
| 8 | WS_METHODS, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { Effect, Stream } from "effect"; | |
| 11 | ||
| 12 | import { type WsRpcProtocolClient } from "./rpc/protocol"; | |
| 13 | import { WsTransport } from "./wsTransport"; | |
| 14 | ||
| 15 | type RpcTag = keyof WsRpcProtocolClient & string; | |
| 16 | type RpcMethod<TTag extends RpcTag> = WsRpcProtocolClient[TTag]; | |
| 17 | type RpcInput<TTag extends RpcTag> = Parameters<RpcMethod<TTag>>[0]; | |
| 18 | ||
| 19 | type RpcUnaryMethod<TTag extends RpcTag> = | |
| 20 | RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any> | |
| 21 | ? (input: RpcInput<TTag>) => Promise<TSuccess> | |
| 22 | : never; | |
| 23 | ||
| 24 | type RpcUnaryNoArgMethod<TTag extends RpcTag> = | |
| 25 | RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any> | |
| 26 | ? () => Promise<TSuccess> | |
| 27 | : never; | |
| 28 | ||
| 29 | type RpcStreamMethod<TTag extends RpcTag> = | |
| 30 | RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<infer TEvent, any, any> | |
| 31 | ? (listener: (event: TEvent) => void) => () => void | |
| 32 | : never; | |
| 33 | ||
| 34 | interface GitRunStackedActionOptions { | |
| 35 | readonly onProgress?: (event: GitActionProgressEvent) => void; | |
| 36 | } | |
| 37 | ||
| 38 | export interface WsRpcClient { | |
| 39 | readonly dispose: () => Promise<void>; | |
| 40 | readonly terminal: { | |
| 41 | readonly open: RpcUnaryMethod<typeof WS_METHODS.terminalOpen>; | |
| 42 | readonly write: RpcUnaryMethod<typeof WS_METHODS.terminalWrite>; | |
| 43 | readonly resize: RpcUnaryMethod<typeof WS_METHODS.terminalResize>; | |
| 44 | readonly clear: RpcUnaryMethod<typeof WS_METHODS.terminalClear>; | |
| 45 | readonly restart: RpcUnaryMethod<typeof WS_METHODS.terminalRestart>; | |
| 46 | readonly close: RpcUnaryMethod<typeof WS_METHODS.terminalClose>; | |
| 47 | readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalEvents>; | |
| 48 | }; | |
| 49 | readonly projects: { | |
| 50 | readonly searchEntries: RpcUnaryMethod<typeof WS_METHODS.projectsSearchEntries>; | |
| 51 | readonly writeFile: RpcUnaryMethod<typeof WS_METHODS.projectsWriteFile>; | |
| 52 | }; | |
| 53 | readonly shell: { | |
| 54 | readonly openInEditor: (input: { | |
| 55 | readonly cwd: Parameters<NativeApi["shell"]["openInEditor"]>[0]; | |
| 56 | readonly editor: Parameters<NativeApi["shell"]["openInEditor"]>[1]; | |
| 57 | }) => ReturnType<NativeApi["shell"]["openInEditor"]>; | |
| 58 | }; | |
| 59 | readonly git: { | |
| 60 | readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>; | |
| 61 | readonly status: RpcUnaryMethod<typeof WS_METHODS.gitStatus>; | |
| 62 | readonly runStackedAction: ( | |
| 63 | input: GitRunStackedActionInput, | |
| 64 | options?: GitRunStackedActionOptions, | |
| 65 | ) => Promise<GitRunStackedActionResult>; | |
| 66 | readonly listBranches: RpcUnaryMethod<typeof WS_METHODS.gitListBranches>; | |
| 67 | readonly createWorktree: RpcUnaryMethod<typeof WS_METHODS.gitCreateWorktree>; | |
| 68 | readonly removeWorktree: RpcUnaryMethod<typeof WS_METHODS.gitRemoveWorktree>; | |
| 69 | readonly createBranch: RpcUnaryMethod<typeof WS_METHODS.gitCreateBranch>; | |
| 70 | readonly checkout: RpcUnaryMethod<typeof WS_METHODS.gitCheckout>; | |
| 71 | readonly init: RpcUnaryMethod<typeof WS_METHODS.gitInit>; | |
| 72 | readonly resolvePullRequest: RpcUnaryMethod<typeof WS_METHODS.gitResolvePullRequest>; | |
| 73 | readonly preparePullRequestThread: RpcUnaryMethod< | |
| 74 | typeof WS_METHODS.gitPreparePullRequestThread | |
| 75 | >; | |
| 76 | }; | |
| 77 | readonly server: { | |
| 78 | readonly getConfig: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetConfig>; | |
| 79 | readonly refreshProviders: RpcUnaryNoArgMethod<typeof WS_METHODS.serverRefreshProviders>; | |
| 80 | readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>; | |
| 81 | readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>; | |
| 82 | readonly updateSettings: ( | |
| 83 | patch: ServerSettingsPatch, | |
| 84 | ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverUpdateSettings>>; | |
| 85 | readonly subscribeConfig: RpcStreamMethod<typeof WS_METHODS.subscribeServerConfig>; | |
| 86 | readonly subscribeLifecycle: RpcStreamMethod<typeof WS_METHODS.subscribeServerLifecycle>; | |
| 87 | }; | |
| 88 | readonly orchestration: { | |
| 89 | readonly getSnapshot: RpcUnaryNoArgMethod<typeof ORCHESTRATION_WS_METHODS.getSnapshot>; | |
| 90 | readonly dispatchCommand: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.dispatchCommand>; | |
| 91 | readonly getTurnDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getTurnDiff>; | |
| 92 | readonly getFullThreadDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getFullThreadDiff>; | |
| 93 | readonly replayEvents: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.replayEvents>; | |
| 94 | readonly onDomainEvent: RpcStreamMethod<typeof WS_METHODS.subscribeOrchestrationDomainEvents>; | |
| 95 | }; | |
| 96 | } | |
| 97 | ||
| 98 | let sharedWsRpcClient: WsRpcClient | null = null; | |
| 99 | ||
| 100 | export function getWsRpcClient(): WsRpcClient { | |
| 101 | if (sharedWsRpcClient) { | |
| 102 | return sharedWsRpcClient; | |
| 103 | } | |
| 104 | sharedWsRpcClient = createWsRpcClient(); | |
| 105 | return sharedWsRpcClient; | |
| 106 | } | |
| 107 | ||
| 108 | export async function __resetWsRpcClientForTests() { | |
| 109 | await sharedWsRpcClient?.dispose(); | |
| 110 | sharedWsRpcClient = null; | |
| 111 | } | |
| 112 | ||
| 113 | export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { | |
| 114 | return { | |
| 115 | dispose: () => transport.dispose(), | |
| 116 | terminal: { | |
| 117 | open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)), | |
| 118 | write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)), | |
| 119 | resize: (input) => transport.request((client) => client[WS_METHODS.terminalResize](input)), | |
| 120 | clear: (input) => transport.request((client) => client[WS_METHODS.terminalClear](input)), | |
| 121 | restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)), | |
| 122 | close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)), | |
| 123 | onEvent: (listener) => | |
| 124 | transport.subscribe((client) => client[WS_METHODS.subscribeTerminalEvents]({}), listener), | |
| 125 | }, | |
| 126 | projects: { | |
| 127 | searchEntries: (input) => | |
| 128 | transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)), | |
| 129 | writeFile: (input) => | |
| 130 | transport.request((client) => client[WS_METHODS.projectsWriteFile](input)), | |
| 131 | }, | |
| 132 | shell: { | |
| 133 | openInEditor: (input) => | |
| 134 | transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), | |
| 135 | }, | |
| 136 | git: { | |
| 137 | pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)), | |
| 138 | status: (input) => transport.request((client) => client[WS_METHODS.gitStatus](input)), | |
| 139 | runStackedAction: async (input, options) => { | |
| 140 | let result: GitRunStackedActionResult | null = null; | |
| 141 | ||
| 142 | await transport.requestStream( | |
| 143 | (client) => client[WS_METHODS.gitRunStackedAction](input), | |
| 144 | (event) => { | |
| 145 | options?.onProgress?.(event); | |
| 146 | if (event.kind === "action_finished") { | |
| 147 | result = event.result; | |
| 148 | } | |
| 149 | }, | |
| 150 | ); | |
| 151 | ||
| 152 | if (result) { | |
| 153 | return result; | |
| 154 | } | |
| 155 | ||
| 156 | throw new Error("Git action stream completed without a final result."); | |
| 157 | }, | |
| 158 | listBranches: (input) => | |
| 159 | transport.request((client) => client[WS_METHODS.gitListBranches](input)), | |
| 160 | createWorktree: (input) => | |
| 161 | transport.request((client) => client[WS_METHODS.gitCreateWorktree](input)), | |
| 162 | removeWorktree: (input) => | |
| 163 | transport.request((client) => client[WS_METHODS.gitRemoveWorktree](input)), | |
| 164 | createBranch: (input) => | |
| 165 | transport.request((client) => client[WS_METHODS.gitCreateBranch](input)), | |
| 166 | checkout: (input) => transport.request((client) => client[WS_METHODS.gitCheckout](input)), | |
| 167 | init: (input) => transport.request((client) => client[WS_METHODS.gitInit](input)), | |
| 168 | resolvePullRequest: (input) => | |
| 169 | transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)), | |
| 170 | preparePullRequestThread: (input) => | |
| 171 | transport.request((client) => client[WS_METHODS.gitPreparePullRequestThread](input)), | |
| 172 | }, | |
| 173 | server: { | |
| 174 | getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})), | |
| 175 | refreshProviders: () => | |
| 176 | transport.request((client) => client[WS_METHODS.serverRefreshProviders]({})), | |
| 177 | upsertKeybinding: (input) => | |
| 178 | transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)), | |
| 179 | getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), | |
| 180 | updateSettings: (patch) => | |
| 181 | transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), | |
| 182 | subscribeConfig: (listener) => | |
| 183 | transport.subscribe((client) => client[WS_METHODS.subscribeServerConfig]({}), listener), | |
| 184 | subscribeLifecycle: (listener) => | |
| 185 | transport.subscribe((client) => client[WS_METHODS.subscribeServerLifecycle]({}), listener), | |
| 186 | }, | |
| 187 | orchestration: { | |
| 188 | getSnapshot: () => | |
| 189 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})), | |
| 190 | dispatchCommand: (input) => | |
| 191 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), | |
| 192 | getTurnDiff: (input) => | |
| 193 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)), | |
| 194 | getFullThreadDiff: (input) => | |
| 195 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)), | |
| 196 | replayEvents: (input) => | |
| 197 | transport | |
| 198 | .request((client) => client[ORCHESTRATION_WS_METHODS.replayEvents](input)) | |
| 199 | .then((events) => [...events]), | |
| 200 | onDomainEvent: (listener) => | |
| 201 | transport.subscribe( | |
| 202 | (client) => client[WS_METHODS.subscribeOrchestrationDomainEvents]({}), | |
| 203 | listener, | |
| 204 | ), | |
| 205 | }, | |
| 206 | }; | |
| 207 | } |
apps/web/src/wsTransport.test.ts+297-110| ... | ... | @@ -1,10 +1,11 @@ |
| 1 | import { WS_CHANNELS } from "@t3tools/contracts"; | |
| 1 | import { WS_METHODS } from "@t3tools/contracts"; | |
| 2 | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 3 | 3 | |
| 4 | 4 | import { WsTransport } from "./wsTransport"; |
| 5 | 5 | |
| 6 | 6 | type WsEventType = "open" | "message" | "close" | "error"; |
| 7 | type WsListener = (event?: { data?: unknown }) => void; | |
| 7 | type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; | |
| 8 | type WsListener = (event?: WsEvent) => void; | |
| 8 | 9 | |
| 9 | 10 | const sockets: MockWebSocket[] = []; |
| 10 | 11 | |
| ... | ... | @@ -16,9 +17,11 @@ class MockWebSocket { |
| 16 | 17 | |
| 17 | 18 | readyState = MockWebSocket.CONNECTING; |
| 18 | 19 | readonly sent: string[] = []; |
| 20 | readonly url: string; | |
| 19 | 21 | private readonly listeners = new Map<WsEventType, Set<WsListener>>(); |
| 20 | 22 | |
| 21 | constructor(_url: string) { | |
| 23 | constructor(url: string) { | |
| 24 | this.url = url; | |
| 22 | 25 | sockets.push(this); |
| 23 | 26 | } |
| 24 | 27 | |
| ... | ... | @@ -28,25 +31,29 @@ class MockWebSocket { |
| 28 | 31 | this.listeners.set(type, listeners); |
| 29 | 32 | } |
| 30 | 33 | |
| 34 | removeEventListener(type: WsEventType, listener: WsListener) { | |
| 35 | this.listeners.get(type)?.delete(listener); | |
| 36 | } | |
| 37 | ||
| 31 | 38 | send(data: string) { |
| 32 | 39 | this.sent.push(data); |
| 33 | 40 | } |
| 34 | 41 | |
| 35 | close() { | |
| 42 | close(code = 1000, reason = "") { | |
| 36 | 43 | this.readyState = MockWebSocket.CLOSED; |
| 37 | this.emit("close"); | |
| 44 | this.emit("close", { code, reason, type: "close" }); | |
| 38 | 45 | } |
| 39 | 46 | |
| 40 | 47 | open() { |
| 41 | 48 | this.readyState = MockWebSocket.OPEN; |
| 42 | this.emit("open"); | |
| 49 | this.emit("open", { type: "open" }); | |
| 43 | 50 | } |
| 44 | 51 | |
| 45 | 52 | serverMessage(data: unknown) { |
| 46 | this.emit("message", { data }); | |
| 53 | this.emit("message", { data, type: "message" }); | |
| 47 | 54 | } |
| 48 | 55 | |
| 49 | private emit(type: WsEventType, event?: { data?: unknown }) { | |
| 56 | private emit(type: WsEventType, event?: WsEvent) { | |
| 50 | 57 | const listeners = this.listeners.get(type); |
| 51 | 58 | if (!listeners) return; |
| 52 | 59 | for (const listener of listeners) { |
| ... | ... | @@ -65,13 +72,33 @@ function getSocket(): MockWebSocket { |
| 65 | 72 | return socket; |
| 66 | 73 | } |
| 67 | 74 | |
| 75 | async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise<void> { | |
| 76 | const startedAt = Date.now(); | |
| 77 | for (;;) { | |
| 78 | try { | |
| 79 | assertion(); | |
| 80 | return; | |
| 81 | } catch (error) { | |
| 82 | if (Date.now() - startedAt >= timeoutMs) { | |
| 83 | throw error; | |
| 84 | } | |
| 85 | await new Promise((resolve) => setTimeout(resolve, 10)); | |
| 86 | } | |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 68 | 90 | beforeEach(() => { |
| 69 | 91 | sockets.length = 0; |
| 70 | 92 | |
| 71 | 93 | Object.defineProperty(globalThis, "window", { |
| 72 | 94 | configurable: true, |
| 73 | 95 | value: { |
| 74 | location: { hostname: "localhost", port: "3020" }, | |
| 96 | location: { | |
| 97 | origin: "http://localhost:3020", | |
| 98 | hostname: "localhost", | |
| 99 | port: "3020", | |
| 100 | protocol: "http:", | |
| 101 | }, | |
| 75 | 102 | desktopBridge: undefined, |
| 76 | 103 | }, |
| 77 | 104 | }); |
| ... | ... | @@ -85,172 +112,332 @@ afterEach(() => { |
| 85 | 112 | }); |
| 86 | 113 | |
| 87 | 114 | describe("WsTransport", () => { |
| 88 | it("routes valid push envelopes to channel listeners", () => { | |
| 115 | it("normalizes root websocket urls to /ws and preserves query params", async () => { | |
| 116 | const transport = new WsTransport("ws://localhost:3020/?token=secret-token"); | |
| 117 | ||
| 118 | await waitFor(() => { | |
| 119 | expect(sockets).toHaveLength(1); | |
| 120 | }); | |
| 121 | ||
| 122 | expect(getSocket().url).toBe("ws://localhost:3020/ws?token=secret-token"); | |
| 123 | await transport.dispose(); | |
| 124 | }); | |
| 125 | ||
| 126 | it("uses wss when falling back to an https page origin", async () => { | |
| 127 | Object.assign(window.location, { | |
| 128 | origin: "https://app.example.com", | |
| 129 | hostname: "app.example.com", | |
| 130 | port: "", | |
| 131 | protocol: "https:", | |
| 132 | }); | |
| 133 | ||
| 134 | const transport = new WsTransport(); | |
| 135 | ||
| 136 | await waitFor(() => { | |
| 137 | expect(sockets).toHaveLength(1); | |
| 138 | }); | |
| 139 | ||
| 140 | expect(getSocket().url).toBe("wss://app.example.com/ws"); | |
| 141 | await transport.dispose(); | |
| 142 | }); | |
| 143 | ||
| 144 | it("sends unary RPC requests and resolves successful exits", async () => { | |
| 89 | 145 | const transport = new WsTransport("ws://localhost:3020"); |
| 146 | ||
| 147 | const requestPromise = transport.request((client) => | |
| 148 | client[WS_METHODS.serverUpsertKeybinding]({ | |
| 149 | command: "terminal.toggle", | |
| 150 | key: "ctrl+k", | |
| 151 | }), | |
| 152 | ); | |
| 153 | ||
| 154 | await waitFor(() => { | |
| 155 | expect(sockets).toHaveLength(1); | |
| 156 | }); | |
| 157 | ||
| 90 | 158 | const socket = getSocket(); |
| 91 | 159 | socket.open(); |
| 92 | 160 | |
| 93 | const listener = vi.fn(); | |
| 94 | transport.subscribe(WS_CHANNELS.serverConfigUpdated, listener); | |
| 161 | await waitFor(() => { | |
| 162 | expect(socket.sent).toHaveLength(1); | |
| 163 | }); | |
| 164 | ||
| 165 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { | |
| 166 | _tag: string; | |
| 167 | id: string; | |
| 168 | payload: unknown; | |
| 169 | tag: string; | |
| 170 | }; | |
| 171 | expect(requestMessage).toMatchObject({ | |
| 172 | _tag: "Request", | |
| 173 | tag: WS_METHODS.serverUpsertKeybinding, | |
| 174 | payload: { | |
| 175 | command: "terminal.toggle", | |
| 176 | key: "ctrl+k", | |
| 177 | }, | |
| 178 | }); | |
| 95 | 179 | |
| 96 | 180 | socket.serverMessage( |
| 97 | 181 | JSON.stringify({ |
| 98 | type: "push", | |
| 99 | sequence: 1, | |
| 100 | channel: WS_CHANNELS.serverConfigUpdated, | |
| 101 | data: { issues: [] }, | |
| 182 | _tag: "Exit", | |
| 183 | requestId: requestMessage.id, | |
| 184 | exit: { | |
| 185 | _tag: "Success", | |
| 186 | value: { | |
| 187 | keybindings: [], | |
| 188 | issues: [], | |
| 189 | }, | |
| 190 | }, | |
| 102 | 191 | }), |
| 103 | 192 | ); |
| 104 | 193 | |
| 105 | expect(listener).toHaveBeenCalledTimes(1); | |
| 106 | expect(listener).toHaveBeenCalledWith({ | |
| 107 | type: "push", | |
| 108 | sequence: 1, | |
| 109 | channel: WS_CHANNELS.serverConfigUpdated, | |
| 110 | data: { issues: [] }, | |
| 194 | await expect(requestPromise).resolves.toEqual({ | |
| 195 | keybindings: [], | |
| 196 | issues: [], | |
| 111 | 197 | }); |
| 112 | 198 | |
| 113 | transport.dispose(); | |
| 199 | await transport.dispose(); | |
| 114 | 200 | }); |
| 115 | 201 | |
| 116 | it("resolves pending requests for valid response envelopes", async () => { | |
| 202 | it("delivers stream chunks to subscribers", async () => { | |
| 117 | 203 | const transport = new WsTransport("ws://localhost:3020"); |
| 204 | const listener = vi.fn(); | |
| 205 | ||
| 206 | const unsubscribe = transport.subscribe( | |
| 207 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | |
| 208 | listener, | |
| 209 | ); | |
| 210 | await waitFor(() => { | |
| 211 | expect(sockets).toHaveLength(1); | |
| 212 | }); | |
| 213 | ||
| 118 | 214 | const socket = getSocket(); |
| 119 | 215 | socket.open(); |
| 120 | 216 | |
| 121 | const requestPromise = transport.request("projects.list"); | |
| 122 | const sent = socket.sent.at(-1); | |
| 123 | if (!sent) { | |
| 124 | throw new Error("Expected request envelope to be sent"); | |
| 125 | } | |
| 217 | await waitFor(() => { | |
| 218 | expect(socket.sent).toHaveLength(1); | |
| 219 | }); | |
| 220 | ||
| 221 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string }; | |
| 222 | expect(requestMessage.tag).toBe(WS_METHODS.subscribeServerLifecycle); | |
| 223 | ||
| 224 | const welcomeEvent = { | |
| 225 | version: 1, | |
| 226 | sequence: 1, | |
| 227 | type: "welcome", | |
| 228 | payload: { | |
| 229 | cwd: "/tmp/workspace", | |
| 230 | projectName: "workspace", | |
| 231 | }, | |
| 232 | }; | |
| 126 | 233 | |
| 127 | const requestEnvelope = JSON.parse(sent) as { id: string }; | |
| 128 | 234 | socket.serverMessage( |
| 129 | 235 | JSON.stringify({ |
| 130 | id: requestEnvelope.id, | |
| 131 | result: { projects: [] }, | |
| 236 | _tag: "Chunk", | |
| 237 | requestId: requestMessage.id, | |
| 238 | values: [welcomeEvent], | |
| 132 | 239 | }), |
| 133 | 240 | ); |
| 134 | 241 | |
| 135 | await expect(requestPromise).resolves.toEqual({ projects: [] }); | |
| 242 | await waitFor(() => { | |
| 243 | expect(listener).toHaveBeenCalledWith(welcomeEvent); | |
| 244 | }); | |
| 136 | 245 | |
| 137 | transport.dispose(); | |
| 246 | unsubscribe(); | |
| 247 | await transport.dispose(); | |
| 138 | 248 | }); |
| 139 | 249 | |
| 140 | it("drops malformed envelopes without crashing transport", () => { | |
| 141 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | |
| 250 | it("re-subscribes stream listeners after the stream exits", async () => { | |
| 142 | 251 | const transport = new WsTransport("ws://localhost:3020"); |
| 252 | const listener = vi.fn(); | |
| 253 | ||
| 254 | const unsubscribe = transport.subscribe( | |
| 255 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | |
| 256 | listener, | |
| 257 | ); | |
| 258 | await waitFor(() => { | |
| 259 | expect(sockets).toHaveLength(1); | |
| 260 | }); | |
| 261 | ||
| 143 | 262 | const socket = getSocket(); |
| 144 | 263 | socket.open(); |
| 145 | 264 | |
| 146 | const listener = vi.fn(); | |
| 147 | transport.subscribe(WS_CHANNELS.serverConfigUpdated, listener); | |
| 265 | await waitFor(() => { | |
| 266 | expect(socket.sent).toHaveLength(1); | |
| 267 | }); | |
| 148 | 268 | |
| 149 | socket.serverMessage("{ invalid-json"); | |
| 269 | const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | |
| 150 | 270 | socket.serverMessage( |
| 151 | 271 | JSON.stringify({ |
| 152 | type: "push", | |
| 153 | sequence: 2, | |
| 154 | channel: 42, | |
| 155 | data: { bad: true }, | |
| 272 | _tag: "Chunk", | |
| 273 | requestId: firstRequest.id, | |
| 274 | values: [ | |
| 275 | { | |
| 276 | version: 1, | |
| 277 | sequence: 1, | |
| 278 | type: "welcome", | |
| 279 | payload: { | |
| 280 | cwd: "/tmp/one", | |
| 281 | projectName: "one", | |
| 282 | }, | |
| 283 | }, | |
| 284 | ], | |
| 156 | 285 | }), |
| 157 | 286 | ); |
| 158 | 287 | socket.serverMessage( |
| 159 | 288 | JSON.stringify({ |
| 160 | type: "push", | |
| 161 | sequence: 3, | |
| 162 | channel: WS_CHANNELS.serverConfigUpdated, | |
| 163 | data: { issues: [] }, | |
| 289 | _tag: "Exit", | |
| 290 | requestId: firstRequest.id, | |
| 291 | exit: { | |
| 292 | _tag: "Success", | |
| 293 | value: null, | |
| 294 | }, | |
| 164 | 295 | }), |
| 165 | 296 | ); |
| 166 | 297 | |
| 167 | expect(listener).toHaveBeenCalledTimes(1); | |
| 168 | expect(listener).toHaveBeenCalledWith({ | |
| 169 | type: "push", | |
| 170 | sequence: 3, | |
| 171 | channel: WS_CHANNELS.serverConfigUpdated, | |
| 172 | data: { issues: [] }, | |
| 298 | await waitFor(() => { | |
| 299 | const nextRequest = socket.sent | |
| 300 | .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) | |
| 301 | .find((message) => message._tag === "Request" && message.id !== firstRequest.id); | |
| 302 | expect(nextRequest).toBeDefined(); | |
| 173 | 303 | }); |
| 174 | expect(warnSpy).toHaveBeenCalledTimes(2); | |
| 175 | expect(warnSpy).toHaveBeenNthCalledWith( | |
| 176 | 1, | |
| 177 | "Dropped inbound WebSocket envelope", | |
| 178 | expect.stringMatching(/^SyntaxError:/), | |
| 179 | ); | |
| 180 | expect(warnSpy).toHaveBeenNthCalledWith( | |
| 181 | 2, | |
| 182 | "Dropped inbound WebSocket envelope", | |
| 183 | expect.stringContaining('Expected "server.configUpdated"'), | |
| 184 | ); | |
| 185 | ||
| 186 | transport.dispose(); | |
| 187 | }); | |
| 188 | 304 | |
| 189 | it("queues requests until the websocket opens", async () => { | |
| 190 | const transport = new WsTransport("ws://localhost:3020"); | |
| 191 | const socket = getSocket(); | |
| 192 | ||
| 193 | const requestPromise = transport.request("projects.list"); | |
| 194 | expect(socket.sent).toHaveLength(0); | |
| 195 | ||
| 196 | socket.open(); | |
| 197 | expect(socket.sent).toHaveLength(1); | |
| 198 | const requestEnvelope = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | |
| 305 | const secondRequest = socket.sent | |
| 306 | .map((message) => JSON.parse(message) as { _tag?: string; id?: string; tag?: string }) | |
| 307 | .find( | |
| 308 | (message): message is { _tag: "Request"; id: string; tag: string } => | |
| 309 | message._tag === "Request" && message.id !== firstRequest.id, | |
| 310 | ); | |
| 311 | if (!secondRequest) { | |
| 312 | throw new Error("Expected a resubscribe request"); | |
| 313 | } | |
| 314 | expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle); | |
| 315 | expect(secondRequest.id).not.toBe(firstRequest.id); | |
| 316 | ||
| 317 | const secondEvent = { | |
| 318 | version: 1, | |
| 319 | sequence: 2, | |
| 320 | type: "welcome", | |
| 321 | payload: { | |
| 322 | cwd: "/tmp/two", | |
| 323 | projectName: "two", | |
| 324 | }, | |
| 325 | }; | |
| 199 | 326 | socket.serverMessage( |
| 200 | 327 | JSON.stringify({ |
| 201 | id: requestEnvelope.id, | |
| 202 | result: { projects: [] }, | |
| 328 | _tag: "Chunk", | |
| 329 | requestId: secondRequest.id, | |
| 330 | values: [secondEvent], | |
| 203 | 331 | }), |
| 204 | 332 | ); |
| 205 | 333 | |
| 206 | await expect(requestPromise).resolves.toEqual({ projects: [] }); | |
| 207 | transport.dispose(); | |
| 334 | await waitFor(() => { | |
| 335 | expect(listener).toHaveBeenLastCalledWith(secondEvent); | |
| 336 | }); | |
| 337 | ||
| 338 | unsubscribe(); | |
| 339 | await transport.dispose(); | |
| 208 | 340 | }); |
| 209 | 341 | |
| 210 | it("does not create a timeout for requests with timeoutMs null", async () => { | |
| 211 | const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); | |
| 342 | it("streams finite request events without re-subscribing", async () => { | |
| 212 | 343 | const transport = new WsTransport("ws://localhost:3020"); |
| 344 | const listener = vi.fn(); | |
| 345 | ||
| 346 | await waitFor(() => { | |
| 347 | expect(sockets).toHaveLength(1); | |
| 348 | }); | |
| 213 | 349 | const socket = getSocket(); |
| 214 | 350 | socket.open(); |
| 215 | 351 | |
| 216 | const requestPromise = transport.request( | |
| 217 | "git.runStackedAction", | |
| 218 | { cwd: "/repo" }, | |
| 219 | { timeoutMs: null }, | |
| 352 | const requestPromise = transport.requestStream( | |
| 353 | (client) => | |
| 354 | client[WS_METHODS.gitRunStackedAction]({ | |
| 355 | actionId: "action-1", | |
| 356 | cwd: "/repo", | |
| 357 | action: "commit", | |
| 358 | }), | |
| 359 | listener, | |
| 220 | 360 | ); |
| 221 | const sent = socket.sent.at(-1); | |
| 222 | if (!sent) { | |
| 223 | throw new Error("Expected request envelope to be sent"); | |
| 224 | } | |
| 225 | const requestEnvelope = JSON.parse(sent) as { id: string }; | |
| 361 | ||
| 362 | await waitFor(() => { | |
| 363 | expect(socket.sent).toHaveLength(1); | |
| 364 | }); | |
| 365 | ||
| 366 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | |
| 367 | const progressEvent = { | |
| 368 | actionId: "action-1", | |
| 369 | cwd: "/repo", | |
| 370 | action: "commit", | |
| 371 | kind: "phase_started", | |
| 372 | phase: "commit", | |
| 373 | label: "Committing...", | |
| 374 | } as const; | |
| 226 | 375 | |
| 227 | 376 | socket.serverMessage( |
| 228 | 377 | JSON.stringify({ |
| 229 | id: requestEnvelope.id, | |
| 230 | result: { ok: true }, | |
| 378 | _tag: "Chunk", | |
| 379 | requestId: requestMessage.id, | |
| 380 | values: [progressEvent], | |
| 381 | }), | |
| 382 | ); | |
| 383 | socket.serverMessage( | |
| 384 | JSON.stringify({ | |
| 385 | _tag: "Exit", | |
| 386 | requestId: requestMessage.id, | |
| 387 | exit: { | |
| 388 | _tag: "Success", | |
| 389 | value: null, | |
| 390 | }, | |
| 231 | 391 | }), |
| 232 | 392 | ); |
| 233 | 393 | |
| 234 | await expect(requestPromise).resolves.toEqual({ ok: true }); | |
| 235 | expect(timeoutSpy.mock.calls.some(([callback]) => typeof callback === "function")).toBe(false); | |
| 236 | ||
| 237 | transport.dispose(); | |
| 394 | await expect(requestPromise).resolves.toBeUndefined(); | |
| 395 | expect(listener).toHaveBeenCalledWith(progressEvent); | |
| 396 | expect( | |
| 397 | socket.sent.filter((message) => { | |
| 398 | const parsed = JSON.parse(message) as { _tag?: string; tag?: string }; | |
| 399 | return parsed._tag === "Request" && parsed.tag === WS_METHODS.gitRunStackedAction; | |
| 400 | }), | |
| 401 | ).toHaveLength(1); | |
| 402 | await transport.dispose(); | |
| 238 | 403 | }); |
| 239 | 404 | |
| 240 | it("rejects pending requests when the websocket closes", async () => { | |
| 241 | const transport = new WsTransport("ws://localhost:3020"); | |
| 242 | const socket = getSocket(); | |
| 243 | socket.open(); | |
| 405 | it("closes the client scope on the transport runtime before disposing the runtime", async () => { | |
| 406 | const callOrder: string[] = []; | |
| 407 | let resolveClose!: () => void; | |
| 408 | const closePromise = new Promise<void>((resolve) => { | |
| 409 | resolveClose = resolve; | |
| 410 | }); | |
| 244 | 411 | |
| 245 | const requestPromise = transport.request( | |
| 246 | "git.runStackedAction", | |
| 247 | { cwd: "/repo" }, | |
| 248 | { timeoutMs: null }, | |
| 249 | ); | |
| 412 | const runtime = { | |
| 413 | runPromise: vi.fn(async () => { | |
| 414 | callOrder.push("close:start"); | |
| 415 | await closePromise; | |
| 416 | callOrder.push("close:done"); | |
| 417 | return undefined; | |
| 418 | }), | |
| 419 | dispose: vi.fn(async () => { | |
| 420 | callOrder.push("runtime:dispose"); | |
| 421 | }), | |
| 422 | }; | |
| 423 | const transport = { | |
| 424 | disposed: false, | |
| 425 | clientScope: {} as never, | |
| 426 | runtime, | |
| 427 | } as unknown as WsTransport; | |
| 428 | ||
| 429 | WsTransport.prototype.dispose.call(transport); | |
| 430 | ||
| 431 | expect(runtime.runPromise).toHaveBeenCalledTimes(1); | |
| 432 | expect(runtime.dispose).not.toHaveBeenCalled(); | |
| 433 | expect((transport as unknown as { disposed: boolean }).disposed).toBe(true); | |
| 250 | 434 | |
| 251 | socket.close(); | |
| 435 | resolveClose(); | |
| 436 | ||
| 437 | await waitFor(() => { | |
| 438 | expect(runtime.dispose).toHaveBeenCalledTimes(1); | |
| 439 | }); | |
| 252 | 440 | |
| 253 | await expect(requestPromise).rejects.toThrow("WebSocket connection closed."); | |
| 254 | transport.dispose(); | |
| 441 | expect(callOrder).toEqual(["close:start", "close:done", "runtime:dispose"]); | |
| 255 | 442 | }); |
| 256 | 443 | }); |
apps/web/src/wsTransport.ts+93-271| ... | ... | @@ -1,309 +1,131 @@ |
| 1 | import { | |
| 2 | type WsPush, | |
| 3 | type WsPushChannel, | |
| 4 | type WsPushMessage, | |
| 5 | WebSocketResponse, | |
| 6 | type WsResponse as WsResponseMessage, | |
| 7 | WsResponse as WsResponseSchema, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { decodeUnknownJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; | |
| 10 | import { Result, Schema } from "effect"; | |
| 11 | ||
| 12 | type PushListener<C extends WsPushChannel> = (message: WsPushMessage<C>) => void; | |
| 1 | import { Duration, Effect, Exit, ManagedRuntime, Option, Scope, Stream } from "effect"; | |
| 13 | 2 | |
| 14 | interface PendingRequest { | |
| 15 | resolve: (result: unknown) => void; | |
| 16 | reject: (error: Error) => void; | |
| 17 | timeout: ReturnType<typeof setTimeout> | null; | |
| 18 | } | |
| 3 | import { | |
| 4 | createWsRpcProtocolLayer, | |
| 5 | makeWsRpcProtocolClient, | |
| 6 | type WsRpcProtocolClient, | |
| 7 | } from "./rpc/protocol"; | |
| 8 | import { RpcClient } from "effect/unstable/rpc"; | |
| 19 | 9 | |
| 20 | 10 | interface SubscribeOptions { |
| 21 | readonly replayLatest?: boolean; | |
| 11 | readonly retryDelay?: Duration.Input; | |
| 22 | 12 | } |
| 23 | 13 | |
| 24 | 14 | interface RequestOptions { |
| 25 | readonly timeoutMs?: number | null; | |
| 15 | readonly timeout?: Option.Option<Duration.Input>; | |
| 26 | 16 | } |
| 27 | 17 | |
| 28 | type TransportState = "connecting" | "open" | "reconnecting" | "closed" | "disposed"; | |
| 29 | ||
| 30 | const REQUEST_TIMEOUT_MS = 60_000; | |
| 31 | const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000]; | |
| 32 | const decodeWsResponse = decodeUnknownJsonResult(WsResponseSchema); | |
| 33 | const isWebSocketResponseEnvelope = Schema.is(WebSocketResponse); | |
| 34 | ||
| 35 | const isWsPushMessage = (value: WsResponseMessage): value is WsPush => | |
| 36 | "type" in value && value.type === "push"; | |
| 18 | const DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS = Duration.millis(250); | |
| 37 | 19 | |
| 38 | interface WsRequestEnvelope { | |
| 39 | id: string; | |
| 40 | body: { | |
| 41 | _tag: string; | |
| 42 | [key: string]: unknown; | |
| 43 | }; | |
| 44 | } | |
| 45 | ||
| 46 | function asError(value: unknown, fallback: string): Error { | |
| 47 | if (value instanceof Error) { | |
| 48 | return value; | |
| 20 | function formatErrorMessage(error: unknown): string { | |
| 21 | if (error instanceof Error && error.message.trim().length > 0) { | |
| 22 | return error.message; | |
| 49 | 23 | } |
| 50 | return new Error(fallback); | |
| 24 | return String(error); | |
| 51 | 25 | } |
| 52 | 26 | |
| 53 | 27 | export class WsTransport { |
| 54 | private ws: WebSocket | null = null; | |
| 55 | private nextId = 1; | |
| 56 | private readonly pending = new Map<string, PendingRequest>(); | |
| 57 | private readonly listeners = new Map<string, Set<(message: WsPush) => void>>(); | |
| 58 | private readonly latestPushByChannel = new Map<string, WsPush>(); | |
| 59 | private readonly outboundQueue: string[] = []; | |
| 60 | private reconnectAttempt = 0; | |
| 61 | private reconnectTimer: ReturnType<typeof setTimeout> | null = null; | |
| 28 | private readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>; | |
| 29 | private readonly clientScope: Scope.Closeable; | |
| 30 | private readonly clientPromise: Promise<WsRpcProtocolClient>; | |
| 62 | 31 | private disposed = false; |
| 63 | private state: TransportState = "connecting"; | |
| 64 | private readonly url: string; | |
| 65 | 32 | |
| 66 | 33 | constructor(url?: string) { |
| 67 | const bridgeUrl = window.desktopBridge?.getWsUrl(); | |
| 68 | const envUrl = import.meta.env.VITE_WS_URL as string | undefined; | |
| 69 | this.url = | |
| 70 | url ?? | |
| 71 | (bridgeUrl && bridgeUrl.length > 0 | |
| 72 | ? bridgeUrl | |
| 73 | : envUrl && envUrl.length > 0 | |
| 74 | ? envUrl | |
| 75 | : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`); | |
| 76 | this.connect(); | |
| 34 | this.runtime = ManagedRuntime.make(createWsRpcProtocolLayer(url)); | |
| 35 | this.clientScope = this.runtime.runSync(Scope.make()); | |
| 36 | this.clientPromise = this.runtime.runPromise( | |
| 37 | Scope.provide(this.clientScope)(makeWsRpcProtocolClient), | |
| 38 | ); | |
| 77 | 39 | } |
| 78 | 40 | |
| 79 | async request<T = unknown>( | |
| 80 | method: string, | |
| 81 | params?: unknown, | |
| 82 | options?: RequestOptions, | |
| 83 | ): Promise<T> { | |
| 84 | if (typeof method !== "string" || method.length === 0) { | |
| 85 | throw new Error("Request method is required"); | |
| 86 | } | |
| 87 | ||
| 88 | const id = String(this.nextId++); | |
| 89 | const body = params != null ? { ...params, _tag: method } : { _tag: method }; | |
| 90 | const message: WsRequestEnvelope = { id, body }; | |
| 91 | const encoded = JSON.stringify(message); | |
| 92 | ||
| 93 | return new Promise<T>((resolve, reject) => { | |
| 94 | const timeoutMs = options?.timeoutMs === undefined ? REQUEST_TIMEOUT_MS : options.timeoutMs; | |
| 95 | const timeout = | |
| 96 | timeoutMs === null | |
| 97 | ? null | |
| 98 | : setTimeout(() => { | |
| 99 | this.pending.delete(id); | |
| 100 | reject(new Error(`Request timed out: ${method}`)); | |
| 101 | }, timeoutMs); | |
| 102 | ||
| 103 | this.pending.set(id, { | |
| 104 | resolve: resolve as (result: unknown) => void, | |
| 105 | reject, | |
| 106 | timeout, | |
| 107 | }); | |
| 108 | ||
| 109 | this.send(encoded); | |
| 110 | }); | |
| 111 | } | |
| 112 | ||
| 113 | subscribe<C extends WsPushChannel>( | |
| 114 | channel: C, | |
| 115 | listener: PushListener<C>, | |
| 116 | options?: SubscribeOptions, | |
| 117 | ): () => void { | |
| 118 | let channelListeners = this.listeners.get(channel); | |
| 119 | if (!channelListeners) { | |
| 120 | channelListeners = new Set<(message: WsPush) => void>(); | |
| 121 | this.listeners.set(channel, channelListeners); | |
| 122 | } | |
| 123 | ||
| 124 | const wrappedListener = (message: WsPush) => { | |
| 125 | listener(message as WsPushMessage<C>); | |
| 126 | }; | |
| 127 | channelListeners.add(wrappedListener); | |
| 128 | ||
| 129 | if (options?.replayLatest) { | |
| 130 | const latest = this.latestPushByChannel.get(channel); | |
| 131 | if (latest) { | |
| 132 | wrappedListener(latest); | |
| 133 | } | |
| 134 | } | |
| 135 | ||
| 136 | return () => { | |
| 137 | channelListeners?.delete(wrappedListener); | |
| 138 | if (channelListeners?.size === 0) { | |
| 139 | this.listeners.delete(channel); | |
| 140 | } | |
| 141 | }; | |
| 142 | } | |
| 143 | ||
| 144 | getLatestPush<C extends WsPushChannel>(channel: C): WsPushMessage<C> | null { | |
| 145 | const latest = this.latestPushByChannel.get(channel); | |
| 146 | return latest ? (latest as WsPushMessage<C>) : null; | |
| 147 | } | |
| 148 | ||
| 149 | getState(): TransportState { | |
| 150 | return this.state; | |
| 151 | } | |
| 152 | ||
| 153 | dispose() { | |
| 154 | this.disposed = true; | |
| 155 | this.state = "disposed"; | |
| 156 | if (this.reconnectTimer !== null) { | |
| 157 | clearTimeout(this.reconnectTimer); | |
| 158 | this.reconnectTimer = null; | |
| 159 | } | |
| 160 | for (const pending of this.pending.values()) { | |
| 161 | if (pending.timeout !== null) { | |
| 162 | clearTimeout(pending.timeout); | |
| 163 | } | |
| 164 | pending.reject(new Error("Transport disposed")); | |
| 165 | } | |
| 166 | this.pending.clear(); | |
| 167 | this.outboundQueue.length = 0; | |
| 168 | this.ws?.close(); | |
| 169 | this.ws = null; | |
| 170 | } | |
| 171 | ||
| 172 | private connect() { | |
| 41 | async request<TSuccess>( | |
| 42 | execute: (client: WsRpcProtocolClient) => Effect.Effect<TSuccess, Error, never>, | |
| 43 | _options?: RequestOptions, | |
| 44 | ): Promise<TSuccess> { | |
| 173 | 45 | if (this.disposed) { |
| 174 | return; | |
| 46 | throw new Error("Transport disposed"); | |
| 175 | 47 | } |
| 176 | 48 | |
| 177 | this.state = this.reconnectAttempt > 0 ? "reconnecting" : "connecting"; | |
| 178 | const ws = new WebSocket(this.url); | |
| 179 | ||
| 180 | ws.addEventListener("open", () => { | |
| 181 | this.ws = ws; | |
| 182 | this.state = "open"; | |
| 183 | this.reconnectAttempt = 0; | |
| 184 | this.flushQueue(); | |
| 185 | }); | |
| 186 | ||
| 187 | ws.addEventListener("message", (event) => { | |
| 188 | this.handleMessage(event.data); | |
| 189 | }); | |
| 190 | ||
| 191 | ws.addEventListener("close", () => { | |
| 192 | if (this.ws === ws) { | |
| 193 | this.ws = null; | |
| 194 | this.outboundQueue.length = 0; | |
| 195 | for (const [id, pending] of this.pending.entries()) { | |
| 196 | if (pending.timeout !== null) { | |
| 197 | clearTimeout(pending.timeout); | |
| 198 | } | |
| 199 | this.pending.delete(id); | |
| 200 | pending.reject(new Error("WebSocket connection closed.")); | |
| 201 | } | |
| 202 | } | |
| 203 | if (this.disposed) { | |
| 204 | this.state = "disposed"; | |
| 205 | return; | |
| 206 | } | |
| 207 | this.state = "closed"; | |
| 208 | this.scheduleReconnect(); | |
| 209 | }); | |
| 210 | ||
| 211 | ws.addEventListener("error", (event) => { | |
| 212 | // Log WebSocket errors for debugging (close event will follow) | |
| 213 | console.warn("WebSocket connection error", { type: event.type, url: this.url }); | |
| 214 | }); | |
| 49 | const client = await this.clientPromise; | |
| 50 | return await this.runtime.runPromise(Effect.suspend(() => execute(client))); | |
| 215 | 51 | } |
| 216 | 52 | |
| 217 | private handleMessage(raw: unknown) { | |
| 218 | const result = decodeWsResponse(raw); | |
| 219 | if (Result.isFailure(result)) { | |
| 220 | console.warn("Dropped inbound WebSocket envelope", formatSchemaError(result.failure)); | |
| 221 | return; | |
| 53 | async requestStream<TValue>( | |
| 54 | connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>, | |
| 55 | listener: (value: TValue) => void, | |
| 56 | ): Promise<void> { | |
| 57 | if (this.disposed) { | |
| 58 | throw new Error("Transport disposed"); | |
| 222 | 59 | } |
| 223 | 60 | |
| 224 | const message = result.success; | |
| 225 | if (isWsPushMessage(message)) { | |
| 226 | this.latestPushByChannel.set(message.channel, message); | |
| 227 | const channelListeners = this.listeners.get(message.channel); | |
| 228 | if (channelListeners) { | |
| 229 | for (const listener of channelListeners) { | |
| 61 | const client = await this.clientPromise; | |
| 62 | await this.runtime.runPromise( | |
| 63 | Stream.runForEach(connect(client), (value) => | |
| 64 | Effect.sync(() => { | |
| 230 | 65 | try { |
| 231 | listener(message); | |
| 66 | listener(value); | |
| 232 | 67 | } catch { |
| 233 | // Swallow listener errors | |
| 68 | // Swallow listener errors so the stream can finish cleanly. | |
| 234 | 69 | } |
| 235 | } | |
| 236 | } | |
| 237 | return; | |
| 238 | } | |
| 239 | ||
| 240 | if (!isWebSocketResponseEnvelope(message)) { | |
| 241 | return; | |
| 242 | } | |
| 243 | ||
| 244 | const pending = this.pending.get(message.id); | |
| 245 | if (!pending) { | |
| 246 | return; | |
| 247 | } | |
| 248 | ||
| 249 | if (pending.timeout !== null) { | |
| 250 | clearTimeout(pending.timeout); | |
| 251 | } | |
| 252 | this.pending.delete(message.id); | |
| 253 | ||
| 254 | if (message.error) { | |
| 255 | pending.reject(new Error(message.error.message)); | |
| 256 | return; | |
| 257 | } | |
| 258 | ||
| 259 | pending.resolve(message.result); | |
| 70 | }), | |
| 71 | ), | |
| 72 | ); | |
| 260 | 73 | } |
| 261 | 74 | |
| 262 | private send(encodedMessage: string) { | |
| 75 | subscribe<TValue>( | |
| 76 | connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>, | |
| 77 | listener: (value: TValue) => void, | |
| 78 | options?: SubscribeOptions, | |
| 79 | ): () => void { | |
| 263 | 80 | if (this.disposed) { |
| 264 | return; | |
| 265 | } | |
| 266 | ||
| 267 | this.outboundQueue.push(encodedMessage); | |
| 268 | try { | |
| 269 | this.flushQueue(); | |
| 270 | } catch { | |
| 271 | // Swallow: flushQueue has queued the message for retry on reconnect | |
| 272 | } | |
| 273 | } | |
| 274 | ||
| 275 | private flushQueue() { | |
| 276 | if (this.ws?.readyState !== WebSocket.OPEN) { | |
| 277 | return; | |
| 278 | } | |
| 81 | return () => undefined; | |
| 82 | } | |
| 83 | ||
| 84 | let active = true; | |
| 85 | const retryDelayMs = options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS; | |
| 86 | const cancel = this.runtime.runCallback( | |
| 87 | Effect.promise(() => this.clientPromise).pipe( | |
| 88 | Effect.flatMap((client) => | |
| 89 | Stream.runForEach(connect(client), (value) => | |
| 90 | Effect.sync(() => { | |
| 91 | if (!active) { | |
| 92 | return; | |
| 93 | } | |
| 94 | try { | |
| 95 | listener(value); | |
| 96 | } catch { | |
| 97 | // Swallow listener errors so the stream stays live. | |
| 98 | } | |
| 99 | }), | |
| 100 | ), | |
| 101 | ), | |
| 102 | Effect.catch((error) => { | |
| 103 | if (!active || this.disposed) { | |
| 104 | return Effect.interrupt; | |
| 105 | } | |
| 106 | return Effect.sync(() => { | |
| 107 | console.warn("WebSocket RPC subscription disconnected", { | |
| 108 | error: formatErrorMessage(error), | |
| 109 | }); | |
| 110 | }).pipe(Effect.andThen(Effect.sleep(retryDelayMs))); | |
| 111 | }), | |
| 112 | Effect.forever, | |
| 113 | ), | |
| 114 | ); | |
| 279 | 115 | |
| 280 | while (this.outboundQueue.length > 0) { | |
| 281 | const message = this.outboundQueue.shift(); | |
| 282 | if (!message) { | |
| 283 | continue; | |
| 284 | } | |
| 285 | try { | |
| 286 | this.ws.send(message); | |
| 287 | } catch (error) { | |
| 288 | this.outboundQueue.unshift(message); | |
| 289 | throw asError(error, "Failed to send WebSocket request."); | |
| 290 | } | |
| 291 | } | |
| 116 | return () => { | |
| 117 | active = false; | |
| 118 | cancel(); | |
| 119 | }; | |
| 292 | 120 | } |
| 293 | 121 | |
| 294 | private scheduleReconnect() { | |
| 295 | if (this.disposed || this.reconnectTimer !== null) { | |
| 122 | async dispose() { | |
| 123 | if (this.disposed) { | |
| 296 | 124 | return; |
| 297 | 125 | } |
| 298 | ||
| 299 | const delay = | |
| 300 | RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] ?? | |
| 301 | RECONNECT_DELAYS_MS[0]!; | |
| 302 | ||
| 303 | this.reconnectAttempt += 1; | |
| 304 | this.reconnectTimer = setTimeout(() => { | |
| 305 | this.reconnectTimer = null; | |
| 306 | this.connect(); | |
| 307 | }, delay); | |
| 126 | this.disposed = true; | |
| 127 | await this.runtime.runPromise(Scope.close(this.clientScope, Exit.void)).finally(() => { | |
| 128 | this.runtime.dispose(); | |
| 129 | }); | |
| 308 | 130 | } |
| 309 | 131 | } |
apps/web/test/wsRpcHarness.ts created+169| ... | ... | @@ -0,0 +1,169 @@ |
| 1 | import { Effect, Exit, PubSub, Scope, Stream } from "effect"; | |
| 2 | import { WS_METHODS, WsRpcGroup } from "@t3tools/contracts"; | |
| 3 | import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; | |
| 4 | ||
| 5 | type RpcServerInstance = RpcServer.RpcServer<any>; | |
| 6 | ||
| 7 | type BrowserWsClient = { | |
| 8 | send: (data: string) => void; | |
| 9 | }; | |
| 10 | ||
| 11 | export type NormalizedWsRpcRequestBody = { | |
| 12 | _tag: string; | |
| 13 | [key: string]: unknown; | |
| 14 | }; | |
| 15 | ||
| 16 | type UnaryResolverResult = unknown | Promise<unknown>; | |
| 17 | ||
| 18 | interface BrowserWsRpcHarnessOptions { | |
| 19 | readonly resolveUnary?: (request: NormalizedWsRpcRequestBody) => UnaryResolverResult; | |
| 20 | readonly getInitialStreamValues?: ( | |
| 21 | request: NormalizedWsRpcRequestBody, | |
| 22 | ) => ReadonlyArray<unknown> | undefined; | |
| 23 | } | |
| 24 | ||
| 25 | const STREAM_METHODS = new Set<string>([ | |
| 26 | WS_METHODS.gitRunStackedAction, | |
| 27 | WS_METHODS.subscribeOrchestrationDomainEvents, | |
| 28 | WS_METHODS.subscribeTerminalEvents, | |
| 29 | WS_METHODS.subscribeServerConfig, | |
| 30 | WS_METHODS.subscribeServerLifecycle, | |
| 31 | ]); | |
| 32 | ||
| 33 | const ALL_RPC_METHODS = Array.from(WsRpcGroup.requests.keys()); | |
| 34 | ||
| 35 | function normalizeRequest(tag: string, payload: unknown): NormalizedWsRpcRequestBody { | |
| 36 | if (payload && typeof payload === "object" && !Array.isArray(payload)) { | |
| 37 | return { | |
| 38 | _tag: tag, | |
| 39 | ...(payload as Record<string, unknown>), | |
| 40 | }; | |
| 41 | } | |
| 42 | return { _tag: tag, payload }; | |
| 43 | } | |
| 44 | ||
| 45 | function asEffect(result: UnaryResolverResult): Effect.Effect<unknown> { | |
| 46 | if (result instanceof Promise) { | |
| 47 | return Effect.promise(() => result); | |
| 48 | } | |
| 49 | return Effect.succeed(result); | |
| 50 | } | |
| 51 | ||
| 52 | export class BrowserWsRpcHarness { | |
| 53 | readonly requests: Array<NormalizedWsRpcRequestBody> = []; | |
| 54 | ||
| 55 | private readonly parser = RpcSerialization.json.makeUnsafe(); | |
| 56 | private client: BrowserWsClient | null = null; | |
| 57 | private scope: Scope.Closeable | null = null; | |
| 58 | private serverReady: Promise<RpcServerInstance> | null = null; | |
| 59 | private resolveUnary: NonNullable<BrowserWsRpcHarnessOptions["resolveUnary"]> = () => ({}); | |
| 60 | private getInitialStreamValues: NonNullable< | |
| 61 | BrowserWsRpcHarnessOptions["getInitialStreamValues"] | |
| 62 | > = () => []; | |
| 63 | private streamPubSubs = new Map<string, PubSub.PubSub<unknown>>(); | |
| 64 | ||
| 65 | async reset(options?: BrowserWsRpcHarnessOptions): Promise<void> { | |
| 66 | await this.disconnect(); | |
| 67 | this.requests.length = 0; | |
| 68 | this.resolveUnary = options?.resolveUnary ?? (() => ({})); | |
| 69 | this.getInitialStreamValues = options?.getInitialStreamValues ?? (() => []); | |
| 70 | this.initializeStreamPubSubs(); | |
| 71 | } | |
| 72 | ||
| 73 | connect(client: BrowserWsClient): void { | |
| 74 | if (this.scope) { | |
| 75 | void Effect.runPromise(Scope.close(this.scope, Exit.void)).catch(() => undefined); | |
| 76 | } | |
| 77 | if (this.streamPubSubs.size === 0) { | |
| 78 | this.initializeStreamPubSubs(); | |
| 79 | } | |
| 80 | this.client = client; | |
| 81 | this.scope = Effect.runSync(Scope.make()); | |
| 82 | this.serverReady = Effect.runPromise( | |
| 83 | Scope.provide(this.scope)( | |
| 84 | RpcServer.makeNoSerialization(WsRpcGroup, this.makeServerOptions()), | |
| 85 | ).pipe(Effect.provide(this.makeLayer())), | |
| 86 | ) as Promise<RpcServerInstance>; | |
| 87 | } | |
| 88 | ||
| 89 | async disconnect(): Promise<void> { | |
| 90 | if (this.scope) { | |
| 91 | await Effect.runPromise(Scope.close(this.scope, Exit.void)).catch(() => undefined); | |
| 92 | this.scope = null; | |
| 93 | } | |
| 94 | for (const pubsub of this.streamPubSubs.values()) { | |
| 95 | Effect.runSync(PubSub.shutdown(pubsub)); | |
| 96 | } | |
| 97 | this.streamPubSubs.clear(); | |
| 98 | this.serverReady = null; | |
| 99 | this.client = null; | |
| 100 | } | |
| 101 | ||
| 102 | private initializeStreamPubSubs(): void { | |
| 103 | this.streamPubSubs = new Map( | |
| 104 | Array.from(STREAM_METHODS, (method) => [method, Effect.runSync(PubSub.unbounded<unknown>())]), | |
| 105 | ); | |
| 106 | } | |
| 107 | ||
| 108 | async onMessage(rawData: string): Promise<void> { | |
| 109 | const server = await this.serverReady; | |
| 110 | if (!server) { | |
| 111 | throw new Error("RPC test server is not connected"); | |
| 112 | } | |
| 113 | const messages = this.parser.decode(rawData); | |
| 114 | for (const message of messages) { | |
| 115 | await Effect.runPromise(server.write(0, message as never)); | |
| 116 | } | |
| 117 | } | |
| 118 | ||
| 119 | emitStreamValue(method: string, value: unknown): void { | |
| 120 | const pubsub = this.streamPubSubs.get(method); | |
| 121 | if (!pubsub) { | |
| 122 | throw new Error(`No stream registered for ${method}`); | |
| 123 | } | |
| 124 | Effect.runSync(PubSub.publish(pubsub, value)); | |
| 125 | } | |
| 126 | ||
| 127 | private makeLayer() { | |
| 128 | const handlers: Record<string, (payload: unknown) => unknown> = {}; | |
| 129 | for (const method of ALL_RPC_METHODS) { | |
| 130 | handlers[method] = STREAM_METHODS.has(method) | |
| 131 | ? (payload) => this.handleStream(method, payload) | |
| 132 | : (payload) => this.handleUnary(method, payload); | |
| 133 | } | |
| 134 | return WsRpcGroup.toLayer(handlers as never); | |
| 135 | } | |
| 136 | ||
| 137 | private makeServerOptions() { | |
| 138 | return { | |
| 139 | onFromServer: (response: unknown) => | |
| 140 | Effect.sync(() => { | |
| 141 | if (!this.client) { | |
| 142 | return; | |
| 143 | } | |
| 144 | const encoded = this.parser.encode(response); | |
| 145 | if (typeof encoded === "string") { | |
| 146 | this.client.send(encoded); | |
| 147 | } | |
| 148 | }), | |
| 149 | }; | |
| 150 | } | |
| 151 | ||
| 152 | private handleUnary(method: string, payload: unknown) { | |
| 153 | const request = normalizeRequest(method, payload); | |
| 154 | this.requests.push(request); | |
| 155 | return asEffect(this.resolveUnary(request)); | |
| 156 | } | |
| 157 | ||
| 158 | private handleStream(method: string, payload: unknown) { | |
| 159 | const request = normalizeRequest(method, payload); | |
| 160 | this.requests.push(request); | |
| 161 | const pubsub = this.streamPubSubs.get(method); | |
| 162 | if (!pubsub) { | |
| 163 | throw new Error(`No stream registered for ${method}`); | |
| 164 | } | |
| 165 | return Stream.fromIterable(this.getInitialStreamValues(request) ?? []).pipe( | |
| 166 | Stream.concat(Stream.fromPubSub(pubsub)), | |
| 167 | ); | |
| 168 | } | |
| 169 | } |
apps/web/tsconfig.json+1-1| ... | ... | @@ -21,5 +21,5 @@ |
| 21 | 21 | } |
| 22 | 22 | ] |
| 23 | 23 | }, |
| 24 | "include": ["src", "vite.config.ts"] | |
| 24 | "include": ["src", "vite.config.ts", "test"] | |
| 25 | 25 | } |
bun.lock+9-3| ... | ... | @@ -44,17 +44,17 @@ |
| 44 | 44 | "name": "t3", |
| 45 | 45 | "version": "0.0.15", |
| 46 | 46 | "bin": { |
| 47 | "t3": "./dist/index.mjs", | |
| 47 | "t3": "./dist/bin.mjs", | |
| 48 | 48 | }, |
| 49 | 49 | "dependencies": { |
| 50 | 50 | "@anthropic-ai/claude-agent-sdk": "^0.2.77", |
| 51 | "@effect/platform-bun": "catalog:", | |
| 51 | 52 | "@effect/platform-node": "catalog:", |
| 52 | 53 | "@effect/sql-sqlite-bun": "catalog:", |
| 53 | 54 | "@pierre/diffs": "^1.1.0-beta.16", |
| 54 | 55 | "effect": "catalog:", |
| 55 | 56 | "node-pty": "^1.1.0", |
| 56 | 57 | "open": "^10.1.0", |
| 57 | "ws": "^8.18.0", | |
| 58 | 58 | }, |
| 59 | 59 | "devDependencies": { |
| 60 | 60 | "@effect/language-service": "catalog:", |
| ... | ... | @@ -64,7 +64,6 @@ |
| 64 | 64 | "@t3tools/web": "workspace:*", |
| 65 | 65 | "@types/bun": "catalog:", |
| 66 | 66 | "@types/node": "catalog:", |
| 67 | "@types/ws": "^8.5.13", | |
| 68 | 67 | "tsdown": "catalog:", |
| 69 | 68 | "typescript": "catalog:", |
| 70 | 69 | "vitest": "catalog:", |
| ... | ... | @@ -79,6 +78,7 @@ |
| 79 | 78 | "@dnd-kit/modifiers": "^9.0.0", |
| 80 | 79 | "@dnd-kit/sortable": "^10.0.0", |
| 81 | 80 | "@dnd-kit/utilities": "^3.2.2", |
| 81 | "@effect/atom-react": "catalog:", | |
| 82 | 82 | "@formkit/auto-animate": "^0.9.0", |
| 83 | 83 | "@lexical/react": "^0.41.0", |
| 84 | 84 | "@pierre/diffs": "^1.1.0-beta.16", |
| ... | ... | @@ -175,7 +175,9 @@ |
| 175 | 175 | "vite": "^8.0.0", |
| 176 | 176 | }, |
| 177 | 177 | "catalog": { |
| 178 | "@effect/atom-react": "4.0.0-beta.43", | |
| 178 | 179 | "@effect/language-service": "0.84.2", |
| 180 | "@effect/platform-bun": "4.0.0-beta.43", | |
| 179 | 181 | "@effect/platform-node": "4.0.0-beta.43", |
| 180 | 182 | "@effect/sql-sqlite-bun": "4.0.0-beta.43", |
| 181 | 183 | "@effect/vitest": "4.0.0-beta.43", |
| ... | ... | @@ -267,8 +269,12 @@ |
| 267 | 269 | |
| 268 | 270 | "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], |
| 269 | 271 | |
| 272 | "@effect/atom-react": ["@effect/atom-react@4.0.0-beta.43", "", { "peerDependencies": { "effect": "^4.0.0-beta.43", "react": "^19.2.4", "scheduler": "*" } }, "sha512-xSrRbGXuo4d0g4ph66TQST1GNSjtQZrZj8V7OiAQFuzMYcZ0kwRIPUUFwOBtbWHK43/zNENdNWOnlXh/iYM1dw=="], | |
| 273 | ||
| 270 | 274 | "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="], |
| 271 | 275 | |
| 276 | "@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.43", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.43" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-nMZ9JsD6CzJNQ+5pDUFbPw7PSZdQdTQ092MbYrocVtvlf6qEFU/hji3ITvRIOX7eabyQ8AUyp55qFPQUeq+GIA=="], | |
| 277 | ||
| 272 | 278 | "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.43", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.43", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43", "ioredis": "^5.7.0" } }, "sha512-Uq6E1rjaIpjHauzjwoB2HzAg3battYt2Boy8XO50GoHiWCXKE6WapYZ0/AnaBx5v5qg2sOfqpuiLsUf9ZgxOkA=="], |
| 273 | 279 | |
| 274 | 280 | "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.43", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-A9q0GEb61pYcQ06Dr6gXj1nKlDI3KHsar1sk3qb1ZY+kVSR64tBAylI8zGon23KY+NPtTUj/sEIToB7jc3Qt5w=="], |
package.json+2| ... | ... | @@ -9,6 +9,8 @@ |
| 9 | 9 | ], |
| 10 | 10 | "catalog": { |
| 11 | 11 | "effect": "4.0.0-beta.43", |
| 12 | "@effect/atom-react": "4.0.0-beta.43", | |
| 13 | "@effect/platform-bun": "4.0.0-beta.43", | |
| 12 | 14 | "@effect/platform-node": "4.0.0-beta.43", |
| 13 | 15 | "@effect/sql-sqlite-bun": "4.0.0-beta.43", |
| 14 | 16 | "@effect/vitest": "4.0.0-beta.43", |
packages/contracts/src/editor.ts+5| ... | ... | @@ -24,3 +24,8 @@ export const OpenInEditorInput = Schema.Struct({ |
| 24 | 24 | editor: EditorId, |
| 25 | 25 | }); |
| 26 | 26 | export type OpenInEditorInput = typeof OpenInEditorInput.Type; |
| 27 | ||
| 28 | export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", { | |
| 29 | message: Schema.String, | |
| 30 | cause: Schema.optional(Schema.Defect), | |
| 31 | }) {} |
packages/contracts/src/git.ts+54| ... | ... | @@ -223,6 +223,60 @@ export const GitPullResult = Schema.Struct({ |
| 223 | 223 | }); |
| 224 | 224 | export type GitPullResult = typeof GitPullResult.Type; |
| 225 | 225 | |
| 226 | // RPC / domain errors | |
| 227 | export class GitCommandError extends Schema.TaggedErrorClass<GitCommandError>()("GitCommandError", { | |
| 228 | operation: Schema.String, | |
| 229 | command: Schema.String, | |
| 230 | cwd: Schema.String, | |
| 231 | detail: Schema.String, | |
| 232 | cause: Schema.optional(Schema.Defect), | |
| 233 | }) { | |
| 234 | override get message(): string { | |
| 235 | return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; | |
| 236 | } | |
| 237 | } | |
| 238 | ||
| 239 | export class GitHubCliError extends Schema.TaggedErrorClass<GitHubCliError>()("GitHubCliError", { | |
| 240 | operation: Schema.String, | |
| 241 | detail: Schema.String, | |
| 242 | cause: Schema.optional(Schema.Defect), | |
| 243 | }) { | |
| 244 | override get message(): string { | |
| 245 | return `GitHub CLI failed in ${this.operation}: ${this.detail}`; | |
| 246 | } | |
| 247 | } | |
| 248 | ||
| 249 | export class TextGenerationError extends Schema.TaggedErrorClass<TextGenerationError>()( | |
| 250 | "TextGenerationError", | |
| 251 | { | |
| 252 | operation: Schema.String, | |
| 253 | detail: Schema.String, | |
| 254 | cause: Schema.optional(Schema.Defect), | |
| 255 | }, | |
| 256 | ) { | |
| 257 | override get message(): string { | |
| 258 | return `Text generation failed in ${this.operation}: ${this.detail}`; | |
| 259 | } | |
| 260 | } | |
| 261 | ||
| 262 | export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()("GitManagerError", { | |
| 263 | operation: Schema.String, | |
| 264 | detail: Schema.String, | |
| 265 | cause: Schema.optional(Schema.Defect), | |
| 266 | }) { | |
| 267 | override get message(): string { | |
| 268 | return `Git manager failed in ${this.operation}: ${this.detail}`; | |
| 269 | } | |
| 270 | } | |
| 271 | ||
| 272 | export const GitManagerServiceError = Schema.Union([ | |
| 273 | GitManagerError, | |
| 274 | GitCommandError, | |
| 275 | GitHubCliError, | |
| 276 | TextGenerationError, | |
| 277 | ]); | |
| 278 | export type GitManagerServiceError = typeof GitManagerServiceError.Type; | |
| 279 | ||
| 226 | 280 | const GitActionProgressBase = Schema.Struct({ |
| 227 | 281 | actionId: TrimmedNonEmptyStringSchema, |
| 228 | 282 | cwd: TrimmedNonEmptyStringSchema, |
packages/contracts/src/index.ts+1-1| ... | ... | @@ -4,7 +4,6 @@ export * from "./terminal"; |
| 4 | 4 | export * from "./provider"; |
| 5 | 5 | export * from "./providerRuntime"; |
| 6 | 6 | export * from "./model"; |
| 7 | export * from "./ws"; | |
| 8 | 7 | export * from "./keybindings"; |
| 9 | 8 | export * from "./server"; |
| 10 | 9 | export * from "./settings"; |
| ... | ... | @@ -12,3 +11,4 @@ export * from "./git"; |
| 12 | 11 | export * from "./orchestration"; |
| 13 | 12 | export * from "./editor"; |
| 14 | 13 | export * from "./project"; |
| 14 | export * from "./rpc"; |
packages/contracts/src/ipc.ts-5| ... | ... | @@ -1,6 +1,5 @@ |
| 1 | 1 | import type { |
| 2 | 2 | GitCheckoutInput, |
| 3 | GitActionProgressEvent, | |
| 4 | 3 | GitCreateBranchInput, |
| 5 | 4 | GitPreparePullRequestThreadInput, |
| 6 | 5 | GitPreparePullRequestThreadResult, |
| ... | ... | @@ -14,8 +13,6 @@ import type { |
| 14 | 13 | GitPullResult, |
| 15 | 14 | GitRemoveWorktreeInput, |
| 16 | 15 | GitResolvePullRequestResult, |
| 17 | GitRunStackedActionInput, | |
| 18 | GitRunStackedActionResult, | |
| 19 | 16 | GitStatusInput, |
| 20 | 17 | GitStatusResult, |
| 21 | 18 | } from "./git"; |
| ... | ... | @@ -161,8 +158,6 @@ export interface NativeApi { |
| 161 | 158 | // Stacked action API |
| 162 | 159 | pull: (input: GitPullInput) => Promise<GitPullResult>; |
| 163 | 160 | status: (input: GitStatusInput) => Promise<GitStatusResult>; |
| 164 | runStackedAction: (input: GitRunStackedActionInput) => Promise<GitRunStackedActionResult>; | |
| 165 | onActionProgress: (callback: (event: GitActionProgressEvent) => void) => () => void; | |
| 166 | 161 | }; |
| 167 | 162 | contextMenu: { |
| 168 | 163 | show: <T extends string>( |
packages/contracts/src/keybindings.ts+22-6| ... | ... | @@ -85,24 +85,27 @@ export const KeybindingShortcut = Schema.Struct({ |
| 85 | 85 | }); |
| 86 | 86 | export type KeybindingShortcut = typeof KeybindingShortcut.Type; |
| 87 | 87 | |
| 88 | export const KeybindingWhenNode: Schema.Schema<KeybindingWhenNode> = Schema.Union([ | |
| 88 | const KeybindingWhenNodeRef = Schema.suspend( | |
| 89 | (): Schema.Codec<KeybindingWhenNode> => KeybindingWhenNode, | |
| 90 | ); | |
| 91 | export const KeybindingWhenNode = Schema.Union([ | |
| 89 | 92 | Schema.Struct({ |
| 90 | 93 | type: Schema.Literal("identifier"), |
| 91 | 94 | name: Schema.NonEmptyString, |
| 92 | 95 | }), |
| 93 | 96 | Schema.Struct({ |
| 94 | 97 | type: Schema.Literal("not"), |
| 95 | node: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode), | |
| 98 | node: KeybindingWhenNodeRef, | |
| 96 | 99 | }), |
| 97 | 100 | Schema.Struct({ |
| 98 | 101 | type: Schema.Literal("and"), |
| 99 | left: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode), | |
| 100 | right: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode), | |
| 102 | left: KeybindingWhenNodeRef, | |
| 103 | right: KeybindingWhenNodeRef, | |
| 101 | 104 | }), |
| 102 | 105 | Schema.Struct({ |
| 103 | 106 | type: Schema.Literal("or"), |
| 104 | left: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode), | |
| 105 | right: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode), | |
| 107 | left: KeybindingWhenNodeRef, | |
| 108 | right: KeybindingWhenNodeRef, | |
| 106 | 109 | }), |
| 107 | 110 | ]); |
| 108 | 111 | export type KeybindingWhenNode = |
| ... | ... | @@ -122,3 +125,16 @@ export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).ch |
| 122 | 125 | Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), |
| 123 | 126 | ); |
| 124 | 127 | export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type; |
| 128 | ||
| 129 | export class KeybindingsConfigError extends Schema.TaggedErrorClass<KeybindingsConfigError>()( | |
| 130 | "KeybindingsConfigParseError", | |
| 131 | { | |
| 132 | configPath: Schema.String, | |
| 133 | detail: Schema.String, | |
| 134 | cause: Schema.optional(Schema.Defect), | |
| 135 | }, | |
| 136 | ) { | |
| 137 | override get message(): string { | |
| 138 | return `Unable to parse keybindings config at ${this.configPath}: ${this.detail}`; | |
| 139 | } | |
| 140 | } |
packages/contracts/src/orchestration.ts+40-4| ... | ... | @@ -23,10 +23,6 @@ export const ORCHESTRATION_WS_METHODS = { |
| 23 | 23 | replayEvents: "orchestration.replayEvents", |
| 24 | 24 | } as const; |
| 25 | 25 | |
| 26 | export const ORCHESTRATION_WS_CHANNELS = { | |
| 27 | domainEvent: "orchestration.domainEvent", | |
| 28 | } as const; | |
| 29 | ||
| 30 | 26 | export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]); |
| 31 | 27 | export type ProviderKind = typeof ProviderKind.Type; |
| 32 | 28 | export const ProviderApprovalPolicy = Schema.Literals([ |
| ... | ... | @@ -1037,3 +1033,43 @@ export const OrchestrationRpcSchemas = { |
| 1037 | 1033 | output: OrchestrationReplayEventsResult, |
| 1038 | 1034 | }, |
| 1039 | 1035 | } as const; |
| 1036 | ||
| 1037 | export class OrchestrationGetSnapshotError extends Schema.TaggedErrorClass<OrchestrationGetSnapshotError>()( | |
| 1038 | "OrchestrationGetSnapshotError", | |
| 1039 | { | |
| 1040 | message: TrimmedNonEmptyString, | |
| 1041 | cause: Schema.optional(Schema.Defect), | |
| 1042 | }, | |
| 1043 | ) {} | |
| 1044 | ||
| 1045 | export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass<OrchestrationDispatchCommandError>()( | |
| 1046 | "OrchestrationDispatchCommandError", | |
| 1047 | { | |
| 1048 | message: TrimmedNonEmptyString, | |
| 1049 | cause: Schema.optional(Schema.Defect), | |
| 1050 | }, | |
| 1051 | ) {} | |
| 1052 | ||
| 1053 | export class OrchestrationGetTurnDiffError extends Schema.TaggedErrorClass<OrchestrationGetTurnDiffError>()( | |
| 1054 | "OrchestrationGetTurnDiffError", | |
| 1055 | { | |
| 1056 | message: TrimmedNonEmptyString, | |
| 1057 | cause: Schema.optional(Schema.Defect), | |
| 1058 | }, | |
| 1059 | ) {} | |
| 1060 | ||
| 1061 | export class OrchestrationGetFullThreadDiffError extends Schema.TaggedErrorClass<OrchestrationGetFullThreadDiffError>()( | |
| 1062 | "OrchestrationGetFullThreadDiffError", | |
| 1063 | { | |
| 1064 | message: TrimmedNonEmptyString, | |
| 1065 | cause: Schema.optional(Schema.Defect), | |
| 1066 | }, | |
| 1067 | ) {} | |
| 1068 | ||
| 1069 | export class OrchestrationReplayEventsError extends Schema.TaggedErrorClass<OrchestrationReplayEventsError>()( | |
| 1070 | "OrchestrationReplayEventsError", | |
| 1071 | { | |
| 1072 | message: TrimmedNonEmptyString, | |
| 1073 | cause: Schema.optional(Schema.Defect), | |
| 1074 | }, | |
| 1075 | ) {} |
packages/contracts/src/project.ts+16| ... | ... | @@ -26,6 +26,14 @@ export const ProjectSearchEntriesResult = Schema.Struct({ |
| 26 | 26 | }); |
| 27 | 27 | export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; |
| 28 | 28 | |
| 29 | export class ProjectSearchEntriesError extends Schema.TaggedErrorClass<ProjectSearchEntriesError>()( | |
| 30 | "ProjectSearchEntriesError", | |
| 31 | { | |
| 32 | message: TrimmedNonEmptyString, | |
| 33 | cause: Schema.optional(Schema.Defect), | |
| 34 | }, | |
| 35 | ) {} | |
| 36 | ||
| 29 | 37 | export const ProjectWriteFileInput = Schema.Struct({ |
| 30 | 38 | cwd: TrimmedNonEmptyString, |
| 31 | 39 | relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH)), |
| ... | ... | @@ -37,3 +45,11 @@ export const ProjectWriteFileResult = Schema.Struct({ |
| 37 | 45 | relativePath: TrimmedNonEmptyString, |
| 38 | 46 | }); |
| 39 | 47 | export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type; |
| 48 | ||
| 49 | export class ProjectWriteFileError extends Schema.TaggedErrorClass<ProjectWriteFileError>()( | |
| 50 | "ProjectWriteFileError", | |
| 51 | { | |
| 52 | message: TrimmedNonEmptyString, | |
| 53 | cause: Schema.optional(Schema.Defect), | |
| 54 | }, | |
| 55 | ) {} |
packages/contracts/src/rpc.ts created+359| ... | ... | @@ -0,0 +1,359 @@ |
| 1 | import { Schema } from "effect"; | |
| 2 | import * as Rpc from "effect/unstable/rpc/Rpc"; | |
| 3 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; | |
| 4 | ||
| 5 | import { OpenError, OpenInEditorInput } from "./editor"; | |
| 6 | import { | |
| 7 | GitActionProgressEvent, | |
| 8 | GitCheckoutInput, | |
| 9 | GitCommandError, | |
| 10 | GitCreateBranchInput, | |
| 11 | GitCreateWorktreeInput, | |
| 12 | GitCreateWorktreeResult, | |
| 13 | GitInitInput, | |
| 14 | GitListBranchesInput, | |
| 15 | GitListBranchesResult, | |
| 16 | GitManagerServiceError, | |
| 17 | GitPreparePullRequestThreadInput, | |
| 18 | GitPreparePullRequestThreadResult, | |
| 19 | GitPullInput, | |
| 20 | GitPullRequestRefInput, | |
| 21 | GitPullResult, | |
| 22 | GitRemoveWorktreeInput, | |
| 23 | GitResolvePullRequestResult, | |
| 24 | GitRunStackedActionInput, | |
| 25 | GitStatusInput, | |
| 26 | GitStatusResult, | |
| 27 | } from "./git"; | |
| 28 | import { KeybindingsConfigError } from "./keybindings"; | |
| 29 | import { | |
| 30 | ClientOrchestrationCommand, | |
| 31 | OrchestrationEvent, | |
| 32 | ORCHESTRATION_WS_METHODS, | |
| 33 | OrchestrationDispatchCommandError, | |
| 34 | OrchestrationGetFullThreadDiffError, | |
| 35 | OrchestrationGetFullThreadDiffInput, | |
| 36 | OrchestrationGetSnapshotError, | |
| 37 | OrchestrationGetSnapshotInput, | |
| 38 | OrchestrationGetTurnDiffError, | |
| 39 | OrchestrationGetTurnDiffInput, | |
| 40 | OrchestrationReplayEventsError, | |
| 41 | OrchestrationReplayEventsInput, | |
| 42 | OrchestrationRpcSchemas, | |
| 43 | } from "./orchestration"; | |
| 44 | import { | |
| 45 | ProjectSearchEntriesError, | |
| 46 | ProjectSearchEntriesInput, | |
| 47 | ProjectSearchEntriesResult, | |
| 48 | ProjectWriteFileError, | |
| 49 | ProjectWriteFileInput, | |
| 50 | ProjectWriteFileResult, | |
| 51 | } from "./project"; | |
| 52 | import { | |
| 53 | TerminalClearInput, | |
| 54 | TerminalCloseInput, | |
| 55 | TerminalError, | |
| 56 | TerminalEvent, | |
| 57 | TerminalOpenInput, | |
| 58 | TerminalResizeInput, | |
| 59 | TerminalRestartInput, | |
| 60 | TerminalSessionSnapshot, | |
| 61 | TerminalWriteInput, | |
| 62 | } from "./terminal"; | |
| 63 | import { | |
| 64 | ServerConfigStreamEvent, | |
| 65 | ServerConfig, | |
| 66 | ServerLifecycleStreamEvent, | |
| 67 | ServerProviderUpdatedPayload, | |
| 68 | ServerUpsertKeybindingInput, | |
| 69 | ServerUpsertKeybindingResult, | |
| 70 | } from "./server"; | |
| 71 | import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings"; | |
| 72 | ||
| 73 | export const WS_METHODS = { | |
| 74 | // Project registry methods | |
| 75 | projectsList: "projects.list", | |
| 76 | projectsAdd: "projects.add", | |
| 77 | projectsRemove: "projects.remove", | |
| 78 | projectsSearchEntries: "projects.searchEntries", | |
| 79 | projectsWriteFile: "projects.writeFile", | |
| 80 | ||
| 81 | // Shell methods | |
| 82 | shellOpenInEditor: "shell.openInEditor", | |
| 83 | ||
| 84 | // Git methods | |
| 85 | gitPull: "git.pull", | |
| 86 | gitStatus: "git.status", | |
| 87 | gitRunStackedAction: "git.runStackedAction", | |
| 88 | gitListBranches: "git.listBranches", | |
| 89 | gitCreateWorktree: "git.createWorktree", | |
| 90 | gitRemoveWorktree: "git.removeWorktree", | |
| 91 | gitCreateBranch: "git.createBranch", | |
| 92 | gitCheckout: "git.checkout", | |
| 93 | gitInit: "git.init", | |
| 94 | gitResolvePullRequest: "git.resolvePullRequest", | |
| 95 | gitPreparePullRequestThread: "git.preparePullRequestThread", | |
| 96 | ||
| 97 | // Terminal methods | |
| 98 | terminalOpen: "terminal.open", | |
| 99 | terminalWrite: "terminal.write", | |
| 100 | terminalResize: "terminal.resize", | |
| 101 | terminalClear: "terminal.clear", | |
| 102 | terminalRestart: "terminal.restart", | |
| 103 | terminalClose: "terminal.close", | |
| 104 | ||
| 105 | // Server meta | |
| 106 | serverGetConfig: "server.getConfig", | |
| 107 | serverRefreshProviders: "server.refreshProviders", | |
| 108 | serverUpsertKeybinding: "server.upsertKeybinding", | |
| 109 | serverGetSettings: "server.getSettings", | |
| 110 | serverUpdateSettings: "server.updateSettings", | |
| 111 | ||
| 112 | // Streaming subscriptions | |
| 113 | subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents", | |
| 114 | subscribeTerminalEvents: "subscribeTerminalEvents", | |
| 115 | subscribeServerConfig: "subscribeServerConfig", | |
| 116 | subscribeServerLifecycle: "subscribeServerLifecycle", | |
| 117 | } as const; | |
| 118 | ||
| 119 | export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { | |
| 120 | payload: ServerUpsertKeybindingInput, | |
| 121 | success: ServerUpsertKeybindingResult, | |
| 122 | error: KeybindingsConfigError, | |
| 123 | }); | |
| 124 | ||
| 125 | export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { | |
| 126 | payload: Schema.Struct({}), | |
| 127 | success: ServerConfig, | |
| 128 | error: Schema.Union([KeybindingsConfigError, ServerSettingsError]), | |
| 129 | }); | |
| 130 | ||
| 131 | export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { | |
| 132 | payload: Schema.Struct({}), | |
| 133 | success: ServerProviderUpdatedPayload, | |
| 134 | }); | |
| 135 | ||
| 136 | export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { | |
| 137 | payload: Schema.Struct({}), | |
| 138 | success: ServerSettings, | |
| 139 | error: ServerSettingsError, | |
| 140 | }); | |
| 141 | ||
| 142 | export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, { | |
| 143 | payload: Schema.Struct({ patch: ServerSettingsPatch }), | |
| 144 | success: ServerSettings, | |
| 145 | error: ServerSettingsError, | |
| 146 | }); | |
| 147 | ||
| 148 | export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { | |
| 149 | payload: ProjectSearchEntriesInput, | |
| 150 | success: ProjectSearchEntriesResult, | |
| 151 | error: ProjectSearchEntriesError, | |
| 152 | }); | |
| 153 | ||
| 154 | export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { | |
| 155 | payload: ProjectWriteFileInput, | |
| 156 | success: ProjectWriteFileResult, | |
| 157 | error: ProjectWriteFileError, | |
| 158 | }); | |
| 159 | ||
| 160 | export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { | |
| 161 | payload: OpenInEditorInput, | |
| 162 | error: OpenError, | |
| 163 | }); | |
| 164 | ||
| 165 | export const WsGitStatusRpc = Rpc.make(WS_METHODS.gitStatus, { | |
| 166 | payload: GitStatusInput, | |
| 167 | success: GitStatusResult, | |
| 168 | error: GitManagerServiceError, | |
| 169 | }); | |
| 170 | ||
| 171 | export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, { | |
| 172 | payload: GitPullInput, | |
| 173 | success: GitPullResult, | |
| 174 | error: GitCommandError, | |
| 175 | }); | |
| 176 | ||
| 177 | export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { | |
| 178 | payload: GitRunStackedActionInput, | |
| 179 | success: GitActionProgressEvent, | |
| 180 | error: GitManagerServiceError, | |
| 181 | stream: true, | |
| 182 | }); | |
| 183 | ||
| 184 | export const WsGitResolvePullRequestRpc = Rpc.make(WS_METHODS.gitResolvePullRequest, { | |
| 185 | payload: GitPullRequestRefInput, | |
| 186 | success: GitResolvePullRequestResult, | |
| 187 | error: GitManagerServiceError, | |
| 188 | }); | |
| 189 | ||
| 190 | export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePullRequestThread, { | |
| 191 | payload: GitPreparePullRequestThreadInput, | |
| 192 | success: GitPreparePullRequestThreadResult, | |
| 193 | error: GitManagerServiceError, | |
| 194 | }); | |
| 195 | ||
| 196 | export const WsGitListBranchesRpc = Rpc.make(WS_METHODS.gitListBranches, { | |
| 197 | payload: GitListBranchesInput, | |
| 198 | success: GitListBranchesResult, | |
| 199 | error: GitCommandError, | |
| 200 | }); | |
| 201 | ||
| 202 | export const WsGitCreateWorktreeRpc = Rpc.make(WS_METHODS.gitCreateWorktree, { | |
| 203 | payload: GitCreateWorktreeInput, | |
| 204 | success: GitCreateWorktreeResult, | |
| 205 | error: GitCommandError, | |
| 206 | }); | |
| 207 | ||
| 208 | export const WsGitRemoveWorktreeRpc = Rpc.make(WS_METHODS.gitRemoveWorktree, { | |
| 209 | payload: GitRemoveWorktreeInput, | |
| 210 | error: GitCommandError, | |
| 211 | }); | |
| 212 | ||
| 213 | export const WsGitCreateBranchRpc = Rpc.make(WS_METHODS.gitCreateBranch, { | |
| 214 | payload: GitCreateBranchInput, | |
| 215 | error: GitCommandError, | |
| 216 | }); | |
| 217 | ||
| 218 | export const WsGitCheckoutRpc = Rpc.make(WS_METHODS.gitCheckout, { | |
| 219 | payload: GitCheckoutInput, | |
| 220 | error: GitCommandError, | |
| 221 | }); | |
| 222 | ||
| 223 | export const WsGitInitRpc = Rpc.make(WS_METHODS.gitInit, { | |
| 224 | payload: GitInitInput, | |
| 225 | error: GitCommandError, | |
| 226 | }); | |
| 227 | ||
| 228 | export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { | |
| 229 | payload: TerminalOpenInput, | |
| 230 | success: TerminalSessionSnapshot, | |
| 231 | error: TerminalError, | |
| 232 | }); | |
| 233 | ||
| 234 | export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, { | |
| 235 | payload: TerminalWriteInput, | |
| 236 | error: TerminalError, | |
| 237 | }); | |
| 238 | ||
| 239 | export const WsTerminalResizeRpc = Rpc.make(WS_METHODS.terminalResize, { | |
| 240 | payload: TerminalResizeInput, | |
| 241 | error: TerminalError, | |
| 242 | }); | |
| 243 | ||
| 244 | export const WsTerminalClearRpc = Rpc.make(WS_METHODS.terminalClear, { | |
| 245 | payload: TerminalClearInput, | |
| 246 | error: TerminalError, | |
| 247 | }); | |
| 248 | ||
| 249 | export const WsTerminalRestartRpc = Rpc.make(WS_METHODS.terminalRestart, { | |
| 250 | payload: TerminalRestartInput, | |
| 251 | success: TerminalSessionSnapshot, | |
| 252 | error: TerminalError, | |
| 253 | }); | |
| 254 | ||
| 255 | export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { | |
| 256 | payload: TerminalCloseInput, | |
| 257 | error: TerminalError, | |
| 258 | }); | |
| 259 | ||
| 260 | export const WsOrchestrationGetSnapshotRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getSnapshot, { | |
| 261 | payload: OrchestrationGetSnapshotInput, | |
| 262 | success: OrchestrationRpcSchemas.getSnapshot.output, | |
| 263 | error: OrchestrationGetSnapshotError, | |
| 264 | }); | |
| 265 | ||
| 266 | export const WsOrchestrationDispatchCommandRpc = Rpc.make( | |
| 267 | ORCHESTRATION_WS_METHODS.dispatchCommand, | |
| 268 | { | |
| 269 | payload: ClientOrchestrationCommand, | |
| 270 | success: OrchestrationRpcSchemas.dispatchCommand.output, | |
| 271 | error: OrchestrationDispatchCommandError, | |
| 272 | }, | |
| 273 | ); | |
| 274 | ||
| 275 | export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, { | |
| 276 | payload: OrchestrationGetTurnDiffInput, | |
| 277 | success: OrchestrationRpcSchemas.getTurnDiff.output, | |
| 278 | error: OrchestrationGetTurnDiffError, | |
| 279 | }); | |
| 280 | ||
| 281 | export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make( | |
| 282 | ORCHESTRATION_WS_METHODS.getFullThreadDiff, | |
| 283 | { | |
| 284 | payload: OrchestrationGetFullThreadDiffInput, | |
| 285 | success: OrchestrationRpcSchemas.getFullThreadDiff.output, | |
| 286 | error: OrchestrationGetFullThreadDiffError, | |
| 287 | }, | |
| 288 | ); | |
| 289 | ||
| 290 | export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.replayEvents, { | |
| 291 | payload: OrchestrationReplayEventsInput, | |
| 292 | success: OrchestrationRpcSchemas.replayEvents.output, | |
| 293 | error: OrchestrationReplayEventsError, | |
| 294 | }); | |
| 295 | ||
| 296 | export const WsSubscribeOrchestrationDomainEventsRpc = Rpc.make( | |
| 297 | WS_METHODS.subscribeOrchestrationDomainEvents, | |
| 298 | { | |
| 299 | payload: Schema.Struct({}), | |
| 300 | success: OrchestrationEvent, | |
| 301 | stream: true, | |
| 302 | }, | |
| 303 | ); | |
| 304 | ||
| 305 | export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, { | |
| 306 | payload: Schema.Struct({}), | |
| 307 | success: TerminalEvent, | |
| 308 | stream: true, | |
| 309 | }); | |
| 310 | ||
| 311 | export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerConfig, { | |
| 312 | payload: Schema.Struct({}), | |
| 313 | success: ServerConfigStreamEvent, | |
| 314 | error: Schema.Union([KeybindingsConfigError, ServerSettingsError]), | |
| 315 | stream: true, | |
| 316 | }); | |
| 317 | ||
| 318 | export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, { | |
| 319 | payload: Schema.Struct({}), | |
| 320 | success: ServerLifecycleStreamEvent, | |
| 321 | stream: true, | |
| 322 | }); | |
| 323 | ||
| 324 | export const WsRpcGroup = RpcGroup.make( | |
| 325 | WsServerGetConfigRpc, | |
| 326 | WsServerRefreshProvidersRpc, | |
| 327 | WsServerUpsertKeybindingRpc, | |
| 328 | WsServerGetSettingsRpc, | |
| 329 | WsServerUpdateSettingsRpc, | |
| 330 | WsProjectsSearchEntriesRpc, | |
| 331 | WsProjectsWriteFileRpc, | |
| 332 | WsShellOpenInEditorRpc, | |
| 333 | WsGitStatusRpc, | |
| 334 | WsGitPullRpc, | |
| 335 | WsGitRunStackedActionRpc, | |
| 336 | WsGitResolvePullRequestRpc, | |
| 337 | WsGitPreparePullRequestThreadRpc, | |
| 338 | WsGitListBranchesRpc, | |
| 339 | WsGitCreateWorktreeRpc, | |
| 340 | WsGitRemoveWorktreeRpc, | |
| 341 | WsGitCreateBranchRpc, | |
| 342 | WsGitCheckoutRpc, | |
| 343 | WsGitInitRpc, | |
| 344 | WsTerminalOpenRpc, | |
| 345 | WsTerminalWriteRpc, | |
| 346 | WsTerminalResizeRpc, | |
| 347 | WsTerminalClearRpc, | |
| 348 | WsTerminalRestartRpc, | |
| 349 | WsTerminalCloseRpc, | |
| 350 | WsSubscribeOrchestrationDomainEventsRpc, | |
| 351 | WsSubscribeTerminalEventsRpc, | |
| 352 | WsSubscribeServerConfigRpc, | |
| 353 | WsSubscribeServerLifecycleRpc, | |
| 354 | WsOrchestrationGetSnapshotRpc, | |
| 355 | WsOrchestrationDispatchCommandRpc, | |
| 356 | WsOrchestrationGetTurnDiffRpc, | |
| 357 | WsOrchestrationGetFullThreadDiffRpc, | |
| 358 | WsOrchestrationReplayEventsRpc, | |
| 359 | ); |
packages/contracts/src/server.ts+100-2| ... | ... | @@ -1,5 +1,11 @@ |
| 1 | 1 | import { Schema } from "effect"; |
| 2 | import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; | |
| 2 | import { | |
| 3 | IsoDateTime, | |
| 4 | NonNegativeInt, | |
| 5 | ProjectId, | |
| 6 | ThreadId, | |
| 7 | TrimmedNonEmptyString, | |
| 8 | } from "./baseSchemas"; | |
| 3 | 9 | import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; |
| 4 | 10 | import { EditorId } from "./editor"; |
| 5 | 11 | import { ModelCapabilities } from "./model"; |
| ... | ... | @@ -63,7 +69,8 @@ export const ServerProvider = Schema.Struct({ |
| 63 | 69 | }); |
| 64 | 70 | export type ServerProvider = typeof ServerProvider.Type; |
| 65 | 71 | |
| 66 | const ServerProviders = Schema.Array(ServerProvider); | |
| 72 | export const ServerProviders = Schema.Array(ServerProvider); | |
| 73 | export type ServerProviders = typeof ServerProviders.Type; | |
| 67 | 74 | |
| 68 | 75 | export const ServerConfig = Schema.Struct({ |
| 69 | 76 | cwd: TrimmedNonEmptyString, |
| ... | ... | @@ -87,10 +94,101 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T |
| 87 | 94 | |
| 88 | 95 | export const ServerConfigUpdatedPayload = Schema.Struct({ |
| 89 | 96 | issues: ServerConfigIssues, |
| 97 | providers: ServerProviders, | |
| 90 | 98 | settings: Schema.optional(ServerSettings), |
| 91 | 99 | }); |
| 92 | 100 | export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type; |
| 93 | 101 | |
| 102 | export const ServerConfigKeybindingsUpdatedPayload = Schema.Struct({ | |
| 103 | issues: ServerConfigIssues, | |
| 104 | }); | |
| 105 | export type ServerConfigKeybindingsUpdatedPayload = | |
| 106 | typeof ServerConfigKeybindingsUpdatedPayload.Type; | |
| 107 | ||
| 108 | export const ServerConfigProviderStatusesPayload = Schema.Struct({ | |
| 109 | providers: ServerProviders, | |
| 110 | }); | |
| 111 | export type ServerConfigProviderStatusesPayload = typeof ServerConfigProviderStatusesPayload.Type; | |
| 112 | ||
| 113 | export const ServerConfigSettingsUpdatedPayload = Schema.Struct({ | |
| 114 | settings: ServerSettings, | |
| 115 | }); | |
| 116 | export type ServerConfigSettingsUpdatedPayload = typeof ServerConfigSettingsUpdatedPayload.Type; | |
| 117 | ||
| 118 | export const ServerConfigStreamSnapshotEvent = Schema.Struct({ | |
| 119 | version: Schema.Literal(1), | |
| 120 | type: Schema.Literal("snapshot"), | |
| 121 | config: ServerConfig, | |
| 122 | }); | |
| 123 | export type ServerConfigStreamSnapshotEvent = typeof ServerConfigStreamSnapshotEvent.Type; | |
| 124 | ||
| 125 | export const ServerConfigStreamKeybindingsUpdatedEvent = Schema.Struct({ | |
| 126 | version: Schema.Literal(1), | |
| 127 | type: Schema.Literal("keybindingsUpdated"), | |
| 128 | payload: ServerConfigKeybindingsUpdatedPayload, | |
| 129 | }); | |
| 130 | export type ServerConfigStreamKeybindingsUpdatedEvent = | |
| 131 | typeof ServerConfigStreamKeybindingsUpdatedEvent.Type; | |
| 132 | ||
| 133 | export const ServerConfigStreamProviderStatusesEvent = Schema.Struct({ | |
| 134 | version: Schema.Literal(1), | |
| 135 | type: Schema.Literal("providerStatuses"), | |
| 136 | payload: ServerConfigProviderStatusesPayload, | |
| 137 | }); | |
| 138 | export type ServerConfigStreamProviderStatusesEvent = | |
| 139 | typeof ServerConfigStreamProviderStatusesEvent.Type; | |
| 140 | ||
| 141 | export const ServerConfigStreamSettingsUpdatedEvent = Schema.Struct({ | |
| 142 | version: Schema.Literal(1), | |
| 143 | type: Schema.Literal("settingsUpdated"), | |
| 144 | payload: ServerConfigSettingsUpdatedPayload, | |
| 145 | }); | |
| 146 | export type ServerConfigStreamSettingsUpdatedEvent = | |
| 147 | typeof ServerConfigStreamSettingsUpdatedEvent.Type; | |
| 148 | ||
| 149 | export const ServerConfigStreamEvent = Schema.Union([ | |
| 150 | ServerConfigStreamSnapshotEvent, | |
| 151 | ServerConfigStreamKeybindingsUpdatedEvent, | |
| 152 | ServerConfigStreamProviderStatusesEvent, | |
| 153 | ServerConfigStreamSettingsUpdatedEvent, | |
| 154 | ]); | |
| 155 | export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; | |
| 156 | ||
| 157 | export const ServerLifecycleReadyPayload = Schema.Struct({ | |
| 158 | at: IsoDateTime, | |
| 159 | }); | |
| 160 | export type ServerLifecycleReadyPayload = typeof ServerLifecycleReadyPayload.Type; | |
| 161 | ||
| 162 | export const ServerLifecycleWelcomePayload = Schema.Struct({ | |
| 163 | cwd: TrimmedNonEmptyString, | |
| 164 | projectName: TrimmedNonEmptyString, | |
| 165 | bootstrapProjectId: Schema.optional(ProjectId), | |
| 166 | bootstrapThreadId: Schema.optional(ThreadId), | |
| 167 | }); | |
| 168 | export type ServerLifecycleWelcomePayload = typeof ServerLifecycleWelcomePayload.Type; | |
| 169 | ||
| 170 | export const ServerLifecycleStreamWelcomeEvent = Schema.Struct({ | |
| 171 | version: Schema.Literal(1), | |
| 172 | sequence: NonNegativeInt, | |
| 173 | type: Schema.Literal("welcome"), | |
| 174 | payload: ServerLifecycleWelcomePayload, | |
| 175 | }); | |
| 176 | export type ServerLifecycleStreamWelcomeEvent = typeof ServerLifecycleStreamWelcomeEvent.Type; | |
| 177 | ||
| 178 | export const ServerLifecycleStreamReadyEvent = Schema.Struct({ | |
| 179 | version: Schema.Literal(1), | |
| 180 | sequence: NonNegativeInt, | |
| 181 | type: Schema.Literal("ready"), | |
| 182 | payload: ServerLifecycleReadyPayload, | |
| 183 | }); | |
| 184 | export type ServerLifecycleStreamReadyEvent = typeof ServerLifecycleStreamReadyEvent.Type; | |
| 185 | ||
| 186 | export const ServerLifecycleStreamEvent = Schema.Union([ | |
| 187 | ServerLifecycleStreamWelcomeEvent, | |
| 188 | ServerLifecycleStreamReadyEvent, | |
| 189 | ]); | |
| 190 | export type ServerLifecycleStreamEvent = typeof ServerLifecycleStreamEvent.Type; | |
| 191 | ||
| 94 | 192 | export const ServerProviderUpdatedPayload = Schema.Struct({ |
| 95 | 193 | providers: ServerProviders, |
| 96 | 194 | }); |
packages/contracts/src/settings.ts+13| ... | ... | @@ -93,6 +93,19 @@ export type ServerSettings = typeof ServerSettings.Type; |
| 93 | 93 | |
| 94 | 94 | export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({}); |
| 95 | 95 | |
| 96 | export class ServerSettingsError extends Schema.TaggedErrorClass<ServerSettingsError>()( | |
| 97 | "ServerSettingsError", | |
| 98 | { | |
| 99 | settingsPath: Schema.String, | |
| 100 | detail: Schema.String, | |
| 101 | cause: Schema.optional(Schema.Defect), | |
| 102 | }, | |
| 103 | ) { | |
| 104 | override get message(): string { | |
| 105 | return `Server settings error at ${this.settingsPath}: ${this.detail}`; | |
| 106 | } | |
| 107 | } | |
| 108 | ||
| 96 | 109 | // ── Unified type ───────────────────────────────────────────────────── |
| 97 | 110 | |
| 98 | 111 | export type UnifiedSettings = ServerSettings & ClientSettings; |
packages/contracts/src/terminal.ts+71| ... | ... | @@ -149,3 +149,74 @@ export const TerminalEvent = Schema.Union([ |
| 149 | 149 | TerminalActivityEvent, |
| 150 | 150 | ]); |
| 151 | 151 | export type TerminalEvent = typeof TerminalEvent.Type; |
| 152 | ||
| 153 | export class TerminalCwdError extends Schema.TaggedErrorClass<TerminalCwdError>()( | |
| 154 | "TerminalCwdError", | |
| 155 | { | |
| 156 | cwd: Schema.String, | |
| 157 | reason: Schema.Literals(["notFound", "notDirectory", "statFailed"]), | |
| 158 | cause: Schema.optional(Schema.Defect), | |
| 159 | }, | |
| 160 | ) { | |
| 161 | override get message() { | |
| 162 | if (this.reason === "notDirectory") { | |
| 163 | return `Terminal cwd is not a directory: ${this.cwd}`; | |
| 164 | } | |
| 165 | if (this.reason === "notFound") { | |
| 166 | return `Terminal cwd does not exist: ${this.cwd}`; | |
| 167 | } | |
| 168 | const causeMessage = | |
| 169 | this.cause && typeof this.cause === "object" && "message" in this.cause | |
| 170 | ? this.cause.message | |
| 171 | : undefined; | |
| 172 | return causeMessage | |
| 173 | ? `Failed to access terminal cwd: ${this.cwd} (${causeMessage})` | |
| 174 | : `Failed to access terminal cwd: ${this.cwd}`; | |
| 175 | } | |
| 176 | } | |
| 177 | ||
| 178 | export class TerminalHistoryError extends Schema.TaggedErrorClass<TerminalHistoryError>()( | |
| 179 | "TerminalHistoryError", | |
| 180 | { | |
| 181 | operation: Schema.Literals(["read", "truncate", "migrate"]), | |
| 182 | threadId: Schema.String, | |
| 183 | terminalId: Schema.String, | |
| 184 | cause: Schema.optional(Schema.Defect), | |
| 185 | }, | |
| 186 | ) { | |
| 187 | override get message() { | |
| 188 | return `Failed to ${this.operation} terminal history for thread: ${this.threadId}, terminal: ${this.terminalId}`; | |
| 189 | } | |
| 190 | } | |
| 191 | ||
| 192 | export class TerminalSessionLookupError extends Schema.TaggedErrorClass<TerminalSessionLookupError>()( | |
| 193 | "TerminalSessionLookupError", | |
| 194 | { | |
| 195 | threadId: Schema.String, | |
| 196 | terminalId: Schema.String, | |
| 197 | }, | |
| 198 | ) { | |
| 199 | override get message() { | |
| 200 | return `Unknown terminal thread: ${this.threadId}, terminal: ${this.terminalId}`; | |
| 201 | } | |
| 202 | } | |
| 203 | ||
| 204 | export class TerminalNotRunningError extends Schema.TaggedErrorClass<TerminalNotRunningError>()( | |
| 205 | "TerminalNotRunningError", | |
| 206 | { | |
| 207 | threadId: Schema.String, | |
| 208 | terminalId: Schema.String, | |
| 209 | }, | |
| 210 | ) { | |
| 211 | override get message() { | |
| 212 | return `Terminal is not running for thread: ${this.threadId}, terminal: ${this.terminalId}`; | |
| 213 | } | |
| 214 | } | |
| 215 | ||
| 216 | export const TerminalError = Schema.Union([ | |
| 217 | TerminalCwdError, | |
| 218 | TerminalHistoryError, | |
| 219 | TerminalSessionLookupError, | |
| 220 | TerminalNotRunningError, | |
| 221 | ]); | |
| 222 | export type TerminalError = typeof TerminalError.Type; |
packages/contracts/src/ws.test.ts deleted-157| ... | ... | @@ -1,157 +0,0 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import { Effect, Schema } from "effect"; | |
| 3 | ||
| 4 | import { ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS } from "./orchestration"; | |
| 5 | import { WebSocketRequest, WsResponse, WS_CHANNELS, WS_METHODS } from "./ws"; | |
| 6 | ||
| 7 | const decodeWebSocketRequest = Schema.decodeUnknownEffect(WebSocketRequest); | |
| 8 | const decodeWsResponse = Schema.decodeUnknownEffect(WsResponse); | |
| 9 | ||
| 10 | it.effect("accepts getTurnDiff requests when fromTurnCount <= toTurnCount", () => | |
| 11 | Effect.gen(function* () { | |
| 12 | const parsed = yield* decodeWebSocketRequest({ | |
| 13 | id: "req-1", | |
| 14 | body: { | |
| 15 | _tag: ORCHESTRATION_WS_METHODS.getTurnDiff, | |
| 16 | threadId: "thread-1", | |
| 17 | fromTurnCount: 1, | |
| 18 | toTurnCount: 2, | |
| 19 | }, | |
| 20 | }); | |
| 21 | assert.strictEqual(parsed.body._tag, ORCHESTRATION_WS_METHODS.getTurnDiff); | |
| 22 | }), | |
| 23 | ); | |
| 24 | ||
| 25 | it.effect("rejects getTurnDiff requests when fromTurnCount > toTurnCount", () => | |
| 26 | Effect.gen(function* () { | |
| 27 | const result = yield* Effect.exit( | |
| 28 | decodeWebSocketRequest({ | |
| 29 | id: "req-1", | |
| 30 | body: { | |
| 31 | _tag: ORCHESTRATION_WS_METHODS.getTurnDiff, | |
| 32 | threadId: "thread-1", | |
| 33 | fromTurnCount: 3, | |
| 34 | toTurnCount: 2, | |
| 35 | }, | |
| 36 | }), | |
| 37 | ); | |
| 38 | assert.strictEqual(result._tag, "Failure"); | |
| 39 | }), | |
| 40 | ); | |
| 41 | ||
| 42 | it.effect("trims websocket request id and nested orchestration ids", () => | |
| 43 | Effect.gen(function* () { | |
| 44 | const parsed = yield* decodeWebSocketRequest({ | |
| 45 | id: " req-1 ", | |
| 46 | body: { | |
| 47 | _tag: ORCHESTRATION_WS_METHODS.getTurnDiff, | |
| 48 | threadId: " thread-1 ", | |
| 49 | fromTurnCount: 0, | |
| 50 | toTurnCount: 0, | |
| 51 | }, | |
| 52 | }); | |
| 53 | assert.strictEqual(parsed.id, "req-1"); | |
| 54 | assert.strictEqual(parsed.body._tag, ORCHESTRATION_WS_METHODS.getTurnDiff); | |
| 55 | if (parsed.body._tag === ORCHESTRATION_WS_METHODS.getTurnDiff) { | |
| 56 | assert.strictEqual(parsed.body.threadId, "thread-1"); | |
| 57 | } | |
| 58 | }), | |
| 59 | ); | |
| 60 | ||
| 61 | it.effect("accepts git.preparePullRequestThread requests", () => | |
| 62 | Effect.gen(function* () { | |
| 63 | const parsed = yield* decodeWebSocketRequest({ | |
| 64 | id: "req-pr-1", | |
| 65 | body: { | |
| 66 | _tag: WS_METHODS.gitPreparePullRequestThread, | |
| 67 | cwd: "/repo", | |
| 68 | reference: "#42", | |
| 69 | mode: "worktree", | |
| 70 | }, | |
| 71 | }); | |
| 72 | assert.strictEqual(parsed.body._tag, WS_METHODS.gitPreparePullRequestThread); | |
| 73 | }), | |
| 74 | ); | |
| 75 | ||
| 76 | it.effect("accepts typed websocket push envelopes with sequence", () => | |
| 77 | Effect.gen(function* () { | |
| 78 | const parsed = yield* decodeWsResponse({ | |
| 79 | type: "push", | |
| 80 | sequence: 1, | |
| 81 | channel: WS_CHANNELS.serverWelcome, | |
| 82 | data: { | |
| 83 | cwd: "/tmp/workspace", | |
| 84 | projectName: "workspace", | |
| 85 | }, | |
| 86 | }); | |
| 87 | ||
| 88 | if (!("type" in parsed) || parsed.type !== "push") { | |
| 89 | assert.fail("expected websocket response to decode as a push envelope"); | |
| 90 | } | |
| 91 | ||
| 92 | assert.strictEqual(parsed.type, "push"); | |
| 93 | assert.strictEqual(parsed.sequence, 1); | |
| 94 | assert.strictEqual(parsed.channel, WS_CHANNELS.serverWelcome); | |
| 95 | }), | |
| 96 | ); | |
| 97 | ||
| 98 | it.effect("accepts git.actionProgress push envelopes", () => | |
| 99 | Effect.gen(function* () { | |
| 100 | const parsed = yield* decodeWsResponse({ | |
| 101 | type: "push", | |
| 102 | sequence: 3, | |
| 103 | channel: WS_CHANNELS.gitActionProgress, | |
| 104 | data: { | |
| 105 | actionId: "action-1", | |
| 106 | cwd: "/repo", | |
| 107 | action: "commit", | |
| 108 | kind: "phase_started", | |
| 109 | phase: "commit", | |
| 110 | label: "Committing...", | |
| 111 | }, | |
| 112 | }); | |
| 113 | ||
| 114 | if (!("type" in parsed) || parsed.type !== "push") { | |
| 115 | assert.fail("expected websocket response to decode as a push envelope"); | |
| 116 | } | |
| 117 | ||
| 118 | assert.strictEqual(parsed.channel, WS_CHANNELS.gitActionProgress); | |
| 119 | }), | |
| 120 | ); | |
| 121 | ||
| 122 | it.effect("accepts server.providersUpdated push envelopes", () => | |
| 123 | Effect.gen(function* () { | |
| 124 | const parsed = yield* decodeWsResponse({ | |
| 125 | type: "push", | |
| 126 | sequence: 4, | |
| 127 | channel: WS_CHANNELS.serverProvidersUpdated, | |
| 128 | data: { | |
| 129 | providers: [], | |
| 130 | }, | |
| 131 | }); | |
| 132 | ||
| 133 | if (!("type" in parsed) || parsed.type !== "push") { | |
| 134 | assert.fail("expected websocket response to decode as a push envelope"); | |
| 135 | } | |
| 136 | ||
| 137 | assert.strictEqual(parsed.channel, WS_CHANNELS.serverProvidersUpdated); | |
| 138 | }), | |
| 139 | ); | |
| 140 | ||
| 141 | it.effect("rejects push envelopes when channel payload does not match the channel schema", () => | |
| 142 | Effect.gen(function* () { | |
| 143 | const result = yield* Effect.exit( | |
| 144 | decodeWsResponse({ | |
| 145 | type: "push", | |
| 146 | sequence: 2, | |
| 147 | channel: ORCHESTRATION_WS_CHANNELS.domainEvent, | |
| 148 | data: { | |
| 149 | cwd: "/tmp/workspace", | |
| 150 | projectName: "workspace", | |
| 151 | }, | |
| 152 | }), | |
| 153 | ); | |
| 154 | ||
| 155 | assert.strictEqual(result._tag, "Failure"); | |
| 156 | }), | |
| 157 | ); |
packages/contracts/src/ws.ts deleted-257| ... | ... | @@ -1,257 +0,0 @@ |
| 1 | import { Schema, Struct } from "effect"; | |
| 2 | import { NonNegativeInt, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; | |
| 3 | ||
| 4 | import { | |
| 5 | ClientOrchestrationCommand, | |
| 6 | OrchestrationEvent, | |
| 7 | ORCHESTRATION_WS_CHANNELS, | |
| 8 | OrchestrationGetFullThreadDiffInput, | |
| 9 | ORCHESTRATION_WS_METHODS, | |
| 10 | OrchestrationGetSnapshotInput, | |
| 11 | OrchestrationGetTurnDiffInput, | |
| 12 | OrchestrationReplayEventsInput, | |
| 13 | } from "./orchestration"; | |
| 14 | import { | |
| 15 | GitActionProgressEvent, | |
| 16 | GitCheckoutInput, | |
| 17 | GitCreateBranchInput, | |
| 18 | GitPreparePullRequestThreadInput, | |
| 19 | GitCreateWorktreeInput, | |
| 20 | GitInitInput, | |
| 21 | GitListBranchesInput, | |
| 22 | GitPullInput, | |
| 23 | GitPullRequestRefInput, | |
| 24 | GitRemoveWorktreeInput, | |
| 25 | GitRunStackedActionInput, | |
| 26 | GitStatusInput, | |
| 27 | } from "./git"; | |
| 28 | import { | |
| 29 | TerminalClearInput, | |
| 30 | TerminalCloseInput, | |
| 31 | TerminalEvent, | |
| 32 | TerminalOpenInput, | |
| 33 | TerminalResizeInput, | |
| 34 | TerminalRestartInput, | |
| 35 | TerminalWriteInput, | |
| 36 | } from "./terminal"; | |
| 37 | import { KeybindingRule } from "./keybindings"; | |
| 38 | import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; | |
| 39 | import { OpenInEditorInput } from "./editor"; | |
| 40 | import { ServerConfigUpdatedPayload, ServerProviderUpdatedPayload } from "./server"; | |
| 41 | import { ServerSettingsPatch } from "./settings"; | |
| 42 | ||
| 43 | // ── WebSocket RPC Method Names ─────────────────────────────────────── | |
| 44 | ||
| 45 | export const WS_METHODS = { | |
| 46 | // Project registry methods | |
| 47 | projectsList: "projects.list", | |
| 48 | projectsAdd: "projects.add", | |
| 49 | projectsRemove: "projects.remove", | |
| 50 | projectsSearchEntries: "projects.searchEntries", | |
| 51 | projectsWriteFile: "projects.writeFile", | |
| 52 | ||
| 53 | // Shell methods | |
| 54 | shellOpenInEditor: "shell.openInEditor", | |
| 55 | ||
| 56 | // Git methods | |
| 57 | gitPull: "git.pull", | |
| 58 | gitStatus: "git.status", | |
| 59 | gitRunStackedAction: "git.runStackedAction", | |
| 60 | gitListBranches: "git.listBranches", | |
| 61 | gitCreateWorktree: "git.createWorktree", | |
| 62 | gitRemoveWorktree: "git.removeWorktree", | |
| 63 | gitCreateBranch: "git.createBranch", | |
| 64 | gitCheckout: "git.checkout", | |
| 65 | gitInit: "git.init", | |
| 66 | gitResolvePullRequest: "git.resolvePullRequest", | |
| 67 | gitPreparePullRequestThread: "git.preparePullRequestThread", | |
| 68 | ||
| 69 | // Terminal methods | |
| 70 | terminalOpen: "terminal.open", | |
| 71 | terminalWrite: "terminal.write", | |
| 72 | terminalResize: "terminal.resize", | |
| 73 | terminalClear: "terminal.clear", | |
| 74 | terminalRestart: "terminal.restart", | |
| 75 | terminalClose: "terminal.close", | |
| 76 | ||
| 77 | // Server meta | |
| 78 | serverGetConfig: "server.getConfig", | |
| 79 | serverRefreshProviders: "server.refreshProviders", | |
| 80 | serverUpsertKeybinding: "server.upsertKeybinding", | |
| 81 | serverGetSettings: "server.getSettings", | |
| 82 | serverUpdateSettings: "server.updateSettings", | |
| 83 | } as const; | |
| 84 | ||
| 85 | // ── Push Event Channels ────────────────────────────────────────────── | |
| 86 | ||
| 87 | export const WS_CHANNELS = { | |
| 88 | gitActionProgress: "git.actionProgress", | |
| 89 | terminalEvent: "terminal.event", | |
| 90 | serverWelcome: "server.welcome", | |
| 91 | serverConfigUpdated: "server.configUpdated", | |
| 92 | serverProvidersUpdated: "server.providersUpdated", | |
| 93 | } as const; | |
| 94 | ||
| 95 | // -- Tagged Union of all request body schemas ───────────────────────── | |
| 96 | ||
| 97 | const tagRequestBody = <const Tag extends string, const Fields extends Schema.Struct.Fields>( | |
| 98 | tag: Tag, | |
| 99 | schema: Schema.Struct<Fields>, | |
| 100 | ) => | |
| 101 | schema.mapFields( | |
| 102 | Struct.assign({ _tag: Schema.tag(tag) }), | |
| 103 | // PreserveChecks is safe here. No existing schema should have checks depending on the tag | |
| 104 | { unsafePreserveChecks: true }, | |
| 105 | ); | |
| 106 | ||
| 107 | const WebSocketRequestBody = Schema.Union([ | |
| 108 | // Orchestration methods | |
| 109 | tagRequestBody( | |
| 110 | ORCHESTRATION_WS_METHODS.dispatchCommand, | |
| 111 | Schema.Struct({ command: ClientOrchestrationCommand }), | |
| 112 | ), | |
| 113 | tagRequestBody(ORCHESTRATION_WS_METHODS.getSnapshot, OrchestrationGetSnapshotInput), | |
| 114 | tagRequestBody(ORCHESTRATION_WS_METHODS.getTurnDiff, OrchestrationGetTurnDiffInput), | |
| 115 | tagRequestBody(ORCHESTRATION_WS_METHODS.getFullThreadDiff, OrchestrationGetFullThreadDiffInput), | |
| 116 | tagRequestBody(ORCHESTRATION_WS_METHODS.replayEvents, OrchestrationReplayEventsInput), | |
| 117 | ||
| 118 | // Project Search | |
| 119 | tagRequestBody(WS_METHODS.projectsSearchEntries, ProjectSearchEntriesInput), | |
| 120 | tagRequestBody(WS_METHODS.projectsWriteFile, ProjectWriteFileInput), | |
| 121 | ||
| 122 | // Shell methods | |
| 123 | tagRequestBody(WS_METHODS.shellOpenInEditor, OpenInEditorInput), | |
| 124 | ||
| 125 | // Git methods | |
| 126 | tagRequestBody(WS_METHODS.gitPull, GitPullInput), | |
| 127 | tagRequestBody(WS_METHODS.gitStatus, GitStatusInput), | |
| 128 | tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput), | |
| 129 | tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput), | |
| 130 | tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput), | |
| 131 | tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput), | |
| 132 | tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput), | |
| 133 | tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput), | |
| 134 | tagRequestBody(WS_METHODS.gitInit, GitInitInput), | |
| 135 | tagRequestBody(WS_METHODS.gitResolvePullRequest, GitPullRequestRefInput), | |
| 136 | tagRequestBody(WS_METHODS.gitPreparePullRequestThread, GitPreparePullRequestThreadInput), | |
| 137 | ||
| 138 | // Terminal methods | |
| 139 | tagRequestBody(WS_METHODS.terminalOpen, TerminalOpenInput), | |
| 140 | tagRequestBody(WS_METHODS.terminalWrite, TerminalWriteInput), | |
| 141 | tagRequestBody(WS_METHODS.terminalResize, TerminalResizeInput), | |
| 142 | tagRequestBody(WS_METHODS.terminalClear, TerminalClearInput), | |
| 143 | tagRequestBody(WS_METHODS.terminalRestart, TerminalRestartInput), | |
| 144 | tagRequestBody(WS_METHODS.terminalClose, TerminalCloseInput), | |
| 145 | ||
| 146 | // Server meta | |
| 147 | tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), | |
| 148 | tagRequestBody(WS_METHODS.serverRefreshProviders, Schema.Struct({})), | |
| 149 | tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), | |
| 150 | tagRequestBody(WS_METHODS.serverGetSettings, Schema.Struct({})), | |
| 151 | tagRequestBody(WS_METHODS.serverUpdateSettings, Schema.Struct({ patch: ServerSettingsPatch })), | |
| 152 | ]); | |
| 153 | ||
| 154 | export const WebSocketRequest = Schema.Struct({ | |
| 155 | id: TrimmedNonEmptyString, | |
| 156 | body: WebSocketRequestBody, | |
| 157 | }); | |
| 158 | export type WebSocketRequest = typeof WebSocketRequest.Type; | |
| 159 | ||
| 160 | export const WebSocketResponse = Schema.Struct({ | |
| 161 | id: TrimmedNonEmptyString, | |
| 162 | result: Schema.optional(Schema.Unknown), | |
| 163 | error: Schema.optional( | |
| 164 | Schema.Struct({ | |
| 165 | message: Schema.String, | |
| 166 | }), | |
| 167 | ), | |
| 168 | }); | |
| 169 | export type WebSocketResponse = typeof WebSocketResponse.Type; | |
| 170 | ||
| 171 | export const WsPushSequence = NonNegativeInt; | |
| 172 | export type WsPushSequence = typeof WsPushSequence.Type; | |
| 173 | ||
| 174 | export const WsWelcomePayload = Schema.Struct({ | |
| 175 | cwd: TrimmedNonEmptyString, | |
| 176 | projectName: TrimmedNonEmptyString, | |
| 177 | bootstrapProjectId: Schema.optional(ProjectId), | |
| 178 | bootstrapThreadId: Schema.optional(ThreadId), | |
| 179 | }); | |
| 180 | export type WsWelcomePayload = typeof WsWelcomePayload.Type; | |
| 181 | ||
| 182 | export interface WsPushPayloadByChannel { | |
| 183 | readonly [WS_CHANNELS.serverWelcome]: WsWelcomePayload; | |
| 184 | readonly [WS_CHANNELS.serverConfigUpdated]: typeof ServerConfigUpdatedPayload.Type; | |
| 185 | readonly [WS_CHANNELS.serverProvidersUpdated]: typeof ServerProviderUpdatedPayload.Type; | |
| 186 | readonly [WS_CHANNELS.gitActionProgress]: typeof GitActionProgressEvent.Type; | |
| 187 | readonly [WS_CHANNELS.terminalEvent]: typeof TerminalEvent.Type; | |
| 188 | readonly [ORCHESTRATION_WS_CHANNELS.domainEvent]: OrchestrationEvent; | |
| 189 | } | |
| 190 | ||
| 191 | export type WsPushChannel = keyof WsPushPayloadByChannel; | |
| 192 | export type WsPushData<C extends WsPushChannel> = WsPushPayloadByChannel[C]; | |
| 193 | ||
| 194 | const makeWsPushSchema = <const Channel extends string, Payload extends Schema.Schema<any>>( | |
| 195 | channel: Channel, | |
| 196 | payload: Payload, | |
| 197 | ) => | |
| 198 | Schema.Struct({ | |
| 199 | type: Schema.Literal("push"), | |
| 200 | sequence: WsPushSequence, | |
| 201 | channel: Schema.Literal(channel), | |
| 202 | data: payload, | |
| 203 | }); | |
| 204 | ||
| 205 | export const WsPushServerWelcome = makeWsPushSchema(WS_CHANNELS.serverWelcome, WsWelcomePayload); | |
| 206 | export const WsPushServerConfigUpdated = makeWsPushSchema( | |
| 207 | WS_CHANNELS.serverConfigUpdated, | |
| 208 | ServerConfigUpdatedPayload, | |
| 209 | ); | |
| 210 | export const WsPushServerProvidersUpdated = makeWsPushSchema( | |
| 211 | WS_CHANNELS.serverProvidersUpdated, | |
| 212 | ServerProviderUpdatedPayload, | |
| 213 | ); | |
| 214 | export const WsPushGitActionProgress = makeWsPushSchema( | |
| 215 | WS_CHANNELS.gitActionProgress, | |
| 216 | GitActionProgressEvent, | |
| 217 | ); | |
| 218 | export const WsPushTerminalEvent = makeWsPushSchema(WS_CHANNELS.terminalEvent, TerminalEvent); | |
| 219 | export const WsPushOrchestrationDomainEvent = makeWsPushSchema( | |
| 220 | ORCHESTRATION_WS_CHANNELS.domainEvent, | |
| 221 | OrchestrationEvent, | |
| 222 | ); | |
| 223 | ||
| 224 | export const WsPushChannelSchema = Schema.Literals([ | |
| 225 | WS_CHANNELS.gitActionProgress, | |
| 226 | WS_CHANNELS.serverWelcome, | |
| 227 | WS_CHANNELS.serverConfigUpdated, | |
| 228 | WS_CHANNELS.serverProvidersUpdated, | |
| 229 | WS_CHANNELS.terminalEvent, | |
| 230 | ORCHESTRATION_WS_CHANNELS.domainEvent, | |
| 231 | ]); | |
| 232 | export type WsPushChannelSchema = typeof WsPushChannelSchema.Type; | |
| 233 | ||
| 234 | export const WsPush = Schema.Union([ | |
| 235 | WsPushServerWelcome, | |
| 236 | WsPushServerConfigUpdated, | |
| 237 | WsPushServerProvidersUpdated, | |
| 238 | WsPushGitActionProgress, | |
| 239 | WsPushTerminalEvent, | |
| 240 | WsPushOrchestrationDomainEvent, | |
| 241 | ]); | |
| 242 | export type WsPush = typeof WsPush.Type; | |
| 243 | ||
| 244 | export type WsPushMessage<C extends WsPushChannel> = Extract<WsPush, { channel: C }>; | |
| 245 | ||
| 246 | export const WsPushEnvelopeBase = Schema.Struct({ | |
| 247 | type: Schema.Literal("push"), | |
| 248 | sequence: WsPushSequence, | |
| 249 | channel: WsPushChannelSchema, | |
| 250 | data: Schema.Unknown, | |
| 251 | }); | |
| 252 | export type WsPushEnvelopeBase = typeof WsPushEnvelopeBase.Type; | |
| 253 | ||
| 254 | // ── Union of all server → client messages ───────────────────────────── | |
| 255 | ||
| 256 | export const WsResponse = Schema.Union([WebSocketResponse, WsPush]); | |
| 257 | export type WsResponse = typeof WsResponse.Type; |