Unified · Split
| @@ -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", |
| @@ -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. | |
| @@ -10,11 +10,11 @@ const devServerUrl = `http://localhost:${port}`; · 9 unmodified lines | ||
| 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; |
| @@ -399,7 +399,7 @@ function resolveAboutCommitHash(): string | null { · 398 unmodified lines | ||
| 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 { |
| @@ -69,6 +69,7 @@ import { · 68 unmodified lines | ||
| 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>( · 50 unmodified lines | ||
| 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 = ( · 188 unmodified lines | ||
| 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), |
| @@ -57,7 +57,7 @@ function waitForSync<A>( · 56 unmodified lines | ||
| 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; |
| @@ -8,29 +8,29 @@ · 7 unmodified lines | ||
| 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 @@ · 3 unmodified lines | ||
| 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:" |
| @@ -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 | +); | |
| @@ -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. |
| @@ -10,7 +10,7 @@ import { CheckpointStoreLive } from "./CheckpointStore.ts"; · 9 unmodified lines | ||
| 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 | |
| @@ -14,7 +14,7 @@ import { randomUUID } from "node:crypto"; · 13 unmodified lines | ||
| 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"; |
| @@ -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 | +}); | |
| @@ -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 | +}); | |
| @@ -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; | |
| @@ -6,11 +6,12 @@ · 5 unmodified lines | ||
| 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 { · 17 unmodified lines | ||
| 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* ( · 33 unmodified lines | ||
| 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 · 12 unmodified lines | ||
| 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, |
| @@ -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; | |
| @@ -14,7 +14,7 @@ import { ClaudeModelSelection } from "@t3tools/contracts"; · 13 unmodified lines | ||
| 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, |
| @@ -5,7 +5,7 @@ import { expect } from "vitest"; · 4 unmodified lines | ||
| 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 | |
| @@ -8,7 +8,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar · 7 unmodified lines | ||
| 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, |
| @@ -8,7 +8,7 @@ import { describe, expect, vi } from "vitest"; · 7 unmodified lines | ||
| 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 | |
| @@ -18,7 +18,7 @@ import { · 17 unmodified lines | ||
| 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?: { · 1916 unmodified lines | ||
| 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 | ); |
| @@ -2,7 +2,7 @@ import { Effect, Layer, Schema } from "effect"; · 1 unmodified line | ||
| 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, |
| @@ -8,7 +8,7 @@ import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; · 7 unmodified lines | ||
| 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, |
| @@ -14,7 +14,7 @@ import { · 13 unmodified lines | ||
| 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"; · 4 unmodified lines | ||
| 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; |
| @@ -7,7 +7,7 @@ import { · 6 unmodified lines | ||
| 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", () => { |
| @@ -22,7 +22,7 @@ import type { · 21 unmodified lines | ||
| 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; |
| @@ -9,7 +9,7 @@ import { ServiceMap } from "effect"; · 8 unmodified lines | ||
| 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; |
| @@ -19,7 +19,7 @@ import { · 18 unmodified lines | ||
| 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>; |
| @@ -10,7 +10,7 @@ import { ServiceMap } from "effect"; · 9 unmodified lines | ||
| 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"; |
| @@ -5,7 +5,7 @@ · 4 unmodified lines | ||
| 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"; |
| @@ -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 | +); | |
| @@ -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); | |
| @@ -8,13 +8,13 @@ import { ServerConfig } from "./config"; · 7 unmodified lines | ||
| 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 = () => { |
| @@ -9,6 +9,7 @@ · 8 unmodified lines | ||
| 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"; · 31 unmodified lines | ||
| 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" } |
| @@ -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 | −} | |
| @@ -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 | −}); | |
| @@ -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 | −); | |
| @@ -10,17 +10,14 @@ import { spawn } from "node:child_process"; · 9 unmodified lines | ||
| 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; |
| @@ -39,6 +39,7 @@ import { · 38 unmodified lines | ||
| 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( · 70 unmodified lines | ||
| 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( · 18 unmodified lines | ||
| 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 { · 44 unmodified lines | ||
| 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", () => { · 65 unmodified lines | ||
| 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", () => { · 737 unmodified lines | ||
| 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({ |
| @@ -205,7 +205,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 204 unmodified lines | ||
| 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 | |
| @@ -3,7 +3,6 @@ import { · 2 unmodified lines | ||
| 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 · 1261 unmodified lines | ||
| 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( · 13 unmodified lines | ||
| 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), |
| @@ -17,7 +17,7 @@ import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effe · 16 unmodified lines | ||
| 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"; |
| @@ -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 | + }); | |
| @@ -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 | −}); | |
| @@ -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 | −); | |
| @@ -208,6 +208,9 @@ async function readFirstPromptText( · 207 unmodified lines | ||
| 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; |
| @@ -533,7 +533,7 @@ function buildUserMessage(input: { · 532 unmodified lines | ||
| 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 | } |
| @@ -24,7 +24,8 @@ import { · 23 unmodified lines | ||
| 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> = [ |
| @@ -46,7 +46,8 @@ import { · 45 unmodified lines | ||
| 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"]); |
| @@ -3,7 +3,7 @@ import { Duration, Effect, PubSub, Ref, Scope, Stream } from "effect"; · 2 unmodified lines | ||
| 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>; |
| @@ -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 | +}); | |
| @@ -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 | +>; | |
| @@ -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 | −} | |
| @@ -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 | +); | |
| @@ -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 | +); | |
| @@ -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); |
| @@ -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 | +); | |
| @@ -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 | +); | |
| @@ -16,6 +16,7 @@ import { · 15 unmodified lines | ||
| 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 { · 13 unmodified lines | ||
| 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* () { · 155 unmodified lines | ||
| 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 | } |
| @@ -10,85 +10,59 @@ import { · 9 unmodified lines | ||
| 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 | */ |
| @@ -9,9 +9,11 @@ import { GitCoreLive } from "../../git/Layers/GitCore.ts"; · 8 unmodified lines | ||
| 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(), { |
| @@ -11,6 +11,7 @@ import { · 10 unmodified lines | ||
| 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 => · 202 unmodified lines | ||
| 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* () { · 210 unmodified lines | ||
| 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)); |
| @@ -12,12 +12,13 @@ import { WorkspacePathsLive } from "./WorkspacePaths.ts"; · 11 unmodified lines | ||
| 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(), { |
| @@ -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 | +); | |
| @@ -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 | −}); | |
| @@ -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); | |
| @@ -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 | −}); | |
| @@ -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 | − }); | |
| @@ -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 | −}); | |
| @@ -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, |
| @@ -6,8 +6,13 @@ export default mergeConfig( · 5 unmodified lines | ||
| 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 | ); |
| @@ -19,6 +19,7 @@ · 18 unmodified lines | ||
| 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", |
| @@ -4,16 +4,14 @@ import "../index.css"; · 3 unmodified lines | ||
| 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 { · 12 unmodified lines | ||
| 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"; · 4 unmodified lines | ||
| 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 · 319 unmodified lines | ||
| 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 { · 217 unmodified lines | ||
| 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 { · 40 unmodified lines | ||
| 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 { · 12 unmodified lines | ||
| 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( · 167 unmodified lines | ||
| 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: { · 131 unmodified lines | ||
| 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)", () => { · 81 unmodified lines | ||
| 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)", () => { · 203 unmodified lines | ||
| 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)", () => { · 1 unmodified line | ||
| 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)", () => { · 29 unmodified lines | ||
| 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)", () => { · 1 unmodified line | ||
| 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)", () => { · 29 unmodified lines | ||
| 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)", () => { · 42 unmodified lines | ||
| 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)", () => { · 8 unmodified lines | ||
| 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)", () => { · 1 unmodified line | ||
| 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( |
| @@ -11,11 +11,9 @@ import { · 10 unmodified lines | ||
| 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"; · 8 unmodified lines | ||
| 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 { · 158 unmodified lines | ||
| 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) { · 584 unmodified lines | ||
| 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) { · 400 unmodified lines | ||
| 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) { · 488 unmodified lines | ||
| 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) => { |
| @@ -294,74 +294,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 293 unmodified lines | ||
| 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 · 115 unmodified lines | ||
| 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 { |
| @@ -6,9 +6,8 @@ import { · 5 unmodified lines | ||
| 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 } · 3 unmodified lines | ||
| 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"; · 2 unmodified lines | ||
| 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 { · 139 unmodified lines | ||
| 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", () => { · 77 unmodified lines | ||
| 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: {}, |
| @@ -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 | ); |
| @@ -43,9 +43,8 @@ import { · 42 unmodified lines | ||
| 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 { · 15 unmodified lines | ||
| 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 { · 51 unmodified lines | ||
| 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() { · 335 unmodified lines | ||
| 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); |
| @@ -9,7 +9,7 @@ import { · 8 unmodified lines | ||
| 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 { · 24 unmodified lines | ||
| 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"; · 12 unmodified lines | ||
| 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 = { · 16 unmodified lines | ||
| 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() { · 427 unmodified lines | ||
| 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() { · 19 unmodified lines | ||
| 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() { · 1 unmodified line | ||
| 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() { · 1 unmodified line | ||
| 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); |
| @@ -10,15 +10,12 @@ · 9 unmodified lines | ||
| 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 { · 4 unmodified lines | ||
| 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>): { · 31 unmodified lines | ||
| 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>( · 2 unmodified lines | ||
| 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>( · 2 unmodified lines | ||
| 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() { · 5 unmodified lines | ||
| 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() { · 2 unmodified lines | ||
| 126 | 118 | setClientSettings((prev) => ({ ...prev, ...clientPatch })); |
| 127 | 119 | } |
| 128 | 120 | }, |
| 129 | − [queryClient, setClientSettings], | |
| 121 | + [setClientSettings], | |
| 130 | 122 | ); |
| 131 | 123 | |
| 132 | 124 | const resetSettings = useCallback(() => { |
| @@ -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: { · 114 unmodified lines | ||
| 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); |
| @@ -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 | −} | |
| @@ -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()); · 29 unmodified lines | ||
| 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 | +}; | |
| @@ -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 { · 17 unmodified lines | ||
| 24 | 24 | } |
| 25 | 25 | return api; |
| 26 | 26 | } |
| 27 | + | |
| 28 | +export function __resetNativeApiForTests() { | |
| 29 | + cachedApi = undefined; | |
| 30 | + __resetWsNativeApiForTests(); | |
| 31 | +} | |
| @@ -2,6 +2,7 @@ import { createElement } from "react"; · 1 unmodified line | ||
| 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) { · 5 unmodified lines | ||
| 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 | |
| @@ -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"; · 2 unmodified lines | ||
| 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"; · 3 unmodified lines | ||
| 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() { · 25 unmodified lines | ||
| 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() { · 81 unmodified lines | ||
| 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() { · 129 unmodified lines | ||
| 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() { · 17 unmodified lines | ||
| 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() { · 8 unmodified lines | ||
| 433 | 451 | syncThreads, |
| 434 | 452 | ]); |
| 435 | 453 | |
| 454 | + useServerWelcomeSubscription(handleWelcome); | |
| 455 | + useServerConfigUpdatedSubscription(handleServerConfigUpdated); | |
| 456 | + | |
| 436 | 457 | return null; |
| 437 | 458 | } |
| 438 | 459 | |
| @@ -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 |
| @@ -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 | +} | |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 = · 1 unmodified line | ||
| 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", () => ({ · 1 unmodified line | ||
| 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 · 7 unmodified lines | ||
| 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> = [ · 7 unmodified lines | ||
| 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(() => { · 2 unmodified lines | ||
| 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", () => { · 2 unmodified lines | ||
| 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", () => { · 10 unmodified lines | ||
| 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", () => { · 11 unmodified lines | ||
| 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", () => { · 3 unmodified lines | ||
| 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 | }); |
| @@ -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 { · 9 unmodified lines | ||
| 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 { · 3 unmodified lines | ||
| 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 { · 7 unmodified lines | ||
| 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 | } |
| @@ -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 | +} | |
| @@ -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 { · 5 unmodified lines | ||
| 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 { · 3 unmodified lines | ||
| 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 { · 12 unmodified lines | ||
| 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(() => { · 7 unmodified lines | ||
| 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 | }); |
| @@ -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 | } |
| @@ -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 | +} | |
| @@ -21,5 +21,5 @@ · 20 unmodified lines | ||
| 21 | 21 | } |
| 22 | 22 | ] |
| 23 | 23 | }, |
| 24 | − "include": ["src", "vite.config.ts"] | |
| 24 | + "include": ["src", "vite.config.ts", "test"] | |
| 25 | 25 | } |
| @@ -44,17 +44,17 @@ · 43 unmodified lines | ||
| 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 @@ · 3 unmodified lines | ||
| 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 @@ · 8 unmodified lines | ||
| 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 @@ · 90 unmodified lines | ||
| 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 @@ · 85 unmodified lines | ||
| 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=="], |
| @@ -9,6 +9,8 @@ · 8 unmodified lines | ||
| 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", |
| @@ -24,3 +24,8 @@ export const OpenInEditorInput = Schema.Struct({ · 23 unmodified lines | ||
| 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 | +}) {} | |
| @@ -223,6 +223,60 @@ export const GitPullResult = Schema.Struct({ · 222 unmodified lines | ||
| 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, |
| @@ -4,7 +4,6 @@ export * from "./terminal"; · 3 unmodified lines | ||
| 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"; · 1 unmodified line | ||
| 12 | 11 | export * from "./orchestration"; |
| 13 | 12 | export * from "./editor"; |
| 14 | 13 | export * from "./project"; |
| 14 | +export * from "./rpc"; | |
| @@ -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 { · 7 unmodified lines | ||
| 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 { · 139 unmodified lines | ||
| 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>( |
| @@ -85,24 +85,27 @@ export const KeybindingShortcut = Schema.Struct({ · 84 unmodified lines | ||
| 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 · 13 unmodified lines | ||
| 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 | +} | |
| @@ -23,10 +23,6 @@ export const ORCHESTRATION_WS_METHODS = { · 22 unmodified lines | ||
| 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 = { · 1004 unmodified lines | ||
| 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 | +) {} | |
| @@ -26,6 +26,14 @@ export const ProjectSearchEntriesResult = Schema.Struct({ · 25 unmodified lines | ||
| 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({ · 5 unmodified lines | ||
| 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 | +) {} | |
| @@ -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 | +); | |
| @@ -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({ · 57 unmodified lines | ||
| 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 · 17 unmodified lines | ||
| 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 | }); |
| @@ -93,6 +93,19 @@ export type ServerSettings = typeof ServerSettings.Type; · 92 unmodified lines | ||
| 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; |
| @@ -149,3 +149,74 @@ export const TerminalEvent = Schema.Union([ · 148 unmodified lines | ||
| 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; | |
| @@ -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 | −); | |
| @@ -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; | |