authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-31 23:58:35-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-31 23:58:35-07:00
logd8aa2f85dfd76dde3698754f195c9b4bb9c613a3
treedaf1cfb2feafb9db3ab04a19db18e86b25f5f6b1
parent64d6938cf40de16554c3243572bc0eb38214434d
signature Signed by PGP key B5690EEEBB952194

chore: effect rpc and layer based startup (#929)

Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>

116 files changed, 6676 insertions(+), 6648 deletions(-)

.oxfmtrc.json+1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1{1{
2 "$schema": "./node_modules/oxfmt/configuration_schema.json",2 "$schema": "./node_modules/oxfmt/configuration_schema.json",
3 "ignorePatterns": [3 "ignorePatterns": [
4 ".reference",
4 ".plans",5 ".plans",
5 "dist",6 "dist",
6 "dist-electron",7 "dist-electron",
.plans/effect-atom.md created+89
...@@ -0,0 +1,89 @@
1# Replace React Query With AtomRpc + Atom State
2
3## Summary
4- Use `effect/unstable/reactivity/AtomRpc` over the existing `WsRpcGroup`; stop wrapping RPC in promises via [wsRpcClient.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsRpcClient.ts) and [wsNativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApi.ts).
5- Keep Zustand for orchestration read model and UI state.
6- Keep a narrow `desktopBridge` adapter for dialogs, menus, external links, theme, and updater APIs.
7- Do not introduce Suspense in this migration. Atom-backed hooks should keep returning `data`, `error`, `isLoading|isPending`, `refresh`, and `mutateAsync`-style surfaces so component churn stays low.
8
9## Target Architecture
10- Extract the websocket `RpcClient.Protocol` layer from [wsTransport.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsTransport.ts) into `rpc/protocol.ts`.
11- Define one `AtomRpc.Service` for `WsRpcGroup` in `rpc/client.ts`.
12- Add `rpc/invalidation.ts` with explicit scoped invalidation keys: `git:${cwd}`, `project:${cwd}`, `checkpoint:${threadId}`, `server-config`.
13- Add `platform/desktopBridge.ts` as the only browser/desktop facade.
14- Remove from web by the end: [wsNativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApi.ts), [nativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/nativeApi.ts), [wsNativeApiState.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApiState.ts), [wsNativeApiAtoms.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApiAtoms.tsx), [wsRpcClient.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsRpcClient.ts), and all `*ReactQuery.ts` modules.
15
16## Phase 1: Infrastructure First
171. 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.
182. Build the AtomRpc client on top of that layer.
193. 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.
204. Replace manual registry wiring with one app-level registry provider based on `@effect/atom-react`.
215. Land this as a no-behavior-change PR.
22
23## Phase 2: Replace `wsNativeApi`-Owned Push State
241. Migrate welcome/config/provider/settings state first, because it is already atom-shaped and is the lowest-risk way to delete `wsNativeApi` responsibilities.
252. 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`.
263. Keep the current hook names for one PR: `useServerConfig`, `useServerSettings`, `useServerProviders`, `useServerKeybindings`, `useServerWelcomeSubscription`, `useServerConfigUpdatedSubscription`.
274. 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).
285. 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
311. Replace [gitReactQuery.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/lib/gitReactQuery.ts) first.
322. Add `rpc/gitAtoms.ts` and `rpc/useGit.ts` with `useGitStatus`, `useGitBranches`, `useResolvePullRequest`, and `useGitMutation`.
333. 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}`.
344. 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.
355. 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}`.
366. Defer the desktop updater until the last phase.
37
38## Phase 4: Move Root Invalidation Off `queryClient`
391. 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.
402. Keep Zustand orchestration/event application unchanged.
413. 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
471. 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`
532. 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.
543. 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.
554. 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
581. Delete `@tanstack/react-query` from `apps/web/package.json`.
592. 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).
603. 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`.
614. Delete the old query-option tests.
62
63## Public Interfaces And Types
64- Preserve the current server-state hook names during the transition.
65- Add permanent domain hooks: `useGitStatus`, `useGitBranches`, `useResolvePullRequest`, `useProjectSearchEntries`, `useCheckpointDiff`, `useDesktopUpdateState`.
66- Do not expose raw AtomRpc clients to components.
67- Do not add Suspense as part of this migration.
68- Final boundary is direct RPC for server features plus `desktopBridge` for local desktop features.
69
70## Test Plan
71- Add unit tests for `rpc/serverState.ts`: snapshot bootstrapping, stream replay, provider/settings updates.
72- Add unit tests for git/project/checkpoint hooks: loading, error mapping, retry behavior, invalidation, keep-previous-result behavior.
73- Update the browser harness in [wsRpcHarness.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/test/wsRpcHarness.ts) to assert direct RPC + atom behavior instead of `__resetNativeApiForTests`.
74- Replace [wsNativeApi.test.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApi.test.ts), `gitReactQuery.test.ts`, `providerReactQuery.test.ts`, and `desktopUpdateReactQuery.test.ts` with equivalent atom-backed coverage.
75- Acceptance scenarios:
76- welcome still bootstraps snapshot and navigation
77- keybindings toast still responds to config stream updates
78- git status/branches refresh after checkout/pull/worktree actions
79- PR resolve dialog keeps cached result while typing
80- `@` path search refreshes after file mutations and orchestration events
81- diff panel refreshes when checkpoints arrive
82- desktop updater still reflects push events and button actions
83
84## Assumptions And Defaults
85- Zustand stays in scope; only `react-query` is being removed.
86- `desktopBridge` remains the only non-RPC boundary.
87- The migration lands as 5-6 small PRs, each green independently.
88- Invalidations are explicit and scoped; do not recreate a global cache client abstraction.
89- Orchestration recovery/order logic stays as-is; only the data-fetching and mutation layer changes.
apps/desktop/scripts/dev-electron.mjs+2-2
...@@ -10,11 +10,11 @@ const devServerUrl = `http://localhost:${port}`;...@@ -10,11 +10,11 @@ const devServerUrl = `http://localhost:${port}`;
10const requiredFiles = [10const requiredFiles = [
11 "dist-electron/main.js",11 "dist-electron/main.js",
12 "dist-electron/preload.js",12 "dist-electron/preload.js",
13 "../server/dist/index.mjs",13 "../server/dist/bin.mjs",
14];14];
15const watchedDirectories = [15const watchedDirectories = [
16 { directory: "dist-electron", files: new Set(["main.js", "preload.js"]) },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];
19const forcedShutdownTimeoutMs = 1_500;19const forcedShutdownTimeoutMs = 1_500;
20const restartDebounceMs = 120;20const restartDebounceMs = 120;
apps/desktop/src/main.ts+1-1
...@@ -399,7 +399,7 @@ function resolveAboutCommitHash(): string | null {...@@ -399,7 +399,7 @@ function resolveAboutCommitHash(): string | null {
399}399}
400400
401function resolveBackendEntry(): string {401function resolveBackendEntry(): string {
402 return Path.join(resolveAppRoot(), "apps/server/dist/index.mjs");402 return Path.join(resolveAppRoot(), "apps/server/dist/bin.mjs");
403}403}
404404
405function resolveBackendCwd(): string {405function resolveBackendCwd(): string {
apps/server/integration/OrchestrationEngineHarness.integration.ts+4-1
...@@ -69,6 +69,7 @@ import {...@@ -69,6 +69,7 @@ import {
69} from "./TestProviderAdapter.integration.ts";69} from "./TestProviderAdapter.integration.ts";
70import { deriveServerPaths, ServerConfig } from "../src/config.ts";70import { deriveServerPaths, ServerConfig } from "../src/config.ts";
71import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts";71import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts";
72import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts";
7273
73function runGit(cwd: string, args: ReadonlyArray<string>) {74function runGit(cwd: string, args: ReadonlyArray<string>) {
74 return execFileSync("git", args, {75 return execFileSync("git", args, {
...@@ -125,7 +126,7 @@ function waitFor<A, E>(...@@ -125,7 +126,7 @@ function waitFor<A, E>(
125 read: Effect.Effect<A, E>,126 read: Effect.Effect<A, E>,
126 predicate: (value: A) => boolean,127 predicate: (value: A) => boolean,
127 description: string,128 description: string,
128 timeoutMs = 10_000,129 timeoutMs = 40_000,
129): Effect.Effect<A, never> {130): Effect.Effect<A, never> {
130 const RETRY_SIGNAL = "wait_for_retry";131 const RETRY_SIGNAL = "wait_for_retry";
131 const retryIntervalMs = 10;132 const retryIntervalMs = 10;
...@@ -320,10 +321,12 @@ export const makeOrchestrationIntegrationHarness = (...@@ -320,10 +321,12 @@ export const makeOrchestrationIntegrationHarness = (
320 Layer.provideMerge(runtimeServicesLayer),321 Layer.provideMerge(runtimeServicesLayer),
321 Layer.provideMerge(322 Layer.provideMerge(
322 WorkspaceEntriesLive.pipe(323 WorkspaceEntriesLive.pipe(
324 Layer.provide(WorkspacePathsLive),
323 Layer.provideMerge(gitCoreLayer),325 Layer.provideMerge(gitCoreLayer),
324 Layer.provide(NodeServices.layer),326 Layer.provide(NodeServices.layer),
325 ),327 ),
326 ),328 ),
329 Layer.provideMerge(WorkspacePathsLive),
327 );330 );
328 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(331 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
329 Layer.provideMerge(runtimeIngestionLayer),332 Layer.provideMerge(runtimeIngestionLayer),
apps/server/integration/orchestrationEngine.integration.test.ts+1-1
...@@ -57,7 +57,7 @@ function waitForSync<A>(...@@ -57,7 +57,7 @@ function waitForSync<A>(
57 read: () => A,57 read: () => A,
58 predicate: (value: A) => boolean,58 predicate: (value: A) => boolean,
59 description: string,59 description: string,
60 timeoutMs = 3000,60 timeoutMs = 10_000,
61): Effect.Effect<A, never> {61): Effect.Effect<A, never> {
62 return Effect.gen(function* () {62 return Effect.gen(function* () {
63 const deadline = Date.now() + timeoutMs;63 const deadline = Date.now() + timeoutMs;
apps/server/package.json+5-6
...@@ -8,29 +8,29 @@...@@ -8,29 +8,29 @@
8 "directory": "apps/server"8 "directory": "apps/server"
9 },9 },
10 "bin": {10 "bin": {
11 "t3": "./dist/index.mjs"11 "t3": "./dist/bin.mjs"
12 },12 },
13 "files": [13 "files": [
14 "dist"14 "dist"
15 ],15 ],
16 "type": "module",16 "type": "module",
17 "scripts": {17 "scripts": {
18 "dev": "bun run src/index.ts",18 "dev": "bun run src/bin.ts",
19 "build": "node scripts/cli.ts build",19 "build": "node scripts/cli.ts build",
20 "start": "node dist/index.mjs",20 "start": "node dist/bin.mjs",
21 "prepare": "effect-language-service patch",21 "prepare": "effect-language-service patch",
22 "typecheck": "tsc --noEmit",22 "typecheck": "tsc --noEmit",
23 "test": "vitest run"23 "test": "vitest run"
24 },24 },
25 "dependencies": {25 "dependencies": {
26 "@anthropic-ai/claude-agent-sdk": "^0.2.77",26 "@anthropic-ai/claude-agent-sdk": "^0.2.77",
27 "@effect/platform-bun": "catalog:",
27 "@effect/platform-node": "catalog:",28 "@effect/platform-node": "catalog:",
28 "@effect/sql-sqlite-bun": "catalog:",29 "@effect/sql-sqlite-bun": "catalog:",
29 "@pierre/diffs": "^1.1.0-beta.16",30 "@pierre/diffs": "^1.1.0-beta.16",
30 "effect": "catalog:",31 "effect": "catalog:",
31 "node-pty": "^1.1.0",32 "node-pty": "^1.1.0",
32 "open": "^10.1.0",33 "open": "^10.1.0"
33 "ws": "^8.18.0"
34 },34 },
35 "devDependencies": {35 "devDependencies": {
36 "@effect/language-service": "catalog:",36 "@effect/language-service": "catalog:",
...@@ -40,7 +40,6 @@...@@ -40,7 +40,6 @@
40 "@t3tools/web": "workspace:*",40 "@t3tools/web": "workspace:*",
41 "@types/bun": "catalog:",41 "@types/bun": "catalog:",
42 "@types/node": "catalog:",42 "@types/node": "catalog:",
43 "@types/ws": "^8.5.13",
44 "tsdown": "catalog:",43 "tsdown": "catalog:",
45 "typescript": "catalog:",44 "typescript": "catalog:",
46 "vitest": "catalog:"45 "vitest": "catalog:"
apps/server/src/bin.ts created+17
...@@ -0,0 +1,17 @@
1import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { Command } from "effect/unstable/cli";
6
7import { NetService } from "@t3tools/shared/Net";
8import { cli } from "./cli";
9import { version } from "../package.json" with { type: "json" };
10
11const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
12
13Command.run(cli, { version }).pipe(
14 Effect.scoped,
15 Effect.provide(CliRuntimeLayer),
16 NodeRuntime.runMain,
17);
apps/server/src/checkpointing/Errors.ts+1-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import type { ProjectionRepositoryError } from "../persistence/Errors.ts";2import type { ProjectionRepositoryError } from "../persistence/Errors.ts";
3import { GitCommandError } from "../git/Errors.ts";3import { GitCommandError } from "@t3tools/contracts";
44
5/**5/**
6 * CheckpointUnavailableError - Expected checkpoint does not exist.6 * CheckpointUnavailableError - Expected checkpoint does not exist.
apps/server/src/checkpointing/Layers/CheckpointStore.test.ts+1-1
...@@ -10,7 +10,7 @@ import { CheckpointStoreLive } from "./CheckpointStore.ts";...@@ -10,7 +10,7 @@ import { CheckpointStoreLive } from "./CheckpointStore.ts";
10import { CheckpointStore } from "../Services/CheckpointStore.ts";10import { CheckpointStore } from "../Services/CheckpointStore.ts";
11import { GitCoreLive } from "../../git/Layers/GitCore.ts";11import { GitCoreLive } from "../../git/Layers/GitCore.ts";
12import { GitCore } from "../../git/Services/GitCore.ts";12import { GitCore } from "../../git/Services/GitCore.ts";
13import { GitCommandError } from "../../git/Errors.ts";13import { GitCommandError } from "@t3tools/contracts";
14import { ServerConfig } from "../../config.ts";14import { ServerConfig } from "../../config.ts";
15import { ThreadId } from "@t3tools/contracts";15import { ThreadId } from "@t3tools/contracts";
1616
apps/server/src/checkpointing/Layers/CheckpointStore.ts+1-1
...@@ -14,7 +14,7 @@ import { randomUUID } from "node:crypto";...@@ -14,7 +14,7 @@ import { randomUUID } from "node:crypto";
14import { Effect, Layer, FileSystem, Path } from "effect";14import { Effect, Layer, FileSystem, Path } from "effect";
1515
16import { CheckpointInvariantError } from "../Errors.ts";16import { CheckpointInvariantError } from "../Errors.ts";
17import { GitCommandError } from "../../git/Errors.ts";17import { GitCommandError } from "@t3tools/contracts";
18import { GitCore } from "../../git/Services/GitCore.ts";18import { GitCore } from "../../git/Services/GitCore.ts";
19import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";19import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";
20import { CheckpointRef } from "@t3tools/contracts";20import { CheckpointRef } from "@t3tools/contracts";
apps/server/src/cli-config.test.ts created+318
...@@ -0,0 +1,318 @@
1import os from "node:os";
2
3import { assert, expect, it } from "@effect/vitest";
4import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect";
5
6import { NetService } from "@t3tools/shared/Net";
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { deriveServerPaths } from "./config";
9import { resolveServerConfig } from "./cli";
10
11it.layer(NodeServices.layer)("cli config resolution", (it) => {
12 const openBootstrapFd = Effect.fn(function* (payload: Record<string, unknown>) {
13 const fs = yield* FileSystem.FileSystem;
14 const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });
15 yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`);
16 const { fd } = yield* fs.open(filePath, { flag: "r" });
17 return fd;
18 });
19
20 it.effect("falls back to effect/config values when flags are omitted", () =>
21 Effect.gen(function* () {
22 const { join } = yield* Path.Path;
23 const baseDir = join(os.tmpdir(), "t3-cli-config-env-base");
24 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173"));
25 const resolved = yield* resolveServerConfig(
26 {
27 mode: Option.none(),
28 port: Option.none(),
29 host: Option.none(),
30 baseDir: Option.none(),
31 devUrl: Option.none(),
32 noBrowser: Option.none(),
33 authToken: Option.none(),
34 bootstrapFd: Option.none(),
35 autoBootstrapProjectFromCwd: Option.none(),
36 logWebSocketEvents: Option.none(),
37 },
38 Option.none(),
39 ).pipe(
40 Effect.provide(
41 Layer.mergeAll(
42 ConfigProvider.layer(
43 ConfigProvider.fromEnv({
44 env: {
45 T3CODE_LOG_LEVEL: "Warn",
46 T3CODE_MODE: "desktop",
47 T3CODE_PORT: "4001",
48 T3CODE_HOST: "0.0.0.0",
49 T3CODE_HOME: baseDir,
50 VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
51 T3CODE_NO_BROWSER: "true",
52 T3CODE_AUTH_TOKEN: "env-token",
53 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
54 T3CODE_LOG_WS_EVENTS: "true",
55 },
56 }),
57 ),
58 NetService.layer,
59 ),
60 ),
61 );
62
63 expect(resolved).toEqual({
64 logLevel: "Warn",
65 mode: "desktop",
66 port: 4001,
67 cwd: process.cwd(),
68 baseDir,
69 ...derivedPaths,
70 host: "0.0.0.0",
71 staticDir: undefined,
72 devUrl: new URL("http://127.0.0.1:5173"),
73 noBrowser: true,
74 authToken: "env-token",
75 autoBootstrapProjectFromCwd: false,
76 logWebSocketEvents: true,
77 });
78 }),
79 );
80
81 it.effect("uses CLI flags when provided", () =>
82 Effect.gen(function* () {
83 const { join } = yield* Path.Path;
84 const baseDir = join(os.tmpdir(), "t3-cli-config-flags-base");
85 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
86 const resolved = yield* resolveServerConfig(
87 {
88 mode: Option.some("web"),
89 port: Option.some(8788),
90 host: Option.some("127.0.0.1"),
91 baseDir: Option.some(baseDir),
92 devUrl: Option.some(new URL("http://127.0.0.1:4173")),
93 noBrowser: Option.some(true),
94 authToken: Option.some("flag-token"),
95 bootstrapFd: Option.none(),
96 autoBootstrapProjectFromCwd: Option.some(true),
97 logWebSocketEvents: Option.some(true),
98 },
99 Option.some("Debug"),
100 ).pipe(
101 Effect.provide(
102 Layer.mergeAll(
103 ConfigProvider.layer(
104 ConfigProvider.fromEnv({
105 env: {
106 T3CODE_LOG_LEVEL: "Warn",
107 T3CODE_MODE: "desktop",
108 T3CODE_PORT: "4001",
109 T3CODE_HOST: "0.0.0.0",
110 T3CODE_HOME: join(os.tmpdir(), "ignored-base"),
111 VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
112 T3CODE_NO_BROWSER: "false",
113 T3CODE_AUTH_TOKEN: "ignored-token",
114 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
115 T3CODE_LOG_WS_EVENTS: "false",
116 },
117 }),
118 ),
119 NetService.layer,
120 ),
121 ),
122 );
123
124 expect(resolved).toEqual({
125 logLevel: "Debug",
126 mode: "web",
127 port: 8788,
128 cwd: process.cwd(),
129 baseDir,
130 ...derivedPaths,
131 host: "127.0.0.1",
132 staticDir: undefined,
133 devUrl: new URL("http://127.0.0.1:4173"),
134 noBrowser: true,
135 authToken: "flag-token",
136 autoBootstrapProjectFromCwd: true,
137 logWebSocketEvents: true,
138 });
139 }),
140 );
141
142 it.effect("uses bootstrap envelope values as fallbacks when flags and env are absent", () =>
143 Effect.gen(function* () {
144 const { join } = yield* Path.Path;
145 const baseDir = "/tmp/t3-bootstrap-home";
146 const fd = yield* openBootstrapFd({
147 mode: "desktop",
148 port: 4888,
149 host: "127.0.0.2",
150 t3Home: baseDir,
151 devUrl: "http://127.0.0.1:5173",
152 noBrowser: true,
153 authToken: "bootstrap-token",
154 autoBootstrapProjectFromCwd: false,
155 logWebSocketEvents: true,
156 });
157 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173"));
158
159 const resolved = yield* resolveServerConfig(
160 {
161 mode: Option.none(),
162 port: Option.none(),
163 host: Option.none(),
164 baseDir: Option.none(),
165 devUrl: Option.none(),
166 noBrowser: Option.none(),
167 authToken: Option.none(),
168 bootstrapFd: Option.none(),
169 autoBootstrapProjectFromCwd: Option.none(),
170 logWebSocketEvents: Option.none(),
171 },
172 Option.none(),
173 ).pipe(
174 Effect.provide(
175 Layer.mergeAll(
176 ConfigProvider.layer(
177 ConfigProvider.fromEnv({
178 env: {
179 T3CODE_BOOTSTRAP_FD: String(fd),
180 },
181 }),
182 ),
183 NetService.layer,
184 ),
185 ),
186 );
187
188 expect(resolved).toEqual({
189 logLevel: "Info",
190 mode: "desktop",
191 port: 4888,
192 cwd: process.cwd(),
193 baseDir,
194 ...derivedPaths,
195 host: "127.0.0.2",
196 staticDir: undefined,
197 devUrl: new URL("http://127.0.0.1:5173"),
198 noBrowser: true,
199 authToken: "bootstrap-token",
200 autoBootstrapProjectFromCwd: false,
201 logWebSocketEvents: true,
202 });
203 assert.equal(join(baseDir, "dev"), resolved.stateDir);
204 }),
205 );
206
207 it.effect("creates derived runtime directories during config resolution", () =>
208 Effect.gen(function* () {
209 const fs = yield* FileSystem.FileSystem;
210 const path = yield* Path.Path;
211 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-dirs-" });
212
213 const resolved = yield* resolveServerConfig(
214 {
215 mode: Option.some("desktop"),
216 port: Option.some(4888),
217 host: Option.none(),
218 baseDir: Option.some(baseDir),
219 devUrl: Option.some(new URL("http://127.0.0.1:5173")),
220 noBrowser: Option.none(),
221 authToken: Option.none(),
222 bootstrapFd: Option.none(),
223 autoBootstrapProjectFromCwd: Option.none(),
224 logWebSocketEvents: Option.none(),
225 },
226 Option.none(),
227 ).pipe(
228 Effect.provide(
229 Layer.mergeAll(
230 ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
231 NetService.layer,
232 ),
233 ),
234 );
235
236 for (const directory of [
237 resolved.stateDir,
238 resolved.logsDir,
239 resolved.providerLogsDir,
240 resolved.terminalLogsDir,
241 resolved.attachmentsDir,
242 resolved.worktreesDir,
243 path.dirname(resolved.serverLogPath),
244 ]) {
245 expect(yield* fs.exists(directory)).toBe(true);
246 }
247 }),
248 );
249
250 it.effect("applies flag then env precedence over bootstrap envelope values", () =>
251 Effect.gen(function* () {
252 const { join } = yield* Path.Path;
253 const baseDir = join(os.tmpdir(), "t3-cli-config-env-wins");
254 const fd = yield* openBootstrapFd({
255 mode: "desktop",
256 port: 4888,
257 host: "127.0.0.2",
258 t3Home: "/tmp/t3-bootstrap-home",
259 devUrl: "http://127.0.0.1:5173",
260 noBrowser: false,
261 authToken: "bootstrap-token",
262 autoBootstrapProjectFromCwd: false,
263 logWebSocketEvents: false,
264 });
265 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
266
267 const resolved = yield* resolveServerConfig(
268 {
269 mode: Option.none(),
270 port: Option.some(8788),
271 host: Option.some("127.0.0.1"),
272 baseDir: Option.none(),
273 devUrl: Option.some(new URL("http://127.0.0.1:4173")),
274 noBrowser: Option.none(),
275 authToken: Option.some("flag-token"),
276 bootstrapFd: Option.none(),
277 autoBootstrapProjectFromCwd: Option.none(),
278 logWebSocketEvents: Option.none(),
279 },
280 Option.some("Debug"),
281 ).pipe(
282 Effect.provide(
283 Layer.mergeAll(
284 ConfigProvider.layer(
285 ConfigProvider.fromEnv({
286 env: {
287 T3CODE_MODE: "web",
288 T3CODE_BOOTSTRAP_FD: String(fd),
289 T3CODE_HOME: baseDir,
290 T3CODE_NO_BROWSER: "true",
291 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
292 T3CODE_LOG_WS_EVENTS: "true",
293 },
294 }),
295 ),
296 NetService.layer,
297 ),
298 ),
299 );
300
301 expect(resolved).toEqual({
302 logLevel: "Debug",
303 mode: "web",
304 port: 8788,
305 cwd: process.cwd(),
306 baseDir,
307 ...derivedPaths,
308 host: "127.0.0.1",
309 staticDir: undefined,
310 devUrl: new URL("http://127.0.0.1:4173"),
311 noBrowser: true,
312 authToken: "flag-token",
313 autoBootstrapProjectFromCwd: true,
314 logWebSocketEvents: true,
315 });
316 }),
317 );
318});
apps/server/src/cli.test.ts created+37
...@@ -0,0 +1,37 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { NetService } from "@t3tools/shared/Net";
3import { assert, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as CliError from "effect/unstable/cli/CliError";
7import { Command } from "effect/unstable/cli";
8
9import { cli } from "./cli.ts";
10
11const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
12
13it.layer(NodeServices.layer)("cli log-level parsing", (it) => {
14 it.effect("accepts the built-in lowercase log-level flag values", () =>
15 Command.runWith(cli, { version: "0.0.0" })(["--log-level", "debug", "--version"]).pipe(
16 Effect.provide(CliRuntimeLayer),
17 ),
18 );
19
20 it.effect("rejects invalid log-level casing before launching the server", () =>
21 Effect.gen(function* () {
22 const error = yield* Command.runWith(cli, { version: "0.0.0" })([
23 "--log-level",
24 "Debug",
25 ]).pipe(Effect.provide(CliRuntimeLayer), Effect.flip);
26
27 if (!CliError.isCliError(error)) {
28 assert.fail(`Expected CliError, got ${String(error)}`);
29 }
30 if (error._tag !== "InvalidValue") {
31 assert.fail(`Expected InvalidValue, got ${error._tag}`);
32 }
33 assert.equal(error.option, "log-level");
34 assert.equal(error.value, "Debug");
35 }),
36 );
37});
apps/server/src/cli.ts created+292
...@@ -0,0 +1,292 @@
1import { NetService } from "@t3tools/shared/Net";
2import { Config, Effect, LogLevel, Option, Schema } from "effect";
3import { Command, Flag, GlobalFlag } from "effect/unstable/cli";
4
5import {
6 DEFAULT_PORT,
7 deriveServerPaths,
8 ensureServerDirectories,
9 resolveStaticDir,
10 ServerConfig,
11 RuntimeMode,
12 type ServerConfigShape,
13} from "./config";
14import { readBootstrapEnvelope } from "./bootstrap";
15import { resolveBaseDir } from "./os-jank";
16import { runServer } from "./server";
17
18const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
19
20const 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
32const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe(
33 Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
34 Flag.optional,
35);
36const portFlag = Flag.integer("port").pipe(
37 Flag.withSchema(PortSchema),
38 Flag.withDescription("Port for the HTTP/WebSocket server."),
39 Flag.optional,
40);
41const 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);
45const baseDirFlag = Flag.string("base-dir").pipe(
46 Flag.withDescription("Base directory path (equivalent to T3CODE_HOME)."),
47 Flag.optional,
48);
49const 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);
54const noBrowserFlag = Flag.boolean("no-browser").pipe(
55 Flag.withDescription("Disable automatic browser opening."),
56 Flag.optional,
57);
58const authTokenFlag = Flag.string("auth-token").pipe(
59 Flag.withDescription("Auth token required for WebSocket connections."),
60 Flag.withAlias("token"),
61 Flag.optional,
62);
63const 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);
68const 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);
74const 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
82const 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
114interface 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
127const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
128 Option.getOrElse(Option.filter(flag, Boolean), () => envValue);
129
130const resolveOptionPrecedence = <Value>(
131 ...values: ReadonlyArray<Option.Option<Value>>
132): Option.Option<Value> => Option.firstSomeOf(values);
133
134export 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
268const 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
281const 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
292export const cli = rootCommand;
apps/server/src/config.ts+26-6
...@@ -6,11 +6,12 @@...@@ -6,11 +6,12 @@
6 *6 *
7 * @module ServerConfig7 * @module ServerConfig
8 */8 */
9import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect";9import { Effect, FileSystem, Layer, LogLevel, Path, Schema, ServiceMap } from "effect";
1010
11export const DEFAULT_PORT = 3773;11export const DEFAULT_PORT = 3773;
1212
13export type RuntimeMode = "web" | "desktop";13export const RuntimeMode = Schema.Literals(["web", "desktop"]);
14export type RuntimeMode = typeof RuntimeMode.Type;
1415
15/**16/**
16 * ServerDerivedPaths - Derived paths from the base directory.17 * ServerDerivedPaths - Derived paths from the base directory.
...@@ -34,6 +35,7 @@ export interface ServerDerivedPaths {...@@ -34,6 +35,7 @@ export interface ServerDerivedPaths {
34 * ServerConfigShape - Process/runtime configuration required by the server.35 * ServerConfigShape - Process/runtime configuration required by the server.
35 */36 */
36export interface ServerConfigShape extends ServerDerivedPaths {37export interface ServerConfigShape extends ServerDerivedPaths {
38 readonly logLevel: LogLevel.LogLevel;
37 readonly mode: RuntimeMode;39 readonly mode: RuntimeMode;
38 readonly port: number;40 readonly port: number;
39 readonly host: string | undefined;41 readonly host: string | undefined;
...@@ -73,6 +75,26 @@ export const deriveServerPaths = Effect.fn(function* (...@@ -73,6 +75,26 @@ export const deriveServerPaths = Effect.fn(function* (
73 };75 };
74});76});
7577
78export 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 * ServerConfig - Service tag for server runtime configuration.99 * ServerConfig - Service tag for server runtime configuration.
78 */100 */
...@@ -91,12 +113,10 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS...@@ -91,12 +113,10 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS
91 ? baseDirOrPrefix113 ? baseDirOrPrefix
92 : yield* fs.makeTempDirectoryScoped({ prefix: baseDirOrPrefix.prefix });114 : yield* fs.makeTempDirectoryScoped({ prefix: baseDirOrPrefix.prefix });
93 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);115 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
94116 yield* ensureServerDirectories(derivedPaths);
95 yield* fs.makeDirectory(derivedPaths.stateDir, { recursive: true });
96 yield* fs.makeDirectory(derivedPaths.logsDir, { recursive: true });
97 yield* fs.makeDirectory(derivedPaths.attachmentsDir, { recursive: true });
98117
99 return {118 return {
119 logLevel: "Error",
100 cwd,120 cwd,
101 baseDir,121 baseDir,
102 ...derivedPaths,122 ...derivedPaths,
apps/server/src/git/Errors.ts deleted-67
...@@ -1,67 +0,0 @@
1import { Schema } from "effect";
2
3/**
4 * GitCommandError - Git command execution failed.
5 */
6export 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 */
21export 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 */
34export 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 */
50export 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 */
63export type GitManagerServiceError =
64 | GitManagerError
65 | GitCommandError
66 | GitHubCliError
67 | TextGenerationError;
apps/server/src/git/Layers/ClaudeTextGeneration.ts+1-1
...@@ -14,7 +14,7 @@ import { ClaudeModelSelection } from "@t3tools/contracts";...@@ -14,7 +14,7 @@ import { ClaudeModelSelection } from "@t3tools/contracts";
14import { resolveApiModelId } from "@t3tools/shared/model";14import { resolveApiModelId } from "@t3tools/shared/model";
15import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";15import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
1616
17import { TextGenerationError } from "../Errors.ts";17import { TextGenerationError } from "@t3tools/contracts";
18import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";18import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
19import {19import {
20 buildBranchNamePrompt,20 buildBranchNamePrompt,
apps/server/src/git/Layers/CodexTextGeneration.test.ts+1-1
...@@ -5,7 +5,7 @@ import { expect } from "vitest";...@@ -5,7 +5,7 @@ import { expect } from "vitest";
55
6import { ServerConfig } from "../../config.ts";6import { ServerConfig } from "../../config.ts";
7import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";7import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
8import { TextGenerationError } from "../Errors.ts";8import { TextGenerationError } from "@t3tools/contracts";
9import { TextGeneration } from "../Services/TextGeneration.ts";9import { TextGeneration } from "../Services/TextGeneration.ts";
10import { ServerSettingsService } from "../../serverSettings.ts";10import { ServerSettingsService } from "../../serverSettings.ts";
1111
apps/server/src/git/Layers/CodexTextGeneration.ts+1-1
...@@ -8,7 +8,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar...@@ -8,7 +8,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar
88
9import { resolveAttachmentPath } from "../../attachmentStore.ts";9import { resolveAttachmentPath } from "../../attachmentStore.ts";
10import { ServerConfig } from "../../config.ts";10import { ServerConfig } from "../../config.ts";
11import { TextGenerationError } from "../Errors.ts";11import { TextGenerationError } from "@t3tools/contracts";
12import {12import {
13 type BranchNameGenerationInput,13 type BranchNameGenerationInput,
14 type ThreadTitleGenerationResult,14 type ThreadTitleGenerationResult,
apps/server/src/git/Layers/GitCore.test.ts+1-1
...@@ -8,7 +8,7 @@ import { describe, expect, vi } from "vitest";...@@ -8,7 +8,7 @@ import { describe, expect, vi } from "vitest";
88
9import { GitCoreLive, makeGitCore } from "./GitCore.ts";9import { GitCoreLive, makeGitCore } from "./GitCore.ts";
10import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";10import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";
11import { GitCommandError } from "../Errors.ts";11import { GitCommandError } from "@t3tools/contracts";
12import { type ProcessRunResult, runProcess } from "../../processRunner.ts";12import { type ProcessRunResult, runProcess } from "../../processRunner.ts";
13import { ServerConfig } from "../../config.ts";13import { ServerConfig } from "../../config.ts";
1414
apps/server/src/git/Layers/GitCore.ts+4-3
...@@ -18,7 +18,7 @@ import {...@@ -18,7 +18,7 @@ import {
18} from "effect";18} from "effect";
19import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";19import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2020
21import { GitCommandError } from "../Errors.ts";21import { GitCommandError } from "@t3tools/contracts";
22import {22import {
23 GitCore,23 GitCore,
24 type ExecuteGitProgress,24 type ExecuteGitProgress,
...@@ -1941,8 +1941,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -1941,8 +1941,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
1941 });1941 });
19421942
1943 // Refresh upstream refs in the background so checkout remains responsive.1943 // Refresh upstream refs in the background so checkout remains responsive.
1944 yield* Effect.forkScoped(1944 yield* refreshCheckedOutBranchUpstream(input.cwd).pipe(
1945 refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })),1945 Effect.ignoreCause({ log: true }),
1946 Effect.forkDetach({ startImmediately: true }),
1946 );1947 );
1947 },1948 },
1948 );1949 );
apps/server/src/git/Layers/GitHubCli.ts+1-1
...@@ -2,7 +2,7 @@ import { Effect, Layer, Schema } from "effect";...@@ -2,7 +2,7 @@ import { Effect, Layer, Schema } from "effect";
2import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";2import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
33
4import { runProcess } from "../../processRunner";4import { runProcess } from "../../processRunner";
5import { GitHubCliError } from "../Errors.ts";5import { GitHubCliError } from "@t3tools/contracts";
6import {6import {
7 GitHubCli,7 GitHubCli,
8 type GitHubRepositoryCloneUrls,8 type GitHubRepositoryCloneUrls,
apps/server/src/git/Layers/GitManager.test.ts+1-1
...@@ -8,7 +8,7 @@ import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";...@@ -8,7 +8,7 @@ import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
8import { expect } from "vitest";8import { expect } from "vitest";
9import type { GitActionProgressEvent, ModelSelection } from "@t3tools/contracts";9import type { GitActionProgressEvent, ModelSelection } from "@t3tools/contracts";
1010
11import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts";11import { GitCommandError, GitHubCliError, TextGenerationError } from "@t3tools/contracts";
12import { type GitManagerShape } from "../Services/GitManager.ts";12import { type GitManagerShape } from "../Services/GitManager.ts";
13import {13import {
14 type GitHubCliShape,14 type GitHubCliShape,
apps/server/src/git/Layers/GitManager.ts+2-2
...@@ -14,7 +14,7 @@ import {...@@ -14,7 +14,7 @@ import {
14 sanitizeFeatureBranchName,14 sanitizeFeatureBranchName,
15} from "@t3tools/shared/git";15} from "@t3tools/shared/git";
1616
17import { GitManagerError } from "../Errors.ts";17import { GitManagerError } from "@t3tools/contracts";
18import {18import {
19 GitManager,19 GitManager,
20 type GitActionProgressReporter,20 type GitActionProgressReporter,
...@@ -25,7 +25,7 @@ import { GitCore } from "../Services/GitCore.ts";...@@ -25,7 +25,7 @@ import { GitCore } from "../Services/GitCore.ts";
25import { GitHubCli } from "../Services/GitHubCli.ts";25import { GitHubCli } from "../Services/GitHubCli.ts";
26import { TextGeneration } from "../Services/TextGeneration.ts";26import { TextGeneration } from "../Services/TextGeneration.ts";
27import { ServerSettingsService } from "../../serverSettings.ts";27import { ServerSettingsService } from "../../serverSettings.ts";
28import type { GitManagerServiceError } from "../Errors.ts";28import type { GitManagerServiceError } from "@t3tools/contracts";
2929
30const COMMIT_TIMEOUT_MS = 10 * 60_000;30const COMMIT_TIMEOUT_MS = 10 * 60_000;
31const MAX_PROGRESS_TEXT_LENGTH = 500;31const MAX_PROGRESS_TEXT_LENGTH = 500;
apps/server/src/git/Prompts.test.ts+1-1
...@@ -7,7 +7,7 @@ import {...@@ -7,7 +7,7 @@ import {
7 buildThreadTitlePrompt,7 buildThreadTitlePrompt,
8} from "./Prompts.ts";8} from "./Prompts.ts";
9import { normalizeCliError, sanitizeThreadTitle } from "./Utils.ts";9import { normalizeCliError, sanitizeThreadTitle } from "./Utils.ts";
10import { TextGenerationError } from "./Errors.ts";10import { TextGenerationError } from "@t3tools/contracts";
1111
12describe("buildCommitMessagePrompt", () => {12describe("buildCommitMessagePrompt", () => {
13 it("includes staged patch and summary in the prompt", () => {13 it("includes staged patch and summary in the prompt", () => {
apps/server/src/git/Services/GitCore.ts+1-1
...@@ -22,7 +22,7 @@ import type {...@@ -22,7 +22,7 @@ import type {
22 GitStatusResult,22 GitStatusResult,
23} from "@t3tools/contracts";23} from "@t3tools/contracts";
2424
25import type { GitCommandError } from "../Errors.ts";25import type { GitCommandError } from "@t3tools/contracts";
2626
27export interface ExecuteGitInput {27export interface ExecuteGitInput {
28 readonly operation: string;28 readonly operation: string;
apps/server/src/git/Services/GitHubCli.ts+1-1
...@@ -9,7 +9,7 @@ import { ServiceMap } from "effect";...@@ -9,7 +9,7 @@ import { ServiceMap } from "effect";
9import type { Effect } from "effect";9import type { Effect } from "effect";
1010
11import type { ProcessRunResult } from "../../processRunner";11import type { ProcessRunResult } from "../../processRunner";
12import type { GitHubCliError } from "../Errors.ts";12import type { GitHubCliError } from "@t3tools/contracts";
1313
14export interface GitHubPullRequestSummary {14export interface GitHubPullRequestSummary {
15 readonly number: number;15 readonly number: number;
apps/server/src/git/Services/GitManager.ts+1-1
...@@ -19,7 +19,7 @@ import {...@@ -19,7 +19,7 @@ import {
19} from "@t3tools/contracts";19} from "@t3tools/contracts";
20import { ServiceMap } from "effect";20import { ServiceMap } from "effect";
21import type { Effect } from "effect";21import type { Effect } from "effect";
22import type { GitManagerServiceError } from "../Errors.ts";22import type { GitManagerServiceError } from "@t3tools/contracts";
2323
24export interface GitActionProgressReporter {24export interface GitActionProgressReporter {
25 readonly publish: (event: GitActionProgressEvent) => Effect.Effect<void, never>;25 readonly publish: (event: GitActionProgressEvent) => Effect.Effect<void, never>;
apps/server/src/git/Services/TextGeneration.ts+1-1
...@@ -10,7 +10,7 @@ import { ServiceMap } from "effect";...@@ -10,7 +10,7 @@ import { ServiceMap } from "effect";
10import type { Effect } from "effect";10import type { Effect } from "effect";
11import type { ChatAttachment, ModelSelection } from "@t3tools/contracts";11import type { ChatAttachment, ModelSelection } from "@t3tools/contracts";
1212
13import type { TextGenerationError } from "../Errors.ts";13import type { TextGenerationError } from "@t3tools/contracts";
1414
15/** Providers that support git text generation (commit messages, PR content, branch names). */15/** Providers that support git text generation (commit messages, PR content, branch names). */
16export type TextGenerationProvider = "codex" | "claudeAgent";16export type TextGenerationProvider = "codex" | "claudeAgent";
apps/server/src/git/Utils.ts+1-1
...@@ -5,7 +5,7 @@...@@ -5,7 +5,7 @@
5 */5 */
6import { Schema } from "effect";6import { Schema } from "effect";
77
8import { TextGenerationError } from "./Errors.ts";8import { TextGenerationError } from "@t3tools/contracts";
99
10import { existsSync } from "node:fs";10import { existsSync } from "node:fs";
11import { join } from "node:path";11import { join } from "node:path";
apps/server/src/http.ts created+197
...@@ -0,0 +1,197 @@
1import Mime from "@effect/platform-node/Mime";
2import { Effect, FileSystem, Option, Path } from "effect";
3import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
4
5import {
6 ATTACHMENTS_ROUTE_PREFIX,
7 normalizeAttachmentRelativePath,
8 resolveAttachmentRelativePath,
9} from "./attachmentPaths";
10import { resolveAttachmentPathById } from "./attachmentStore";
11import { ServerConfig } from "./config";
12import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver";
13
14const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";
15const 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
17export 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
72export 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
112export const staticAndDevRouteLayer = HttpRouter.add(
113 "GET",
114 "*",
115 Effect.gen(function* () {
116 const request = yield* HttpServerRequest.HttpServerRequest;
117 const url = HttpServerRequest.toURL(request);
118 if (Option.isNone(url)) {
119 return HttpServerResponse.text("Bad Request", { status: 400 });
120 }
121
122 const config = yield* ServerConfig;
123 if (config.devUrl) {
124 return HttpServerResponse.redirect(config.devUrl.href, { status: 302 });
125 }
126
127 if (!config.staticDir) {
128 return HttpServerResponse.text("No static directory configured and no dev URL set.", {
129 status: 503,
130 });
131 }
132
133 const fileSystem = yield* FileSystem.FileSystem;
134 const path = yield* Path.Path;
135 const staticRoot = path.resolve(config.staticDir);
136 const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname;
137 const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, "");
138 const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith("..");
139 const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, "");
140 const hasPathTraversalSegment = staticRelativePath.startsWith("..");
141 if (
142 staticRelativePath.length === 0 ||
143 hasRawLeadingParentSegment ||
144 hasPathTraversalSegment ||
145 staticRelativePath.includes("\0")
146 ) {
147 return HttpServerResponse.text("Invalid static file path", { status: 400 });
148 }
149
150 const isWithinStaticRoot = (candidate: string) =>
151 candidate === staticRoot ||
152 candidate.startsWith(staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`);
153
154 let filePath = path.resolve(staticRoot, staticRelativePath);
155 if (!isWithinStaticRoot(filePath)) {
156 return HttpServerResponse.text("Invalid static file path", { status: 400 });
157 }
158
159 const ext = path.extname(filePath);
160 if (!ext) {
161 filePath = path.resolve(filePath, "index.html");
162 if (!isWithinStaticRoot(filePath)) {
163 return HttpServerResponse.text("Invalid static file path", { status: 400 });
164 }
165 }
166
167 const fileInfo = yield* fileSystem
168 .stat(filePath)
169 .pipe(Effect.catch(() => Effect.succeed(null)));
170 if (!fileInfo || fileInfo.type !== "File") {
171 const indexPath = path.resolve(staticRoot, "index.html");
172 const indexData = yield* fileSystem
173 .readFile(indexPath)
174 .pipe(Effect.catch(() => Effect.succeed(null)));
175 if (!indexData) {
176 return HttpServerResponse.text("Not Found", { status: 404 });
177 }
178 return HttpServerResponse.uint8Array(indexData, {
179 status: 200,
180 contentType: "text/html; charset=utf-8",
181 });
182 }
183
184 const contentType = Mime.getType(filePath) ?? "application/octet-stream";
185 const data = yield* fileSystem
186 .readFile(filePath)
187 .pipe(Effect.catch(() => Effect.succeed(null)));
188 if (!data) {
189 return HttpServerResponse.text("Internal Server Error", { status: 500 });
190 }
191
192 return HttpServerResponse.uint8Array(data, {
193 status: 200,
194 contentType,
195 });
196 }),
197);
apps/server/src/index.ts deleted-23
...@@ -1,23 +0,0 @@
1import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import { CliConfig, t3Cli } from "./main";
7import { OpenLive } from "./open";
8import { Command } from "effect/unstable/cli";
9import { version } from "../package.json" with { type: "json" };
10import { ServerLive } from "./wsServer";
11import { NetService } from "@t3tools/shared/Net";
12import { FetchHttpClient } from "effect/unstable/http";
13
14const 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
23Command.run(t3Cli, { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain);
apps/server/src/keybindings.test.ts+1-1
...@@ -8,13 +8,13 @@ import { ServerConfig } from "./config";...@@ -8,13 +8,13 @@ import { ServerConfig } from "./config";
8import {8import {
9 DEFAULT_KEYBINDINGS,9 DEFAULT_KEYBINDINGS,
10 Keybindings,10 Keybindings,
11 KeybindingsConfigError,
12 KeybindingsLive,11 KeybindingsLive,
13 ResolvedKeybindingFromConfig,12 ResolvedKeybindingFromConfig,
14 compileResolvedKeybindingRule,13 compileResolvedKeybindingRule,
15 compileResolvedKeybindingsConfig,14 compileResolvedKeybindingsConfig,
16 parseKeybindingShortcut,15 parseKeybindingShortcut,
17} from "./keybindings";16} from "./keybindings";
17import { KeybindingsConfigError } from "@t3tools/contracts";
1818
19const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);19const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);
20const makeKeybindingsLayer = () => {20const makeKeybindingsLayer = () => {
apps/server/src/keybindings.ts+1-13
...@@ -9,6 +9,7 @@...@@ -9,6 +9,7 @@
9import {9import {
10 KeybindingRule,10 KeybindingRule,
11 KeybindingsConfig,11 KeybindingsConfig,
12 KeybindingsConfigError,
12 KeybindingShortcut,13 KeybindingShortcut,
13 KeybindingWhenNode,14 KeybindingWhenNode,
14 MAX_KEYBINDINGS_COUNT,15 MAX_KEYBINDINGS_COUNT,
...@@ -46,19 +47,6 @@ import * as Semaphore from "effect/Semaphore";...@@ -46,19 +47,6 @@ import * as Semaphore from "effect/Semaphore";
46import { ServerConfig } from "./config";47import { ServerConfig } from "./config";
47import { fromLenientJson } from "@t3tools/shared/schemaJson";48import { fromLenientJson } from "@t3tools/shared/schemaJson";
4849
49export 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
62type WhenToken =50type WhenToken =
63 | { type: "identifier"; value: string }51 | { type: "identifier"; value: string }
64 | { type: "not" }52 | { type: "not" }
apps/server/src/logger.ts deleted-103
...@@ -1,103 +0,0 @@
1import util from "node:util";
2
3type LogLevel = "info" | "warn" | "error" | "event";
4
5type LogContext = Record<string, unknown>;
6
7const 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
16const LEVEL_LABEL: Record<LogLevel, string> = {
17 info: "INFO",
18 warn: "WARN",
19 error: "ERROR",
20 event: "EVENT",
21};
22
23const LEVEL_COLOR: Record<LogLevel, string> = {
24 info: ANSI.cyan,
25 warn: ANSI.yellow,
26 error: ANSI.red,
27 event: ANSI.magenta,
28};
29
30function useColors() {
31 return Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined;
32}
33
34function colorize(value: string, color: string, enabled: boolean) {
35 return enabled ? `${color}${value}${ANSI.reset}` : value;
36}
37
38function timeStamp() {
39 return new Date().toISOString().slice(11, 23);
40}
41
42function 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
63function 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
70function 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
88export function createLogger(scope: string) {
89 return {
90 info(message: string, context?: LogContext) {
91 write("info", scope, message, context);
92 },
93 warn(message: string, context?: LogContext) {
94 write("warn", scope, message, context);
95 },
96 error(message: string, context?: LogContext) {
97 write("error", scope, message, context);
98 },
99 event(message: string, context?: LogContext) {
100 write("event", scope, message, context);
101 },
102 };
103}
apps/server/src/main.test.ts deleted-413
...@@ -1,413 +0,0 @@
1import * as Http from "node:http";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it, vi } from "@effect/vitest";
4import type { OrchestrationReadModel } from "@t3tools/contracts";
5import * as ConfigProvider from "effect/ConfigProvider";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Command from "effect/unstable/cli/Command";
10import { FetchHttpClient } from "effect/unstable/http";
11import { beforeEach } from "vitest";
12import { NetService } from "@t3tools/shared/Net";
13
14import { CliConfig, recordStartupHeartbeat, t3Cli, type CliConfigShape } from "./main";
15import { ServerConfig, type ServerConfigShape } from "./config";
16import { Open, type OpenShape } from "./open";
17import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
18import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
19import { Server, type ServerShape } from "./wsServer";
20import { ServerSettingsService } from "./serverSettings";
21
22const start = vi.fn(() => undefined);
23const stop = vi.fn(() => undefined);
24const fixPath = vi.fn(() => undefined);
25let resolvedConfig: ServerConfigShape | null = null;
26const 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);
34const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred));
35
36// Shared service layer used by this CLI test suite.
37const 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
63const 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
80beforeEach(() => {
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
89it.layer(testLayer)("server CLI command", (it) => {
90 it.effect("parses all CLI flags and wires scoped start/stop", () =>
91 Effect.gen(function* () {
92 yield* runCli([
93 "--mode",
94 "desktop",
95 "--port",
96 "4010",
97 "--host",
98 "0.0.0.0",
99 "--home-dir",
100 "/tmp/t3-cli-home",
101 "--dev-url",
102 "http://127.0.0.1:5173",
103 "--no-browser",
104 "--auth-token",
105 "auth-secret",
106 ]);
107
108 assert.equal(start.mock.calls.length, 1);
109 assert.equal(resolvedConfig?.mode, "desktop");
110 assert.equal(resolvedConfig?.port, 4010);
111 assert.equal(resolvedConfig?.host, "0.0.0.0");
112 assert.equal(resolvedConfig?.baseDir, "/tmp/t3-cli-home");
113 assert.equal(resolvedConfig?.stateDir, "/tmp/t3-cli-home/dev");
114 assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/");
115 assert.equal(resolvedConfig?.noBrowser, true);
116 assert.equal(resolvedConfig?.authToken, "auth-secret");
117 assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false);
118 assert.equal(resolvedConfig?.logWebSocketEvents, true);
119 assert.equal(stop.mock.calls.length, 1);
120 }),
121 );
122
123 it.effect("supports --token as an alias for --auth-token", () =>
124 Effect.gen(function* () {
125 yield* runCli(["--token", "token-secret"]);
126
127 assert.equal(start.mock.calls.length, 1);
128 assert.equal(resolvedConfig?.authToken, "token-secret");
129 }),
130 );
131
132 it.effect("uses env fallbacks when flags are not provided", () =>
133 Effect.gen(function* () {
134 yield* runCli([], {
135 T3CODE_MODE: "desktop",
136 T3CODE_PORT: "4999",
137 T3CODE_HOST: "100.88.10.4",
138 T3CODE_HOME: "/tmp/t3-env-home",
139 VITE_DEV_SERVER_URL: "http://localhost:5173",
140 T3CODE_NO_BROWSER: "true",
141 T3CODE_AUTH_TOKEN: "env-token",
142 });
143
144 assert.equal(start.mock.calls.length, 1);
145 assert.equal(resolvedConfig?.mode, "desktop");
146 assert.equal(resolvedConfig?.port, 4999);
147 assert.equal(resolvedConfig?.host, "100.88.10.4");
148 assert.equal(resolvedConfig?.baseDir, "/tmp/t3-env-home");
149 assert.equal(resolvedConfig?.stateDir, "/tmp/t3-env-home/dev");
150 assert.equal(resolvedConfig?.devUrl?.toString(), "http://localhost:5173/");
151 assert.equal(resolvedConfig?.noBrowser, true);
152 assert.equal(resolvedConfig?.authToken, "env-token");
153 assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false);
154 assert.equal(resolvedConfig?.logWebSocketEvents, true);
155 assert.equal(findAvailablePort.mock.calls.length, 0);
156 }),
157 );
158
159 const openBootstrapFd = Effect.fn(function* (payload: Record<string, unknown>) {
160 const fs = yield* FileSystem.FileSystem;
161 const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });
162 yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`);
163 const { fd } = yield* fs.open(filePath, { flag: "r" });
164 return fd;
165 });
166
167 it.effect("recognizes bootstrap fd from environment config", () =>
168 Effect.gen(function* () {
169 const fd = yield* openBootstrapFd({ authToken: "bootstrap-token" });
170
171 yield* runCli([], {
172 T3CODE_MODE: "web",
173 T3CODE_BOOTSTRAP_FD: String(fd),
174 T3CODE_AUTH_TOKEN: "env-token",
175 T3CODE_NO_BROWSER: "true",
176 });
177
178 assert.equal(start.mock.calls.length, 1);
179 assert.equal(resolvedConfig?.mode, "web");
180 assert.equal(resolvedConfig?.authToken, "env-token");
181 }),
182 );
183
184 it.effect("uses bootstrap envelope values as fallbacks when CLI and env are absent", () =>
185 Effect.gen(function* () {
186 const fd = yield* openBootstrapFd({
187 mode: "desktop",
188 port: 4888,
189 host: "127.0.0.2",
190 t3Home: "/tmp/t3-bootstrap-home",
191 devUrl: "http://127.0.0.1:5173",
192 noBrowser: true,
193 authToken: "bootstrap-token",
194 autoBootstrapProjectFromCwd: false,
195 logWebSocketEvents: true,
196 });
197
198 yield* runCli([], {
199 T3CODE_BOOTSTRAP_FD: String(fd),
200 });
201
202 assert.equal(start.mock.calls.length, 1);
203 assert.equal(resolvedConfig?.mode, "desktop");
204 assert.equal(resolvedConfig?.port, 4888);
205 assert.equal(resolvedConfig?.host, "127.0.0.2");
206 assert.equal(resolvedConfig?.baseDir, "/tmp/t3-bootstrap-home");
207 assert.equal(resolvedConfig?.stateDir, "/tmp/t3-bootstrap-home/dev");
208 assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/");
209 assert.equal(resolvedConfig?.noBrowser, true);
210 assert.equal(resolvedConfig?.authToken, "bootstrap-token");
211 assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false);
212 assert.equal(resolvedConfig?.logWebSocketEvents, true);
213 }),
214 );
215
216 it.effect("applies CLI then env precedence over bootstrap envelope values", () =>
217 Effect.gen(function* () {
218 const fd = yield* openBootstrapFd({
219 mode: "desktop",
220 port: 4888,
221 host: "127.0.0.2",
222 t3Home: "/tmp/t3-bootstrap-home",
223 devUrl: "http://127.0.0.1:5173",
224 noBrowser: false,
225 authToken: "bootstrap-token",
226 autoBootstrapProjectFromCwd: false,
227 logWebSocketEvents: false,
228 });
229
230 yield* runCli(["--port", "4999", "--host", "0.0.0.0", "--auth-token", "cli-token"], {
231 T3CODE_MODE: "web",
232 T3CODE_BOOTSTRAP_FD: String(fd),
233 T3CODE_HOME: "/tmp/t3-env-home",
234 T3CODE_NO_BROWSER: "true",
235 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
236 T3CODE_LOG_WS_EVENTS: "true",
237 });
238
239 assert.equal(start.mock.calls.length, 1);
240 assert.equal(resolvedConfig?.mode, "web");
241 assert.equal(resolvedConfig?.port, 4999);
242 assert.equal(resolvedConfig?.host, "0.0.0.0");
243 assert.equal(resolvedConfig?.baseDir, "/tmp/t3-env-home");
244 assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/");
245 assert.equal(resolvedConfig?.noBrowser, true);
246 assert.equal(resolvedConfig?.authToken, "cli-token");
247 assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, true);
248 assert.equal(resolvedConfig?.logWebSocketEvents, true);
249 }),
250 );
251
252 it.effect("prefers --mode over T3CODE_MODE", () =>
253 Effect.gen(function* () {
254 findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(4666));
255 yield* runCli(["--mode", "web"], {
256 T3CODE_MODE: "desktop",
257 T3CODE_NO_BROWSER: "true",
258 });
259
260 assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]);
261 assert.equal(start.mock.calls.length, 1);
262 assert.equal(resolvedConfig?.mode, "web");
263 assert.equal(resolvedConfig?.port, 4666);
264 assert.equal(resolvedConfig?.host, undefined);
265 }),
266 );
267
268 it.effect("prefers --no-browser over T3CODE_NO_BROWSER", () =>
269 Effect.gen(function* () {
270 yield* runCli(["--no-browser"], {
271 T3CODE_NO_BROWSER: "false",
272 });
273
274 assert.equal(start.mock.calls.length, 1);
275 assert.equal(resolvedConfig?.noBrowser, true);
276 }),
277 );
278
279 it.effect("uses dynamic port discovery in web mode when port is omitted", () =>
280 Effect.gen(function* () {
281 findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(5444));
282 yield* runCli([]);
283
284 assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]);
285 assert.equal(start.mock.calls.length, 1);
286 assert.equal(resolvedConfig?.port, 5444);
287 assert.equal(resolvedConfig?.mode, "web");
288 }),
289 );
290
291 it.effect("uses fixed localhost defaults in desktop mode", () =>
292 Effect.gen(function* () {
293 yield* runCli([], {
294 T3CODE_MODE: "desktop",
295 T3CODE_NO_BROWSER: "true",
296 });
297
298 assert.equal(findAvailablePort.mock.calls.length, 0);
299 assert.equal(start.mock.calls.length, 1);
300 assert.equal(resolvedConfig?.port, 3773);
301 assert.equal(resolvedConfig?.host, "127.0.0.1");
302 assert.equal(resolvedConfig?.mode, "desktop");
303 }),
304 );
305
306 it.effect("allows overriding desktop host with --host", () =>
307 Effect.gen(function* () {
308 yield* runCli(["--host", "0.0.0.0"], {
309 T3CODE_MODE: "desktop",
310 T3CODE_NO_BROWSER: "true",
311 });
312
313 assert.equal(start.mock.calls.length, 1);
314 assert.equal(resolvedConfig?.mode, "desktop");
315 assert.equal(resolvedConfig?.host, "0.0.0.0");
316 }),
317 );
318
319 it.effect("supports CLI and env for bootstrap/log websocket toggles", () =>
320 Effect.gen(function* () {
321 yield* runCli(["--auto-bootstrap-project-from-cwd"], {
322 T3CODE_MODE: "desktop",
323 T3CODE_LOG_WS_EVENTS: "false",
324 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
325 T3CODE_NO_BROWSER: "true",
326 });
327
328 assert.equal(start.mock.calls.length, 1);
329 assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, true);
330 assert.equal(resolvedConfig?.logWebSocketEvents, false);
331 }),
332 );
333
334 it.effect("hydrates PATH before server startup", () =>
335 Effect.gen(function* () {
336 yield* runCli([]);
337
338 assert.equal(fixPath.mock.calls.length, 1);
339 assert.equal(start.mock.calls.length, 1);
340 const fixPathOrder = fixPath.mock.invocationCallOrder[0];
341 const startOrder = start.mock.invocationCallOrder[0];
342 if (typeof fixPathOrder !== "number" || typeof startOrder !== "number") {
343 assert.fail("Expected fixPath and start to be called");
344 }
345 assert.isTrue(fixPathOrder < startOrder);
346 }),
347 );
348
349 it.effect("records a startup heartbeat with thread/project counts", () =>
350 Effect.gen(function* () {
351 const recordTelemetry = vi.fn(
352 (_event: string, _properties?: Readonly<Record<string, unknown>>) => Effect.void,
353 );
354 const getSnapshot = vi.fn(() =>
355 Effect.succeed({
356 snapshotSequence: 2,
357 projects: [{} as OrchestrationReadModel["projects"][number]],
358 threads: [
359 {} as OrchestrationReadModel["threads"][number],
360 {} as OrchestrationReadModel["threads"][number],
361 ],
362 updatedAt: new Date(1).toISOString(),
363 } satisfies OrchestrationReadModel),
364 );
365
366 yield* recordStartupHeartbeat.pipe(
367 Effect.provideService(ProjectionSnapshotQuery, {
368 getSnapshot,
369 }),
370 Effect.provideService(AnalyticsService, {
371 record: recordTelemetry,
372 flush: Effect.void,
373 }),
374 );
375
376 assert.deepEqual(recordTelemetry.mock.calls[0], [
377 "server.boot.heartbeat",
378 {
379 threadCount: 2,
380 projectCount: 1,
381 },
382 ]);
383 }),
384 );
385
386 it.effect("does not start server for invalid --mode values", () =>
387 Effect.gen(function* () {
388 yield* runCli(["--mode", "invalid"]).pipe(Effect.catch(() => Effect.void));
389
390 assert.equal(start.mock.calls.length, 0);
391 assert.equal(stop.mock.calls.length, 0);
392 }),
393 );
394
395 it.effect("does not start server for invalid --dev-url values", () =>
396 Effect.gen(function* () {
397 yield* runCli(["--dev-url", "not-a-url"]).pipe(Effect.catch(() => Effect.void));
398
399 assert.equal(start.mock.calls.length, 0);
400 assert.equal(stop.mock.calls.length, 0);
401 }),
402 );
403
404 it.effect("does not start server for out-of-range --port values", () =>
405 Effect.gen(function* () {
406 yield* runCli(["--port", "70000"]).pipe(Effect.catch(() => Effect.void));
407
408 // effect/unstable/cli renders help/errors for parse failures and returns success.
409 assert.equal(start.mock.calls.length, 0);
410 assert.equal(stop.mock.calls.length, 0);
411 }),
412 );
413});
apps/server/src/main.ts deleted-456
...@@ -1,456 +0,0 @@
1/**
2 * CliConfig - CLI/runtime bootstrap service definitions.
3 *
4 * Defines startup-only service contracts used while resolving process config
5 * and constructing server runtime layers.
6 *
7 * @module CliConfig
8 */
9import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect";
10import { Command, Flag } from "effect/unstable/cli";
11import { NetService } from "@t3tools/shared/Net";
12import {
13 DEFAULT_PORT,
14 deriveServerPaths,
15 resolveStaticDir,
16 ServerConfig,
17 type RuntimeMode,
18 type ServerConfigShape,
19} from "./config";
20import { fixPath, resolveBaseDir } from "./os-jank";
21import { Open } from "./open";
22import * as SqlitePersistence from "./persistence/Layers/Sqlite";
23import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
24import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
25import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry";
26import { Server } from "./wsServer";
27import { ServerLoggerLive } from "./serverLogger";
28import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";
29import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
30import { readBootstrapEnvelope } from "./bootstrap";
31import { ServerSettingsLive } from "./serverSettings";
32
33export class StartupError extends Data.TaggedError("StartupError")<{
34 readonly message: string;
35 readonly cause?: unknown;
36}> {}
37
38const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
39
40const 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
52interface 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 */
68export 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 */
88export 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
108const 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
140const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
141 Option.getOrElse(Option.filter(flag, Boolean), () => envValue);
142
143const resolveOptionPrecedence = <Value>(
144 ...values: ReadonlyArray<Option.Option<Value>>
145): Option.Option<Value> => Option.firstSomeOf(values);
146
147const isValidPort = (value: number): boolean => value >= 1 && value <= 65_535;
148const isRuntimeMode = (value: string): value is RuntimeMode =>
149 value === "web" || value === "desktop";
150
151const 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
293const 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
305const isWildcardHost = (host: string | undefined): boolean =>
306 host === "0.0.0.0" || host === "::" || host === "[::]";
307
308const formatHostForUrl = (host: string): string =>
309 host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
310
311export 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
336const 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
381const 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
392const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe(
393 Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
394 Flag.optional,
395);
396const portFlag = Flag.integer("port").pipe(
397 Flag.withSchema(PortSchema),
398 Flag.withDescription("Port for the HTTP/WebSocket server."),
399 Flag.optional,
400);
401const 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);
405const t3HomeFlag = Flag.string("home-dir").pipe(
406 Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."),
407 Flag.optional,
408);
409const 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);
414const noBrowserFlag = Flag.boolean("no-browser").pipe(
415 Flag.withDescription("Disable automatic browser opening."),
416 Flag.optional,
417);
418const authTokenFlag = Flag.string("auth-token").pipe(
419 Flag.withDescription("Auth token required for WebSocket connections."),
420 Flag.withAlias("token"),
421 Flag.optional,
422);
423const 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);
428const 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);
434const 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
442export const t3Cli = Command.make("t3", {
443 mode: modeFlag,
444 port: portFlag,
445 host: hostFlag,
446 t3Home: t3HomeFlag,
447 devUrl: devUrlFlag,
448 noBrowser: noBrowserFlag,
449 authToken: authTokenFlag,
450 bootstrapFd: bootstrapFdFlag,
451 autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
452 logWebSocketEvents: logWebSocketEventsFlag,
453}).pipe(
454 Command.withDescription("Run the T3 Code server."),
455 Command.withHandler((input) => Effect.scoped(makeServerProgram(input))),
456);
apps/server/src/open.ts+3-6
...@@ -10,17 +10,14 @@ import { spawn } from "node:child_process";...@@ -10,17 +10,14 @@ import { spawn } from "node:child_process";
10import { accessSync, constants, statSync } from "node:fs";10import { accessSync, constants, statSync } from "node:fs";
11import { extname, join } from "node:path";11import { extname, join } from "node:path";
1212
13import { EDITORS, type EditorId } from "@t3tools/contracts";13import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts";
14import { ServiceMap, Schema, Effect, Layer } from "effect";14import { ServiceMap, Effect, Layer } from "effect";
1515
16// ==============================16// ==============================
17// Definitions17// Definitions
18// ==============================18// ==============================
1919
20export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", {20export { OpenError };
21 message: Schema.String,
22 cause: Schema.optional(Schema.Defect),
23}) {}
2421
25export interface OpenInEditorInput {22export interface OpenInEditorInput {
26 readonly cwd: string;23 readonly cwd: string;
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+7-16
...@@ -39,6 +39,7 @@ import {...@@ -39,6 +39,7 @@ import {
39import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts";39import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts";
40import { ServerConfig } from "../../config.ts";40import { ServerConfig } from "../../config.ts";
41import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts";41import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts";
42import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts";
4243
43const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);44const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
44const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);45const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
...@@ -115,7 +116,7 @@ async function waitForThread(...@@ -115,7 +116,7 @@ async function waitForThread(
115 checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>;116 checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>;
116 activities: ReadonlyArray<{ kind: string }>;117 activities: ReadonlyArray<{ kind: string }>;
117 }) => boolean,118 }) => boolean,
118 timeoutMs = 5000,119 timeoutMs = 15_000,
119) {120) {
120 const deadline = Date.now() + timeoutMs;121 const deadline = Date.now() + timeoutMs;
121 const poll = async (): Promise<{122 const poll = async (): Promise<{
...@@ -140,7 +141,7 @@ async function waitForThread(...@@ -140,7 +141,7 @@ async function waitForThread(
140async function waitForEvent(141async function waitForEvent(
141 engine: OrchestrationEngineShape,142 engine: OrchestrationEngineShape,
142 predicate: (event: { type: string }) => boolean,143 predicate: (event: { type: string }) => boolean,
143 timeoutMs = 5000,144 timeoutMs = 15_000,
144) {145) {
145 const deadline = Date.now() + timeoutMs;146 const deadline = Date.now() + timeoutMs;
146 const poll = async () => {147 const poll = async () => {
...@@ -191,7 +192,7 @@ function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string {...@@ -191,7 +192,7 @@ function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string {
191 return runGit(cwd, ["show", `${ref}:${filePath}`]);192 return runGit(cwd, ["show", `${ref}:${filePath}`]);
192}193}
193194
194async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 5000) {195async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 15_000) {
195 const deadline = Date.now() + timeoutMs;196 const deadline = Date.now() + timeoutMs;
196 const poll = async (): Promise<void> => {197 const poll = async (): Promise<void> => {
197 if (gitRefExists(cwd, ref)) {198 if (gitRefExists(cwd, ref)) {
...@@ -263,7 +264,8 @@ describe("CheckpointReactor", () => {...@@ -263,7 +264,8 @@ describe("CheckpointReactor", () => {
263 Layer.provideMerge(RuntimeReceiptBusLive),264 Layer.provideMerge(RuntimeReceiptBusLive),
264 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),265 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
265 Layer.provideMerge(CheckpointStoreLive),266 Layer.provideMerge(CheckpointStoreLive),
266 Layer.provideMerge(WorkspaceEntriesLive),267 Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))),
268 Layer.provideMerge(WorkspacePathsLive),
267 Layer.provideMerge(GitCoreLive),269 Layer.provideMerge(GitCoreLive),
268 Layer.provideMerge(ServerConfigLayer),270 Layer.provideMerge(ServerConfigLayer),
269 Layer.provideMerge(NodeServices.layer),271 Layer.provideMerge(NodeServices.layer),
...@@ -1007,18 +1009,7 @@ describe("CheckpointReactor", () => {...@@ -1007,18 +1009,7 @@ describe("CheckpointReactor", () => {
1007 }),1009 }),
1008 );1010 );
10091011
1010 const deadline = Date.now() + 2000;1012 await harness.drain();
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();
10221013
1023 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2);1014 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2);
1024 expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({1015 expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+1-1
...@@ -205,7 +205,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -205,7 +205,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
205205
206 const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope)));206 const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope)));
207 yield* Effect.forkScoped(worker);207 yield* Effect.forkScoped(worker);
208 yield* Effect.log("orchestration engine started").pipe(208 yield* Effect.logDebug("orchestration engine started").pipe(
209 Effect.annotateLogs({ sequence: readModel.snapshotSequence }),209 Effect.annotateLogs({ sequence: readModel.snapshotSequence }),
210 );210 );
211211
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+1-3
...@@ -3,7 +3,6 @@ import {...@@ -3,7 +3,6 @@ import {
3 type ChatAttachment,3 type ChatAttachment,
4 type OrchestrationEvent,4 type OrchestrationEvent,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect";6import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect";
8import * as SqlClient from "effect/unstable/sql/SqlClient";7import * as SqlClient from "effect/unstable/sql/SqlClient";
98
...@@ -1271,7 +1270,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -1271,7 +1270,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
1271 Effect.provideService(ServerConfig, serverConfig),1270 Effect.provideService(ServerConfig, serverConfig),
1272 Effect.asVoid,1271 Effect.asVoid,
1273 Effect.tap(() =>1272 Effect.tap(() =>
1274 Effect.log("orchestration projection pipeline bootstrapped").pipe(1273 Effect.logDebug("orchestration projection pipeline bootstrapped").pipe(
1275 Effect.annotateLogs({ projectors: projectors.length }),1274 Effect.annotateLogs({ projectors: projectors.length }),
1276 ),1275 ),
1277 ),1276 ),
...@@ -1291,7 +1290,6 @@ export const OrchestrationProjectionPipelineLive = Layer.effect(...@@ -1291,7 +1290,6 @@ export const OrchestrationProjectionPipelineLive = Layer.effect(
1291 OrchestrationProjectionPipeline,1290 OrchestrationProjectionPipeline,
1292 makeOrchestrationProjectionPipeline(),1291 makeOrchestrationProjectionPipeline(),
1293).pipe(1292).pipe(
1294 Layer.provideMerge(NodeServices.layer),
1295 Layer.provideMerge(ProjectionProjectRepositoryLive),1293 Layer.provideMerge(ProjectionProjectRepositoryLive),
1296 Layer.provideMerge(ProjectionThreadRepositoryLive),1294 Layer.provideMerge(ProjectionThreadRepositoryLive),
1297 Layer.provideMerge(ProjectionThreadMessageRepositoryLive),1295 Layer.provideMerge(ProjectionThreadMessageRepositoryLive),
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+1-1
...@@ -17,7 +17,7 @@ import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effe...@@ -17,7 +17,7 @@ import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effe
17import { afterEach, describe, expect, it, vi } from "vitest";17import { afterEach, describe, expect, it, vi } from "vitest";
1818
19import { deriveServerPaths, ServerConfig } from "../../config.ts";19import { deriveServerPaths, ServerConfig } from "../../config.ts";
20import { TextGenerationError } from "../../git/Errors.ts";20import { TextGenerationError } from "@t3tools/contracts";
21import { ProviderAdapterRequestError } from "../../provider/Errors.ts";21import { ProviderAdapterRequestError } from "../../provider/Errors.ts";
22import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";22import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
23import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";23import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
apps/server/src/orchestration/Normalizer.ts created+121
...@@ -0,0 +1,121 @@
1import { Effect, FileSystem, Path } from "effect";
2import {
3 type ClientOrchestrationCommand,
4 type OrchestrationCommand,
5 OrchestrationDispatchCommandError,
6 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
7} from "@t3tools/contracts";
8
9import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore";
10import { ServerConfig } from "../config";
11import { parseBase64DataUrl } from "../imageMime";
12import { WorkspacePaths } from "../workspace/Services/WorkspacePaths";
13
14export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
15 Effect.gen(function* () {
16 const fileSystem = yield* FileSystem.FileSystem;
17 const path = yield* Path.Path;
18 const serverConfig = yield* ServerConfig;
19 const workspacePaths = yield* WorkspacePaths;
20
21 const normalizeProjectWorkspaceRoot = (workspaceRoot: string) =>
22 workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe(
23 Effect.mapError(
24 (cause) =>
25 new OrchestrationDispatchCommandError({
26 message: cause.message,
27 }),
28 ),
29 );
30
31 if (command.type === "project.create") {
32 return {
33 ...command,
34 workspaceRoot: yield* normalizeProjectWorkspaceRoot(command.workspaceRoot),
35 } satisfies OrchestrationCommand;
36 }
37
38 if (command.type === "project.meta.update" && command.workspaceRoot !== undefined) {
39 return {
40 ...command,
41 workspaceRoot: yield* normalizeProjectWorkspaceRoot(command.workspaceRoot),
42 } satisfies OrchestrationCommand;
43 }
44
45 if (command.type !== "thread.turn.start") {
46 return command as OrchestrationCommand;
47 }
48
49 const normalizedAttachments = yield* Effect.forEach(
50 command.message.attachments,
51 (attachment) =>
52 Effect.gen(function* () {
53 const parsed = parseBase64DataUrl(attachment.dataUrl);
54 if (!parsed || !parsed.mimeType.startsWith("image/")) {
55 return yield* new OrchestrationDispatchCommandError({
56 message: `Invalid image attachment payload for '${attachment.name}'.`,
57 });
58 }
59
60 const bytes = Buffer.from(parsed.base64, "base64");
61 if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
62 return yield* new OrchestrationDispatchCommandError({
63 message: `Image attachment '${attachment.name}' is empty or too large.`,
64 });
65 }
66
67 const attachmentId = createAttachmentId(command.threadId);
68 if (!attachmentId) {
69 return yield* new OrchestrationDispatchCommandError({
70 message: "Failed to create a safe attachment id.",
71 });
72 }
73
74 const persistedAttachment = {
75 type: "image" as const,
76 id: attachmentId,
77 name: attachment.name,
78 mimeType: parsed.mimeType.toLowerCase(),
79 sizeBytes: bytes.byteLength,
80 };
81
82 const attachmentPath = resolveAttachmentPath({
83 attachmentsDir: serverConfig.attachmentsDir,
84 attachment: persistedAttachment,
85 });
86 if (!attachmentPath) {
87 return yield* new OrchestrationDispatchCommandError({
88 message: `Failed to resolve persisted path for '${attachment.name}'.`,
89 });
90 }
91
92 yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe(
93 Effect.mapError(
94 () =>
95 new OrchestrationDispatchCommandError({
96 message: `Failed to create attachment directory for '${attachment.name}'.`,
97 }),
98 ),
99 );
100 yield* fileSystem.writeFile(attachmentPath, bytes).pipe(
101 Effect.mapError(
102 () =>
103 new OrchestrationDispatchCommandError({
104 message: `Failed to persist attachment '${attachment.name}'.`,
105 }),
106 ),
107 );
108
109 return persistedAttachment;
110 }),
111 { concurrency: 1 },
112 );
113
114 return {
115 ...command,
116 message: {
117 ...command.message,
118 attachments: normalizedAttachments,
119 },
120 } satisfies OrchestrationCommand;
121 });
apps/server/src/projectFaviconRoute.test.ts deleted-189
...@@ -1,189 +0,0 @@
1import fs from "node:fs";
2import http from "node:http";
3import os from "node:os";
4import path from "node:path";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { Effect } from "effect";
8import { afterEach, describe, expect, it } from "vitest";
9
10import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
11import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
12
13interface HttpResponse {
14 statusCode: number;
15 contentType: string | null;
16 body: string;
17}
18
19const tempDirs: string[] = [];
20
21function makeTempDir(prefix: string): string {
22 const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
23 tempDirs.push(dir);
24 return dir;
25}
26
27async 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
82async 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
91describe("tryHandleProjectFaviconRequest", () => {
92 afterEach(() => {
93 for (const dir of tempDirs.splice(0, tempDirs.length)) {
94 fs.rmSync(dir, { recursive: true, force: true });
95 }
96 });
97
98 it("returns 400 when cwd is missing", async () => {
99 await withRouteServer(async (baseUrl) => {
100 const response = await request(baseUrl, "/api/project-favicon");
101 expect(response.statusCode).toBe(400);
102 expect(response.body).toBe("Missing cwd parameter");
103 });
104 });
105
106 it("serves a well-known favicon file from the project root", async () => {
107 const projectDir = makeTempDir("t3code-favicon-route-root-");
108 fs.writeFileSync(path.join(projectDir, "favicon.svg"), "<svg>favicon</svg>", "utf8");
109
110 await withRouteServer(async (baseUrl) => {
111 const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
112 const response = await request(baseUrl, pathname);
113 expect(response.statusCode).toBe(200);
114 expect(response.contentType).toContain("image/svg+xml");
115 expect(response.body).toBe("<svg>favicon</svg>");
116 });
117 });
118
119 it("resolves icon href from source files when no well-known favicon exists", async () => {
120 const projectDir = makeTempDir("t3code-favicon-route-source-");
121 const iconPath = path.join(projectDir, "public", "brand", "logo.svg");
122 fs.mkdirSync(path.dirname(iconPath), { recursive: true });
123 fs.writeFileSync(
124 path.join(projectDir, "index.html"),
125 '<link rel="icon" href="/brand/logo.svg">',
126 );
127 fs.writeFileSync(iconPath, "<svg>brand</svg>", "utf8");
128
129 await withRouteServer(async (baseUrl) => {
130 const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
131 const response = await request(baseUrl, pathname);
132 expect(response.statusCode).toBe(200);
133 expect(response.contentType).toContain("image/svg+xml");
134 expect(response.body).toBe("<svg>brand</svg>");
135 });
136 });
137
138 it("resolves icon link when href appears before rel in HTML", async () => {
139 const projectDir = makeTempDir("t3code-favicon-route-html-order-");
140 const iconPath = path.join(projectDir, "public", "brand", "logo.svg");
141 fs.mkdirSync(path.dirname(iconPath), { recursive: true });
142 fs.writeFileSync(
143 path.join(projectDir, "index.html"),
144 '<link href="/brand/logo.svg" rel="icon">',
145 );
146 fs.writeFileSync(iconPath, "<svg>brand-html-order</svg>", "utf8");
147
148 await withRouteServer(async (baseUrl) => {
149 const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
150 const response = await request(baseUrl, pathname);
151 expect(response.statusCode).toBe(200);
152 expect(response.contentType).toContain("image/svg+xml");
153 expect(response.body).toBe("<svg>brand-html-order</svg>");
154 });
155 });
156
157 it("resolves object-style icon metadata when href appears before rel", async () => {
158 const projectDir = makeTempDir("t3code-favicon-route-obj-order-");
159 const iconPath = path.join(projectDir, "public", "brand", "obj.svg");
160 fs.mkdirSync(path.dirname(iconPath), { recursive: true });
161 fs.mkdirSync(path.join(projectDir, "src"), { recursive: true });
162 fs.writeFileSync(
163 path.join(projectDir, "src", "root.tsx"),
164 'const links = [{ href: "/brand/obj.svg", rel: "icon" }];',
165 "utf8",
166 );
167 fs.writeFileSync(iconPath, "<svg>brand-obj-order</svg>", "utf8");
168
169 await withRouteServer(async (baseUrl) => {
170 const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
171 const response = await request(baseUrl, pathname);
172 expect(response.statusCode).toBe(200);
173 expect(response.contentType).toContain("image/svg+xml");
174 expect(response.body).toBe("<svg>brand-obj-order</svg>");
175 });
176 });
177
178 it("serves a fallback favicon when no icon exists", async () => {
179 const projectDir = makeTempDir("t3code-favicon-route-fallback-");
180
181 await withRouteServer(async (baseUrl) => {
182 const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
183 const response = await request(baseUrl, pathname);
184 expect(response.statusCode).toBe(200);
185 expect(response.contentType).toContain("image/svg+xml");
186 expect(response.body).toContain('data-fallback="project-favicon"');
187 });
188 });
189});
apps/server/src/projectFaviconRoute.ts deleted-76
...@@ -1,76 +0,0 @@
1import http from "node:http";
2import path from "node:path";
3import { Effect, FileSystem } from "effect";
4
5import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";
6
7const 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
14const 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
16export const tryHandleProjectFaviconRequest = Effect.fn("tryHandleProjectFaviconRequest")(
17 function* (
18 url: URL,
19 res: http.ServerResponse,
20 ): Effect.fn.Return<boolean, never, ProjectFaviconResolver | FileSystem.FileSystem> {
21 const respond = (
22 statusCode: number,
23 headers: Record<string, string>,
24 body: string | Uint8Array,
25 ) => {
26 res.writeHead(statusCode, headers);
27 res.end(body);
28 };
29
30 if (url.pathname !== "/api/project-favicon") {
31 return false;
32 }
33
34 const projectCwd = url.searchParams.get("cwd");
35 if (!projectCwd) {
36 respond(400, { "Content-Type": "text/plain" }, "Missing cwd parameter");
37 return true;
38 }
39
40 const fileSystem = yield* FileSystem.FileSystem;
41 const faviconResolver = yield* ProjectFaviconResolver;
42 const resolvedPath = yield* faviconResolver.resolvePath(projectCwd);
43
44 if (!resolvedPath) {
45 respond(
46 200,
47 {
48 "Content-Type": "image/svg+xml",
49 "Cache-Control": "public, max-age=3600",
50 },
51 FALLBACK_FAVICON_SVG,
52 );
53 return true;
54 }
55
56 const data = yield* fileSystem
57 .readFile(resolvedPath)
58 .pipe(Effect.catch(() => Effect.succeed(null)));
59 if (!data) {
60 respond(500, { "Content-Type": "text/plain" }, "Read error");
61 return true;
62 }
63
64 const ext = path.extname(resolvedPath).toLowerCase();
65 const contentType = FAVICON_MIME_TYPES[ext] ?? "application/octet-stream";
66 respond(
67 200,
68 {
69 "Content-Type": contentType,
70 "Cache-Control": "public, max-age=3600",
71 },
72 data,
73 );
74 return true;
75 },
76);
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+3
...@@ -208,6 +208,9 @@ async function readFirstPromptText(...@@ -208,6 +208,9 @@ async function readFirstPromptText(
208 if (next.done) {208 if (next.done) {
209 return undefined;209 return undefined;
210 }210 }
211 if (typeof next.value.message.content === "string") {
212 return next.value.message.content;
213 }
211 const content = next.value.message.content[0];214 const content = next.value.message.content[0];
212 if (!content || content.type !== "text") {215 if (!content || content.type !== "text") {
213 return undefined;216 return undefined;
apps/server/src/provider/Layers/ClaudeAdapter.ts+1-1
...@@ -533,7 +533,7 @@ function buildUserMessage(input: {...@@ -533,7 +533,7 @@ function buildUserMessage(input: {
533 parent_tool_use_id: null,533 parent_tool_use_id: null,
534 message: {534 message: {
535 role: "user",535 role: "user",
536 content: input.sdkContent,536 content: input.sdkContent as unknown as SDKUserMessage["message"]["content"],
537 },537 },
538 } as SDKUserMessage;538 } as SDKUserMessage;
539}539}
apps/server/src/provider/Layers/ClaudeProvider.ts+2-1
...@@ -24,7 +24,8 @@ import {...@@ -24,7 +24,8 @@ import {
24} from "../providerSnapshot";24} from "../providerSnapshot";
25import { makeManagedServerProvider } from "../makeManagedServerProvider";25import { makeManagedServerProvider } from "../makeManagedServerProvider";
26import { ClaudeProvider } from "../Services/ClaudeProvider";26import { ClaudeProvider } from "../Services/ClaudeProvider";
27import { ServerSettingsError, ServerSettingsService } from "../../serverSettings";27import { ServerSettingsService } from "../../serverSettings";
28import { ServerSettingsError } from "@t3tools/contracts";
2829
29const PROVIDER = "claudeAgent" as const;30const PROVIDER = "claudeAgent" as const;
30const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [31const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
apps/server/src/provider/Layers/CodexProvider.ts+2-1
...@@ -46,7 +46,8 @@ import {...@@ -46,7 +46,8 @@ import {
46} from "../codexAccount";46} from "../codexAccount";
47import { probeCodexAccount } from "../codexAppServer";47import { probeCodexAccount } from "../codexAppServer";
48import { CodexProvider } from "../Services/CodexProvider";48import { CodexProvider } from "../Services/CodexProvider";
49import { ServerSettingsError, ServerSettingsService } from "../../serverSettings";49import { ServerSettingsService } from "../../serverSettings";
50import { ServerSettingsError } from "@t3tools/contracts";
5051
51const PROVIDER = "codex" as const;52const PROVIDER = "codex" as const;
52const OPENAI_AUTH_PROVIDERS = new Set(["openai"]);53const OPENAI_AUTH_PROVIDERS = new Set(["openai"]);
apps/server/src/provider/makeManagedServerProvider.ts+1-1
...@@ -3,7 +3,7 @@ import { Duration, Effect, PubSub, Ref, Scope, Stream } from "effect";...@@ -3,7 +3,7 @@ import { Duration, Effect, PubSub, Ref, Scope, Stream } from "effect";
3import * as Semaphore from "effect/Semaphore";3import * as Semaphore from "effect/Semaphore";
44
5import type { ServerProviderShape } from "./Services/ServerProvider";5import type { ServerProviderShape } from "./Services/ServerProvider";
6import { ServerSettingsError } from "../serverSettings";6import { ServerSettingsError } from "@t3tools/contracts";
77
8export function makeManagedServerProvider<Settings>(input: {8export function makeManagedServerProvider<Settings>(input: {
9 readonly getSettings: Effect.Effect<Settings>;9 readonly getSettings: Effect.Effect<Settings>;
apps/server/src/server.test.ts created+1391
...@@ -0,0 +1,1391 @@
1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
2import * as NodeSocket from "@effect/platform-node/NodeSocket";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import {
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";
20import { assert, it } from "@effect/vitest";
21import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils";
22import { Effect, FileSystem, Layer, Path, Stream } from "effect";
23import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http";
24import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
25
26import type { ServerConfigShape } from "./config.ts";
27import { deriveServerPaths, ServerConfig } from "./config.ts";
28import { makeRoutesLayer } from "./server.ts";
29import { resolveAttachmentRelativePath } from "./attachmentPaths.ts";
30import {
31 CheckpointDiffQuery,
32 type CheckpointDiffQueryShape,
33} from "./checkpointing/Services/CheckpointDiffQuery.ts";
34import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts";
35import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
36import { Keybindings, type KeybindingsShape } from "./keybindings.ts";
37import { Open, type OpenShape } from "./open.ts";
38import {
39 OrchestrationEngineService,
40 type OrchestrationEngineShape,
41} from "./orchestration/Services/OrchestrationEngine.ts";
42import {
43 ProjectionSnapshotQuery,
44 type ProjectionSnapshotQueryShape,
45} from "./orchestration/Services/ProjectionSnapshotQuery.ts";
46import { PersistenceSqlError } from "./persistence/Errors.ts";
47import {
48 ProviderRegistry,
49 type ProviderRegistryShape,
50} from "./provider/Services/ProviderRegistry.ts";
51import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts";
52import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts";
53import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts";
54import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts";
55import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
56import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";
57import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
58import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
59
60const defaultProjectId = ProjectId.makeUnsafe("project-default");
61const defaultThreadId = ThreadId.makeUnsafe("thread-default");
62const defaultModelSelection = {
63 provider: "codex",
64 model: "gpt-5-codex",
65} as const;
66
67const 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
109const 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
119const 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
266const wsRpcProtocolLayer = (wsUrl: string) =>
267 RpcClient.layerProtocolSocket().pipe(
268 Layer.provide(NodeSocket.layerWebSocket(wsUrl)),
269 Layer.provide(RpcSerialization.layerJson),
270 );
271
272const makeWsRpcClient = RpcClient.make(WsRpcGroup);
273type WsRpcClient =
274 typeof makeWsRpcClient extends Effect.Effect<infer Client, any, any> ? Client : never;
275
276const 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
281const 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
288const 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
295it.layer(NodeServices.layer)("server router seam", (it) => {
296 it.effect("serves static index content for GET / when staticDir is configured", () =>
297 Effect.gen(function* () {
298 const fileSystem = yield* FileSystem.FileSystem;
299 const path = yield* Path.Path;
300 const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-static-" });
301 const indexPath = path.join(staticDir, "index.html");
302 yield* fileSystem.writeFileString(indexPath, "<html>router-static-ok</html>");
303
304 yield* buildAppUnderTest({ config: { staticDir } });
305
306 const response = yield* HttpClient.get("/");
307 assert.equal(response.status, 200);
308 assert.include(yield* response.text, "router-static-ok");
309 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
310 );
311
312 it.effect("redirects to dev URL when configured", () =>
313 Effect.gen(function* () {
314 yield* buildAppUnderTest({
315 config: { devUrl: new URL("http://127.0.0.1:5173") },
316 });
317
318 const url = yield* getHttpServerUrl("/foo/bar");
319 const response = yield* Effect.promise(() => fetch(url, { redirect: "manual" }));
320
321 assert.equal(response.status, 302);
322 assert.equal(response.headers.get("location"), "http://127.0.0.1:5173/");
323 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
324 );
325
326 it.effect("serves project favicon requests before the dev URL redirect", () =>
327 Effect.gen(function* () {
328 const fileSystem = yield* FileSystem.FileSystem;
329 const path = yield* Path.Path;
330 const projectDir = yield* fileSystem.makeTempDirectoryScoped({
331 prefix: "t3-router-project-favicon-",
332 });
333 yield* fileSystem.writeFileString(
334 path.join(projectDir, "favicon.svg"),
335 "<svg>router-project-favicon</svg>",
336 );
337
338 yield* buildAppUnderTest({
339 config: { devUrl: new URL("http://127.0.0.1:5173") },
340 });
341
342 const response = yield* HttpClient.get(
343 `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`,
344 );
345
346 assert.equal(response.status, 200);
347 assert.equal(yield* response.text, "<svg>router-project-favicon</svg>");
348 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
349 );
350
351 it.effect("serves the fallback project favicon when no icon exists", () =>
352 Effect.gen(function* () {
353 const fileSystem = yield* FileSystem.FileSystem;
354 const projectDir = yield* fileSystem.makeTempDirectoryScoped({
355 prefix: "t3-router-project-favicon-fallback-",
356 });
357
358 yield* buildAppUnderTest({
359 config: { devUrl: new URL("http://127.0.0.1:5173") },
360 });
361
362 const response = yield* HttpClient.get(
363 `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`,
364 );
365
366 assert.equal(response.status, 200);
367 assert.include(yield* response.text, 'data-fallback="project-favicon"');
368 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
369 );
370
371 it.effect("serves attachment files from state dir", () =>
372 Effect.gen(function* () {
373 const fileSystem = yield* FileSystem.FileSystem;
374 const path = yield* Path.Path;
375 const attachmentId = "thread-11111111-1111-4111-8111-111111111111";
376
377 const config = yield* buildAppUnderTest();
378 const attachmentPath = resolveAttachmentRelativePath({
379 attachmentsDir: config.attachmentsDir,
380 relativePath: `${attachmentId}.bin`,
381 });
382 assert.isNotNull(attachmentPath, "Attachment path should be resolvable");
383
384 yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true });
385 yield* fileSystem.writeFileString(attachmentPath, "attachment-ok");
386
387 const response = yield* HttpClient.get(`/attachments/${attachmentId}`);
388 assert.equal(response.status, 200);
389 assert.equal(yield* response.text, "attachment-ok");
390 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
391 );
392
393 it.effect("serves attachment files for URL-encoded paths", () =>
394 Effect.gen(function* () {
395 const fileSystem = yield* FileSystem.FileSystem;
396 const path = yield* Path.Path;
397
398 const config = yield* buildAppUnderTest();
399 const attachmentPath = resolveAttachmentRelativePath({
400 attachmentsDir: config.attachmentsDir,
401 relativePath: "thread%20folder/message%20folder/file%20name.png",
402 });
403 assert.isNotNull(attachmentPath, "Attachment path should be resolvable");
404
405 yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true });
406 yield* fileSystem.writeFileString(attachmentPath, "attachment-encoded-ok");
407
408 const response = yield* HttpClient.get(
409 "/attachments/thread%20folder/message%20folder/file%20name.png",
410 );
411 assert.equal(response.status, 200);
412 assert.equal(yield* response.text, "attachment-encoded-ok");
413 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
414 );
415
416 it.effect("returns 404 for missing attachment id lookups", () =>
417 Effect.gen(function* () {
418 yield* buildAppUnderTest();
419
420 const response = yield* HttpClient.get(
421 "/attachments/missing-11111111-1111-4111-8111-111111111111",
422 );
423 assert.equal(response.status, 404);
424 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
425 );
426
427 it.effect("routes websocket rpc server.upsertKeybinding", () =>
428 Effect.gen(function* () {
429 const rule: KeybindingRule = {
430 command: "terminal.toggle",
431 key: "ctrl+k",
432 };
433 const resolved: ResolvedKeybindingRule = {
434 command: "terminal.toggle",
435 shortcut: {
436 key: "k",
437 metaKey: false,
438 ctrlKey: true,
439 shiftKey: false,
440 altKey: false,
441 modKey: true,
442 },
443 };
444
445 yield* buildAppUnderTest({
446 layers: {
447 keybindings: {
448 upsertKeybindingRule: () => Effect.succeed([resolved]),
449 },
450 },
451 });
452
453 const wsUrl = yield* getWsServerUrl("/ws");
454 const response = yield* Effect.scoped(
455 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverUpsertKeybinding](rule)),
456 );
457
458 assert.deepEqual(response.issues, []);
459 assert.deepEqual(response.keybindings, [resolved]);
460 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
461 );
462
463 it.effect("rejects websocket rpc handshake when auth token is missing", () =>
464 Effect.gen(function* () {
465 const fs = yield* FileSystem.FileSystem;
466 const path = yield* Path.Path;
467 const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-auth-required-" });
468 yield* fs.writeFileString(
469 path.join(workspaceDir, "needle-file.ts"),
470 "export const needle = 1;",
471 );
472
473 yield* buildAppUnderTest({
474 config: {
475 authToken: "secret-token",
476 },
477 });
478
479 const wsUrl = yield* getWsServerUrl("/ws");
480 const result = yield* Effect.scoped(
481 withWsRpcClient(wsUrl, (client) =>
482 client[WS_METHODS.projectsSearchEntries]({
483 cwd: workspaceDir,
484 query: "needle",
485 limit: 10,
486 }),
487 ).pipe(Effect.result),
488 );
489
490 assertTrue(result._tag === "Failure");
491 assertInclude(String(result.failure), "SocketOpenError");
492 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
493 );
494
495 it.effect("accepts websocket rpc handshake when auth token is provided", () =>
496 Effect.gen(function* () {
497 const fs = yield* FileSystem.FileSystem;
498 const path = yield* Path.Path;
499 const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-auth-ok-" });
500 yield* fs.writeFileString(
501 path.join(workspaceDir, "needle-file.ts"),
502 "export const needle = 1;",
503 );
504
505 yield* buildAppUnderTest({
506 config: {
507 authToken: "secret-token",
508 },
509 });
510
511 const wsUrl = yield* getWsServerUrl("/ws?token=secret-token");
512 const response = yield* Effect.scoped(
513 withWsRpcClient(wsUrl, (client) =>
514 client[WS_METHODS.projectsSearchEntries]({
515 cwd: workspaceDir,
516 query: "needle",
517 limit: 10,
518 }),
519 ),
520 );
521
522 assert.isAtLeast(response.entries.length, 1);
523 assert.equal(response.truncated, false);
524 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
525 );
526
527 it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () =>
528 Effect.gen(function* () {
529 const providers = [] as const;
530 const changeEvent = {
531 keybindings: [],
532 issues: [],
533 } as const;
534
535 yield* buildAppUnderTest({
536 layers: {
537 keybindings: {
538 loadConfigState: Effect.succeed({
539 keybindings: [],
540 issues: [],
541 }),
542 streamChanges: Stream.succeed(changeEvent),
543 },
544 providerRegistry: {
545 getProviders: Effect.succeed(providers),
546 },
547 },
548 });
549
550 const wsUrl = yield* getWsServerUrl("/ws");
551 const events = yield* Effect.scoped(
552 withWsRpcClient(wsUrl, (client) =>
553 client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect),
554 ),
555 );
556
557 const [first, second] = Array.from(events);
558 assert.equal(first?.type, "snapshot");
559 if (first?.type === "snapshot") {
560 assert.equal(first.version, 1);
561 assert.deepEqual(first.config.keybindings, []);
562 assert.deepEqual(first.config.issues, []);
563 assert.deepEqual(first.config.providers, providers);
564 assert.deepEqual(first.config.settings, DEFAULT_SERVER_SETTINGS);
565 }
566 assert.deepEqual(second, {
567 version: 1,
568 type: "keybindingsUpdated",
569 payload: { issues: [] },
570 });
571 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
572 );
573
574 it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () =>
575 Effect.gen(function* () {
576 const providers = [] as const;
577
578 yield* buildAppUnderTest({
579 layers: {
580 keybindings: {
581 loadConfigState: Effect.succeed({
582 keybindings: [],
583 issues: [],
584 }),
585 streamChanges: Stream.empty,
586 },
587 providerRegistry: {
588 getProviders: Effect.succeed([]),
589 streamChanges: Stream.succeed(providers),
590 },
591 },
592 });
593
594 const wsUrl = yield* getWsServerUrl("/ws");
595 const events = yield* Effect.scoped(
596 withWsRpcClient(wsUrl, (client) =>
597 client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect),
598 ),
599 );
600
601 const [first, second] = Array.from(events);
602 assert.equal(first?.type, "snapshot");
603 assert.deepEqual(second, {
604 version: 1,
605 type: "providerStatuses",
606 payload: { providers },
607 });
608 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
609 );
610
611 it.effect(
612 "routes websocket rpc subscribeServerLifecycle replays snapshot and streams updates",
613 () =>
614 Effect.gen(function* () {
615 const lifecycleEvents = [
616 {
617 version: 1 as const,
618 sequence: 1,
619 type: "welcome" as const,
620 payload: {
621 cwd: "/tmp/project",
622 projectName: "project",
623 },
624 },
625 ] as const;
626 const liveEvents = Stream.make({
627 version: 1 as const,
628 sequence: 2,
629 type: "ready" as const,
630 payload: { at: new Date().toISOString() },
631 });
632
633 yield* buildAppUnderTest({
634 layers: {
635 serverLifecycleEvents: {
636 snapshot: Effect.succeed({
637 sequence: 1,
638 events: lifecycleEvents,
639 }),
640 stream: liveEvents,
641 },
642 },
643 });
644
645 const wsUrl = yield* getWsServerUrl("/ws");
646 const events = yield* Effect.scoped(
647 withWsRpcClient(wsUrl, (client) =>
648 client[WS_METHODS.subscribeServerLifecycle]({}).pipe(Stream.take(2), Stream.runCollect),
649 ),
650 );
651
652 const [first, second] = Array.from(events);
653 assert.equal(first?.type, "welcome");
654 assert.equal(first?.sequence, 1);
655 assert.equal(second?.type, "ready");
656 assert.equal(second?.sequence, 2);
657 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
658 );
659
660 it.effect("routes websocket rpc projects.searchEntries", () =>
661 Effect.gen(function* () {
662 const fs = yield* FileSystem.FileSystem;
663 const path = yield* Path.Path;
664 const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-search-" });
665 yield* fs.writeFileString(
666 path.join(workspaceDir, "needle-file.ts"),
667 "export const needle = 1;",
668 );
669
670 yield* buildAppUnderTest();
671
672 const wsUrl = yield* getWsServerUrl("/ws");
673 const response = yield* Effect.scoped(
674 withWsRpcClient(wsUrl, (client) =>
675 client[WS_METHODS.projectsSearchEntries]({
676 cwd: workspaceDir,
677 query: "needle",
678 limit: 10,
679 }),
680 ),
681 );
682
683 assert.isAtLeast(response.entries.length, 1);
684 assert.isTrue(response.entries.some((entry) => entry.path === "needle-file.ts"));
685 assert.equal(response.truncated, false);
686 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
687 );
688
689 it.effect("routes websocket rpc projects.searchEntries errors", () =>
690 Effect.gen(function* () {
691 yield* buildAppUnderTest();
692
693 const wsUrl = yield* getWsServerUrl("/ws");
694 const result = yield* Effect.scoped(
695 withWsRpcClient(wsUrl, (client) =>
696 client[WS_METHODS.projectsSearchEntries]({
697 cwd: "/definitely/not/a/real/workspace/path",
698 query: "needle",
699 limit: 10,
700 }),
701 ).pipe(Effect.result),
702 );
703
704 assertTrue(result._tag === "Failure");
705 assertTrue(result.failure._tag === "ProjectSearchEntriesError");
706 assertInclude(
707 result.failure.message,
708 "Workspace root does not exist: /definitely/not/a/real/workspace/path",
709 );
710 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
711 );
712
713 it.effect("routes websocket rpc projects.writeFile", () =>
714 Effect.gen(function* () {
715 const fs = yield* FileSystem.FileSystem;
716 const path = yield* Path.Path;
717 const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-write-" });
718
719 yield* buildAppUnderTest();
720
721 const wsUrl = yield* getWsServerUrl("/ws");
722 const response = yield* Effect.scoped(
723 withWsRpcClient(wsUrl, (client) =>
724 client[WS_METHODS.projectsWriteFile]({
725 cwd: workspaceDir,
726 relativePath: "nested/created.txt",
727 contents: "written-by-rpc",
728 }),
729 ),
730 );
731
732 assert.equal(response.relativePath, "nested/created.txt");
733 const persisted = yield* fs.readFileString(path.join(workspaceDir, "nested", "created.txt"));
734 assert.equal(persisted, "written-by-rpc");
735 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
736 );
737
738 it.effect("routes websocket rpc projects.writeFile errors", () =>
739 Effect.gen(function* () {
740 const fs = yield* FileSystem.FileSystem;
741 const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-write-" });
742
743 yield* buildAppUnderTest();
744
745 const wsUrl = yield* getWsServerUrl("/ws");
746 const result = yield* Effect.scoped(
747 withWsRpcClient(wsUrl, (client) =>
748 client[WS_METHODS.projectsWriteFile]({
749 cwd: workspaceDir,
750 relativePath: "../escape.txt",
751 contents: "nope",
752 }),
753 ).pipe(Effect.result),
754 );
755
756 assertTrue(result._tag === "Failure");
757 assertTrue(result.failure._tag === "ProjectWriteFileError");
758 assert.equal(
759 result.failure.message,
760 "Workspace file path must stay within the project root.",
761 );
762 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
763 );
764
765 it.effect("routes websocket rpc shell.openInEditor", () =>
766 Effect.gen(function* () {
767 let openedInput: { cwd: string; editor: EditorId } | null = null;
768 yield* buildAppUnderTest({
769 layers: {
770 open: {
771 openInEditor: (input) =>
772 Effect.sync(() => {
773 openedInput = input;
774 }),
775 },
776 },
777 });
778
779 const wsUrl = yield* getWsServerUrl("/ws");
780 yield* Effect.scoped(
781 withWsRpcClient(wsUrl, (client) =>
782 client[WS_METHODS.shellOpenInEditor]({
783 cwd: "/tmp/project",
784 editor: "cursor",
785 }),
786 ),
787 );
788
789 assert.deepEqual(openedInput, { cwd: "/tmp/project", editor: "cursor" });
790 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
791 );
792
793 it.effect("routes websocket rpc shell.openInEditor errors", () =>
794 Effect.gen(function* () {
795 const openError = new OpenError({ message: "Editor command not found: cursor" });
796 yield* buildAppUnderTest({
797 layers: {
798 open: {
799 openInEditor: () => Effect.fail(openError),
800 },
801 },
802 });
803
804 const wsUrl = yield* getWsServerUrl("/ws");
805 const result = yield* Effect.scoped(
806 withWsRpcClient(wsUrl, (client) =>
807 client[WS_METHODS.shellOpenInEditor]({
808 cwd: "/tmp/project",
809 editor: "cursor",
810 }),
811 ).pipe(Effect.result),
812 );
813
814 assertFailure(result, openError);
815 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
816 );
817
818 it.effect("routes websocket rpc git methods", () =>
819 Effect.gen(function* () {
820 yield* buildAppUnderTest({
821 layers: {
822 gitManager: {
823 status: () =>
824 Effect.succeed({
825 branch: "main",
826 hasWorkingTreeChanges: false,
827 workingTree: { files: [], insertions: 0, deletions: 0 },
828 hasUpstream: true,
829 aheadCount: 0,
830 behindCount: 0,
831 pr: null,
832 }),
833 runStackedAction: (input, options) =>
834 Effect.gen(function* () {
835 const result = {
836 action: "commit" as const,
837 branch: { status: "skipped_not_requested" as const },
838 commit: {
839 status: "created" as const,
840 commitSha: "abc123",
841 subject: "feat: demo",
842 },
843 push: { status: "skipped_not_requested" as const },
844 pr: { status: "skipped_not_requested" as const },
845 };
846
847 yield* (
848 options?.progressReporter?.publish({
849 actionId: options.actionId ?? input.actionId,
850 cwd: input.cwd,
851 action: input.action,
852 kind: "phase_started",
853 phase: "commit",
854 label: "Committing...",
855 }) ?? Effect.void
856 );
857
858 yield* (
859 options?.progressReporter?.publish({
860 actionId: options.actionId ?? input.actionId,
861 cwd: input.cwd,
862 action: input.action,
863 kind: "action_finished",
864 result,
865 }) ?? Effect.void
866 );
867
868 return result;
869 }),
870 resolvePullRequest: () =>
871 Effect.succeed({
872 pullRequest: {
873 number: 1,
874 title: "Demo PR",
875 url: "https://example.com/pr/1",
876 baseBranch: "main",
877 headBranch: "feature/demo",
878 state: "open",
879 },
880 }),
881 preparePullRequestThread: () =>
882 Effect.succeed({
883 pullRequest: {
884 number: 1,
885 title: "Demo PR",
886 url: "https://example.com/pr/1",
887 baseBranch: "main",
888 headBranch: "feature/demo",
889 state: "open",
890 },
891 branch: "feature/demo",
892 worktreePath: null,
893 }),
894 },
895 gitCore: {
896 pullCurrentBranch: () =>
897 Effect.succeed({
898 status: "pulled",
899 branch: "main",
900 upstreamBranch: "origin/main",
901 }),
902 listBranches: () =>
903 Effect.succeed({
904 branches: [
905 {
906 name: "main",
907 current: true,
908 isDefault: true,
909 worktreePath: null,
910 },
911 ],
912 isRepo: true,
913 hasOriginRemote: true,
914 }),
915 createWorktree: () =>
916 Effect.succeed({
917 worktree: { path: "/tmp/wt", branch: "feature/demo" },
918 }),
919 removeWorktree: () => Effect.void,
920 createBranch: () => Effect.void,
921 checkoutBranch: () => Effect.void,
922 initRepo: () => Effect.void,
923 },
924 },
925 });
926
927 const wsUrl = yield* getWsServerUrl("/ws");
928
929 const status = yield* Effect.scoped(
930 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitStatus]({ cwd: "/tmp/repo" })),
931 );
932 assert.equal(status.branch, "main");
933
934 const pull = yield* Effect.scoped(
935 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })),
936 );
937 assert.equal(pull.status, "pulled");
938
939 const stackedEvents = yield* Effect.scoped(
940 withWsRpcClient(wsUrl, (client) =>
941 client[WS_METHODS.gitRunStackedAction]({
942 actionId: "action-1",
943 cwd: "/tmp/repo",
944 action: "commit",
945 }).pipe(
946 Stream.runCollect,
947 Effect.map((events) => Array.from(events)),
948 ),
949 ),
950 );
951 const lastStackedEvent = stackedEvents.at(-1);
952 assert.equal(lastStackedEvent?.kind, "action_finished");
953 if (lastStackedEvent?.kind === "action_finished") {
954 assert.equal(lastStackedEvent.result.action, "commit");
955 }
956
957 const resolvedPr = yield* Effect.scoped(
958 withWsRpcClient(wsUrl, (client) =>
959 client[WS_METHODS.gitResolvePullRequest]({
960 cwd: "/tmp/repo",
961 reference: "1",
962 }),
963 ),
964 );
965 assert.equal(resolvedPr.pullRequest.number, 1);
966
967 const prepared = yield* Effect.scoped(
968 withWsRpcClient(wsUrl, (client) =>
969 client[WS_METHODS.gitPreparePullRequestThread]({
970 cwd: "/tmp/repo",
971 reference: "1",
972 mode: "local",
973 }),
974 ),
975 );
976 assert.equal(prepared.branch, "feature/demo");
977
978 const branches = yield* Effect.scoped(
979 withWsRpcClient(wsUrl, (client) =>
980 client[WS_METHODS.gitListBranches]({ cwd: "/tmp/repo" }),
981 ),
982 );
983 assert.equal(branches.branches[0]?.name, "main");
984
985 const worktree = yield* Effect.scoped(
986 withWsRpcClient(wsUrl, (client) =>
987 client[WS_METHODS.gitCreateWorktree]({
988 cwd: "/tmp/repo",
989 branch: "main",
990 path: null,
991 }),
992 ),
993 );
994 assert.equal(worktree.worktree.branch, "feature/demo");
995
996 yield* Effect.scoped(
997 withWsRpcClient(wsUrl, (client) =>
998 client[WS_METHODS.gitRemoveWorktree]({
999 cwd: "/tmp/repo",
1000 path: "/tmp/wt",
1001 }),
1002 ),
1003 );
1004
1005 yield* Effect.scoped(
1006 withWsRpcClient(wsUrl, (client) =>
1007 client[WS_METHODS.gitCreateBranch]({
1008 cwd: "/tmp/repo",
1009 branch: "feature/new",
1010 }),
1011 ),
1012 );
1013
1014 yield* Effect.scoped(
1015 withWsRpcClient(wsUrl, (client) =>
1016 client[WS_METHODS.gitCheckout]({
1017 cwd: "/tmp/repo",
1018 branch: "main",
1019 }),
1020 ),
1021 );
1022
1023 yield* Effect.scoped(
1024 withWsRpcClient(wsUrl, (client) =>
1025 client[WS_METHODS.gitInit]({
1026 cwd: "/tmp/repo",
1027 }),
1028 ),
1029 );
1030 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1031 );
1032
1033 it.effect("routes websocket rpc git.pull errors", () =>
1034 Effect.gen(function* () {
1035 const gitError = new GitCommandError({
1036 operation: "pull",
1037 command: "git pull --ff-only",
1038 cwd: "/tmp/repo",
1039 detail: "upstream missing",
1040 });
1041 yield* buildAppUnderTest({
1042 layers: {
1043 gitCore: {
1044 pullCurrentBranch: () => Effect.fail(gitError),
1045 },
1046 },
1047 });
1048
1049 const wsUrl = yield* getWsServerUrl("/ws");
1050 const result = yield* Effect.scoped(
1051 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })).pipe(
1052 Effect.result,
1053 ),
1054 );
1055
1056 assertFailure(result, gitError);
1057 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1058 );
1059
1060 it.effect("routes websocket rpc orchestration methods", () =>
1061 Effect.gen(function* () {
1062 const now = new Date().toISOString();
1063 const snapshot = {
1064 snapshotSequence: 1,
1065 updatedAt: now,
1066 projects: [
1067 {
1068 id: ProjectId.makeUnsafe("project-a"),
1069 title: "Project A",
1070 workspaceRoot: "/tmp/project-a",
1071 defaultModelSelection,
1072 scripts: [],
1073 createdAt: now,
1074 updatedAt: now,
1075 deletedAt: null,
1076 },
1077 ],
1078 threads: [
1079 {
1080 id: ThreadId.makeUnsafe("thread-1"),
1081 projectId: ProjectId.makeUnsafe("project-a"),
1082 title: "Thread A",
1083 modelSelection: defaultModelSelection,
1084 interactionMode: "default" as const,
1085 runtimeMode: "full-access" as const,
1086 branch: null,
1087 worktreePath: null,
1088 createdAt: now,
1089 updatedAt: now,
1090 archivedAt: null,
1091 latestTurn: null,
1092 messages: [],
1093 session: null,
1094 activities: [],
1095 proposedPlans: [],
1096 checkpoints: [],
1097 deletedAt: null,
1098 },
1099 ],
1100 };
1101
1102 yield* buildAppUnderTest({
1103 layers: {
1104 projectionSnapshotQuery: {
1105 getSnapshot: () => Effect.succeed(snapshot),
1106 },
1107 orchestrationEngine: {
1108 dispatch: () => Effect.succeed({ sequence: 7 }),
1109 readEvents: () => Stream.empty,
1110 },
1111 checkpointDiffQuery: {
1112 getTurnDiff: () =>
1113 Effect.succeed({
1114 threadId: ThreadId.makeUnsafe("thread-1"),
1115 fromTurnCount: 0,
1116 toTurnCount: 1,
1117 diff: "turn-diff",
1118 }),
1119 getFullThreadDiff: () =>
1120 Effect.succeed({
1121 threadId: ThreadId.makeUnsafe("thread-1"),
1122 fromTurnCount: 0,
1123 toTurnCount: 1,
1124 diff: "full-diff",
1125 }),
1126 },
1127 },
1128 });
1129
1130 const wsUrl = yield* getWsServerUrl("/ws");
1131 const snapshotResult = yield* Effect.scoped(
1132 withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})),
1133 );
1134 assert.equal(snapshotResult.snapshotSequence, 1);
1135
1136 const dispatchResult = yield* Effect.scoped(
1137 withWsRpcClient(wsUrl, (client) =>
1138 client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
1139 type: "thread.session.stop",
1140 commandId: CommandId.makeUnsafe("cmd-1"),
1141 threadId: ThreadId.makeUnsafe("thread-1"),
1142 createdAt: now,
1143 }),
1144 ),
1145 );
1146 assert.equal(dispatchResult.sequence, 7);
1147
1148 const turnDiffResult = yield* Effect.scoped(
1149 withWsRpcClient(wsUrl, (client) =>
1150 client[ORCHESTRATION_WS_METHODS.getTurnDiff]({
1151 threadId: ThreadId.makeUnsafe("thread-1"),
1152 fromTurnCount: 0,
1153 toTurnCount: 1,
1154 }),
1155 ),
1156 );
1157 assert.equal(turnDiffResult.diff, "turn-diff");
1158
1159 const fullDiffResult = yield* Effect.scoped(
1160 withWsRpcClient(wsUrl, (client) =>
1161 client[ORCHESTRATION_WS_METHODS.getFullThreadDiff]({
1162 threadId: ThreadId.makeUnsafe("thread-1"),
1163 toTurnCount: 1,
1164 }),
1165 ),
1166 );
1167 assert.equal(fullDiffResult.diff, "full-diff");
1168
1169 const replayResult = yield* Effect.scoped(
1170 withWsRpcClient(wsUrl, (client) =>
1171 client[ORCHESTRATION_WS_METHODS.replayEvents]({
1172 fromSequenceExclusive: 0,
1173 }),
1174 ),
1175 );
1176 assert.deepEqual(replayResult, []);
1177 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1178 );
1179
1180 it.effect(
1181 "routes websocket rpc subscribeOrchestrationDomainEvents with replay/live overlap resilience",
1182 () =>
1183 Effect.gen(function* () {
1184 const now = new Date().toISOString();
1185 const threadId = ThreadId.makeUnsafe("thread-1");
1186 let replayCursor: number | null = null;
1187 const makeEvent = (sequence: number): OrchestrationEvent =>
1188 ({
1189 sequence,
1190 eventId: `event-${sequence}`,
1191 aggregateKind: "thread",
1192 aggregateId: threadId,
1193 occurredAt: now,
1194 commandId: null,
1195 causationEventId: null,
1196 correlationId: null,
1197 metadata: {},
1198 type: "thread.reverted",
1199 payload: {
1200 threadId,
1201 turnCount: sequence,
1202 },
1203 }) as OrchestrationEvent;
1204
1205 yield* buildAppUnderTest({
1206 layers: {
1207 orchestrationEngine: {
1208 getReadModel: () =>
1209 Effect.succeed({
1210 ...makeDefaultOrchestrationReadModel(),
1211 snapshotSequence: 1,
1212 }),
1213 readEvents: (fromSequenceExclusive) => {
1214 replayCursor = fromSequenceExclusive;
1215 return Stream.make(makeEvent(2), makeEvent(3));
1216 },
1217 streamDomainEvents: Stream.make(makeEvent(3), makeEvent(4)),
1218 },
1219 },
1220 });
1221
1222 const wsUrl = yield* getWsServerUrl("/ws");
1223 const events = yield* Effect.scoped(
1224 withWsRpcClient(wsUrl, (client) =>
1225 client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe(
1226 Stream.take(3),
1227 Stream.runCollect,
1228 ),
1229 ),
1230 );
1231
1232 assert.equal(replayCursor, 1);
1233 assert.deepEqual(
1234 Array.from(events).map((event) => event.sequence),
1235 [2, 3, 4],
1236 );
1237 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1238 );
1239
1240 it.effect("routes websocket rpc orchestration.getSnapshot errors", () =>
1241 Effect.gen(function* () {
1242 yield* buildAppUnderTest({
1243 layers: {
1244 projectionSnapshotQuery: {
1245 getSnapshot: () =>
1246 Effect.fail(
1247 new PersistenceSqlError({
1248 operation: "ProjectionSnapshotQuery.getSnapshot",
1249 detail: "projection unavailable",
1250 }),
1251 ),
1252 },
1253 },
1254 });
1255
1256 const wsUrl = yield* getWsServerUrl("/ws");
1257 const result = yield* Effect.scoped(
1258 withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})).pipe(
1259 Effect.result,
1260 ),
1261 );
1262
1263 assertTrue(result._tag === "Failure");
1264 assertTrue(result.failure._tag === "OrchestrationGetSnapshotError");
1265 assertInclude(result.failure.message, "Failed to load orchestration snapshot");
1266 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1267 );
1268
1269 it.effect("routes websocket rpc terminal methods", () =>
1270 Effect.gen(function* () {
1271 const snapshot = {
1272 threadId: "thread-1",
1273 terminalId: "default",
1274 cwd: "/tmp/project",
1275 status: "running" as const,
1276 pid: 1234,
1277 history: "",
1278 exitCode: null,
1279 exitSignal: null,
1280 updatedAt: new Date().toISOString(),
1281 };
1282
1283 yield* buildAppUnderTest({
1284 layers: {
1285 terminalManager: {
1286 open: () => Effect.succeed(snapshot),
1287 write: () => Effect.void,
1288 resize: () => Effect.void,
1289 clear: () => Effect.void,
1290 restart: () => Effect.succeed(snapshot),
1291 close: () => Effect.void,
1292 },
1293 },
1294 });
1295
1296 const wsUrl = yield* getWsServerUrl("/ws");
1297
1298 const opened = yield* Effect.scoped(
1299 withWsRpcClient(wsUrl, (client) =>
1300 client[WS_METHODS.terminalOpen]({
1301 threadId: "thread-1",
1302 terminalId: "default",
1303 cwd: "/tmp/project",
1304 }),
1305 ),
1306 );
1307 assert.equal(opened.terminalId, "default");
1308
1309 yield* Effect.scoped(
1310 withWsRpcClient(wsUrl, (client) =>
1311 client[WS_METHODS.terminalWrite]({
1312 threadId: "thread-1",
1313 terminalId: "default",
1314 data: "echo hi\n",
1315 }),
1316 ),
1317 );
1318
1319 yield* Effect.scoped(
1320 withWsRpcClient(wsUrl, (client) =>
1321 client[WS_METHODS.terminalResize]({
1322 threadId: "thread-1",
1323 terminalId: "default",
1324 cols: 120,
1325 rows: 40,
1326 }),
1327 ),
1328 );
1329
1330 yield* Effect.scoped(
1331 withWsRpcClient(wsUrl, (client) =>
1332 client[WS_METHODS.terminalClear]({
1333 threadId: "thread-1",
1334 terminalId: "default",
1335 }),
1336 ),
1337 );
1338
1339 const restarted = yield* Effect.scoped(
1340 withWsRpcClient(wsUrl, (client) =>
1341 client[WS_METHODS.terminalRestart]({
1342 threadId: "thread-1",
1343 terminalId: "default",
1344 cwd: "/tmp/project",
1345 cols: 120,
1346 rows: 40,
1347 }),
1348 ),
1349 );
1350 assert.equal(restarted.terminalId, "default");
1351
1352 yield* Effect.scoped(
1353 withWsRpcClient(wsUrl, (client) =>
1354 client[WS_METHODS.terminalClose]({
1355 threadId: "thread-1",
1356 terminalId: "default",
1357 }),
1358 ),
1359 );
1360 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1361 );
1362
1363 it.effect("routes websocket rpc terminal.write errors", () =>
1364 Effect.gen(function* () {
1365 const terminalError = new TerminalNotRunningError({
1366 threadId: "thread-1",
1367 terminalId: "default",
1368 });
1369 yield* buildAppUnderTest({
1370 layers: {
1371 terminalManager: {
1372 write: () => Effect.fail(terminalError),
1373 },
1374 },
1375 });
1376
1377 const wsUrl = yield* getWsServerUrl("/ws");
1378 const result = yield* Effect.scoped(
1379 withWsRpcClient(wsUrl, (client) =>
1380 client[WS_METHODS.terminalWrite]({
1381 threadId: "thread-1",
1382 terminalId: "default",
1383 data: "echo fail\n",
1384 }),
1385 ).pipe(Effect.result),
1386 );
1387
1388 assertFailure(result, terminalError);
1389 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1390 );
1391});
apps/server/src/server.ts created+234
...@@ -0,0 +1,234 @@
1import { Effect, Layer } from "effect";
2import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http";
3
4import { ServerConfig } from "./config";
5import { attachmentsRouteLayer, projectFaviconRouteLayer, staticAndDevRouteLayer } from "./http";
6import { fixPath } from "./os-jank";
7import { websocketRpcRouteLayer } from "./ws";
8import { OpenLive } from "./open";
9import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite";
10import { ServerLifecycleEventsLive } from "./serverLifecycleEvents";
11import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";
12import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
13import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
14import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
15import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
16import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter";
17import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
18import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
19import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine";
20import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
21import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
22import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
23import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery";
24import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
25import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
26import { GitCoreLive } from "./git/Layers/GitCore";
27import { GitHubCliLive } from "./git/Layers/GitHubCli";
28import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration";
29import { TerminalManagerLive } from "./terminal/Layers/Manager";
30import { GitManagerLive } from "./git/Layers/GitManager";
31import { KeybindingsLive } from "./keybindings";
32import { ServerLoggerLive } from "./serverLogger";
33import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup";
34import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor";
35import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus";
36import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
37import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor";
38import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor";
39import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry";
40import { ServerSettingsLive } from "./serverSettings";
41import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver";
42import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries";
43import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem";
44import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths";
45
46const 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
58const 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
82const 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
94const 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
102const 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
110const CheckpointingLayerLive = Layer.empty.pipe(
111 Layer.provideMerge(CheckpointDiffQueryLive),
112 Layer.provideMerge(CheckpointStoreLive),
113);
114
115const 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
144const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive));
145
146const 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
157const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive));
158
159const 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
168const 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
191export const makeRoutesLayer = Layer.mergeAll(
192 attachmentsRouteLayer,
193 projectFaviconRouteLayer,
194 staticAndDevRouteLayer,
195 websocketRpcRouteLayer,
196);
197
198export 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.
230export const runServer = Layer.launch(makeServerLayer) satisfies Effect.Effect<
231 never,
232 any,
233 ServerConfig
234>;
apps/server/src/serverLayers.ts deleted-161
...@@ -1,161 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { Effect, FileSystem, Layer, Path } from "effect";
3import * as SqlClient from "effect/unstable/sql/SqlClient";
4
5import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery";
6import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
7import { ServerConfig } from "./config";
8import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
9import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
10import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
11import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine";
12import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor";
13import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor";
14import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor";
15import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
16import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
17import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
18import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus";
19import { ProviderUnsupportedError } from "./provider/Errors";
20import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter";
21import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
22import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
23import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
24import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
25import { ProviderService } from "./provider/Services/ProviderService";
26import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
27import { ServerSettingsService } from "./serverSettings";
28
29import { TerminalManagerLive } from "./terminal/Layers/Manager";
30import { KeybindingsLive } from "./keybindings";
31import { GitManagerLive } from "./git/Layers/GitManager";
32import { GitCoreLive } from "./git/Layers/GitCore";
33import { GitHubCliLive } from "./git/Layers/GitHubCli";
34import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration";
35import { PtyAdapter } from "./terminal/Services/PTY";
36import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
37import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
38import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";
39import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
40import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
41
42type RuntimePtyAdapterLoader = {
43 layer: Layer.Layer<PtyAdapter, never, FileSystem.FileSystem | Path.Path>;
44};
45
46const runtimePtyAdapterLoaders = {
47 bun: () => import("./terminal/Layers/BunPTY"),
48 node: () => import("./terminal/Layers/NodePTY"),
49} satisfies Record<string, () => Promise<RuntimePtyAdapterLoader>>;
50
51const 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
59export 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
96export function makeServerRuntimeServicesLayer() {
97 const textGenerationLayer = RoutingTextGenerationLive;
98 const gitCoreLayer = GitCoreLive;
99 const checkpointStoreLayer = CheckpointStoreLive;
100
101 const orchestrationLayer = OrchestrationEngineLive.pipe(
102 Layer.provide(OrchestrationProjectionPipelineLive),
103 Layer.provide(OrchestrationEventStoreLive),
104 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
105 );
106
107 const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe(
108 Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive),
109 Layer.provideMerge(checkpointStoreLayer),
110 );
111
112 const runtimeServicesLayer = Layer.mergeAll(
113 orchestrationLayer,
114 OrchestrationProjectionSnapshotQueryLive,
115 checkpointStoreLayer,
116 checkpointDiffQueryLayer,
117 RuntimeReceiptBusLive,
118 );
119 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
120 Layer.provideMerge(runtimeServicesLayer),
121 );
122 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
123 Layer.provideMerge(runtimeServicesLayer),
124 Layer.provideMerge(textGenerationLayer),
125 );
126 const checkpointReactorLayer = CheckpointReactorLive.pipe(
127 Layer.provideMerge(runtimeServicesLayer),
128 Layer.provideMerge(WorkspaceEntriesLive),
129 );
130 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
131 Layer.provideMerge(runtimeIngestionLayer),
132 Layer.provideMerge(providerCommandReactorLayer),
133 Layer.provideMerge(checkpointReactorLayer),
134 );
135
136 const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer()));
137
138 const gitManagerLayer = GitManagerLive.pipe(
139 Layer.provideMerge(GitHubCliLive),
140 Layer.provideMerge(textGenerationLayer),
141 );
142
143 const workspacePathsLayer = WorkspacePathsLive;
144 const workspaceEntriesLayer = WorkspaceEntriesLive;
145 const workspaceFileSystemLayer = WorkspaceFileSystemLive.pipe(
146 Layer.provide(workspacePathsLayer),
147 Layer.provide(workspaceEntriesLayer),
148 );
149 const projectFaviconResolverLayer = ProjectFaviconResolverLive;
150
151 return Layer.mergeAll(
152 orchestrationReactorLayer,
153 workspacePathsLayer,
154 workspaceEntriesLayer,
155 workspaceFileSystemLayer,
156 projectFaviconResolverLayer,
157 gitManagerLayer,
158 terminalLayer,
159 KeybindingsLive,
160 ).pipe(Layer.provideMerge(gitCoreLayer), Layer.provideMerge(NodeServices.layer));
161}
apps/server/src/serverLifecycleEvents.test.ts created+42
...@@ -0,0 +1,42 @@
1import { assert, it } from "@effect/vitest";
2import { assertTrue } from "@effect/vitest/utils";
3import { Effect, Option } from "effect";
4
5import { ServerLifecycleEvents, ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts";
6
7it.effect(
8 "publishes lifecycle events without subscribers and snapshots the latest welcome/ready",
9 () =>
10 Effect.gen(function* () {
11 const lifecycleEvents = yield* ServerLifecycleEvents;
12
13 const welcome = yield* lifecycleEvents
14 .publish({
15 version: 1,
16 type: "welcome",
17 payload: {
18 cwd: "/tmp/project",
19 projectName: "project",
20 },
21 })
22 .pipe(Effect.timeoutOption("50 millis"));
23 assertTrue(Option.isSome(welcome));
24 assert.equal(welcome.value.sequence, 1);
25
26 const ready = yield* lifecycleEvents
27 .publish({
28 version: 1,
29 type: "ready",
30 payload: {
31 at: new Date().toISOString(),
32 },
33 })
34 .pipe(Effect.timeoutOption("50 millis"));
35 assertTrue(Option.isSome(ready));
36 assert.equal(ready.value.sequence, 2);
37
38 const snapshot = yield* lifecycleEvents.snapshot;
39 assert.equal(snapshot.sequence, 2);
40 assert.deepEqual(snapshot.events.map((event) => event.type).toSorted(), ["ready", "welcome"]);
41 }).pipe(Effect.provide(ServerLifecycleEventsLive)),
42);
apps/server/src/serverLifecycleEvents.ts created+53
...@@ -0,0 +1,53 @@
1import type { ServerLifecycleStreamEvent } from "@t3tools/contracts";
2import { Effect, Layer, PubSub, Ref, ServiceMap, Stream } from "effect";
3
4type LifecycleEventInput =
5 | Omit<Extract<ServerLifecycleStreamEvent, { type: "welcome" }>, "sequence">
6 | Omit<Extract<ServerLifecycleStreamEvent, { type: "ready" }>, "sequence">;
7
8interface SnapshotState {
9 readonly sequence: number;
10 readonly events: ReadonlyArray<ServerLifecycleStreamEvent>;
11}
12
13export interface ServerLifecycleEventsShape {
14 readonly publish: (event: LifecycleEventInput) => Effect.Effect<ServerLifecycleStreamEvent>;
15 readonly snapshot: Effect.Effect<SnapshotState>;
16 readonly stream: Stream.Stream<ServerLifecycleStreamEvent>;
17}
18
19export class ServerLifecycleEvents extends ServiceMap.Service<
20 ServerLifecycleEvents,
21 ServerLifecycleEventsShape
22>()("t3/serverLifecycleEvents") {}
23
24export const ServerLifecycleEventsLive = Layer.effect(
25 ServerLifecycleEvents,
26 Effect.gen(function* () {
27 const pubsub = yield* PubSub.unbounded<ServerLifecycleStreamEvent>();
28 const state = yield* Ref.make<SnapshotState>({
29 sequence: 0,
30 events: [],
31 });
32
33 return {
34 publish: (event) =>
35 Ref.modify(state, (current) => {
36 const nextSequence = current.sequence + 1;
37 const nextEvent = {
38 ...event,
39 sequence: nextSequence,
40 } satisfies ServerLifecycleStreamEvent;
41 const nextEvents =
42 nextEvent.type === "welcome"
43 ? [nextEvent, ...current.events.filter((entry) => entry.type !== "welcome")]
44 : [nextEvent, ...current.events.filter((entry) => entry.type !== "ready")];
45 return [nextEvent, { sequence: nextSequence, events: nextEvents }] as const;
46 }).pipe(Effect.tap((event) => PubSub.publish(pubsub, event))),
47 snapshot: Ref.get(state),
48 get stream() {
49 return Stream.fromPubSub(pubsub);
50 },
51 } satisfies ServerLifecycleEventsShape;
52 }),
53);
apps/server/src/serverLogger.ts+7-11
...@@ -1,20 +1,16 @@...@@ -1,20 +1,16 @@
1import fs from "node:fs";1import { Effect, Logger, References, Layer } from "effect";
2
3import { Effect, Logger } from "effect";
4import * as Layer from "effect/Layer";
52
6import { ServerConfig } from "./config";3import { ServerConfig } from "./config";
74
8export const ServerLoggerLive = Effect.gen(function* () {5export const ServerLoggerLive = Effect.gen(function* () {
9 const { logsDir, serverLogPath } = yield* ServerConfig;6 const config = yield* ServerConfig;
107 const { serverLogPath } = config;
11 yield* Effect.sync(() => {
12 fs.mkdirSync(logsDir, { recursive: true });
13 });
148
15 const fileLogger = Logger.formatSimple.pipe(Logger.toFile(serverLogPath));9 const fileLogger = Logger.formatSimple.pipe(Logger.toFile(serverLogPath));
1610 const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel);
17 return Logger.layer([Logger.defaultLogger, fileLogger], {11 const loggerLayer = Logger.layer([Logger.consolePretty(), fileLogger], {
18 mergeWithExisting: false,12 mergeWithExisting: false,
19 });13 });
14
15 return Layer.mergeAll(loggerLayer, minimumLogLevelLayer);
20}).pipe(Layer.unwrap);16}).pipe(Layer.unwrap);
apps/server/src/serverRuntimeStartup.test.ts created+49
...@@ -0,0 +1,49 @@
1import { assert, it } from "@effect/vitest";
2import { Deferred, Effect, Fiber, Ref } from "effect";
3import { TestClock } from "effect/testing";
4
5import { makeCommandGate, ServerRuntimeStartupError } from "./serverRuntimeStartup.ts";
6
7it.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
29it.effect("enqueueCommand fails queued work when readiness fails", () =>
30 Effect.scoped(
31 Effect.gen(function* () {
32 const commandGate = yield* makeCommandGate;
33 const failure = yield* Deferred.make<void, never>();
34
35 const queuedCommandFiber = yield* commandGate
36 .enqueueCommand(Deferred.await(failure).pipe(Effect.as("should-not-run")))
37 .pipe(Effect.forkScoped);
38
39 yield* commandGate.failCommandReady(
40 new ServerRuntimeStartupError({
41 message: "startup failed",
42 }),
43 );
44
45 const error = yield* Effect.flip(Fiber.join(queuedCommandFiber));
46 assert.equal(error.message, "startup failed");
47 }),
48 ),
49);
apps/server/src/serverRuntimeStartup.ts created+334
...@@ -0,0 +1,334 @@
1import {
2 CommandId,
3 DEFAULT_PROVIDER_INTERACTION_MODE,
4 type ModelSelection,
5 ProjectId,
6 ThreadId,
7} from "@t3tools/contracts";
8import { Data, Deferred, Effect, Exit, Layer, Path, Queue, Ref, Scope, ServiceMap } from "effect";
9
10import { ServerConfig } from "./config";
11import { Keybindings } from "./keybindings";
12import { Open } from "./open";
13import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
14import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
15import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
16import { ServerLifecycleEvents } from "./serverLifecycleEvents";
17import { ServerSettingsService } from "./serverSettings";
18import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
19
20const isWildcardHost = (host: string | undefined): boolean =>
21 host === "0.0.0.0" || host === "::" || host === "[::]";
22
23const formatHostForUrl = (host: string): string =>
24 host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
25
26export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{
27 readonly message: string;
28 readonly cause?: unknown;
29}> {}
30
31export 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
39export class ServerRuntimeStartup extends ServiceMap.Service<
40 ServerRuntimeStartup,
41 ServerRuntimeStartupShape
42>()("t3/serverRuntimeStartup") {}
43
44interface QueuedCommand {
45 readonly run: Effect.Effect<void, never>;
46}
47
48type CommandReadinessState = "pending" | "ready" | ServerRuntimeStartupError;
49
50interface 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
59const 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
64export 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
108const 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
133const 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
215const 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
237const 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
331export const ServerRuntimeStartupLive = Layer.effect(
332 ServerRuntimeStartup,
333 makeServerRuntimeStartup,
334);
apps/server/src/serverSettings.ts+3-13
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 type ModelSelection,16 type ModelSelection,
17 type ProviderKind,17 type ProviderKind,
18 ServerSettings,18 ServerSettings,
19 ServerSettingsError,
19 type ServerSettingsPatch,20 type ServerSettingsPatch,
20} from "@t3tools/contracts";21} from "@t3tools/contracts";
21import {22import {
...@@ -35,25 +36,13 @@ import {...@@ -35,25 +36,13 @@ import {
35 Scope,36 Scope,
36 ServiceMap,37 ServiceMap,
37 Stream,38 Stream,
39 Cause,
38} from "effect";40} from "effect";
39import * as Semaphore from "effect/Semaphore";41import * as Semaphore from "effect/Semaphore";
40import { ServerConfig } from "./config";42import { ServerConfig } from "./config";
41import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";43import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
42import { fromLenientJson } from "@t3tools/shared/schemaJson";44import { fromLenientJson } from "@t3tools/shared/schemaJson";
4345
44export 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
57export interface ServerSettingsShape {46export interface ServerSettingsShape {
58 /** Start the settings runtime and attach file watching. */47 /** Start the settings runtime and attach file watching. */
59 readonly start: Effect.Effect<void, ServerSettingsError>;48 readonly start: Effect.Effect<void, ServerSettingsError>;
...@@ -215,6 +204,7 @@ const makeServerSettings = Effect.gen(function* () {...@@ -215,6 +204,7 @@ const makeServerSettings = Effect.gen(function* () {
215 if (decoded._tag === "Failure") {204 if (decoded._tag === "Failure") {
216 yield* Effect.logWarning("failed to parse settings.json, using defaults", {205 yield* Effect.logWarning("failed to parse settings.json, using defaults", {
217 path: settingsPath,206 path: settingsPath,
207 issues: Cause.pretty(decoded.cause),
218 });208 });
219 return DEFAULT_SERVER_SETTINGS;209 return DEFAULT_SERVER_SETTINGS;
220 }210 }
apps/server/src/terminal/Services/Manager.ts+39-65
...@@ -10,85 +10,59 @@ import {...@@ -10,85 +10,59 @@ import {
10 TerminalClearInput,10 TerminalClearInput,
11 TerminalCloseInput,11 TerminalCloseInput,
12 TerminalEvent,12 TerminalEvent,
13 TerminalCwdError,
14 TerminalError,
15 TerminalHistoryError,
16 TerminalNotRunningError,
13 TerminalOpenInput,17 TerminalOpenInput,
14 TerminalResizeInput,18 TerminalResizeInput,
15 TerminalRestartInput,19 TerminalRestartInput,
16 TerminalSessionSnapshot,20 TerminalSessionSnapshot,
21 TerminalSessionLookupError,
22 TerminalSessionStatus,
17 TerminalWriteInput,23 TerminalWriteInput,
18} from "@t3tools/contracts";24} from "@t3tools/contracts";
19import { Effect, Schema, ServiceMap } from "effect";25import { PtyProcess } from "./PTY";
26import { Effect, ServiceMap } from "effect";
2027
21export class TerminalCwdError extends Schema.TaggedErrorClass<TerminalCwdError>()(28export {
22 "TerminalCwdError",29 TerminalCwdError,
23 {30 TerminalError,
24 cwd: Schema.String,31 TerminalHistoryError,
25 reason: Schema.Literals(["notFound", "notDirectory", "statFailed"]),32 TerminalNotRunningError,
26 cause: Schema.optional(Schema.Defect),33 TerminalSessionLookupError,
27 },34};
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}
4535
46export class TerminalHistoryError extends Schema.TaggedErrorClass<TerminalHistoryError>()(36export interface TerminalSessionState {
47 "TerminalHistoryError",37 threadId: string;
48 {38 terminalId: string;
49 operation: Schema.Literals(["read", "truncate", "migrate"]),39 cwd: string;
50 threadId: Schema.String,40 status: TerminalSessionStatus;
51 terminalId: Schema.String,41 pid: number | null;
52 cause: Schema.optional(Schema.Defect),42 history: string;
53 },43 pendingHistoryControlSequence: string;
54) {44 exitCode: number | null;
55 override get message() {45 exitSignal: number | null;
56 return `Failed to ${this.operation} terminal history for thread: ${this.threadId}, terminal: ${this.terminalId}`;46 updatedAt: string;
57 }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}
5955
60export class TerminalSessionLookupError extends Schema.TaggedErrorClass<TerminalSessionLookupError>()(56export interface ShellCandidate {
61 "TerminalSessionLookupError",57 shell: string;
62 {58 args?: string[];
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 }
70}59}
7160
72export class TerminalNotRunningError extends Schema.TaggedErrorClass<TerminalNotRunningError>()(61export interface TerminalStartInput extends TerminalOpenInput {
73 "TerminalNotRunningError",62 cols: number;
74 {63 rows: number;
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 }
82}64}
8365
84export const TerminalError = Schema.Union([
85 TerminalCwdError,
86 TerminalHistoryError,
87 TerminalSessionLookupError,
88 TerminalNotRunningError,
89]);
90export type TerminalError = typeof TerminalError.Type;
91
92/**66/**
93 * TerminalManagerShape - Service API for terminal session lifecycle operations.67 * TerminalManagerShape - Service API for terminal session lifecycle operations.
94 */68 */
apps/server/src/workspace/Layers/WorkspaceEntries.test.ts+3-1
...@@ -9,9 +9,11 @@ import { GitCoreLive } from "../../git/Layers/GitCore.ts";...@@ -9,9 +9,11 @@ import { GitCoreLive } from "../../git/Layers/GitCore.ts";
9import { GitCore } from "../../git/Services/GitCore.ts";9import { GitCore } from "../../git/Services/GitCore.ts";
10import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts";10import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts";
11import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts";11import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts";
12import { WorkspacePathsLive } from "./WorkspacePaths.ts";
1213
13const TestLayer = Layer.empty.pipe(14const TestLayer = Layer.empty.pipe(
14 Layer.provideMerge(WorkspaceEntriesLive),15 Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))),
16 Layer.provideMerge(WorkspacePathsLive),
15 Layer.provideMerge(GitCoreLive),17 Layer.provideMerge(GitCoreLive),
16 Layer.provide(18 Layer.provide(
17 ServerConfig.layerTest(process.cwd(), {19 ServerConfig.layerTest(process.cwd(), {
apps/server/src/workspace/Layers/WorkspaceEntries.ts+27-2
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 WorkspaceEntriesError,11 WorkspaceEntriesError,
12 type WorkspaceEntriesShape,12 type WorkspaceEntriesShape,
13} from "../Services/WorkspaceEntries.ts";13} from "../Services/WorkspaceEntries.ts";
14import { WorkspacePaths } from "../Services/WorkspacePaths.ts";
1415
15const WORKSPACE_CACHE_TTL_MS = 15_000;16const WORKSPACE_CACHE_TTL_MS = 15_000;
16const WORKSPACE_CACHE_MAX_KEYS = 4;17const WORKSPACE_CACHE_MAX_KEYS = 4;
...@@ -219,6 +220,7 @@ const processErrorDetail = (cause: unknown): string =>...@@ -219,6 +220,7 @@ const processErrorDetail = (cause: unknown): string =>
219export const makeWorkspaceEntries = Effect.gen(function* () {220export const makeWorkspaceEntries = Effect.gen(function* () {
220 const path = yield* Path.Path;221 const path = yield* Path.Path;
221 const gitOption = yield* Effect.serviceOption(GitCore);222 const gitOption = yield* Effect.serviceOption(GitCore);
223 const workspacePaths = yield* WorkspacePaths;
222224
223 const isInsideGitWorkTree = (cwd: string): Effect.Effect<boolean> =>225 const isInsideGitWorkTree = (cwd: string): Effect.Effect<boolean> =>
224 Option.match(gitOption, {226 Option.match(gitOption, {
...@@ -435,15 +437,38 @@ export const makeWorkspaceEntries = Effect.gen(function* () {...@@ -435,15 +437,38 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
435 Exit.isSuccess(exit) ? Duration.millis(WORKSPACE_CACHE_TTL_MS) : Duration.zero,437 Exit.isSuccess(exit) ? Duration.millis(WORKSPACE_CACHE_TTL_MS) : Duration.zero,
436 });438 });
437439
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 const invalidate: WorkspaceEntriesShape["invalidate"] = Effect.fn("WorkspaceEntries.invalidate")(456 const invalidate: WorkspaceEntriesShape["invalidate"] = Effect.fn("WorkspaceEntries.invalidate")(
439 function* (cwd) {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 );
443467
444 const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")(468 const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")(
445 function* (input) {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 Effect.map((index) => {472 Effect.map((index) => {
448 const normalizedQuery = normalizeQuery(input.query);473 const normalizedQuery = normalizeQuery(input.query);
449 const limit = Math.max(0, Math.floor(input.limit));474 const limit = Math.max(0, Math.floor(input.limit));
apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts+3-2
...@@ -12,12 +12,13 @@ import { WorkspacePathsLive } from "./WorkspacePaths.ts";...@@ -12,12 +12,13 @@ import { WorkspacePathsLive } from "./WorkspacePaths.ts";
1212
13const ProjectLayer = WorkspaceFileSystemLive.pipe(13const ProjectLayer = WorkspaceFileSystemLive.pipe(
14 Layer.provide(WorkspacePathsLive),14 Layer.provide(WorkspacePathsLive),
15 Layer.provide(WorkspaceEntriesLive),15 Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))),
16);16);
1717
18const TestLayer = Layer.empty.pipe(18const TestLayer = Layer.empty.pipe(
19 Layer.provideMerge(ProjectLayer),19 Layer.provideMerge(ProjectLayer),
20 Layer.provideMerge(WorkspaceEntriesLive),20 Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))),
21 Layer.provideMerge(WorkspacePathsLive),
21 Layer.provideMerge(GitCoreLive),22 Layer.provideMerge(GitCoreLive),
22 Layer.provide(23 Layer.provide(
23 ServerConfig.layerTest(process.cwd(), {24 ServerConfig.layerTest(process.cwd(), {
apps/server/src/ws.ts created+343
...@@ -0,0 +1,343 @@
1import { Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect";
2import {
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";
18import { clamp } from "effect/Number";
19import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
20import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
21
22import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
23import { ServerConfig } from "./config";
24import { GitCore } from "./git/Services/GitCore";
25import { GitManager } from "./git/Services/GitManager";
26import { Keybindings } from "./keybindings";
27import { Open, resolveAvailableEditors } from "./open";
28import { normalizeDispatchCommand } from "./orchestration/Normalizer";
29import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
30import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
31import { ProviderRegistry } from "./provider/Services/ProviderRegistry";
32import { ServerLifecycleEvents } from "./serverLifecycleEvents";
33import { ServerRuntimeStartup } from "./serverRuntimeStartup";
34import { ServerSettingsService } from "./serverSettings";
35import { TerminalManager } from "./terminal/Services/Manager";
36import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries";
37import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem";
38import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths";
39
40const 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
318export const websocketRpcRouteLayer = Layer.unwrap(
319 Effect.gen(function* () {
320 const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup).pipe(
321 Effect.provide(Layer.mergeAll(WsRpcLayer, RpcSerialization.layerJson)),
322 );
323 return HttpRouter.add(
324 "GET",
325 "/ws",
326 Effect.gen(function* () {
327 const request = yield* HttpServerRequest.HttpServerRequest;
328 const config = yield* ServerConfig;
329 if (config.authToken) {
330 const url = HttpServerRequest.toURL(request);
331 if (Option.isNone(url)) {
332 return HttpServerResponse.text("Invalid WebSocket URL", { status: 400 });
333 }
334 const token = url.value.searchParams.get("token");
335 if (token !== config.authToken) {
336 return HttpServerResponse.text("Unauthorized WebSocket connection", { status: 401 });
337 }
338 }
339 return yield* rpcWebSocketHttpEffect;
340 }),
341 );
342 }),
343);
apps/server/src/wsServer.test.ts deleted-2069
...@@ -1,2069 +0,0 @@
1import * as Http from "node:http";
2import fs from "node:fs";
3import os from "node:os";
4import path from "node:path";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import {
8 Effect,
9 Exit,
10 Fiber,
11 Layer,
12 ManagedRuntime,
13 PlatformError,
14 PubSub,
15 Scope,
16 Stream,
17} from "effect";
18import { describe, expect, it, afterEach, vi } from "vitest";
19import { createServer } from "./wsServer";
20import WebSocket from "ws";
21import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config";
22import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
23
24import {
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";
46import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";
47import type {
48 TerminalClearInput,
49 TerminalCloseInput,
50 TerminalEvent,
51 TerminalOpenInput,
52 TerminalResizeInput,
53 TerminalSessionSnapshot,
54 TerminalWriteInput,
55} from "@t3tools/contracts";
56import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager";
57import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
58import { SqlClient, SqlError } from "effect/unstable/sql";
59import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
60import { ProviderRegistry, type ProviderRegistryShape } from "./provider/Services/ProviderRegistry";
61import { Open, type OpenShape } from "./open";
62import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
63import type { GitCoreShape } from "./git/Services/GitCore.ts";
64import { GitCore } from "./git/Services/GitCore.ts";
65import { GitCommandError, GitManagerError } from "./git/Errors.ts";
66import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";
67import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
68import { ServerSettingsService } from "./serverSettings.ts";
69
70const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
71const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
72const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
73const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
74
75const defaultOpenService: OpenShape = {
76 openBrowser: () => Effect.void,
77 openInEditor: () => Effect.void,
78};
79
80const 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
93const defaultProviderRegistryService: ProviderRegistryShape = {
94 getProviders: Effect.succeed(defaultProviderStatuses),
95 refresh: () => Effect.succeed(defaultProviderStatuses),
96 streamChanges: Stream.empty,
97};
98
99const defaultServerSettings = DEFAULT_SERVER_SETTINGS;
100
101class 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
244interface 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
253interface SocketChannels {
254 push: MessageChannel<WsPush>;
255 response: MessageChannel<WebSocketResponse>;
256}
257
258const channelsBySocket = new WeakMap<WebSocket, SocketChannels>();
259
260function 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
270function 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
290function 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
296function 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
304function 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
331async 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]. */
349async 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
358async 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
384async 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
403async 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
422async 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
452function 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
464const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]);
465const VALID_EDITOR_IDS = new Set(EDITORS.map((editor) => editor.id));
466
467function 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
475function ensureParentDir(filePath: string): void {
476 fs.mkdirSync(path.dirname(filePath), { recursive: true });
477}
478
479function deriveServerPathsSync(baseDir: string, devUrl: URL | undefined) {
480 return Effect.runSync(
481 deriveServerPaths(baseDir, devUrl).pipe(Effect.provide(NodeServices.layer)),
482 );
483}
484
485describe("WebSocket Server", () => {
486 let server: Http.Server | null = null;
487 let serverScope: Scope.Closeable | null = null;
488 let disposeServerRuntime: (() => Promise<void>) | null = null;
489 const connections: WebSocket[] = [];
490 const tempDirs: string[] = [];
491
492 function makeTempDir(prefix: string): string {
493 const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
494 tempDirs.push(dir);
495 return dir;
496 }
497
498 async function createTestServer(
499 options: {
500 persistenceLayer?: Layer.Layer<
501 SqlClient.SqlClient,
502 SqlError.SqlError | MigrationError | PlatformError.PlatformError
503 >;
504 cwd?: string;
505 autoBootstrapProjectFromCwd?: boolean;
506 logWebSocketEvents?: boolean;
507 devUrl?: string;
508 authToken?: string;
509 baseDir?: string;
510 staticDir?: string;
511 providerLayer?: Layer.Layer<ProviderService, never>;
512 providerRegistry?: ProviderRegistryShape;
513 open?: OpenShape;
514 gitManager?: GitManagerShape;
515 gitCore?: Pick<GitCoreShape, "listBranches" | "initRepo" | "pullCurrentBranch">;
516 terminalManager?: TerminalManagerShape;
517 serverSettings?: Partial<ServerSettings>;
518 } = {},
519 ): Promise<Http.Server> {
520 if (serverScope) {
521 throw new Error("Test server is already running");
522 }
523
524 const baseDir = options.baseDir ?? makeTempDir("t3code-ws-base-");
525 const devUrl = options.devUrl ? new URL(options.devUrl) : undefined;
526 const derivedPaths = deriveServerPathsSync(baseDir, devUrl);
527 const scope = await Effect.runPromise(Scope.make("sequential"));
528 const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory;
529 const providerLayer = options.providerLayer ?? makeServerProviderLayer();
530 const providerRegistryLayer = Layer.succeed(
531 ProviderRegistry,
532 options.providerRegistry ?? defaultProviderRegistryService,
533 );
534 const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService);
535 const nodeServicesLayer = NodeServices.layer;
536 const serverSettingsLayer = ServerSettingsService.layerTest(options.serverSettings);
537 const serverSettingsRuntimeLayer = serverSettingsLayer.pipe(
538 Layer.provideMerge(nodeServicesLayer),
539 );
540 const analyticsLayer = AnalyticsService.layerTest;
541 const serverConfigLayer = Layer.succeed(ServerConfig, {
542 mode: "web",
543 port: 0,
544 host: undefined,
545 cwd: options.cwd ?? "/test/project",
546 baseDir,
547 ...derivedPaths,
548 staticDir: options.staticDir,
549 devUrl,
550 noBrowser: true,
551 authToken: options.authToken,
552 autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false,
553 logWebSocketEvents: options.logWebSocketEvents ?? Boolean(options.devUrl),
554 } satisfies ServerConfigShape);
555 const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer));
556 const providerRuntimeLayer = infrastructureLayer.pipe(
557 Layer.provideMerge(serverConfigLayer),
558 Layer.provideMerge(serverSettingsRuntimeLayer),
559 Layer.provideMerge(analyticsLayer),
560 );
561 const runtimeOverrides = Layer.mergeAll(
562 options.gitManager ? Layer.succeed(GitManager, options.gitManager) : Layer.empty,
563 options.gitCore
564 ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape)
565 : Layer.empty,
566 options.terminalManager
567 ? Layer.succeed(TerminalManager, options.terminalManager)
568 : Layer.empty,
569 );
570
571 const runtimeLayer = Layer.merge(
572 Layer.merge(
573 makeServerRuntimeServicesLayer().pipe(
574 Layer.provideMerge(providerRuntimeLayer),
575 Layer.provideMerge(serverConfigLayer),
576 Layer.provideMerge(serverSettingsRuntimeLayer),
577 Layer.provideMerge(analyticsLayer),
578 Layer.provideMerge(nodeServicesLayer),
579 ),
580 Layer.mergeAll(providerRuntimeLayer, serverSettingsRuntimeLayer, analyticsLayer),
581 ),
582 runtimeOverrides,
583 );
584 const dependenciesLayer = Layer.mergeAll(
585 runtimeLayer,
586 providerRegistryLayer,
587 openLayer,
588 serverConfigLayer,
589 nodeServicesLayer,
590 );
591 const runtime = ManagedRuntime.make(dependenciesLayer);
592 try {
593 const httpServer = await runtime.runPromise(createServer().pipe(Scope.provide(scope)));
594 disposeServerRuntime = () => runtime.dispose();
595 serverScope = scope;
596 return httpServer;
597 } catch (error) {
598 await runtime.dispose();
599 await Effect.runPromise(Scope.close(scope, Exit.void));
600 throw error;
601 }
602 }
603
604 async function closeTestServer() {
605 if (!serverScope && !disposeServerRuntime) return;
606 const scope = serverScope;
607 const disposeRuntime = disposeServerRuntime;
608 serverScope = null;
609 disposeServerRuntime = null;
610 if (scope) {
611 await Effect.runPromise(Scope.close(scope, Exit.void));
612 }
613 if (disposeRuntime) {
614 await disposeRuntime();
615 }
616 }
617
618 afterEach(async () => {
619 for (const ws of connections) {
620 ws.close();
621 }
622 connections.length = 0;
623 await closeTestServer();
624 server = null;
625 for (const dir of tempDirs.splice(0, tempDirs.length)) {
626 fs.rmSync(dir, { recursive: true, force: true });
627 }
628 vi.restoreAllMocks();
629 });
630
631 it("sends welcome message on connect", async () => {
632 server = await createTestServer({ cwd: "/test/project" });
633 const addr = server.address();
634 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
635 expect(port).toBeGreaterThan(0);
636
637 const [ws, welcome] = await connectAndAwaitWelcome(port);
638 connections.push(ws);
639
640 expect(welcome.type).toBe("push");
641 expect(welcome.data).toEqual({
642 cwd: "/test/project",
643 projectName: "project",
644 });
645 });
646
647 it("serves persisted attachments from stateDir", async () => {
648 const baseDir = makeTempDir("t3code-state-attachments-");
649 const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined);
650 const attachmentPath = path.join(attachmentsDir, "thread-a", "message-a", "0.png");
651 fs.mkdirSync(path.dirname(attachmentPath), { recursive: true });
652 fs.writeFileSync(attachmentPath, Buffer.from("hello-attachment"));
653
654 server = await createTestServer({ cwd: "/test/project", baseDir });
655 const addr = server.address();
656 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
657 expect(port).toBeGreaterThan(0);
658
659 const response = await fetch(`http://127.0.0.1:${port}/attachments/thread-a/message-a/0.png`);
660 expect(response.status).toBe(200);
661 expect(response.headers.get("content-type")).toContain("image/png");
662 const bytes = Buffer.from(await response.arrayBuffer());
663 expect(bytes).toEqual(Buffer.from("hello-attachment"));
664 });
665
666 it("serves persisted attachments for URL-encoded paths", async () => {
667 const baseDir = makeTempDir("t3code-state-attachments-encoded-");
668 const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined);
669 const attachmentPath = path.join(
670 attachmentsDir,
671 "thread%20folder",
672 "message%20folder",
673 "file%20name.png",
674 );
675 fs.mkdirSync(path.dirname(attachmentPath), { recursive: true });
676 fs.writeFileSync(attachmentPath, Buffer.from("hello-encoded-attachment"));
677
678 server = await createTestServer({ cwd: "/test/project", baseDir });
679 const addr = server.address();
680 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
681 expect(port).toBeGreaterThan(0);
682
683 const response = await fetch(
684 `http://127.0.0.1:${port}/attachments/thread%20folder/message%20folder/file%20name.png`,
685 );
686 expect(response.status).toBe(200);
687 expect(response.headers.get("content-type")).toContain("image/png");
688 const bytes = Buffer.from(await response.arrayBuffer());
689 expect(bytes).toEqual(Buffer.from("hello-encoded-attachment"));
690 });
691
692 it("serves static index for root path", async () => {
693 const baseDir = makeTempDir("t3code-state-static-root-");
694 const staticDir = makeTempDir("t3code-static-root-");
695 fs.writeFileSync(path.join(staticDir, "index.html"), "<h1>static-root</h1>", "utf8");
696
697 server = await createTestServer({ cwd: "/test/project", baseDir, staticDir });
698 const addr = server.address();
699 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
700 expect(port).toBeGreaterThan(0);
701
702 const response = await fetch(`http://127.0.0.1:${port}/`);
703 expect(response.status).toBe(200);
704 expect(await response.text()).toContain("static-root");
705 });
706
707 it("rejects static path traversal attempts", async () => {
708 const baseDir = makeTempDir("t3code-state-static-traversal-");
709 const staticDir = makeTempDir("t3code-static-traversal-");
710 fs.writeFileSync(path.join(staticDir, "index.html"), "<h1>safe</h1>", "utf8");
711
712 server = await createTestServer({ cwd: "/test/project", baseDir, staticDir });
713 const addr = server.address();
714 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
715 expect(port).toBeGreaterThan(0);
716
717 const response = await requestPath(port, "/..%2f..%2fetc/passwd");
718 expect(response.statusCode).toBe(400);
719 expect(response.body).toBe("Invalid static file path");
720 });
721
722 it("bootstraps the cwd project on startup when enabled", async () => {
723 server = await createTestServer({
724 cwd: "/test/bootstrap-workspace",
725 autoBootstrapProjectFromCwd: true,
726 });
727 const addr = server.address();
728 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
729 expect(port).toBeGreaterThan(0);
730
731 const [ws, welcome] = await connectAndAwaitWelcome(port);
732 connections.push(ws);
733 expect(welcome.data).toEqual(
734 expect.objectContaining({
735 cwd: "/test/bootstrap-workspace",
736 projectName: "bootstrap-workspace",
737 bootstrapProjectId: expect.any(String),
738 bootstrapThreadId: expect.any(String),
739 }),
740 );
741
742 const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot);
743 expect(snapshotResponse.error).toBeUndefined();
744 const snapshot = snapshotResponse.result as {
745 projects: Array<{
746 id: string;
747 workspaceRoot: string;
748 title: string;
749 defaultModelSelection: {
750 provider: string;
751 model: string;
752 } | null;
753 }>;
754 threads: Array<{
755 id: string;
756 projectId: string;
757 title: string;
758 modelSelection: {
759 provider: string;
760 model: string;
761 };
762 branch: string | null;
763 worktreePath: string | null;
764 }>;
765 };
766 const bootstrapProjectId = (welcome.data as { bootstrapProjectId?: string }).bootstrapProjectId;
767 const bootstrapThreadId = (welcome.data as { bootstrapThreadId?: string }).bootstrapThreadId;
768 expect(bootstrapProjectId).toBeDefined();
769 expect(bootstrapThreadId).toBeDefined();
770
771 expect(snapshot.projects).toEqual(
772 expect.arrayContaining([
773 expect.objectContaining({
774 id: bootstrapProjectId,
775 workspaceRoot: "/test/bootstrap-workspace",
776 title: "bootstrap-workspace",
777 defaultModelSelection: {
778 provider: "codex",
779 model: "gpt-5-codex",
780 },
781 }),
782 ]),
783 );
784 expect(snapshot.threads).toEqual(
785 expect.arrayContaining([
786 expect.objectContaining({
787 id: bootstrapThreadId,
788 projectId: bootstrapProjectId,
789 title: "New thread",
790 modelSelection: {
791 provider: "codex",
792 model: "gpt-5-codex",
793 },
794 branch: null,
795 worktreePath: null,
796 }),
797 ]),
798 );
799 });
800
801 it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => {
802 const baseDir = makeTempDir("t3code-state-bootstrap-existing-");
803 const { dbPath } = deriveServerPathsSync(baseDir, undefined);
804 const persistenceLayer = makeSqlitePersistenceLive(dbPath).pipe(
805 Layer.provide(NodeServices.layer),
806 );
807 const cwd = "/test/bootstrap-existing";
808
809 server = await createTestServer({
810 cwd,
811 baseDir,
812 persistenceLayer,
813 autoBootstrapProjectFromCwd: true,
814 });
815 let addr = server.address();
816 let port = typeof addr === "object" && addr !== null ? addr.port : 0;
817 expect(port).toBeGreaterThan(0);
818
819 const [firstWs, firstWelcome] = await connectAndAwaitWelcome(port);
820 connections.push(firstWs);
821 const firstBootstrapProjectId = (firstWelcome.data as { bootstrapProjectId?: string })
822 .bootstrapProjectId;
823 const firstBootstrapThreadId = (firstWelcome.data as { bootstrapThreadId?: string })
824 .bootstrapThreadId;
825 expect(firstBootstrapProjectId).toBeDefined();
826 expect(firstBootstrapThreadId).toBeDefined();
827
828 firstWs.close();
829 await closeTestServer();
830 server = null;
831
832 server = await createTestServer({
833 cwd,
834 baseDir,
835 persistenceLayer,
836 autoBootstrapProjectFromCwd: true,
837 });
838 addr = server.address();
839 port = typeof addr === "object" && addr !== null ? addr.port : 0;
840 expect(port).toBeGreaterThan(0);
841
842 const [secondWs, secondWelcome] = await connectAndAwaitWelcome(port);
843 connections.push(secondWs);
844 expect(secondWelcome.data).toEqual(
845 expect.objectContaining({
846 cwd,
847 projectName: "bootstrap-existing",
848 bootstrapProjectId: firstBootstrapProjectId,
849 bootstrapThreadId: firstBootstrapThreadId,
850 }),
851 );
852 });
853
854 it("logs outbound websocket push events in dev mode", async () => {
855 const logSpy = vi.spyOn(console, "log").mockImplementation(() => {
856 // Keep test output clean while verifying websocket logs.
857 });
858
859 server = await createTestServer({
860 cwd: "/test/project",
861 devUrl: "http://localhost:5173",
862 });
863 const addr = server.address();
864 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
865 expect(port).toBeGreaterThan(0);
866
867 const [ws] = await connectAndAwaitWelcome(port);
868 connections.push(ws);
869
870 expect(
871 logSpy.mock.calls.some(([message]) => {
872 if (typeof message !== "string") return false;
873 return (
874 message.includes("[ws]") &&
875 message.includes("outgoing push") &&
876 message.includes(`channel="${WS_CHANNELS.serverWelcome}"`)
877 );
878 }),
879 ).toBe(true);
880 });
881
882 it("responds to server.getConfig", async () => {
883 const baseDir = makeTempDir("t3code-state-get-config-");
884 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
885 ensureParentDir(keybindingsPath);
886 fs.writeFileSync(keybindingsPath, "[]", "utf8");
887
888 server = await createTestServer({ cwd: "/my/workspace", baseDir });
889 const addr = server.address();
890 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
891
892 const [ws] = await connectAndAwaitWelcome(port);
893 connections.push(ws);
894
895 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
896 expect(response.error).toBeUndefined();
897 expect(response.result).toEqual({
898 cwd: "/my/workspace",
899 keybindingsConfigPath: keybindingsPath,
900 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
901 issues: [],
902 providers: defaultProviderStatuses,
903 availableEditors: expect.any(Array),
904 settings: defaultServerSettings,
905 });
906 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
907 });
908
909 it("bootstraps default keybindings file when missing", async () => {
910 const baseDir = makeTempDir("t3code-state-bootstrap-keybindings-");
911 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
912 expect(fs.existsSync(keybindingsPath)).toBe(false);
913
914 server = await createTestServer({ cwd: "/my/workspace", baseDir });
915 const addr = server.address();
916 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
917
918 const [ws] = await connectAndAwaitWelcome(port);
919 connections.push(ws);
920
921 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
922 expect(response.error).toBeUndefined();
923 expect(response.result).toEqual({
924 cwd: "/my/workspace",
925 keybindingsConfigPath: keybindingsPath,
926 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
927 issues: [],
928 providers: defaultProviderStatuses,
929 availableEditors: expect.any(Array),
930 settings: defaultServerSettings,
931 });
932 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
933
934 const persistedConfig = JSON.parse(
935 fs.readFileSync(keybindingsPath, "utf8"),
936 ) as KeybindingsConfig;
937 expect(persistedConfig).toEqual(DEFAULT_KEYBINDINGS);
938 });
939
940 it("falls back to defaults and reports malformed keybindings config issues", async () => {
941 const baseDir = makeTempDir("t3code-state-malformed-keybindings-");
942 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
943 ensureParentDir(keybindingsPath);
944 fs.writeFileSync(keybindingsPath, "{ not-json", "utf8");
945
946 server = await createTestServer({ cwd: "/my/workspace", baseDir });
947 const addr = server.address();
948 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
949
950 const [ws] = await connectAndAwaitWelcome(port);
951 connections.push(ws);
952
953 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
954 expect(response.error).toBeUndefined();
955 expect(response.result).toEqual({
956 cwd: "/my/workspace",
957 keybindingsConfigPath: keybindingsPath,
958 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
959 issues: [
960 {
961 kind: "keybindings.malformed-config",
962 message: expect.stringContaining("expected JSON array"),
963 },
964 ],
965 providers: defaultProviderStatuses,
966 availableEditors: expect.any(Array),
967 settings: defaultServerSettings,
968 });
969 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
970 expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json");
971 });
972
973 it("ignores invalid keybinding entries but keeps valid entries and reports issues", async () => {
974 const baseDir = makeTempDir("t3code-state-partial-invalid-keybindings-");
975 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
976 ensureParentDir(keybindingsPath);
977 fs.writeFileSync(
978 keybindingsPath,
979 JSON.stringify([
980 { key: "mod+j", command: "terminal.toggle" },
981 { key: "mod+shift+d+o", command: "terminal.new" },
982 { key: "mod+x", command: "not-a-real-command" },
983 ]),
984 "utf8",
985 );
986
987 server = await createTestServer({ cwd: "/my/workspace", baseDir });
988 const addr = server.address();
989 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
990
991 const [ws] = await connectAndAwaitWelcome(port);
992 connections.push(ws);
993
994 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
995 expect(response.error).toBeUndefined();
996 const result = response.result as {
997 cwd: string;
998 keybindingsConfigPath: string;
999 keybindings: ResolvedKeybindingsConfig;
1000 issues: Array<{ kind: string; index?: number; message: string }>;
1001 providers: ReadonlyArray<ServerProvider>;
1002 availableEditors: unknown;
1003 };
1004 expect(result.cwd).toBe("/my/workspace");
1005 expect(result.keybindingsConfigPath).toBe(keybindingsPath);
1006 expect(result.issues).toEqual([
1007 {
1008 kind: "keybindings.invalid-entry",
1009 index: 1,
1010 message: expect.any(String),
1011 },
1012 {
1013 kind: "keybindings.invalid-entry",
1014 index: 2,
1015 message: expect.any(String),
1016 },
1017 ]);
1018 expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length);
1019 expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true);
1020 expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true);
1021 expect(result.providers).toEqual(defaultProviderStatuses);
1022 expectAvailableEditors(result.availableEditors);
1023 });
1024
1025 it("pushes server.configUpdated issues when keybindings file changes", async () => {
1026 const baseDir = makeTempDir("t3code-state-keybindings-watch-");
1027 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
1028 ensureParentDir(keybindingsPath);
1029 fs.writeFileSync(keybindingsPath, "[]", "utf8");
1030
1031 server = await createTestServer({ cwd: "/my/workspace", baseDir });
1032 const addr = server.address();
1033 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1034
1035 const [ws] = await connectAndAwaitWelcome(port);
1036 connections.push(ws);
1037
1038 const malformedPush = await rewriteKeybindingsAndWaitForPush(
1039 ws,
1040 keybindingsPath,
1041 "{ not-json",
1042 (push) =>
1043 Array.isArray(push.data.issues) &&
1044 Boolean(push.data.issues[0]) &&
1045 push.data.issues[0]!.kind === "keybindings.malformed-config",
1046 );
1047 expect(malformedPush.data).toEqual({
1048 issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }],
1049 });
1050
1051 const successPush = await rewriteKeybindingsAndWaitForPush(
1052 ws,
1053 keybindingsPath,
1054 "[]",
1055 (push) => Array.isArray(push.data.issues) && push.data.issues.length === 0,
1056 );
1057 expect(successPush.data).toEqual({ issues: [] });
1058 });
1059
1060 it("routes shell.openInEditor through the injected open service", async () => {
1061 const openCalls: Array<{ cwd: string; editor: string }> = [];
1062 const openService: OpenShape = {
1063 openBrowser: () => Effect.void,
1064 openInEditor: (input) => {
1065 openCalls.push({ cwd: input.cwd, editor: input.editor });
1066 return Effect.void;
1067 },
1068 };
1069
1070 server = await createTestServer({ cwd: "/my/workspace", open: openService });
1071 const addr = server.address();
1072 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1073
1074 const [ws] = await connectAndAwaitWelcome(port);
1075 connections.push(ws);
1076
1077 const response = await sendRequest(ws, WS_METHODS.shellOpenInEditor, {
1078 cwd: "/my/workspace",
1079 editor: "cursor",
1080 });
1081 expect(response.error).toBeUndefined();
1082 expect(openCalls).toEqual([{ cwd: "/my/workspace", editor: "cursor" }]);
1083 });
1084
1085 it("reads keybindings from the configured state directory", async () => {
1086 const baseDir = makeTempDir("t3code-state-keybindings-");
1087 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
1088 ensureParentDir(keybindingsPath);
1089 fs.writeFileSync(
1090 keybindingsPath,
1091 JSON.stringify([
1092 { key: "cmd+j", command: "terminal.toggle" },
1093 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
1094 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
1095 ]),
1096 "utf8",
1097 );
1098 server = await createTestServer({ cwd: "/my/workspace", baseDir });
1099 const addr = server.address();
1100 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1101
1102 const [ws] = await connectAndAwaitWelcome(port);
1103 connections.push(ws);
1104
1105 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
1106 expect(response.error).toBeUndefined();
1107 const persistedConfig = JSON.parse(
1108 fs.readFileSync(keybindingsPath, "utf8"),
1109 ) as KeybindingsConfig;
1110 expect(response.result).toEqual({
1111 cwd: "/my/workspace",
1112 keybindingsConfigPath: keybindingsPath,
1113 keybindings: compileKeybindings(persistedConfig),
1114 issues: [],
1115 providers: defaultProviderStatuses,
1116 availableEditors: expect.any(Array),
1117 settings: defaultServerSettings,
1118 });
1119 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
1120 });
1121
1122 it("upserts keybinding rules and updates cached server config", async () => {
1123 const baseDir = makeTempDir("t3code-state-upsert-keybinding-");
1124 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
1125 ensureParentDir(keybindingsPath);
1126 fs.writeFileSync(
1127 keybindingsPath,
1128 JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]),
1129 "utf8",
1130 );
1131
1132 server = await createTestServer({ cwd: "/my/workspace", baseDir });
1133 const addr = server.address();
1134 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1135
1136 const [ws] = await connectAndAwaitWelcome(port);
1137 connections.push(ws);
1138
1139 const upsertResponse = await sendRequest(ws, WS_METHODS.serverUpsertKeybinding, {
1140 key: "mod+shift+r",
1141 command: "script.run-tests.run",
1142 });
1143 expect(upsertResponse.error).toBeUndefined();
1144 const persistedConfig = JSON.parse(
1145 fs.readFileSync(keybindingsPath, "utf8"),
1146 ) as KeybindingsConfig;
1147 const persistedCommands = new Set(persistedConfig.map((entry) => entry.command));
1148 for (const defaultRule of DEFAULT_KEYBINDINGS) {
1149 expect(persistedCommands.has(defaultRule.command)).toBe(true);
1150 }
1151 expect(persistedCommands.has("script.run-tests.run")).toBe(true);
1152 expect(upsertResponse.result).toEqual({
1153 keybindings: compileKeybindings(persistedConfig),
1154 issues: [],
1155 });
1156
1157 const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig);
1158 expect(configResponse.error).toBeUndefined();
1159 expect(configResponse.result).toEqual({
1160 cwd: "/my/workspace",
1161 keybindingsConfigPath: keybindingsPath,
1162 keybindings: compileKeybindings(persistedConfig),
1163 issues: [],
1164 providers: defaultProviderStatuses,
1165 availableEditors: expect.any(Array),
1166 settings: defaultServerSettings,
1167 });
1168 expectAvailableEditors(
1169 (configResponse.result as { availableEditors: unknown }).availableEditors,
1170 );
1171 });
1172
1173 it("returns error for unknown methods", async () => {
1174 server = await createTestServer({ cwd: "/test" });
1175 const addr = server.address();
1176 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1177
1178 const [ws] = await connectAndAwaitWelcome(port);
1179 connections.push(ws);
1180
1181 const response = await sendRequest(ws, "nonexistent.method");
1182 expect(response.error).toBeDefined();
1183 expect(response.error!.message).toContain("Invalid request format");
1184 });
1185
1186 it("returns error when requesting turn diff for unknown thread", async () => {
1187 server = await createTestServer({ cwd: "/test" });
1188 const addr = server.address();
1189 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1190
1191 const [ws] = await connectAndAwaitWelcome(port);
1192 connections.push(ws);
1193
1194 const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, {
1195 threadId: "thread-missing",
1196 fromTurnCount: 1,
1197 toTurnCount: 2,
1198 });
1199 expect(response.result).toBeUndefined();
1200 expect(response.error?.message).toContain("Thread 'thread-missing' not found.");
1201 });
1202
1203 it("returns error when requesting turn diff with an inverted range", async () => {
1204 server = await createTestServer({ cwd: "/test" });
1205 const addr = server.address();
1206 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1207
1208 const [ws] = await connectAndAwaitWelcome(port);
1209 connections.push(ws);
1210
1211 const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, {
1212 threadId: "thread-any",
1213 fromTurnCount: 2,
1214 toTurnCount: 1,
1215 });
1216 expect(response.result).toBeUndefined();
1217 expect(response.error?.message).toContain(
1218 "fromTurnCount must be less than or equal to toTurnCount",
1219 );
1220 });
1221
1222 it("returns error when requesting full thread diff for unknown thread", async () => {
1223 server = await createTestServer({ cwd: "/test" });
1224 const addr = server.address();
1225 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1226
1227 const [ws] = await connectAndAwaitWelcome(port);
1228 connections.push(ws);
1229
1230 const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getFullThreadDiff, {
1231 threadId: "thread-missing",
1232 toTurnCount: 2,
1233 });
1234 expect(response.result).toBeUndefined();
1235 expect(response.error?.message).toContain("Thread 'thread-missing' not found.");
1236 });
1237
1238 it("returns retryable error when requested turn exceeds current checkpoint turn count", async () => {
1239 server = await createTestServer({ cwd: "/test" });
1240 const addr = server.address();
1241 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1242
1243 const [ws] = await connectAndAwaitWelcome(port);
1244 connections.push(ws);
1245
1246 const workspaceRoot = makeTempDir("t3code-ws-diff-project-");
1247 const createdAt = new Date().toISOString();
1248 const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
1249 type: "project.create",
1250 commandId: "cmd-diff-project-create",
1251 projectId: "project-diff",
1252 title: "Diff Project",
1253 workspaceRoot,
1254 defaultModelSelection: {
1255 provider: "codex",
1256 model: "gpt-5-codex",
1257 },
1258 createdAt,
1259 });
1260 expect(createProjectResponse.error).toBeUndefined();
1261 const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
1262 type: "thread.create",
1263 commandId: "cmd-diff-thread-create",
1264 threadId: "thread-diff",
1265 projectId: "project-diff",
1266 title: "Diff Thread",
1267 modelSelection: {
1268 provider: "codex",
1269 model: "gpt-5-codex",
1270 },
1271 runtimeMode: "full-access",
1272 interactionMode: "default",
1273 branch: null,
1274 worktreePath: null,
1275 createdAt,
1276 });
1277 expect(createThreadResponse.error).toBeUndefined();
1278
1279 const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, {
1280 threadId: "thread-diff",
1281 fromTurnCount: 0,
1282 toTurnCount: 1,
1283 });
1284 expect(response.result).toBeUndefined();
1285 expect(response.error?.message).toContain("exceeds current turn count");
1286 });
1287
1288 it("rejects project.create when the workspace root does not exist", async () => {
1289 server = await createTestServer({ cwd: "/test" });
1290 const addr = server.address();
1291 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1292
1293 const [ws] = await connectAndAwaitWelcome(port);
1294 connections.push(ws);
1295
1296 const missingWorkspaceRoot = path.join(makeTempDir("t3code-ws-project-missing-"), "missing");
1297 const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
1298 type: "project.create",
1299 commandId: "cmd-ws-project-create-missing",
1300 projectId: "project-missing",
1301 title: "Missing Project",
1302 workspaceRoot: missingWorkspaceRoot,
1303 defaultModelSelection: {
1304 provider: "codex",
1305 model: "gpt-5-codex",
1306 },
1307 createdAt: new Date().toISOString(),
1308 });
1309
1310 expect(response.result).toBeUndefined();
1311 expect(response.error?.message).toContain("Workspace root does not exist:");
1312 });
1313
1314 it("keeps orchestration domain push behavior for provider runtime events", async () => {
1315 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
1316 const emitRuntimeEvent = (event: ProviderRuntimeEvent) => {
1317 Effect.runSync(PubSub.publish(runtimeEventPubSub, event));
1318 };
1319 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
1320 const providerService: ProviderServiceShape = {
1321 startSession: (threadId) =>
1322 Effect.succeed({
1323 provider: "codex",
1324 status: "ready",
1325 runtimeMode: "full-access",
1326 threadId,
1327 createdAt: new Date().toISOString(),
1328 updatedAt: new Date().toISOString(),
1329 }),
1330 sendTurn: ({ threadId }) =>
1331 Effect.succeed({
1332 threadId,
1333 turnId: asTurnId("provider-turn-1"),
1334 }),
1335 interruptTurn: () => unsupported(),
1336 respondToRequest: () => unsupported(),
1337 respondToUserInput: () => unsupported(),
1338 stopSession: () => unsupported(),
1339 listSessions: () => Effect.succeed([]),
1340 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
1341 rollbackConversation: () => unsupported(),
1342 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
1343 };
1344 const providerLayer = Layer.succeed(ProviderService, providerService);
1345
1346 server = await createTestServer({
1347 cwd: "/test",
1348 providerLayer,
1349 serverSettings: { enableAssistantStreaming: true },
1350 });
1351 const addr = server.address();
1352 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1353
1354 const [ws] = await connectAndAwaitWelcome(port);
1355 connections.push(ws);
1356
1357 const workspaceRoot = makeTempDir("t3code-ws-project-");
1358 const createdAt = new Date().toISOString();
1359 const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
1360 type: "project.create",
1361 commandId: "cmd-ws-project-create",
1362 projectId: "project-1",
1363 title: "WS Project",
1364 workspaceRoot,
1365 defaultModelSelection: {
1366 provider: "codex",
1367 model: "gpt-5-codex",
1368 },
1369 createdAt,
1370 });
1371 expect(createProjectResponse.error).toBeUndefined();
1372 const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
1373 type: "thread.create",
1374 commandId: "cmd-ws-runtime-thread-create",
1375 threadId: "thread-1",
1376 projectId: "project-1",
1377 title: "Thread 1",
1378 modelSelection: {
1379 provider: "codex",
1380 model: "gpt-5-codex",
1381 },
1382 runtimeMode: "full-access",
1383 interactionMode: "default",
1384 branch: null,
1385 worktreePath: null,
1386 createdAt,
1387 });
1388 expect(createThreadResponse.error).toBeUndefined();
1389
1390 const startTurnResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
1391 type: "thread.turn.start",
1392 commandId: "cmd-ws-runtime-turn-start",
1393 threadId: "thread-1",
1394 message: {
1395 messageId: "msg-ws-runtime-1",
1396 role: "user",
1397 text: "hello",
1398 attachments: [],
1399 },
1400 runtimeMode: "approval-required",
1401 interactionMode: "default",
1402 createdAt,
1403 });
1404 expect(startTurnResponse.error).toBeUndefined();
1405
1406 await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => {
1407 const event = push.data as { type?: string };
1408 return event.type === "thread.session-set";
1409 });
1410
1411 emitRuntimeEvent({
1412 type: "content.delta",
1413 eventId: asEventId("evt-ws-runtime-message-delta"),
1414 provider: "codex",
1415 threadId: asThreadId("thread-1"),
1416 createdAt: new Date().toISOString(),
1417 turnId: asTurnId("turn-1"),
1418 itemId: asProviderItemId("item-1"),
1419 payload: {
1420 streamKind: "assistant_text",
1421 delta: "hello from runtime",
1422 },
1423 } as unknown as ProviderRuntimeEvent);
1424
1425 const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => {
1426 const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } };
1427 return (
1428 event.type === "thread.message-sent" && event.payload?.messageId === "assistant:item-1"
1429 );
1430 });
1431
1432 const domainEvent = domainPush.data as {
1433 type: string;
1434 payload: { messageId: string; text: string };
1435 };
1436 expect(domainEvent.type).toBe("thread.message-sent");
1437 expect(domainEvent.payload.messageId).toBe("assistant:item-1");
1438 expect(domainEvent.payload.text).toBe("hello from runtime");
1439 });
1440
1441 it("routes terminal RPC methods and broadcasts terminal events", async () => {
1442 const cwd = makeTempDir("t3code-ws-terminal-cwd-");
1443 const terminalManager = new MockTerminalManager();
1444 server = await createTestServer({
1445 cwd: "/test",
1446 terminalManager,
1447 });
1448 const addr = server.address();
1449 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1450
1451 const [ws] = await connectAndAwaitWelcome(port);
1452 connections.push(ws);
1453
1454 const open = await sendRequest(ws, WS_METHODS.terminalOpen, {
1455 threadId: "thread-1",
1456 cwd,
1457 cols: 100,
1458 rows: 24,
1459 });
1460 expect(open.error).toBeUndefined();
1461 expect((open.result as TerminalSessionSnapshot).threadId).toBe("thread-1");
1462 expect((open.result as TerminalSessionSnapshot).terminalId).toBe(DEFAULT_TERMINAL_ID);
1463
1464 const write = await sendRequest(ws, WS_METHODS.terminalWrite, {
1465 threadId: "thread-1",
1466 data: "echo hello\n",
1467 });
1468 expect(write.error).toBeUndefined();
1469
1470 const resize = await sendRequest(ws, WS_METHODS.terminalResize, {
1471 threadId: "thread-1",
1472 cols: 120,
1473 rows: 30,
1474 });
1475 expect(resize.error).toBeUndefined();
1476
1477 const clear = await sendRequest(ws, WS_METHODS.terminalClear, {
1478 threadId: "thread-1",
1479 });
1480 expect(clear.error).toBeUndefined();
1481
1482 const restart = await sendRequest(ws, WS_METHODS.terminalRestart, {
1483 threadId: "thread-1",
1484 cwd,
1485 cols: 120,
1486 rows: 30,
1487 });
1488 expect(restart.error).toBeUndefined();
1489
1490 const close = await sendRequest(ws, WS_METHODS.terminalClose, {
1491 threadId: "thread-1",
1492 deleteHistory: true,
1493 });
1494 expect(close.error).toBeUndefined();
1495
1496 const manualEvent: TerminalEvent = {
1497 type: "output",
1498 threadId: "thread-1",
1499 terminalId: DEFAULT_TERMINAL_ID,
1500 createdAt: new Date().toISOString(),
1501 data: "manual test output\n",
1502 };
1503 terminalManager.emitEvent(manualEvent);
1504
1505 const push = await waitForPush(
1506 ws,
1507 WS_CHANNELS.terminalEvent,
1508 (candidate) => (candidate.data as TerminalEvent).type === "output",
1509 );
1510 expect(push.type).toBe("push");
1511 expect(push.channel).toBe(WS_CHANNELS.terminalEvent);
1512 });
1513
1514 it("shuts down cleanly for injected terminal managers", async () => {
1515 const terminalManager = new MockTerminalManager();
1516 server = await createTestServer({
1517 cwd: "/test",
1518 terminalManager,
1519 });
1520
1521 await closeTestServer();
1522 server = null;
1523
1524 expect(() =>
1525 terminalManager.emitEvent({
1526 type: "output",
1527 threadId: "thread-1",
1528 terminalId: DEFAULT_TERMINAL_ID,
1529 createdAt: new Date().toISOString(),
1530 data: "after shutdown\n",
1531 }),
1532 ).not.toThrow();
1533 });
1534
1535 it("returns validation errors for invalid terminal open params", async () => {
1536 server = await createTestServer({ cwd: "/test" });
1537 const addr = server.address();
1538 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1539
1540 const [ws] = await connectAndAwaitWelcome(port);
1541 connections.push(ws);
1542
1543 const response = await sendRequest(ws, WS_METHODS.terminalOpen, {
1544 threadId: "",
1545 cwd: "",
1546 cols: 1,
1547 rows: 1,
1548 });
1549 expect(response.error).toBeDefined();
1550 });
1551
1552 it("handles invalid JSON gracefully", async () => {
1553 server = await createTestServer({ cwd: "/test" });
1554 const addr = server.address();
1555 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1556
1557 const [ws] = await connectAndAwaitWelcome(port);
1558 connections.push(ws);
1559
1560 // Send garbage
1561 ws.send("not json at all");
1562
1563 // Error response goes to the response channel
1564 const channels = channelsBySocket.get(ws)!;
1565 let response: WebSocketResponse | null = null;
1566 for (let attempt = 0; attempt < 5; attempt += 1) {
1567 const message = await dequeue(channels.response, 5_000);
1568 if (message.id === "unknown") {
1569 response = message;
1570 break;
1571 }
1572 if (message.error) {
1573 response = message;
1574 break;
1575 }
1576 }
1577 expect(response).toBeDefined();
1578 expect(response!.error).toBeDefined();
1579 expect(response!.error!.message).toContain("Invalid request format");
1580 });
1581
1582 it("catches websocket message handler rejections and keeps the socket usable", async () => {
1583 const unhandledRejections: unknown[] = [];
1584 const onUnhandledRejection = (reason: unknown) => {
1585 unhandledRejections.push(reason);
1586 };
1587 process.on("unhandledRejection", onUnhandledRejection);
1588
1589 const brokenOpenService: OpenShape = {
1590 openBrowser: () => Effect.void,
1591 openInEditor: () =>
1592 Effect.sync(() => BigInt(1)).pipe(Effect.map((result) => result as unknown as void)),
1593 };
1594
1595 try {
1596 server = await createTestServer({ cwd: "/test", open: brokenOpenService });
1597 const addr = server.address();
1598 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1599
1600 const [ws] = await connectAndAwaitWelcome(port);
1601 connections.push(ws);
1602
1603 ws.send(
1604 JSON.stringify({
1605 id: "req-broken-open",
1606 body: {
1607 _tag: WS_METHODS.shellOpenInEditor,
1608 cwd: "/tmp",
1609 editor: "cursor",
1610 },
1611 }),
1612 );
1613
1614 await new Promise((resolve) => setTimeout(resolve, 50));
1615 expect(unhandledRejections).toHaveLength(0);
1616
1617 const workspace = makeTempDir("t3code-ws-handler-still-usable-");
1618 fs.writeFileSync(path.join(workspace, "file.txt"), "ok\n", "utf8");
1619 const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, {
1620 cwd: workspace,
1621 query: "file",
1622 limit: 5,
1623 });
1624 expect(response.error).toBeUndefined();
1625 expect(response.result).toEqual(
1626 expect.objectContaining({
1627 entries: expect.arrayContaining([
1628 expect.objectContaining({
1629 path: "file.txt",
1630 kind: "file",
1631 }),
1632 ]),
1633 }),
1634 );
1635 } finally {
1636 process.off("unhandledRejection", onUnhandledRejection);
1637 }
1638 });
1639
1640 it("returns errors for removed projects CRUD methods", async () => {
1641 server = await createTestServer({ cwd: "/test" });
1642 const addr = server.address();
1643 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1644
1645 const [ws] = await connectAndAwaitWelcome(port);
1646 connections.push(ws);
1647
1648 const listResponse = await sendRequest(ws, WS_METHODS.projectsList);
1649 expect(listResponse.result).toBeUndefined();
1650 expect(listResponse.error?.message).toContain("Invalid request format");
1651
1652 const addResponse = await sendRequest(ws, WS_METHODS.projectsAdd, {
1653 cwd: "/tmp/project-a",
1654 });
1655 expect(addResponse.result).toBeUndefined();
1656 expect(addResponse.error?.message).toContain("Invalid request format");
1657
1658 const removeResponse = await sendRequest(ws, WS_METHODS.projectsRemove, {
1659 id: "project-a",
1660 });
1661 expect(removeResponse.result).toBeUndefined();
1662 expect(removeResponse.error?.message).toContain("Invalid request format");
1663 });
1664
1665 it("supports projects.searchEntries", async () => {
1666 const workspace = makeTempDir("t3code-ws-workspace-entries-");
1667 fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true });
1668 fs.writeFileSync(
1669 path.join(workspace, "src", "components", "Composer.tsx"),
1670 "export {};",
1671 "utf8",
1672 );
1673 fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8");
1674 fs.mkdirSync(path.join(workspace, ".git"), { recursive: true });
1675 fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8");
1676
1677 server = await createTestServer({ cwd: "/test" });
1678 const addr = server.address();
1679 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1680
1681 const [ws] = await connectAndAwaitWelcome(port);
1682 connections.push(ws);
1683
1684 const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, {
1685 cwd: workspace,
1686 query: "comp",
1687 limit: 10,
1688 });
1689 expect(response.error).toBeUndefined();
1690 expect(response.result).toEqual({
1691 entries: expect.arrayContaining([
1692 expect.objectContaining({ path: "src/components", kind: "directory" }),
1693 expect.objectContaining({ path: "src/components/Composer.tsx", kind: "file" }),
1694 ]),
1695 truncated: false,
1696 });
1697 });
1698
1699 it("supports projects.writeFile within the workspace root", async () => {
1700 const workspace = makeTempDir("t3code-ws-write-file-");
1701
1702 server = await createTestServer({ cwd: "/test" });
1703 const addr = server.address();
1704 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1705
1706 const [ws] = await connectAndAwaitWelcome(port);
1707 connections.push(ws);
1708
1709 const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, {
1710 cwd: workspace,
1711 relativePath: "plans/effect-rpc.md",
1712 contents: "# Plan\n\n- step 1\n",
1713 });
1714
1715 expect(response.error).toBeUndefined();
1716 expect(response.result).toEqual({
1717 relativePath: "plans/effect-rpc.md",
1718 });
1719 expect(fs.readFileSync(path.join(workspace, "plans", "effect-rpc.md"), "utf8")).toBe(
1720 "# Plan\n\n- step 1\n",
1721 );
1722 });
1723
1724 it("invalidates workspace entry search cache after projects.writeFile", async () => {
1725 const workspace = makeTempDir("t3code-ws-write-file-invalidate-");
1726 fs.mkdirSync(path.join(workspace, "src"), { recursive: true });
1727 fs.writeFileSync(path.join(workspace, "src", "existing.ts"), "export {};\n", "utf8");
1728
1729 server = await createTestServer({ cwd: "/test" });
1730 const addr = server.address();
1731 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1732
1733 const [ws] = await connectAndAwaitWelcome(port);
1734 connections.push(ws);
1735
1736 const beforeWrite = await sendRequest(ws, WS_METHODS.projectsSearchEntries, {
1737 cwd: workspace,
1738 query: "rpc",
1739 limit: 10,
1740 });
1741 expect(beforeWrite.error).toBeUndefined();
1742 expect(beforeWrite.result).toEqual({
1743 entries: [],
1744 truncated: false,
1745 });
1746
1747 const writeResponse = await sendRequest(ws, WS_METHODS.projectsWriteFile, {
1748 cwd: workspace,
1749 relativePath: "plans/effect-rpc.md",
1750 contents: "# Plan\n",
1751 });
1752 expect(writeResponse.error).toBeUndefined();
1753
1754 const afterWrite = await sendRequest(ws, WS_METHODS.projectsSearchEntries, {
1755 cwd: workspace,
1756 query: "rpc",
1757 limit: 10,
1758 });
1759 expect(afterWrite.error).toBeUndefined();
1760 expect(afterWrite.result).toEqual({
1761 entries: expect.arrayContaining([
1762 expect.objectContaining({ path: "plans/effect-rpc.md", kind: "file" }),
1763 ]),
1764 truncated: false,
1765 });
1766 });
1767
1768 it("rejects projects.writeFile paths outside the workspace root", async () => {
1769 const workspace = makeTempDir("t3code-ws-write-file-reject-");
1770
1771 server = await createTestServer({ cwd: "/test" });
1772 const addr = server.address();
1773 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1774
1775 const [ws] = await connectAndAwaitWelcome(port);
1776 connections.push(ws);
1777
1778 const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, {
1779 cwd: workspace,
1780 relativePath: "../escape.md",
1781 contents: "# no\n",
1782 });
1783
1784 expect(response.result).toBeUndefined();
1785 expect(response.error?.message).toContain(
1786 "Workspace file path must be relative to the project root: ../escape.md",
1787 );
1788 expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false);
1789 });
1790
1791 it("routes git core methods over websocket", async () => {
1792 const listBranches = vi.fn(() =>
1793 Effect.succeed({
1794 branches: [],
1795 isRepo: false,
1796 hasOriginRemote: false,
1797 }),
1798 );
1799 const initRepo = vi.fn(() => Effect.void);
1800 const pullCurrentBranch = vi.fn(() =>
1801 Effect.fail(
1802 new GitCommandError({
1803 operation: "GitCore.test.pullCurrentBranch",
1804 detail: "No upstream configured",
1805 command: "git pull",
1806 cwd: "/repo/path",
1807 }),
1808 ),
1809 );
1810
1811 server = await createTestServer({
1812 cwd: "/test",
1813 gitCore: {
1814 listBranches,
1815 initRepo,
1816 pullCurrentBranch,
1817 },
1818 });
1819 const addr = server.address();
1820 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1821
1822 const [ws] = await connectAndAwaitWelcome(port);
1823 connections.push(ws);
1824
1825 const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: "/repo/path" });
1826 expect(listResponse.error).toBeUndefined();
1827 expect(listResponse.result).toEqual({ branches: [], isRepo: false, hasOriginRemote: false });
1828 expect(listBranches).toHaveBeenCalledWith({ cwd: "/repo/path" });
1829
1830 const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: "/repo/path" });
1831 expect(initResponse.error).toBeUndefined();
1832 expect(initRepo).toHaveBeenCalledWith({ cwd: "/repo/path" });
1833
1834 const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { cwd: "/repo/path" });
1835 expect(pullResponse.result).toBeUndefined();
1836 expect(pullResponse.error?.message).toContain("No upstream configured");
1837 expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path");
1838 });
1839
1840 it("supports git.status over websocket", async () => {
1841 const statusResult = {
1842 branch: "feature/test",
1843 hasWorkingTreeChanges: true,
1844 workingTree: {
1845 files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }],
1846 insertions: 7,
1847 deletions: 2,
1848 },
1849 hasUpstream: false,
1850 aheadCount: 0,
1851 behindCount: 0,
1852 pr: null,
1853 };
1854
1855 const status = vi.fn(() => Effect.succeed(statusResult));
1856 const runStackedAction = vi.fn(() => Effect.void as any);
1857 const resolvePullRequest = vi.fn(() => Effect.void as any);
1858 const preparePullRequestThread = vi.fn(() => Effect.void as any);
1859 const gitManager: GitManagerShape = {
1860 status,
1861 resolvePullRequest,
1862 preparePullRequestThread,
1863 runStackedAction,
1864 };
1865
1866 server = await createTestServer({ cwd: "/test", gitManager });
1867 const addr = server.address();
1868 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1869
1870 const [ws] = await connectAndAwaitWelcome(port);
1871 connections.push(ws);
1872
1873 const response = await sendRequest(ws, WS_METHODS.gitStatus, {
1874 cwd: "/test",
1875 });
1876 expect(response.error).toBeUndefined();
1877 expect(response.result).toEqual(statusResult);
1878 expect(status).toHaveBeenCalledWith({ cwd: "/test" });
1879 });
1880
1881 it("supports git pull request routing over websocket", async () => {
1882 const resolvePullRequestResult = {
1883 pullRequest: {
1884 number: 42,
1885 title: "PR thread flow",
1886 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
1887 baseBranch: "main",
1888 headBranch: "feature/pr-threads",
1889 state: "open" as const,
1890 },
1891 };
1892 const preparePullRequestThreadResult = {
1893 ...resolvePullRequestResult,
1894 branch: "feature/pr-threads",
1895 worktreePath: "/tmp/pr-threads",
1896 };
1897
1898 const gitManager: GitManagerShape = {
1899 status: vi.fn(() => Effect.void as any),
1900 resolvePullRequest: vi.fn(() => Effect.succeed(resolvePullRequestResult)),
1901 preparePullRequestThread: vi.fn(() => Effect.succeed(preparePullRequestThreadResult)),
1902 runStackedAction: vi.fn(() => Effect.void as any),
1903 };
1904
1905 server = await createTestServer({ cwd: "/test", gitManager });
1906 const addr = server.address();
1907 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1908
1909 const [ws] = await connectAndAwaitWelcome(port);
1910 connections.push(ws);
1911
1912 const resolveResponse = await sendRequest(ws, WS_METHODS.gitResolvePullRequest, {
1913 cwd: "/test",
1914 reference: "#42",
1915 });
1916 expect(resolveResponse.error).toBeUndefined();
1917 expect(resolveResponse.result).toEqual(resolvePullRequestResult);
1918
1919 const prepareResponse = await sendRequest(ws, WS_METHODS.gitPreparePullRequestThread, {
1920 cwd: "/test",
1921 reference: "42",
1922 mode: "worktree",
1923 });
1924 expect(prepareResponse.error).toBeUndefined();
1925 expect(prepareResponse.result).toEqual(preparePullRequestThreadResult);
1926 expect(gitManager.resolvePullRequest).toHaveBeenCalledWith({
1927 cwd: "/test",
1928 reference: "#42",
1929 });
1930 expect(gitManager.preparePullRequestThread).toHaveBeenCalledWith({
1931 cwd: "/test",
1932 reference: "42",
1933 mode: "worktree",
1934 });
1935 });
1936
1937 it("returns errors from git.runStackedAction", async () => {
1938 const runStackedAction = vi.fn(() =>
1939 Effect.fail(
1940 new GitManagerError({
1941 operation: "GitManager.test.runStackedAction",
1942 detail: "Cannot push from detached HEAD.",
1943 }),
1944 ),
1945 );
1946 const gitManager: GitManagerShape = {
1947 status: vi.fn(() => Effect.void as any),
1948 resolvePullRequest: vi.fn(() => Effect.void as any),
1949 preparePullRequestThread: vi.fn(() => Effect.void as any),
1950 runStackedAction,
1951 };
1952
1953 server = await createTestServer({ cwd: "/test", gitManager });
1954 const addr = server.address();
1955 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1956
1957 const [ws] = await connectAndAwaitWelcome(port);
1958 connections.push(ws);
1959
1960 const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, {
1961 actionId: "client-action-1",
1962 cwd: "/test",
1963 action: "commit_push",
1964 modelSelection: {
1965 provider: "codex",
1966 model: "gpt-5.4-mini",
1967 },
1968 });
1969 expect(response.result).toBeUndefined();
1970 expect(response.error?.message).toContain("detached HEAD");
1971 expect(runStackedAction).toHaveBeenCalledWith(
1972 {
1973 actionId: "client-action-1",
1974 cwd: "/test",
1975 action: "commit_push",
1976 },
1977 expect.objectContaining({
1978 actionId: "client-action-1",
1979 progressReporter: expect.any(Object),
1980 }),
1981 );
1982 });
1983
1984 it("publishes git action progress only to the initiating websocket", async () => {
1985 const runStackedAction = vi.fn(
1986 (_input, options) =>
1987 options?.progressReporter
1988 ?.publish({
1989 actionId: options.actionId ?? "action-1",
1990 cwd: "/test",
1991 action: "commit",
1992 kind: "phase_started",
1993 phase: "commit",
1994 label: "Committing...",
1995 })
1996 .pipe(
1997 Effect.flatMap(() =>
1998 Effect.succeed({
1999 action: "commit" as const,
2000 branch: { status: "skipped_not_requested" as const },
2001 commit: {
2002 status: "created" as const,
2003 commitSha: "abc1234",
2004 subject: "Test commit",
2005 },
2006 push: { status: "skipped_not_requested" as const },
2007 pr: { status: "skipped_not_requested" as const },
2008 }),
2009 ),
2010 ) ?? Effect.void,
2011 );
2012 const gitManager: GitManagerShape = {
2013 status: vi.fn(() => Effect.void as any),
2014 resolvePullRequest: vi.fn(() => Effect.void as any),
2015 preparePullRequestThread: vi.fn(() => Effect.void as any),
2016 runStackedAction,
2017 };
2018
2019 server = await createTestServer({ cwd: "/test", gitManager });
2020 const addr = server.address();
2021 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
2022
2023 const [initiatingWs] = await connectAndAwaitWelcome(port);
2024 const [otherWs] = await connectAndAwaitWelcome(port);
2025 connections.push(initiatingWs, otherWs);
2026
2027 const responsePromise = sendRequest(initiatingWs, WS_METHODS.gitRunStackedAction, {
2028 actionId: "client-action-2",
2029 cwd: "/test",
2030 action: "commit",
2031 modelSelection: {
2032 provider: "codex",
2033 model: "gpt-5.4-mini",
2034 },
2035 });
2036 const progressPush = await waitForPush(initiatingWs, WS_CHANNELS.gitActionProgress);
2037
2038 expect(progressPush.data).toEqual({
2039 actionId: "client-action-2",
2040 cwd: "/test",
2041 action: "commit",
2042 kind: "phase_started",
2043 phase: "commit",
2044 label: "Committing...",
2045 });
2046
2047 await expect(
2048 waitForPush(otherWs, WS_CHANNELS.gitActionProgress, undefined, 10, 100),
2049 ).rejects.toThrow("Timed out waiting for WebSocket message after 100ms");
2050 await expect(responsePromise).resolves.toEqual(
2051 expect.objectContaining({
2052 result: expect.objectContaining({
2053 action: "commit",
2054 }),
2055 }),
2056 );
2057 });
2058
2059 it("rejects websocket connections without a valid auth token", async () => {
2060 server = await createTestServer({ cwd: "/test", authToken: "secret-token" });
2061 const addr = server.address();
2062 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
2063
2064 await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed");
2065
2066 const [authorizedWs] = await connectAndAwaitWelcome(port, "secret-token");
2067 connections.push(authorizedWs);
2068 });
2069});
apps/server/src/wsServer.ts deleted-998
...@@ -1,998 +0,0 @@
1/**
2 * Server - HTTP/WebSocket server service interface.
3 *
4 * Owns startup and shutdown lifecycle of the HTTP server, static asset serving,
5 * and WebSocket request routing.
6 *
7 * @module Server
8 */
9import http from "node:http";
10import type { Duplex } from "node:stream";
11
12import Mime from "@effect/platform-node/Mime";
13import {
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";
30import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
31import {
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";
46import { WebSocketServer, type WebSocket } from "ws";
47
48import { createLogger } from "./logger";
49import { GitManager } from "./git/Services/GitManager.ts";
50import { TerminalManager } from "./terminal/Services/Manager.ts";
51import { Keybindings } from "./keybindings";
52import { ServerSettingsService } from "./serverSettings";
53import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
54import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
55import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
56import { ProviderService } from "./provider/Services/ProviderService";
57import { ProviderRegistry } from "./provider/Services/ProviderRegistry";
58import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
59import { clamp } from "effect/Number";
60import { Open, resolveAvailableEditors } from "./open";
61import { ServerConfig } from "./config";
62import { GitCore } from "./git/Services/GitCore.ts";
63import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
64import {
65 ATTACHMENTS_ROUTE_PREFIX,
66 normalizeAttachmentRelativePath,
67 resolveAttachmentRelativePath,
68} from "./attachmentPaths";
69
70import {
71 createAttachmentId,
72 resolveAttachmentPath,
73 resolveAttachmentPathById,
74} from "./attachmentStore.ts";
75import { parseBase64DataUrl } from "./imageMime.ts";
76import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
77import { makeServerPushBus } from "./wsServer/pushBus.ts";
78import { makeServerReadiness } from "./wsServer/readiness.ts";
79import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";
80import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";
81import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";
82import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";
83import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts";
84
85/**
86 * ServerShape - Service API for server lifecycle control.
87 */
88export 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 */
107export class Server extends ServiceMap.Service<Server, ServerShape>()("t3/wsServer/Server") {}
108
109const 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
116function 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
127function 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
159function stripRequestTag<T extends { _tag: string }>(body: T) {
160 return Struct.omit(body, ["_tag"]);
161}
162
163const encodeWsResponse = Schema.encodeEffect(Schema.fromJsonString(WsResponse));
164const decodeWebSocketRequest = decodeJsonResult(WebSocketRequest);
165
166export type ServerCoreRuntimeServices =
167 | OrchestrationEngineService
168 | ProjectionSnapshotQuery
169 | CheckpointDiffQuery
170 | OrchestrationReactor
171 | ProviderService
172 | ProviderRegistry;
173
174export 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
188export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()(
189 "ServerLifecycleError",
190 {
191 operation: Schema.String,
192 cause: Schema.optional(Schema.Defect),
193 },
194) {}
195
196class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("RouteRequestError", {
197 message: Schema.String,
198}) {}
199
200export 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
995export const ServerLive = Layer.succeed(Server, {
996 start: createServer(),
997 stopSignal: Effect.never,
998} satisfies ServerShape);
apps/server/src/wsServer/pushBus.test.ts deleted-101
...@@ -1,101 +0,0 @@
1import type { WebSocket } from "ws";
2import { it } from "@effect/vitest";
3import { describe, expect } from "vitest";
4import { Effect, Ref } from "effect";
5import { WS_CHANNELS } from "@t3tools/contracts";
6
7import { makeServerPushBus } from "./pushBus";
8
9class 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
43describe("makeServerPushBus", () => {
44 it.live("waits for the welcome push before a new client joins broadcast delivery", () =>
45 Effect.scoped(
46 Effect.gen(function* () {
47 const client = new MockWebSocket();
48 const clients = yield* Ref.make(new Set<WebSocket>());
49 const pushBus = yield* makeServerPushBus({
50 clients,
51 logOutgoingPush: () => {},
52 });
53
54 yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, {
55 issues: [{ kind: "keybindings.malformed-config", message: "queued-before-connect" }],
56 });
57
58 const delivered = yield* pushBus.publishClient(
59 client as unknown as WebSocket,
60 WS_CHANNELS.serverWelcome,
61 {
62 cwd: "/tmp/project",
63 projectName: "project",
64 },
65 );
66 expect(delivered).toBe(true);
67
68 yield* Ref.update(clients, (current) => current.add(client as unknown as WebSocket));
69
70 yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, {
71 issues: [],
72 });
73
74 yield* Effect.promise(() => client.waitForSentCount(2));
75
76 const messages = client.sent.map(
77 (message) => JSON.parse(message) as { channel: string; data: unknown },
78 );
79
80 expect(messages).toHaveLength(2);
81 expect(messages[0]).toEqual({
82 type: "push",
83 sequence: 2,
84 channel: WS_CHANNELS.serverWelcome,
85 data: {
86 cwd: "/tmp/project",
87 projectName: "project",
88 },
89 });
90 expect(messages[1]).toEqual({
91 type: "push",
92 sequence: 3,
93 channel: WS_CHANNELS.serverConfigUpdated,
94 data: {
95 issues: [],
96 },
97 });
98 }),
99 ),
100 );
101});
apps/server/src/wsServer/pushBus.ts deleted-114
...@@ -1,114 +0,0 @@
1import {
2 WsPush,
3 type WsPushChannel,
4 type WsPushData,
5 type WsPushEnvelopeBase,
6} from "@t3tools/contracts";
7import { Deferred, Effect, Queue, Ref, Schema } from "effect";
8import type { Scope } from "effect";
9import type { WebSocket } from "ws";
10
11type PushTarget =
12 | { readonly kind: "all" }
13 | { readonly kind: "client"; readonly client: WebSocket };
14
15interface 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
22export 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
34export const makeServerPushBus = (input: {
35 readonly clients: Ref.Ref<Set<WebSocket>>;
36 readonly logOutgoingPush: (push: WsPushEnvelopeBase, recipients: number) => void;
37}): Effect.Effect<ServerPushBus, never, Scope.Scope> =>
38 Effect.gen(function* () {
39 const nextSequence = yield* Ref.make(0);
40 const queue = yield* Queue.unbounded<PushJob>();
41 const encodePush = Schema.encodeUnknownEffect(Schema.fromJsonString(WsPush));
42
43 const settleDelivery = (job: PushJob, delivered: boolean) =>
44 job.delivered === null
45 ? Effect.void
46 : Deferred.succeed(job.delivered, delivered).pipe(Effect.orDie);
47
48 const send = Effect.fnUntraced(function* (job: PushJob) {
49 const sequence = yield* Ref.updateAndGet(nextSequence, (current) => current + 1);
50 const push: WsPushEnvelopeBase = {
51 type: "push",
52 sequence,
53 channel: job.channel,
54 data: job.data,
55 };
56 const recipients =
57 job.target.kind === "all" ? yield* Ref.get(input.clients) : new Set([job.target.client]);
58
59 return yield* encodePush(push).pipe(
60 Effect.map((message) => {
61 let recipientCount = 0;
62 for (const client of recipients) {
63 if (client.readyState !== client.OPEN) {
64 continue;
65 }
66 client.send(message);
67 recipientCount += 1;
68 }
69
70 input.logOutgoingPush(push, recipientCount);
71 return recipientCount > 0;
72 }),
73 );
74 });
75
76 yield* Effect.forkScoped(
77 Effect.forever(
78 Queue.take(queue).pipe(
79 Effect.flatMap((job) =>
80 send(job).pipe(
81 Effect.tap((delivered) => settleDelivery(job, delivered)),
82 Effect.tapCause(() => settleDelivery(job, false)),
83 Effect.ignoreCause({ log: true }),
84 ),
85 ),
86 ),
87 ),
88 );
89
90 const publish =
91 (target: PushTarget) =>
92 <C extends WsPushChannel>(channel: C, data: WsPushData<C>) =>
93 Queue.offer(queue, {
94 channel,
95 data,
96 target,
97 delivered: null,
98 }).pipe(Effect.asVoid);
99
100 return {
101 publishAll: publish({ kind: "all" }),
102 publishClient: (client, channel, data) =>
103 Effect.gen(function* () {
104 const delivered = yield* Deferred.make<boolean>();
105 yield* Queue.offer(queue, {
106 channel,
107 data,
108 target: { kind: "client", client },
109 delivered,
110 }).pipe(Effect.asVoid);
111 return yield* Deferred.await(delivered);
112 }),
113 } satisfies ServerPushBus;
114 });
apps/server/src/wsServer/readiness.ts deleted-36
...@@ -1,36 +0,0 @@
1import { Deferred, Effect } from "effect";
2
3export 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
12export const makeServerReadiness = Effect.gen(function* () {
13 const httpListening = yield* Deferred.make<void>();
14 const pushBusReady = yield* Deferred.make<void>();
15 const keybindingsReady = yield* Deferred.make<void>();
16 const terminalSubscriptionsReady = yield* Deferred.make<void>();
17 const orchestrationSubscriptionsReady = yield* Deferred.make<void>();
18
19 const complete = (deferred: Deferred.Deferred<void>) =>
20 Deferred.succeed(deferred, undefined).pipe(Effect.orDie);
21
22 return {
23 awaitServerReady: Effect.all([
24 Deferred.await(httpListening),
25 Deferred.await(pushBusReady),
26 Deferred.await(keybindingsReady),
27 Deferred.await(terminalSubscriptionsReady),
28 Deferred.await(orchestrationSubscriptionsReady),
29 ]).pipe(Effect.asVoid),
30 markHttpListening: complete(httpListening),
31 markPushBusReady: complete(pushBusReady),
32 markKeybindingsReady: complete(keybindingsReady),
33 markTerminalSubscriptionsReady: complete(terminalSubscriptionsReady),
34 markOrchestrationSubscriptionsReady: complete(orchestrationSubscriptionsReady),
35 } satisfies ServerReadiness;
36});
apps/server/tsdown.config.ts+1-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { defineConfig } from "tsdown";1import { defineConfig } from "tsdown";
22
3export default defineConfig({3export default defineConfig({
4 entry: ["src/index.ts"],4 entry: ["src/bin.ts"],
5 format: ["esm", "cjs"],5 format: ["esm", "cjs"],
6 checks: {6 checks: {
7 legacyCjs: false,7 legacyCjs: false,
apps/server/vitest.config.ts+7-2
...@@ -6,8 +6,13 @@ export default mergeConfig(...@@ -6,8 +6,13 @@ export default mergeConfig(
6 baseConfig,6 baseConfig,
7 defineConfig({7 defineConfig({
8 test: {8 test: {
9 testTimeout: 15_000,9 // The server suite exercises sqlite, git, temp worktrees, and orchestration
10 hookTimeout: 15_000,10 // runtimes heavily. Running files in parallel introduces load-sensitive flakes.
11 fileParallelism: false,
12 // Server integration tests exercise sqlite, git, and orchestration together.
13 // Under package-wide parallel runs they regularly exceed the default 15s budget.
14 testTimeout: 60_000,
15 hookTimeout: 60_000,
11 },16 },
12 }),17 }),
13);18);
apps/web/package.json+1
...@@ -19,6 +19,7 @@...@@ -19,6 +19,7 @@
19 "@dnd-kit/modifiers": "^9.0.0",19 "@dnd-kit/modifiers": "^9.0.0",
20 "@dnd-kit/sortable": "^10.0.0",20 "@dnd-kit/sortable": "^10.0.0",
21 "@dnd-kit/utilities": "^3.2.2",21 "@dnd-kit/utilities": "^3.2.2",
22 "@effect/atom-react": "catalog:",
22 "@formkit/auto-animate": "^0.9.0",23 "@formkit/auto-animate": "^0.9.0",
23 "@lexical/react": "^0.41.0",24 "@lexical/react": "^0.41.0",
24 "@pierre/diffs": "^1.1.0-beta.16",25 "@pierre/diffs": "^1.1.0-beta.16",
apps/web/src/components/ChatView.browser.tsx+65-64
...@@ -4,16 +4,14 @@ import "../index.css";...@@ -4,16 +4,14 @@ import "../index.css";
4import {4import {
5 EventId,5 EventId,
6 ORCHESTRATION_WS_METHODS,6 ORCHESTRATION_WS_METHODS,
7 ORCHESTRATION_WS_CHANNELS,
8 type MessageId,7 type MessageId,
9 type OrchestrationEvent,8 type OrchestrationEvent,
10 type OrchestrationReadModel,9 type OrchestrationReadModel,
11 type ProjectId,10 type ProjectId,
12 type ServerConfig,11 type ServerConfig,
12 type ServerLifecycleWelcomePayload,
13 type ThreadId,13 type ThreadId,
14 type TurnId,14 type TurnId,
15 type WsWelcomePayload,
16 WS_CHANNELS,
17 WS_METHODS,15 WS_METHODS,
18 OrchestrationSessionStatus,16 OrchestrationSessionStatus,
19 DEFAULT_SERVER_SETTINGS,17 DEFAULT_SERVER_SETTINGS,
...@@ -32,8 +30,10 @@ import {...@@ -32,8 +30,10 @@ import {
32 removeInlineTerminalContextPlaceholder,30 removeInlineTerminalContextPlaceholder,
33} from "../lib/terminalContext";31} from "../lib/terminalContext";
34import { isMacPlatform } from "../lib/utils";32import { isMacPlatform } from "../lib/utils";
33import { __resetNativeApiForTests } from "../nativeApi";
35import { getRouter } from "../router";34import { getRouter } from "../router";
36import { useStore } from "../store";35import { useStore } from "../store";
36import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness";
37import { estimateTimelineMessageHeight } from "./timelineHeight";37import { estimateTimelineMessageHeight } from "./timelineHeight";
38import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";38import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
3939
...@@ -44,25 +44,16 @@ const NOW_ISO = "2026-03-04T12:00:00.000Z";...@@ -44,25 +44,16 @@ const NOW_ISO = "2026-03-04T12:00:00.000Z";
44const BASE_TIME_MS = Date.parse(NOW_ISO);44const BASE_TIME_MS = Date.parse(NOW_ISO);
45const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='300'></svg>";45const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='300'></svg>";
4646
47interface WsRequestEnvelope {
48 id: string;
49 body: {
50 _tag: string;
51 [key: string]: unknown;
52 };
53}
54
55interface TestFixture {47interface TestFixture {
56 snapshot: OrchestrationReadModel;48 snapshot: OrchestrationReadModel;
57 serverConfig: ServerConfig;49 serverConfig: ServerConfig;
58 welcome: WsWelcomePayload;50 welcome: ServerLifecycleWelcomePayload;
59}51}
6052
61let fixture: TestFixture;53let fixture: TestFixture;
62const wsRequests: WsRequestEnvelope["body"][] = [];54const rpcHarness = new BrowserWsRpcHarness();
63let customWsRpcResolver: ((body: WsRequestEnvelope["body"]) => unknown | undefined) | null = null;55const wsRequests = rpcHarness.requests;
64let wsClient: { send: (message: string) => void } | null = null;56let customWsRpcResolver: ((body: NormalizedWsRpcRequestBody) => unknown | undefined) | null = null;
65let pushSequence = 1;
66const wsLink = ws.link(/ws(s)?:\/\/.*/);57const wsLink = ws.link(/ws(s)?:\/\/.*/);
6758
68interface ViewportSpec {59interface ViewportSpec {
...@@ -388,32 +379,20 @@ function createThreadCreatedEvent(threadId: ThreadId, sequence: number): Orchest...@@ -388,32 +379,20 @@ function createThreadCreatedEvent(threadId: ThreadId, sequence: number): Orchest
388}379}
389380
390function sendOrchestrationDomainEvent(event: OrchestrationEvent): void {381function sendOrchestrationDomainEvent(event: OrchestrationEvent): void {
391 if (!wsClient) {382 rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event);
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 );
402}383}
403384
404async function waitForWsClient(): Promise<{ send: (message: string) => void }> {385async function waitForWsClient(): Promise<void> {
405 let client: { send: (message: string) => void } | null = null;
406 await vi.waitFor(386 await vi.waitFor(
407 () => {387 () => {
408 client = wsClient;388 expect(
409 expect(client).toBeTruthy();389 wsRequests.some(
390 (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents,
391 ),
392 ).toBe(true);
410 },393 },
411 { timeout: 8_000, interval: 16 },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}
418397
419async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> {398async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> {
...@@ -637,7 +616,7 @@ function createSnapshotWithPlanFollowUpPrompt(): OrchestrationReadModel {...@@ -637,7 +616,7 @@ function createSnapshotWithPlanFollowUpPrompt(): OrchestrationReadModel {
637 };616 };
638}617}
639618
640function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {619function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {
641 const customResult = customWsRpcResolver?.(body);620 const customResult = customWsRpcResolver?.(body);
642 if (customResult !== undefined) {621 if (customResult !== undefined) {
643 return customResult;622 return customResult;
...@@ -684,6 +663,9 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {...@@ -684,6 +663,9 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
684 truncated: false,663 truncated: false,
685 };664 };
686 }665 }
666 if (tag === WS_METHODS.shellOpenInEditor) {
667 return null;
668 }
687 if (tag === WS_METHODS.terminalOpen) {669 if (tag === WS_METHODS.terminalOpen) {
688 return {670 return {
689 threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID,671 threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID,
...@@ -702,34 +684,11 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {...@@ -702,34 +684,11 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
702684
703const worker = setupWorker(685const worker = setupWorker(
704 wsLink.addEventListener("connection", ({ client }) => {686 wsLink.addEventListener("connection", ({ client }) => {
705 wsClient = client;687 void rpcHarness.connect(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 );
715 client.addEventListener("message", (event) => {688 client.addEventListener("message", (event) => {
716 const rawData = event.data;689 const rawData = event.data;
717 if (typeof rawData !== "string") return;690 if (typeof rawData !== "string") return;
718 let request: WsRequestEnvelope;691 void rpcHarness.onMessage(rawData);
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 );
733 });692 });
734 }),693 }),
735 http.get("*/attachments/:attachmentId", () =>694 http.get("*/attachments/:attachmentId", () =>
...@@ -903,7 +862,9 @@ async function waitForInteractionModeButton(...@@ -903,7 +862,9 @@ async function waitForInteractionModeButton(
903async function waitForServerConfigToApply(): Promise<void> {862async function waitForServerConfigToApply(): Promise<void> {
904 await vi.waitFor(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 { timeout: 8_000, interval: 16 },869 { timeout: 8_000, interval: 16 },
909 );870 );
...@@ -1041,7 +1002,7 @@ async function mountChatView(options: {...@@ -1041,7 +1002,7 @@ async function mountChatView(options: {
1041 viewport: ViewportSpec;1002 viewport: ViewportSpec;
1042 snapshot: OrchestrationReadModel;1003 snapshot: OrchestrationReadModel;
1043 configureFixture?: (fixture: TestFixture) => void;1004 configureFixture?: (fixture: TestFixture) => void;
1044 resolveRpc?: (body: WsRequestEnvelope["body"]) => unknown | undefined;1005 resolveRpc?: (body: NormalizedWsRpcRequestBody) => unknown | undefined;
1045}): Promise<MountedChatView> {1006}): Promise<MountedChatView> {
1046 fixture = buildFixture(options.snapshot);1007 fixture = buildFixture(options.snapshot);
1047 options.configureFixture?.(fixture);1008 options.configureFixture?.(fixture);
...@@ -1129,10 +1090,37 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1129,10 +1090,37 @@ describe("ChatView timeline estimator parity (full app)", () => {
1129 });1090 });
11301091
1131 afterAll(async () => {1092 afterAll(async () => {
1093 await rpcHarness.disconnect();
1132 await worker.stop();1094 await worker.stop();
1133 });1095 });
11341096
1135 beforeEach(async () => {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 await setViewport(DEFAULT_VIEWPORT);1124 await setViewport(DEFAULT_VIEWPORT);
1137 localStorage.clear();1125 localStorage.clear();
1138 document.body.innerHTML = "";1126 document.body.innerHTML = "";
...@@ -1342,6 +1330,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1342,6 +1330,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
1342 });1330 });
13431331
1344 try {1332 try {
1333 await waitForServerConfigToApply();
1345 const openButton = await waitForElement(1334 const openButton = await waitForElement(
1346 () =>1335 () =>
1347 Array.from(document.querySelectorAll("button")).find(1336 Array.from(document.querySelectorAll("button")).find(
...@@ -1349,6 +1338,9 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1349,6 +1338,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
1349 ) as HTMLButtonElement | null,1338 ) as HTMLButtonElement | null,
1350 "Unable to find Open button.",1339 "Unable to find Open button.",
1351 );1340 );
1341 await vi.waitFor(() => {
1342 expect(openButton.disabled).toBe(false);
1343 });
1352 openButton.click();1344 openButton.click();
13531345
1354 await vi.waitFor(1346 await vi.waitFor(
...@@ -1384,6 +1376,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1384,6 +1376,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
1384 });1376 });
13851377
1386 try {1378 try {
1379 await waitForServerConfigToApply();
1387 const openButton = await waitForElement(1380 const openButton = await waitForElement(
1388 () =>1381 () =>
1389 Array.from(document.querySelectorAll("button")).find(1382 Array.from(document.querySelectorAll("button")).find(
...@@ -1391,6 +1384,9 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1391,6 +1384,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
1391 ) as HTMLButtonElement | null,1384 ) as HTMLButtonElement | null,
1392 "Unable to find Open button.",1385 "Unable to find Open button.",
1393 );1386 );
1387 await vi.waitFor(() => {
1388 expect(openButton.disabled).toBe(false);
1389 });
1394 openButton.click();1390 openButton.click();
13951391
1396 await vi.waitFor(1392 await vi.waitFor(
...@@ -1426,6 +1422,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1426,6 +1422,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
1426 });1422 });
14271423
1428 try {1424 try {
1425 await waitForServerConfigToApply();
1429 const menuButton = await waitForElement(1426 const menuButton = await waitForElement(
1430 () => document.querySelector('button[aria-label="Copy options"]'),1427 () => document.querySelector('button[aria-label="Copy options"]'),
1431 "Unable to find Open picker button.",1428 "Unable to find Open picker button.",
...@@ -1474,7 +1471,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1474,7 +1471,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
1474 });1471 });
14751472
1476 it("falls back to the first installed editor when the stored favorite is unavailable", async () => {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 setDraftThreadWithoutWorktree();1475 setDraftThreadWithoutWorktree();
14791476
1480 const mounted = await mountChatView({1477 const mounted = await mountChatView({
...@@ -1489,6 +1486,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1489,6 +1486,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
1489 });1486 });
14901487
1491 try {1488 try {
1489 await waitForServerConfigToApply();
1492 const openButton = await waitForElement(1490 const openButton = await waitForElement(
1493 () =>1491 () =>
1494 Array.from(document.querySelectorAll("button")).find(1492 Array.from(document.querySelectorAll("button")).find(
...@@ -1496,6 +1494,9 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1496,6 +1494,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
1496 ) as HTMLButtonElement | null,1494 ) as HTMLButtonElement | null,
1497 "Unable to find Open button.",1495 "Unable to find Open button.",
1498 );1496 );
1497 await vi.waitFor(() => {
1498 expect(openButton.disabled).toBe(false);
1499 });
1499 openButton.click();1500 openButton.click();
15001501
1501 await vi.waitFor(1502 await vi.waitFor(
apps/web/src/components/ChatView.tsx+10-11
...@@ -11,11 +11,9 @@ import {...@@ -11,11 +11,9 @@ import {
11 type ProviderApprovalDecision,11 type ProviderApprovalDecision,
12 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,12 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
13 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,13 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
14 type ResolvedKeybindingsConfig,
15 type ServerProvider,14 type ServerProvider,
16 type ThreadId,15 type ThreadId,
17 type TurnId,16 type TurnId,
18 type EditorId,
19 type KeybindingCommand,17 type KeybindingCommand,
20 OrchestrationThreadActivity,18 OrchestrationThreadActivity,
21 ProviderInteractionMode,19 ProviderInteractionMode,
...@@ -30,7 +28,6 @@ import { useDebouncedValue } from "@tanstack/react-pacer";...@@ -30,7 +28,6 @@ import { useDebouncedValue } from "@tanstack/react-pacer";
30import { useNavigate, useSearch } from "@tanstack/react-router";28import { useNavigate, useSearch } from "@tanstack/react-router";
31import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";29import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
32import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";30import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
33import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
34import { isElectron } from "../env";31import { isElectron } from "../env";
35import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";32import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
36import {33import {
...@@ -195,15 +192,18 @@ import {...@@ -195,15 +192,18 @@ import {
195 waitForStartedServerThread,192 waitForStartedServerThread,
196} from "./ChatView.logic";193} from "./ChatView.logic";
197import { useLocalStorage } from "~/hooks/useLocalStorage";194import { useLocalStorage } from "~/hooks/useLocalStorage";
195import {
196 useServerAvailableEditors,
197 useServerConfig,
198 useServerKeybindings,
199} from "~/rpc/serverState";
198200
199const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;201const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;
200const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;202const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
201const IMAGE_ONLY_BOOTSTRAP_PROMPT =203const IMAGE_ONLY_BOOTSTRAP_PROMPT =
202 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";204 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
203const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];205const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
204const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
205const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];206const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
206const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];
207const EMPTY_PROVIDERS: ServerProvider[] = [];207const EMPTY_PROVIDERS: ServerProvider[] = [];
208const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};208const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
209209
...@@ -794,8 +794,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -794,8 +794,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
794 const lockedProvider: ProviderKind | null = hasThreadStarted794 const lockedProvider: ProviderKind | null = hasThreadStarted
795 ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null)795 ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null)
796 : null;796 : null;
797 const serverConfigQuery = useQuery(serverConfigQueryOptions());797 const serverConfig = useServerConfig();
798 const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDERS;798 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
799 const unlockedSelectedProvider = resolveSelectableProvider(799 const unlockedSelectedProvider = resolveSelectableProvider(
800 providerStatuses,800 providerStatuses,
801 selectedProviderByThreadId ?? threadProvider ?? "codex",801 selectedProviderByThreadId ?? threadProvider ?? "codex",
...@@ -1202,8 +1202,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1202,8 +1202,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
1202 );1202 );
1203 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";1203 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
1204 const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd));1204 const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd));
1205 const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS;1205 const keybindings = useServerKeybindings();
1206 const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS;1206 const availableEditors = useServerAvailableEditors();
1207 const modelOptionsByProvider = useMemo(1207 const modelOptionsByProvider = useMemo(
1208 () => ({1208 () => ({
1209 codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [],1209 codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [],
...@@ -1698,10 +1698,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1698,10 +1698,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
16981698
1699 if (isElectron && keybindingRule) {1699 if (isElectron && keybindingRule) {
1700 await api.server.upsertKeybinding(keybindingRule);1700 await api.server.upsertKeybinding(keybindingRule);
1701 await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all });
1702 }1701 }
1703 },1702 },
1704 [queryClient],1703 [],
1705 );1704 );
1706 const saveProjectScript = useCallback(1705 const saveProjectScript = useCallback(
1707 async (input: NewProjectScriptInput) => {1706 async (input: NewProjectScriptInput) => {
apps/web/src/components/GitActionsControl.tsx+57-68
...@@ -294,74 +294,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -294,74 +294,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
294 })294 })
295 : null;295 : null;
296296
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 useEffect(() => {297 useEffect(() => {
366 const interval = window.setInterval(() => {298 const interval = window.setInterval(() => {
367 if (!activeGitActionProgressRef.current) {299 if (!activeGitActionProgressRef.current) {
...@@ -483,12 +415,69 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -483,12 +415,69 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
483 });415 });
484 }416 }
485417
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 const promise = runImmediateGitActionMutation.mutateAsync({474 const promise = runImmediateGitActionMutation.mutateAsync({
487 actionId,475 actionId,
488 action,476 action,
489 ...(commitMessage ? { commitMessage } : {}),477 ...(commitMessage ? { commitMessage } : {}),
490 ...(featureBranch ? { featureBranch } : {}),478 ...(featureBranch ? { featureBranch } : {}),
491 ...(filePaths ? { filePaths } : {}),479 ...(filePaths ? { filePaths } : {}),
480 onProgress: applyProgressEvent,
492 });481 });
493482
494 try {483 try {
apps/web/src/components/KeybindingsToast.browser.tsx+41-44
...@@ -6,9 +6,8 @@ import {...@@ -6,9 +6,8 @@ import {
6 type OrchestrationReadModel,6 type OrchestrationReadModel,
7 type ProjectId,7 type ProjectId,
8 type ServerConfig,8 type ServerConfig,
9 type ServerLifecycleWelcomePayload,
9 type ThreadId,10 type ThreadId,
10 type WsWelcomePayload,
11 WS_CHANNELS,
12 WS_METHODS,11 WS_METHODS,
13} from "@t3tools/contracts";12} from "@t3tools/contracts";
14import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";13import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
...@@ -18,8 +17,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }...@@ -18,8 +17,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
18import { render } from "vitest-browser-react";17import { render } from "vitest-browser-react";
1918
20import { useComposerDraftStore } from "../composerDraftStore";19import { useComposerDraftStore } from "../composerDraftStore";
20import { __resetNativeApiForTests } from "../nativeApi";
21import { getRouter } from "../router";21import { getRouter } from "../router";
22import { useStore } from "../store";22import { useStore } from "../store";
23import { BrowserWsRpcHarness } from "../../test/wsRpcHarness";
2324
24const THREAD_ID = "thread-kb-toast-test" as ThreadId;25const THREAD_ID = "thread-kb-toast-test" as ThreadId;
25const PROJECT_ID = "project-1" as ProjectId;26const PROJECT_ID = "project-1" as ProjectId;
...@@ -28,12 +29,11 @@ const NOW_ISO = "2026-03-04T12:00:00.000Z";...@@ -28,12 +29,11 @@ const NOW_ISO = "2026-03-04T12:00:00.000Z";
28interface TestFixture {29interface TestFixture {
29 snapshot: OrchestrationReadModel;30 snapshot: OrchestrationReadModel;
30 serverConfig: ServerConfig;31 serverConfig: ServerConfig;
31 welcome: WsWelcomePayload;32 welcome: ServerLifecycleWelcomePayload;
32}33}
3334
34let fixture: TestFixture;35let fixture: TestFixture;
35let wsClient: { send: (data: string) => void } | null = null;36const rpcHarness = new BrowserWsRpcHarness();
36let pushSequence = 1;
3737
38const wsLink = ws.link(/ws(s)?:\/\/.*/);38const wsLink = ws.link(/ws(s)?:\/\/.*/);
3939
...@@ -179,52 +179,23 @@ function resolveWsRpc(tag: string): unknown {...@@ -179,52 +179,23 @@ function resolveWsRpc(tag: string): unknown {
179179
180const worker = setupWorker(180const worker = setupWorker(
181 wsLink.addEventListener("connection", ({ client }) => {181 wsLink.addEventListener("connection", ({ client }) => {
182 wsClient = client;182 void rpcHarness.connect(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 );
192 client.addEventListener("message", (event) => {183 client.addEventListener("message", (event) => {
193 const rawData = event.data;184 const rawData = event.data;
194 if (typeof rawData !== "string") return;185 if (typeof rawData !== "string") return;
195 let request: { id: string; body: { _tag: string; [key: string]: unknown } };186 void rpcHarness.onMessage(rawData);
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 );
209 });187 });
210 }),188 }),
211 http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })),189 http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })),
212 http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })),190 http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })),
213);191);
214192
215function sendServerConfigUpdatedPush(issues: Array<{ kind: string; message: string }>) {193function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) {
216 if (!wsClient) throw new Error("WebSocket client not connected");194 rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, {
217 wsClient.send(195 version: 1,
218 JSON.stringify({196 type: "keybindingsUpdated",
219 type: "push",197 payload: { issues },
220 sequence: pushSequence++,198 });
221 channel: WS_CHANNELS.serverConfigUpdated,
222 data: {
223 issues,
224 providers: fixture.serverConfig.providers,
225 },
226 }),
227 );
228}199}
229200
230function queryToastTitles(): string[] {201function queryToastTitles(): string[] {
...@@ -308,13 +279,39 @@ describe("Keybindings update toast", () => {...@@ -308,13 +279,39 @@ describe("Keybindings update toast", () => {
308 });279 });
309280
310 afterAll(async () => {281 afterAll(async () => {
282 await rpcHarness.disconnect();
311 await worker.stop();283 await worker.stop();
312 });284 });
313285
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 localStorage.clear();313 localStorage.clear();
316 document.body.innerHTML = "";314 document.body.innerHTML = "";
317 pushSequence = 1;
318 useComposerDraftStore.setState({315 useComposerDraftStore.setState({
319 draftsByThreadId: {},316 draftsByThreadId: {},
320 draftThreadsByThreadId: {},317 draftThreadsByThreadId: {},
apps/web/src/components/ProjectFavicon.tsx+6-21
...@@ -1,30 +1,15 @@...@@ -1,30 +1,15 @@
1import { FolderIcon } from "lucide-react";1import { FolderIcon } from "lucide-react";
2import { useState } from "react";2import { useState } from "react";
33import { resolveServerUrl } from "~/lib/utils";
4function 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
22const serverHttpOrigin = getServerHttpOrigin();
234
24const loadedProjectFaviconSrcs = new Set<string>();5const loadedProjectFaviconSrcs = new Set<string>();
256
26export function ProjectFavicon({ cwd, className }: { cwd: string; className?: string }) {7export 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 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>13 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
29 loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",14 loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",
30 );15 );
apps/web/src/components/Sidebar.tsx+3-9
...@@ -43,9 +43,8 @@ import {...@@ -43,9 +43,8 @@ import {
43 ProjectId,43 ProjectId,
44 ThreadId,44 ThreadId,
45 type GitStatusResult,45 type GitStatusResult,
46 type ResolvedKeybindingsConfig,
47} from "@t3tools/contracts";46} from "@t3tools/contracts";
48import { useQueries, useQuery } from "@tanstack/react-query";47import { useQueries } from "@tanstack/react-query";
49import { useLocation, useNavigate, useParams } from "@tanstack/react-router";48import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
50import {49import {
51 type SidebarProjectSortOrder,50 type SidebarProjectSortOrder,
...@@ -67,7 +66,6 @@ import {...@@ -67,7 +66,6 @@ import {
67} from "../keybindings";66} from "../keybindings";
68import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";67import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
69import { gitStatusQueryOptions } from "../lib/gitReactQuery";68import { gitStatusQueryOptions } from "../lib/gitReactQuery";
70import { serverConfigQueryOptions } from "../lib/serverReactQuery";
71import { readNativeApi } from "../nativeApi";69import { readNativeApi } from "../nativeApi";
72import { useComposerDraftStore } from "../composerDraftStore";70import { useComposerDraftStore } from "../composerDraftStore";
73import { useHandleNewThread } from "../hooks/useHandleNewThread";71import { useHandleNewThread } from "../hooks/useHandleNewThread";
...@@ -125,9 +123,8 @@ import {...@@ -125,9 +123,8 @@ import {
125import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";123import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
126import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";124import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
127import { useSettings, useUpdateSettings } from "~/hooks/useSettings";125import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
126import { useServerKeybindings } from "../rpc/serverState";
128import type { Project, Thread } from "../types";127import type { Project, Thread } from "../types";
129
130const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
131const THREAD_PREVIEW_LIMIT = 6;128const THREAD_PREVIEW_LIMIT = 6;
132const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {129const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
133 updated_at: "Last user message",130 updated_at: "Last user message",
...@@ -469,10 +466,7 @@ export default function Sidebar() {...@@ -469,10 +466,7 @@ export default function Sidebar() {
469 strict: false,466 strict: false,
470 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),467 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
471 });468 });
472 const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({469 const keybindings = useServerKeybindings();
473 ...serverConfigQueryOptions(),
474 select: (config) => config.keybindings,
475 });
476 const [addingProject, setAddingProject] = useState(false);470 const [addingProject, setAddingProject] = useState(false);
477 const [newCwd, setNewCwd] = useState("");471 const [newCwd, setNewCwd] = useState("");
478 const [isPickingFolder, setIsPickingFolder] = useState(false);472 const [isPickingFolder, setIsPickingFolder] = useState(false);
apps/web/src/components/settings/SettingsPanels.tsx+10-11
...@@ -9,7 +9,7 @@ import {...@@ -9,7 +9,7 @@ import {
9 Undo2Icon,9 Undo2Icon,
10 XIcon,10 XIcon,
11} from "lucide-react";11} from "lucide-react";
12import { useQuery, useQueryClient } from "@tanstack/react-query";12import { useQueryClient } from "@tanstack/react-query";
13import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";13import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
14import {14import {
15 PROVIDER_DISPLAY_NAMES,15 PROVIDER_DISPLAY_NAMES,
...@@ -40,7 +40,6 @@ import {...@@ -40,7 +40,6 @@ import {
40 setDesktopUpdateStateQueryData,40 setDesktopUpdateStateQueryData,
41 useDesktopUpdateState,41 useDesktopUpdateState,
42} from "../../lib/desktopUpdateReactQuery";42} from "../../lib/desktopUpdateReactQuery";
43import { serverConfigQueryOptions, serverQueryKeys } from "../../lib/serverReactQuery";
44import {43import {
45 MAX_CUSTOM_MODEL_LENGTH,44 MAX_CUSTOM_MODEL_LENGTH,
46 getCustomModelOptionsByProvider,45 getCustomModelOptionsByProvider,
...@@ -59,6 +58,11 @@ import { Switch } from "../ui/switch";...@@ -59,6 +58,11 @@ import { Switch } from "../ui/switch";
59import { toastManager } from "../ui/toast";58import { toastManager } from "../ui/toast";
60import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";59import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
61import { ProjectFavicon } from "../ProjectFavicon";60import { ProjectFavicon } from "../ProjectFavicon";
61import {
62 useServerAvailableEditors,
63 useServerKeybindingsConfigPath,
64 useServerProviders,
65} from "../../rpc/serverState";
6266
63const THEME_OPTIONS = [67const THEME_OPTIONS = [
64 {68 {
...@@ -81,8 +85,6 @@ const TIMESTAMP_FORMAT_LABELS = {...@@ -81,8 +85,6 @@ const TIMESTAMP_FORMAT_LABELS = {
81 "24-hour": "24-hour",85 "24-hour": "24-hour",
82} as const;86} as const;
8387
84const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = [];
85
86type InstallProviderSettings = {88type InstallProviderSettings = {
87 provider: ProviderKind;89 provider: ProviderKind;
88 title: string;90 title: string;
...@@ -516,7 +518,6 @@ export function GeneralSettingsPanel() {...@@ -516,7 +518,6 @@ export function GeneralSettingsPanel() {
516 const { theme, setTheme } = useTheme();518 const { theme, setTheme } = useTheme();
517 const settings = useSettings();519 const settings = useSettings();
518 const { updateSettings } = useUpdateSettings();520 const { updateSettings } = useUpdateSettings();
519 const serverConfigQuery = useQuery(serverConfigQueryOptions());
520 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);521 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
521 const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null);522 const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null);
522 const [openProviderDetails, setOpenProviderDetails] = useState<Record<ProviderKind, boolean>>({523 const [openProviderDetails, setOpenProviderDetails] = useState<Record<ProviderKind, boolean>>({
...@@ -542,7 +543,6 @@ export function GeneralSettingsPanel() {...@@ -542,7 +543,6 @@ export function GeneralSettingsPanel() {
542 >({});543 >({});
543 const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);544 const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
544 const refreshingRef = useRef(false);545 const refreshingRef = useRef(false);
545 const queryClient = useQueryClient();
546 const modelListRefs = useRef<Partial<Record<ProviderKind, HTMLDivElement | null>>>({});546 const modelListRefs = useRef<Partial<Record<ProviderKind, HTMLDivElement | null>>>({});
547 const refreshProviders = useCallback(() => {547 const refreshProviders = useCallback(() => {
548 if (refreshingRef.current) return;548 if (refreshingRef.current) return;
...@@ -550,7 +550,6 @@ export function GeneralSettingsPanel() {...@@ -550,7 +550,6 @@ export function GeneralSettingsPanel() {
550 setIsRefreshingProviders(true);550 setIsRefreshingProviders(true);
551 void ensureNativeApi()551 void ensureNativeApi()
552 .server.refreshProviders()552 .server.refreshProviders()
553 .then(() => queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }))
554 .catch((error: unknown) => {553 .catch((error: unknown) => {
555 console.warn("Failed to refresh providers", error);554 console.warn("Failed to refresh providers", error);
556 })555 })
...@@ -558,11 +557,11 @@ export function GeneralSettingsPanel() {...@@ -558,11 +557,11 @@ export function GeneralSettingsPanel() {
558 refreshingRef.current = false;557 refreshingRef.current = false;
559 setIsRefreshingProviders(false);558 setIsRefreshingProviders(false);
560 });559 });
561 }, [queryClient]);560 }, []);
562561
563 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;562 const keybindingsConfigPath = useServerKeybindingsConfigPath();
564 const availableEditors = serverConfigQuery.data?.availableEditors;563 const availableEditors = useServerAvailableEditors();
565 const serverProviders = serverConfigQuery.data?.providers ?? EMPTY_SERVER_PROVIDERS;564 const serverProviders = useServerProviders();
566 const codexHomePath = settings.providers.codex.homePath;565 const codexHomePath = settings.providers.codex.homePath;
567566
568 const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);567 const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
apps/web/src/hooks/useSettings.ts+10-18
...@@ -10,15 +10,12 @@...@@ -10,15 +10,12 @@
10 * store.10 * store.
11 */11 */
12import { useCallback, useMemo } from "react";12import { useCallback, useMemo } from "react";
13import { useQuery, useQueryClient } from "@tanstack/react-query";
14import {13import {
15 ServerSettings,14 ServerSettings,
16 ServerSettingsPatch,15 ServerSettingsPatch,
17 ServerConfig,
18 ModelSelection,16 ModelSelection,
19 ThreadEnvMode,17 ThreadEnvMode,
20} from "@t3tools/contracts";18} from "@t3tools/contracts";
21import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
22import {19import {
23 type ClientSettings,20 type ClientSettings,
24 ClientSettingsSchema,21 ClientSettingsSchema,
...@@ -29,13 +26,13 @@ import {...@@ -29,13 +26,13 @@ import {
29 TimestampFormat,26 TimestampFormat,
30 UnifiedSettings,27 UnifiedSettings,
31} from "@t3tools/contracts/settings";28} from "@t3tools/contracts/settings";
32import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
33import { ensureNativeApi } from "~/nativeApi";29import { ensureNativeApi } from "~/nativeApi";
34import { useLocalStorage } from "./useLocalStorage";30import { useLocalStorage } from "./useLocalStorage";
35import { normalizeCustomModelSlugs } from "~/modelSelection";31import { normalizeCustomModelSlugs } from "~/modelSelection";
36import { Predicate, Schema, Struct } from "effect";32import { Predicate, Schema, Struct } from "effect";
37import { DeepMutable } from "effect/Types";33import { DeepMutable } from "effect/Types";
38import { deepMerge } from "@t3tools/shared/Struct";34import { deepMerge } from "@t3tools/shared/Struct";
35import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState";
3936
40const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1";37const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1";
41const OLD_SETTINGS_KEY = "t3code:app-settings:v1";38const OLD_SETTINGS_KEY = "t3code:app-settings:v1";
...@@ -73,7 +70,7 @@ function splitPatch(patch: Partial<UnifiedSettings>): {...@@ -73,7 +70,7 @@ function splitPatch(patch: Partial<UnifiedSettings>): {
73export function useSettings<T extends UnifiedSettings = UnifiedSettings>(70export function useSettings<T extends UnifiedSettings = UnifiedSettings>(
74 selector?: (s: UnifiedSettings) => T,71 selector?: (s: UnifiedSettings) => T,
75): T {72): T {
76 const { data: serverConfig } = useQuery(serverConfigQueryOptions());73 const serverSettings = useServerSettings();
77 const [clientSettings] = useLocalStorage(74 const [clientSettings] = useLocalStorage(
78 CLIENT_SETTINGS_STORAGE_KEY,75 CLIENT_SETTINGS_STORAGE_KEY,
79 DEFAULT_CLIENT_SETTINGS,76 DEFAULT_CLIENT_SETTINGS,
...@@ -82,10 +79,10 @@ export function useSettings<T extends UnifiedSettings = UnifiedSettings>(...@@ -82,10 +79,10 @@ export function useSettings<T extends UnifiedSettings = UnifiedSettings>(
8279
83 const merged = useMemo<UnifiedSettings>(80 const merged = useMemo<UnifiedSettings>(
84 () => ({81 () => ({
85 ...(serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS),82 ...serverSettings,
86 ...clientSettings,83 ...clientSettings,
87 }),84 }),
88 [serverConfig?.settings, clientSettings],85 [clientSettings, serverSettings],
89 );86 );
9087
91 return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]);88 return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]);
...@@ -94,11 +91,10 @@ export function useSettings<T extends UnifiedSettings = UnifiedSettings>(...@@ -94,11 +91,10 @@ export function useSettings<T extends UnifiedSettings = UnifiedSettings>(
94/**91/**
95 * Returns an updater that routes each key to the correct backing store.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, then94 * Server keys are optimistically patched in atom-backed server state, then
98 * persisted via RPC. Client keys go straight to localStorage.95 * persisted via RPC. Client keys go straight to localStorage.
99 */96 */
100export function useUpdateSettings() {97export function useUpdateSettings() {
101 const queryClient = useQueryClient();
102 const [, setClientSettings] = useLocalStorage(98 const [, setClientSettings] = useLocalStorage(
103 CLIENT_SETTINGS_STORAGE_KEY,99 CLIENT_SETTINGS_STORAGE_KEY,
104 DEFAULT_CLIENT_SETTINGS,100 DEFAULT_CLIENT_SETTINGS,
...@@ -110,14 +106,10 @@ export function useUpdateSettings() {...@@ -110,14 +106,10 @@ export function useUpdateSettings() {
110 const { serverPatch, clientPatch } = splitPatch(patch);106 const { serverPatch, clientPatch } = splitPatch(patch);
111107
112 if (Object.keys(serverPatch).length > 0) {108 if (Object.keys(serverPatch).length > 0) {
113 // Optimistic update of the React Query cache109 const currentServerConfig = getServerConfig();
114 queryClient.setQueryData<ServerConfig>(serverQueryKeys.config(), (old) => {110 if (currentServerConfig) {
115 if (!old) return old;111 applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch));
116 return {112 }
117 ...old,
118 settings: deepMerge(old.settings, serverPatch),
119 };
120 });
121 // Fire-and-forget RPC — push will reconcile on success113 // Fire-and-forget RPC — push will reconcile on success
122 void ensureNativeApi().server.updateSettings(serverPatch);114 void ensureNativeApi().server.updateSettings(serverPatch);
123 }115 }
...@@ -126,7 +118,7 @@ export function useUpdateSettings() {...@@ -126,7 +118,7 @@ export function useUpdateSettings() {
126 setClientSettings((prev) => ({ ...prev, ...clientPatch }));118 setClientSettings((prev) => ({ ...prev, ...clientPatch }));
127 }119 }
128 },120 },
129 [queryClient, setClientSettings],121 [setClientSettings],
130 );122 );
131123
132 const resetSettings = useCallback(() => {124 const resetSettings = useCallback(() => {
apps/web/src/lib/gitReactQuery.ts+15-10
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { type GitStackedAction } from "@t3tools/contracts";1import { type GitActionProgressEvent, type GitStackedAction } from "@t3tools/contracts";
2import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";2import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
3import { ensureNativeApi } from "../nativeApi";3import { ensureNativeApi } from "../nativeApi";
4import { getWsRpcClient } from "../wsRpcClient";
45
5const GIT_STATUS_STALE_TIME_MS = 5_000;6const GIT_STATUS_STALE_TIME_MS = 5_000;
6const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000;7const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000;
...@@ -121,23 +122,27 @@ export function gitRunStackedActionMutationOptions(input: {...@@ -121,23 +122,27 @@ export function gitRunStackedActionMutationOptions(input: {
121 commitMessage,122 commitMessage,
122 featureBranch,123 featureBranch,
123 filePaths,124 filePaths,
125 onProgress,
124 }: {126 }: {
125 actionId: string;127 actionId: string;
126 action: GitStackedAction;128 action: GitStackedAction;
127 commitMessage?: string;129 commitMessage?: string;
128 featureBranch?: boolean;130 featureBranch?: boolean;
129 filePaths?: string[];131 filePaths?: string[];
132 onProgress?: (event: GitActionProgressEvent) => void;
130 }) => {133 }) => {
131 const api = ensureNativeApi();
132 if (!input.cwd) throw new Error("Git action is unavailable.");134 if (!input.cwd) throw new Error("Git action is unavailable.");
133 return api.git.runStackedAction({135 return getWsRpcClient().git.runStackedAction(
134 actionId,136 {
135 cwd: input.cwd,137 actionId,
136 action,138 cwd: input.cwd,
137 ...(commitMessage ? { commitMessage } : {}),139 action,
138 ...(featureBranch ? { featureBranch } : {}),140 ...(commitMessage ? { commitMessage } : {}),
139 ...(filePaths ? { filePaths } : {}),141 ...(featureBranch ? { featureBranch } : {}),
140 });142 ...(filePaths ? { filePaths } : {}),
143 },
144 ...(onProgress ? [{ onProgress }] : []),
145 );
141 },146 },
142 onSettled: async () => {147 onSettled: async () => {
143 await invalidateGitQueries(input.queryClient);148 await invalidateGitQueries(input.queryClient);
apps/web/src/lib/serverReactQuery.ts deleted-24
...@@ -1,24 +0,0 @@
1import { queryOptions } from "@tanstack/react-query";
2import { ensureNativeApi } from "~/nativeApi";
3
4export 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 */
16export function serverConfigQueryOptions() {
17 return queryOptions({
18 queryKey: serverQueryKeys.config(),
19 queryFn: async () => {
20 const api = ensureNativeApi();
21 return api.server.getConfig();
22 },
23 });
24}
apps/web/src/lib/utils.ts+39
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { CommandId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts";1import { CommandId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
2import { String, Predicate } from "effect";
2import { type CxOptions, cx } from "class-variance-authority";3import { type CxOptions, cx } from "class-variance-authority";
3import { twMerge } from "tailwind-merge";4import { twMerge } from "tailwind-merge";
4import * as Random from "effect/Random";5import * as Random from "effect/Random";
...@@ -34,3 +35,41 @@ export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(randomUUID());...@@ -34,3 +35,41 @@ export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(randomUUID());
34export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID());35export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID());
3536
36export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID());37export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID());
38
39const isNonEmptyString = Predicate.compose(Predicate.isString, String.isNonEmpty);
40const 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
49export const resolveServerUrl = (options?: {
50 url?: string | undefined;
51 protocol?: "http" | "https" | "ws" | "wss" | undefined;
52 pathname?: string | undefined;
53 searchParams?: Record<string, string> | undefined;
54}): string => {
55 const rawUrl = firstNonEmptyString(
56 options?.url,
57 window.desktopBridge?.getWsUrl(),
58 import.meta.env.VITE_WS_URL,
59 window.location.origin,
60 );
61
62 const parsedUrl = new URL(rawUrl);
63 if (options?.protocol) {
64 parsedUrl.protocol = options.protocol;
65 }
66 if (options?.pathname) {
67 parsedUrl.pathname = options.pathname;
68 } else {
69 parsedUrl.pathname = "/";
70 }
71 if (options?.searchParams) {
72 parsedUrl.search = new URLSearchParams(options.searchParams).toString();
73 }
74 return parsedUrl.toString();
75};
apps/web/src/nativeApi.ts+6-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import type { NativeApi } from "@t3tools/contracts";1import type { NativeApi } from "@t3tools/contracts";
22
3import { createWsNativeApi } from "./wsNativeApi";3import { __resetWsNativeApiForTests, createWsNativeApi } from "./wsNativeApi";
44
5let cachedApi: NativeApi | undefined;5let cachedApi: NativeApi | undefined;
66
...@@ -24,3 +24,8 @@ export function ensureNativeApi(): NativeApi {...@@ -24,3 +24,8 @@ export function ensureNativeApi(): NativeApi {
24 }24 }
25 return api;25 return api;
26}26}
27
28export function __resetNativeApiForTests() {
29 cachedApi = undefined;
30 __resetWsNativeApiForTests();
31}
apps/web/src/router.ts+7-1
...@@ -2,6 +2,7 @@ import { createElement } from "react";...@@ -2,6 +2,7 @@ import { createElement } from "react";
2import { QueryClient, QueryClientProvider } from "@tanstack/react-query";2import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3import { createRouter, RouterHistory } from "@tanstack/react-router";3import { createRouter, RouterHistory } from "@tanstack/react-router";
44
5import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
5import { routeTree } from "./routeTree.gen";6import { routeTree } from "./routeTree.gen";
67
7export function getRouter(history: RouterHistory) {8export function getRouter(history: RouterHistory) {
...@@ -13,7 +14,12 @@ export function getRouter(history: RouterHistory) {...@@ -13,7 +14,12 @@ export function getRouter(history: RouterHistory) {
13 context: {14 context: {
14 queryClient,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}
1925
apps/web/src/routes/__root.tsx+123-102
...@@ -1,12 +1,16 @@...@@ -1,12 +1,16 @@
1import { OrchestrationEvent, ThreadId } from "@t3tools/contracts";1import {
2 OrchestrationEvent,
3 ThreadId,
4 type ServerLifecycleWelcomePayload,
5} from "@t3tools/contracts";
2import {6import {
3 Outlet,7 Outlet,
4 createRootRouteWithContext,8 createRootRouteWithContext,
5 type ErrorComponentProps,9 type ErrorComponentProps,
6 useNavigate,10 useNavigate,
7 useRouterState,11 useLocation,
8} from "@tanstack/react-router";12} from "@tanstack/react-router";
9import { useEffect, useRef } from "react";13import { useEffect, useEffectEvent, useRef } from "react";
10import { QueryClient, useQueryClient } from "@tanstack/react-query";14import { QueryClient, useQueryClient } from "@tanstack/react-query";
11import { Throttler } from "@tanstack/react-pacer";15import { Throttler } from "@tanstack/react-pacer";
1216
...@@ -15,8 +19,14 @@ import { AppSidebarLayout } from "../components/AppSidebarLayout";...@@ -15,8 +19,14 @@ import { AppSidebarLayout } from "../components/AppSidebarLayout";
15import { Button } from "../components/ui/button";19import { Button } from "../components/ui/button";
16import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";20import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";
17import { resolveAndPersistPreferredEditor } from "../editorPreferences";21import { resolveAndPersistPreferredEditor } from "../editorPreferences";
18import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
19import { readNativeApi } from "../nativeApi";22import { readNativeApi } from "../nativeApi";
23import {
24 type ServerConfigUpdateSource,
25 useServerConfig,
26 useServerConfigUpdatedSubscription,
27 useServerWelcomeSubscription,
28} from "../rpc/serverState";
29import { ServerStateBootstrap } from "../rpc/serverStateBootstrap";
20import {30import {
21 clearPromotedDraftThread,31 clearPromotedDraftThread,
22 clearPromotedDraftThreads,32 clearPromotedDraftThreads,
...@@ -26,7 +36,6 @@ import { useStore } from "../store";...@@ -26,7 +36,6 @@ import { useStore } from "../store";
26import { useUiStateStore } from "../uiStateStore";36import { useUiStateStore } from "../uiStateStore";
27import { useTerminalStateStore } from "../terminalStateStore";37import { useTerminalStateStore } from "../terminalStateStore";
28import { terminalRunningSubprocessFromEvent } from "../terminalActivity";38import { terminalRunningSubprocessFromEvent } from "../terminalActivity";
29import { onServerConfigUpdated, onServerProvidersUpdated, onServerWelcome } from "../wsNativeApi";
30import { migrateLocalSettingsToServer } from "../hooks/useSettings";39import { migrateLocalSettingsToServer } from "../hooks/useSettings";
31import { providerQueryKeys } from "../lib/providerReactQuery";40import { providerQueryKeys } from "../lib/providerReactQuery";
32import { projectQueryKeys } from "../lib/projectReactQuery";41import { projectQueryKeys } from "../lib/projectReactQuery";
...@@ -58,15 +67,18 @@ function RootRouteView() {...@@ -58,15 +67,18 @@ function RootRouteView() {
58 }67 }
5968
60 return (69 return (
61 <ToastProvider>70 <>
62 <AnchoredToastProvider>71 <ServerStateBootstrap />
63 <EventRouter />72 <ToastProvider>
64 <DesktopProjectBootstrap />73 <AnchoredToastProvider>
65 <AppSidebarLayout>74 <EventRouter />
66 <Outlet />75 <DesktopProjectBootstrap />
67 </AppSidebarLayout>76 <AppSidebarLayout>
68 </AnchoredToastProvider>77 <Outlet />
69 </ToastProvider>78 </AppSidebarLayout>
79 </AnchoredToastProvider>
80 </ToastProvider>
81 </>
70 );82 );
71}83}
7284
...@@ -154,16 +166,107 @@ function EventRouter() {...@@ -154,16 +166,107 @@ function EventRouter() {
154 );166 );
155 const queryClient = useQueryClient();167 const queryClient = useQueryClient();
156 const navigate = useNavigate();168 const navigate = useNavigate();
157 const pathname = useRouterState({ select: (state) => state.location.pathname });169 const pathname = useLocation({ select: (loc) => loc.pathname });
158 const pathnameRef = useRef(pathname);170 const pathnameRef = useRef(pathname);
159 const handledBootstrapThreadIdRef = useRef<string | null>(null);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();
160176
161 pathnameRef.current = pathname;177 pathnameRef.current = pathname;
162178
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 useEffect(() => {265 useEffect(() => {
164 const api = readNativeApi();266 const api = readNativeApi();
165 if (!api) return;267 if (!api) return;
166 let disposed = false;268 let disposed = false;
269 disposedRef.current = false;
167 const recovery = createOrchestrationRecoveryCoordinator();270 const recovery = createOrchestrationRecoveryCoordinator();
168 let needsProviderInvalidation = false;271 let needsProviderInvalidation = false;
169272
...@@ -299,11 +402,11 @@ function EventRouter() {...@@ -299,11 +402,11 @@ function EventRouter() {
299 const bootstrapFromSnapshot = async (): Promise<void> => {402 const bootstrapFromSnapshot = async (): Promise<void> => {
300 await runSnapshotRecovery("bootstrap");403 await runSnapshotRecovery("bootstrap");
301 };404 };
405 bootstrapFromSnapshotRef.current = bootstrapFromSnapshot;
302406
303 const fallbackToSnapshotRecovery = async (): Promise<void> => {407 const fallbackToSnapshotRecovery = async (): Promise<void> => {
304 await runSnapshotRecovery("replay-failed");408 await runSnapshotRecovery("replay-failed");
305 };409 };
306
307 const unsubDomainEvent = api.orchestration.onDomainEvent((event) => {410 const unsubDomainEvent = api.orchestration.onDomainEvent((event) => {
308 const action = recovery.classifyDomainEvent(event.sequence);411 const action = recovery.classifyDomainEvent(event.sequence);
309 if (action === "apply") {412 if (action === "apply") {
...@@ -327,98 +430,13 @@ function EventRouter() {...@@ -327,98 +430,13 @@ function EventRouter() {
327 hasRunningSubprocess,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 return () => {433 return () => {
414 disposed = true;434 disposed = true;
435 disposedRef.current = true;
415 needsProviderInvalidation = false;436 needsProviderInvalidation = false;
416 queryInvalidationThrottler.cancel();437 queryInvalidationThrottler.cancel();
417 unsubDomainEvent();438 unsubDomainEvent();
418 unsubTerminalEvent();439 unsubTerminalEvent();
419 unsubWelcome();
420 unsubServerConfigUpdated();
421 unsubProvidersUpdated();
422 };440 };
423 }, [441 }, [
424 applyOrchestrationEvents,442 applyOrchestrationEvents,
...@@ -433,6 +451,9 @@ function EventRouter() {...@@ -433,6 +451,9 @@ function EventRouter() {
433 syncThreads,451 syncThreads,
434 ]);452 ]);
435453
454 useServerWelcomeSubscription(handleWelcome);
455 useServerConfigUpdatedSubscription(handleServerConfigUpdated);
456
436 return null;457 return null;
437}458}
438459
apps/web/src/routes/_chat.tsx+2-7
...@@ -1,26 +1,21 @@...@@ -1,26 +1,21 @@
1import { type ResolvedKeybindingsConfig } from "@t3tools/contracts";
2import { useQuery } from "@tanstack/react-query";
3import { Outlet, createFileRoute } from "@tanstack/react-router";1import { Outlet, createFileRoute } from "@tanstack/react-router";
4import { useEffect } from "react";2import { useEffect } from "react";
53
6import { useHandleNewThread } from "../hooks/useHandleNewThread";4import { useHandleNewThread } from "../hooks/useHandleNewThread";
7import { isTerminalFocused } from "../lib/terminalFocus";5import { isTerminalFocused } from "../lib/terminalFocus";
8import { serverConfigQueryOptions } from "../lib/serverReactQuery";
9import { resolveShortcutCommand } from "../keybindings";6import { resolveShortcutCommand } from "../keybindings";
10import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";7import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
11import { useThreadSelectionStore } from "../threadSelectionStore";8import { useThreadSelectionStore } from "../threadSelectionStore";
12import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";9import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";
13import { useSettings } from "~/hooks/useSettings";10import { useSettings } from "~/hooks/useSettings";
1411import { useServerKeybindings } from "~/rpc/serverState";
15const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
1612
17function ChatRouteGlobalShortcuts() {13function ChatRouteGlobalShortcuts() {
18 const clearSelection = useThreadSelectionStore((state) => state.clearSelection);14 const clearSelection = useThreadSelectionStore((state) => state.clearSelection);
19 const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size);15 const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size);
20 const { activeDraftThread, activeThread, defaultProjectId, handleNewThread, routeThreadId } =16 const { activeDraftThread, activeThread, defaultProjectId, handleNewThread, routeThreadId } =
21 useHandleNewThread();17 useHandleNewThread();
22 const serverConfigQuery = useQuery(serverConfigQueryOptions());18 const keybindings = useServerKeybindings();
23 const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS;
24 const terminalOpen = useTerminalStateStore((state) =>19 const terminalOpen = useTerminalStateStore((state) =>
25 routeThreadId20 routeThreadId
26 ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen21 ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen
apps/web/src/rpc/atomRegistry.tsx created+14
...@@ -0,0 +1,14 @@
1import { RegistryContext } from "@effect/atom-react";
2import { AtomRegistry } from "effect/unstable/reactivity";
3import type { ReactNode } from "react";
4
5export let appAtomRegistry = AtomRegistry.make();
6
7export function AppAtomRegistryProvider({ children }: { readonly children: ReactNode }) {
8 return <RegistryContext.Provider value={appAtomRegistry}>{children}</RegistryContext.Provider>;
9}
10
11export function resetAppAtomRegistryForTests() {
12 appAtomRegistry.dispose();
13 appAtomRegistry = AtomRegistry.make();
14}
apps/web/src/rpc/client.test.ts created+231
...@@ -0,0 +1,231 @@
1import { DEFAULT_SERVER_SETTINGS, WS_METHODS } from "@t3tools/contracts";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3import { AsyncResult, AtomRegistry } from "effect/unstable/reactivity";
4
5import { __resetWsRpcAtomClientForTests, runRpc, WsRpcAtomClient } from "./client";
6
7type WsEventType = "open" | "message" | "close" | "error";
8type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string };
9type WsListener = (event?: WsEvent) => void;
10
11const sockets: MockWebSocket[] = [];
12
13class 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
68const originalWebSocket = globalThis.WebSocket;
69
70function 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
78async 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
93beforeEach(() => {
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
112afterEach(() => {
113 __resetWsRpcAtomClientForTests();
114 globalThis.WebSocket = originalWebSocket;
115 vi.restoreAllMocks();
116});
117
118describe("WsRpcAtomClient", () => {
119 it("runs unary requests through the AtomRpc service", async () => {
120 const expectedSettings = {
121 ...DEFAULT_SERVER_SETTINGS,
122 enableAssistantStreaming: true,
123 defaultThreadEnvMode: "worktree" as const,
124 textGenerationModelSelection: {
125 provider: "codex" as const,
126 model: "gpt-5.4",
127 },
128 providers: {
129 codex: {
130 ...DEFAULT_SERVER_SETTINGS.providers.codex,
131 homePath: "/tmp/codex-home",
132 },
133 claudeAgent: {
134 ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent,
135 enabled: false,
136 },
137 },
138 };
139 const requestPromise = runRpc((client) => client(WS_METHODS.serverGetSettings, {}));
140
141 await waitFor(() => {
142 expect(sockets).toHaveLength(1);
143 });
144
145 const socket = getSocket();
146 socket.open();
147
148 await waitFor(() => {
149 expect(socket.sent).toHaveLength(1);
150 });
151
152 const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string };
153 expect(requestMessage.tag).toBe(WS_METHODS.serverGetSettings);
154
155 socket.serverMessage(
156 JSON.stringify({
157 _tag: "Exit",
158 requestId: requestMessage.id,
159 exit: {
160 _tag: "Success",
161 value: expectedSettings,
162 },
163 }),
164 );
165
166 await expect(requestPromise).resolves.toEqual(expectedSettings);
167 });
168
169 it("exposes atom-backed query state for unary RPC methods", async () => {
170 const expectedSettings = {
171 ...DEFAULT_SERVER_SETTINGS,
172 enableAssistantStreaming: true,
173 defaultThreadEnvMode: "worktree" as const,
174 textGenerationModelSelection: {
175 provider: "codex" as const,
176 model: "gpt-5.4",
177 },
178 providers: {
179 codex: {
180 ...DEFAULT_SERVER_SETTINGS.providers.codex,
181 homePath: "/tmp/codex-home",
182 },
183 claudeAgent: {
184 ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent,
185 enabled: false,
186 },
187 },
188 };
189 const registry = AtomRegistry.make();
190 const query = WsRpcAtomClient.query(WS_METHODS.serverGetSettings, {});
191 const release = registry.mount(query);
192
193 await waitFor(() => {
194 expect(sockets).toHaveLength(1);
195 });
196
197 const socket = getSocket();
198 socket.open();
199
200 await waitFor(() => {
201 expect(socket.sent).toHaveLength(1);
202 });
203
204 const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string };
205 expect(requestMessage.tag).toBe(WS_METHODS.serverGetSettings);
206 expect(registry.get(query)._tag).toBe("Initial");
207
208 socket.serverMessage(
209 JSON.stringify({
210 _tag: "Exit",
211 requestId: requestMessage.id,
212 exit: {
213 _tag: "Success",
214 value: expectedSettings,
215 },
216 }),
217 );
218
219 await waitFor(() => {
220 const result = registry.get(query);
221 expect(AsyncResult.isSuccess(result)).toBe(true);
222 if (!AsyncResult.isSuccess(result)) {
223 return;
224 }
225 expect(result.value).toEqual(expectedSettings);
226 });
227
228 release();
229 registry.dispose();
230 });
231});
apps/web/src/rpc/client.ts created+33
...@@ -0,0 +1,33 @@
1import { WsRpcGroup } from "@t3tools/contracts";
2import { Effect, Layer, ManagedRuntime } from "effect";
3import { AtomRpc } from "effect/unstable/reactivity";
4
5import { createWsRpcProtocolLayer } from "./protocol";
6
7export class WsRpcAtomClient extends AtomRpc.Service<WsRpcAtomClient>()("WsRpcAtomClient", {
8 group: WsRpcGroup,
9 protocol: Layer.suspend(() => createWsRpcProtocolLayer()),
10}) {}
11
12let sharedRuntime: ManagedRuntime.ManagedRuntime<WsRpcAtomClient, never> | null = null;
13
14function getRuntime() {
15 if (sharedRuntime !== null) {
16 return sharedRuntime;
17 }
18
19 sharedRuntime = ManagedRuntime.make(WsRpcAtomClient.layer);
20 return sharedRuntime;
21}
22
23export 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
29export async function __resetWsRpcAtomClientForTests() {
30 const runtime = sharedRuntime;
31 sharedRuntime = null;
32 await runtime?.dispose();
33}
apps/web/src/rpc/protocol.ts created+27
...@@ -0,0 +1,27 @@
1import { WsRpcGroup } from "@t3tools/contracts";
2import { Effect, Layer } from "effect";
3import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
4import * as Socket from "effect/unstable/socket/Socket";
5
6import { resolveServerUrl } from "../lib/utils";
7
8export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup);
9
10type RpcClientFactory = typeof makeWsRpcProtocolClient;
11export type WsRpcProtocolClient =
12 RpcClientFactory extends Effect.Effect<infer Client, any, any> ? Client : never;
13
14export function createWsRpcProtocolLayer(url?: string) {
15 const resolvedUrl = resolveServerUrl({
16 url,
17 protocol: window.location.protocol === "https:" ? "wss" : "ws",
18 pathname: "/ws",
19 });
20 const socketLayer = Socket.layerWebSocket(resolvedUrl).pipe(
21 Layer.provide(Socket.layerWebSocketConstructorGlobal),
22 );
23
24 return RpcClient.layerProtocolSocket({ retryTransientErrors: true }).pipe(
25 Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson)),
26 );
27}
apps/web/src/rpc/serverState.test.ts created+311
...@@ -0,0 +1,311 @@
1import {
2 DEFAULT_SERVER_SETTINGS,
3 ProjectId,
4 ThreadId,
5 type ServerConfig,
6 type ServerConfigStreamEvent,
7 type ServerLifecycleStreamEvent,
8 type ServerProvider,
9} from "@t3tools/contracts";
10import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
12import {
13 getServerConfig,
14 onProvidersUpdated,
15 onServerConfigUpdated,
16 onWelcome,
17 resetServerStateForTests,
18 startServerStateSync,
19} from "./serverState";
20
21function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) {
22 listeners.add(listener);
23 return () => {
24 listeners.delete(listener);
25 };
26}
27
28function 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
37const lifecycleListeners = new Set<(event: ServerLifecycleStreamEvent) => void>();
38const configListeners = new Set<(event: ServerConfigStreamEvent) => void>();
39
40const 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
53const 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
63const 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
73function emitLifecycleEvent(event: ServerLifecycleStreamEvent) {
74 for (const listener of lifecycleListeners) {
75 listener(event);
76 }
77}
78
79function emitServerConfigEvent(event: ServerConfigStreamEvent) {
80 for (const listener of configListeners) {
81 listener(event);
82 }
83}
84
85async 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
100beforeEach(() => {
101 vi.clearAllMocks();
102 lifecycleListeners.clear();
103 configListeners.clear();
104 resetServerStateForTests();
105});
106
107afterEach(() => {
108 resetServerStateForTests();
109});
110
111describe("serverState", () => {
112 it("bootstraps the server config snapshot and replays it to late subscribers", async () => {
113 serverApi.getConfig.mockResolvedValueOnce(baseServerConfig);
114
115 const configListener = vi.fn();
116 const stop = startServerStateSync(serverApi);
117 const unsubscribe = onServerConfigUpdated(configListener);
118
119 await waitFor(() => {
120 expect(getServerConfig()).toEqual(baseServerConfig);
121 });
122
123 expect(serverApi.subscribeConfig).toHaveBeenCalledOnce();
124 expect(serverApi.subscribeLifecycle).toHaveBeenCalledOnce();
125 expect(serverApi.getConfig).toHaveBeenCalledOnce();
126 expect(configListener).toHaveBeenCalledWith(
127 {
128 issues: [],
129 providers: defaultProviders,
130 settings: DEFAULT_SERVER_SETTINGS,
131 },
132 "snapshot",
133 );
134
135 const lateListener = vi.fn();
136 const unsubscribeLate = onServerConfigUpdated(lateListener);
137 expect(lateListener).toHaveBeenCalledWith(
138 {
139 issues: [],
140 providers: defaultProviders,
141 settings: DEFAULT_SERVER_SETTINGS,
142 },
143 "snapshot",
144 );
145
146 unsubscribeLate();
147 unsubscribe();
148 stop();
149 });
150
151 it("keeps the streamed snapshot when it arrives before the fallback fetch resolves", async () => {
152 const deferred = createDeferredPromise<ServerConfig>();
153 serverApi.getConfig.mockReturnValueOnce(deferred.promise);
154 const stop = startServerStateSync(serverApi);
155
156 const streamedConfig: ServerConfig = {
157 ...baseServerConfig,
158 cwd: "/tmp/from-stream",
159 };
160
161 emitServerConfigEvent({
162 version: 1,
163 type: "snapshot",
164 config: streamedConfig,
165 });
166
167 await waitFor(() => {
168 expect(getServerConfig()).toEqual(streamedConfig);
169 });
170
171 deferred.resolve(baseServerConfig);
172 await new Promise((resolve) => setTimeout(resolve, 0));
173
174 expect(getServerConfig()).toEqual(streamedConfig);
175 stop();
176 });
177
178 it("replays welcome events to late subscribers", async () => {
179 serverApi.getConfig.mockResolvedValueOnce(baseServerConfig);
180 const stop = startServerStateSync(serverApi);
181
182 const listener = vi.fn();
183 const unsubscribe = onWelcome(listener);
184
185 emitLifecycleEvent({
186 version: 1,
187 sequence: 1,
188 type: "welcome",
189 payload: {
190 cwd: "/tmp/workspace",
191 projectName: "t3-code",
192 bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
193 bootstrapThreadId: ThreadId.makeUnsafe("thread-1"),
194 },
195 });
196
197 expect(listener).toHaveBeenCalledWith({
198 cwd: "/tmp/workspace",
199 projectName: "t3-code",
200 bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
201 bootstrapThreadId: ThreadId.makeUnsafe("thread-1"),
202 });
203
204 const lateListener = vi.fn();
205 const unsubscribeLate = onWelcome(lateListener);
206 expect(lateListener).toHaveBeenCalledWith({
207 cwd: "/tmp/workspace",
208 projectName: "t3-code",
209 bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
210 bootstrapThreadId: ThreadId.makeUnsafe("thread-1"),
211 });
212
213 unsubscribeLate();
214 unsubscribe();
215 stop();
216 });
217
218 it("merges provider, settings, and keybinding updates into the cached config", async () => {
219 serverApi.getConfig.mockResolvedValueOnce(baseServerConfig);
220 const configListener = vi.fn();
221 const providersListener = vi.fn();
222 const stop = startServerStateSync(serverApi);
223 const unsubscribeConfig = onServerConfigUpdated(configListener);
224 const unsubscribeProviders = onProvidersUpdated(providersListener);
225
226 await waitFor(() => {
227 expect(getServerConfig()).toEqual(baseServerConfig);
228 });
229
230 const nextProviders: ReadonlyArray<ServerProvider> = [
231 {
232 ...defaultProviders[0]!,
233 status: "warning",
234 checkedAt: "2026-01-02T00:00:00.000Z",
235 message: "rate limited",
236 },
237 ];
238
239 emitServerConfigEvent({
240 version: 1,
241 type: "keybindingsUpdated",
242 payload: {
243 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
244 },
245 });
246 emitServerConfigEvent({
247 version: 1,
248 type: "providerStatuses",
249 payload: {
250 providers: nextProviders,
251 },
252 });
253 emitServerConfigEvent({
254 version: 1,
255 type: "settingsUpdated",
256 payload: {
257 settings: {
258 ...DEFAULT_SERVER_SETTINGS,
259 enableAssistantStreaming: true,
260 },
261 },
262 });
263
264 await waitFor(() => {
265 expect(getServerConfig()).toEqual({
266 ...baseServerConfig,
267 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
268 providers: nextProviders,
269 settings: {
270 ...DEFAULT_SERVER_SETTINGS,
271 enableAssistantStreaming: true,
272 },
273 });
274 });
275
276 expect(providersListener).toHaveBeenLastCalledWith({ providers: nextProviders });
277 expect(configListener).toHaveBeenNthCalledWith(
278 2,
279 {
280 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
281 providers: defaultProviders,
282 settings: DEFAULT_SERVER_SETTINGS,
283 },
284 "keybindingsUpdated",
285 );
286 expect(configListener).toHaveBeenNthCalledWith(
287 3,
288 {
289 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
290 providers: nextProviders,
291 settings: DEFAULT_SERVER_SETTINGS,
292 },
293 "providerStatuses",
294 );
295 expect(configListener).toHaveBeenLastCalledWith(
296 {
297 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
298 providers: nextProviders,
299 settings: {
300 ...DEFAULT_SERVER_SETTINGS,
301 enableAssistantStreaming: true,
302 },
303 },
304 "settingsUpdated",
305 );
306
307 unsubscribeProviders();
308 unsubscribeConfig();
309 stop();
310 });
311});
apps/web/src/rpc/serverState.ts created+280
...@@ -0,0 +1,280 @@
1import { useAtomSubscribe, useAtomValue } from "@effect/atom-react";
2import {
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";
13import { Atom } from "effect/unstable/reactivity";
14
15import type { WsRpcClient } from "../wsRpcClient";
16import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry";
17
18export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"];
19
20export interface ServerConfigUpdatedNotification {
21 readonly payload: ServerConfigUpdatedPayload;
22 readonly source: ServerConfigUpdateSource;
23}
24
25type ServerStateClient = Pick<
26 WsRpcClient["server"],
27 "getConfig" | "subscribeConfig" | "subscribeLifecycle"
28>;
29
30function makeStateAtom<A>(label: string, initialValue: A) {
31 return Atom.make(initialValue).pipe(Atom.keepAlive, Atom.withLabel(label));
32}
33
34function toServerConfigUpdatedPayload(config: ServerConfig): ServerConfigUpdatedPayload {
35 return {
36 issues: config.issues,
37 providers: config.providers,
38 settings: config.settings,
39 };
40}
41
42const EMPTY_AVAILABLE_EDITORS: ReadonlyArray<EditorId> = [];
43const EMPTY_KEYBINDINGS: ServerConfig["keybindings"] = [];
44const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = [];
45
46const selectAvailableEditors = (config: ServerConfig | null): ReadonlyArray<EditorId> =>
47 config?.availableEditors ?? EMPTY_AVAILABLE_EDITORS;
48const selectKeybindings = (config: ServerConfig | null) => config?.keybindings ?? EMPTY_KEYBINDINGS;
49const selectKeybindingsConfigPath = (config: ServerConfig | null) =>
50 config?.keybindingsConfigPath ?? null;
51const selectProviders = (config: ServerConfig | null) =>
52 config?.providers ?? EMPTY_SERVER_PROVIDERS;
53const selectSettings = (config: ServerConfig | null): ServerSettings =>
54 config?.settings ?? DEFAULT_SERVER_SETTINGS;
55
56export const welcomeAtom = makeStateAtom<ServerLifecycleWelcomePayload | null>(
57 "server-welcome",
58 null,
59);
60export const serverConfigAtom = makeStateAtom<ServerConfig | null>("server-config", null);
61export const serverConfigUpdatedAtom = makeStateAtom<ServerConfigUpdatedNotification | null>(
62 "server-config-updated",
63 null,
64);
65export const providersUpdatedAtom = makeStateAtom<ServerProviderUpdatedPayload | null>(
66 "server-providers-updated",
67 null,
68);
69
70export function getServerConfig(): ServerConfig | null {
71 return appAtomRegistry.get(serverConfigAtom);
72}
73
74export function setServerConfigSnapshot(config: ServerConfig): void {
75 resolveServerConfig(config);
76 emitProvidersUpdated({ providers: config.providers });
77 emitServerConfigUpdated(toServerConfigUpdatedPayload(config), "snapshot");
78}
79
80export 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
110export 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
126export 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
140export function emitWelcome(payload: ServerLifecycleWelcomePayload): void {
141 appAtomRegistry.set(welcomeAtom, payload);
142}
143
144export function onWelcome(listener: (payload: ServerLifecycleWelcomePayload) => void): () => void {
145 return subscribeLatest(welcomeAtom, listener);
146}
147
148export 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
156export function onProvidersUpdated(
157 listener: (payload: ServerProviderUpdatedPayload) => void,
158): () => void {
159 return subscribeLatest(providersUpdatedAtom, listener);
160}
161
162export 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
195export function resetServerStateForTests() {
196 resetAppAtomRegistryForTests();
197}
198
199function resolveServerConfig(config: ServerConfig): void {
200 appAtomRegistry.set(serverConfigAtom, config);
201}
202
203function emitProvidersUpdated(payload: ServerProviderUpdatedPayload): void {
204 appAtomRegistry.set(providersUpdatedAtom, payload);
205}
206
207function emitServerConfigUpdated(
208 payload: ServerConfigUpdatedPayload,
209 source: ServerConfigUpdateSource,
210): void {
211 appAtomRegistry.set(serverConfigUpdatedAtom, { payload, source });
212}
213
214function 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
230function 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
246export function useServerConfig(): ServerConfig | null {
247 return useAtomValue(serverConfigAtom);
248}
249
250export function useServerSettings(): ServerSettings {
251 return useAtomValue(serverConfigAtom, selectSettings);
252}
253
254export function useServerProviders(): ReadonlyArray<ServerProvider> {
255 return useAtomValue(serverConfigAtom, selectProviders);
256}
257
258export function useServerKeybindings(): ServerConfig["keybindings"] {
259 return useAtomValue(serverConfigAtom, selectKeybindings);
260}
261
262export function useServerAvailableEditors(): ReadonlyArray<EditorId> {
263 return useAtomValue(serverConfigAtom, selectAvailableEditors);
264}
265
266export function useServerKeybindingsConfigPath(): string | null {
267 return useAtomValue(serverConfigAtom, selectKeybindingsConfigPath);
268}
269
270export function useServerWelcomeSubscription(
271 listener: (payload: ServerLifecycleWelcomePayload) => void,
272): void {
273 useLatestAtomSubscription(welcomeAtom, listener);
274}
275
276export function useServerConfigUpdatedSubscription(
277 listener: (notification: ServerConfigUpdatedNotification) => void,
278): void {
279 useLatestAtomSubscription(serverConfigUpdatedAtom, listener);
280}
apps/web/src/rpc/serverStateBootstrap.tsx created+10
...@@ -0,0 +1,10 @@
1import { useEffect } from "react";
2
3import { getWsRpcClient } from "../wsRpcClient";
4import { startServerStateSync } from "./serverState";
5
6export function ServerStateBootstrap() {
7 useEffect(() => startServerStateSync(getWsRpcClient().server), []);
8
9 return null;
10}
apps/web/src/wsNativeApi.test.ts+178-280
...@@ -1,23 +1,19 @@...@@ -1,23 +1,19 @@
1import {1import {
2 CommandId,2 CommandId,
3 type ContextMenuItem,3 DEFAULT_SERVER_SETTINGS,
4 type DesktopBridge,
4 EventId,5 EventId,
5 ORCHESTRATION_WS_CHANNELS,
6 ORCHESTRATION_WS_METHODS,
7 type OrchestrationEvent,
8 ProjectId,6 ProjectId,
9 ThreadId,7 type OrchestrationEvent,
10 type WsPushChannel,8 type ServerConfig,
11 type WsPushData,
12 type WsPushMessage,
13 WS_CHANNELS,
14 WS_METHODS,
15 type WsPush,
16 type ServerProvider,9 type ServerProvider,
10 type TerminalEvent,
11 ThreadId,
17} from "@t3tools/contracts";12} from "@t3tools/contracts";
18import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";13import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1914
20const requestMock = vi.fn<(...args: Array<unknown>) => Promise<unknown>>();15import type { ContextMenuItem } from "@t3tools/contracts";
16
21const showContextMenuFallbackMock =17const showContextMenuFallbackMock =
22 vi.fn<18 vi.fn<
23 <T extends string>(19 <T extends string>(
...@@ -25,39 +21,75 @@ const showContextMenuFallbackMock =...@@ -25,39 +21,75 @@ const showContextMenuFallbackMock =
25 position?: { x: number; y: number },21 position?: { x: number; y: number },
26 ) => Promise<T | null>22 ) => Promise<T | null>
27 >();23 >();
28const channelListeners = new Map<string, Set<(message: WsPush) => void>>();24
29const latestPushByChannel = new Map<string, WsPush>();25function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) {
30const 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>();
38 listeners.add(listener);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 return () => {27 return () => {
45 listeners.delete(listener);28 listeners.delete(listener);
46 if (listeners.size === 0) {
47 channelListeners.delete(channel);
48 }
49 };29 };
50});30}
5131
52vi.mock("./wsTransport", () => {32const terminalEventListeners = new Set<(event: TerminalEvent) => void>();
33const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>();
34
35const 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
89vi.mock("./wsRpcClient", () => {
53 return {90 return {
54 WsTransport: class MockWsTransport {91 getWsRpcClient: () => rpcClientMock,
55 request = requestMock;92 __resetWsRpcClientForTests: vi.fn(),
56 subscribe = subscribeMock;
57 getLatestPush(channel: string) {
58 return latestPushByChannel.get(channel) ?? null;
59 }
60 },
61 };93 };
62});94});
6395
...@@ -65,20 +97,9 @@ vi.mock("./contextMenuFallback", () => ({...@@ -65,20 +97,9 @@ vi.mock("./contextMenuFallback", () => ({
65 showContextMenuFallback: showContextMenuFallbackMock,97 showContextMenuFallback: showContextMenuFallbackMock,
66}));98}));
6799
68let nextPushSequence = 1;100function emitEvent<T>(listeners: Set<(event: T) => void>, event: T) {
69
70function 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;
80 for (const listener of listeners) {101 for (const listener of listeners) {
81 listener(message);102 listener(event);
82 }103 }
83}104}
84105
...@@ -92,6 +113,32 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn...@@ -92,6 +113,32 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn
92 return testGlobal.window;113 return testGlobal.window;
93}114}
94115
116function 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
95const defaultProviders: ReadonlyArray<ServerProvider> = [142const defaultProviders: ReadonlyArray<ServerProvider> = [
96 {143 {
97 provider: "codex",144 provider: "codex",
...@@ -105,14 +152,22 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [...@@ -105,14 +152,22 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [
105 },152 },
106];153];
107154
155const 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
108beforeEach(() => {165beforeEach(() => {
109 vi.resetModules();166 vi.resetModules();
110 requestMock.mockReset();167 vi.clearAllMocks();
111 showContextMenuFallbackMock.mockReset();168 showContextMenuFallbackMock.mockReset();
112 subscribeMock.mockClear();169 terminalEventListeners.clear();
113 channelListeners.clear();170 orchestrationEventListeners.clear();
114 latestPushByChannel.clear();
115 nextPushSequence = 1;
116 Reflect.deleteProperty(getWindowForTest(), "desktopBridge");171 Reflect.deleteProperty(getWindowForTest(), "desktopBridge");
117});172});
118173
...@@ -121,149 +176,27 @@ afterEach(() => {...@@ -121,149 +176,27 @@ afterEach(() => {
121});176});
122177
123describe("wsNativeApi", () => {178describe("wsNativeApi", () => {
124 it("delivers and caches valid server.welcome payloads", async () => {179 it("forwards server config fetches directly to the RPC client", async () => {
125 const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi");180 rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig);
126181 const { createWsNativeApi } = await import("./wsNativeApi");
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);
246182
247 expect(listener).toHaveBeenCalledTimes(1);183 const api = createWsNativeApi();
248 expect(listener).toHaveBeenCalledWith(payload);
249184
250 const lateListener = vi.fn();185 await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig);
251 onServerProvidersUpdated(lateListener);186 expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith();
252 expect(lateListener).toHaveBeenCalledTimes(1);187 expect(rpcClientMock.server.subscribeConfig).not.toHaveBeenCalled();
253 expect(lateListener).toHaveBeenCalledWith(payload);188 expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled();
254 });189 });
255190
256 it("forwards valid terminal and orchestration events", async () => {191 it("forwards terminal and orchestration stream events", async () => {
257 const { createWsNativeApi } = await import("./wsNativeApi");192 const { createWsNativeApi } = await import("./wsNativeApi");
258193
259 const api = createWsNativeApi();194 const api = createWsNativeApi();
260 const onTerminalEvent = vi.fn();195 const onTerminalEvent = vi.fn();
261 const onDomainEvent = vi.fn();196 const onDomainEvent = vi.fn();
262 const onActionProgress = vi.fn();
263197
264 api.terminal.onEvent(onTerminalEvent);198 api.terminal.onEvent(onTerminalEvent);
265 api.orchestration.onDomainEvent(onDomainEvent);199 api.orchestration.onDomainEvent(onDomainEvent);
266 api.git.onActionProgress(onActionProgress);
267200
268 const terminalEvent = {201 const terminalEvent = {
269 threadId: "thread-1",202 threadId: "thread-1",
...@@ -272,7 +205,7 @@ describe("wsNativeApi", () => {...@@ -272,7 +205,7 @@ describe("wsNativeApi", () => {
272 type: "output",205 type: "output",
273 data: "hello",206 data: "hello",
274 } as const;207 } as const;
275 emitPush(WS_CHANNELS.terminalEvent, terminalEvent);208 emitEvent(terminalEventListeners, terminalEvent);
276209
277 const orchestrationEvent = {210 const orchestrationEvent = {
278 sequence: 1,211 sequence: 1,
...@@ -289,39 +222,23 @@ describe("wsNativeApi", () => {...@@ -289,39 +222,23 @@ describe("wsNativeApi", () => {
289 projectId: ProjectId.makeUnsafe("project-1"),222 projectId: ProjectId.makeUnsafe("project-1"),
290 title: "Project",223 title: "Project",
291 workspaceRoot: "/tmp/workspace",224 workspaceRoot: "/tmp/workspace",
292 defaultModelSelection: null,225 defaultModelSelection: {
226 provider: "codex",
227 model: "gpt-5-codex",
228 },
293 scripts: [],229 scripts: [],
294 createdAt: "2026-02-24T00:00:00.000Z",230 createdAt: "2026-02-24T00:00:00.000Z",
295 updatedAt: "2026-02-24T00:00:00.000Z",231 updatedAt: "2026-02-24T00:00:00.000Z",
296 },232 },
297 } satisfies Extract<OrchestrationEvent, { type: "project.created" }>;233 } satisfies Extract<OrchestrationEvent, { type: "project.created" }>;
298 emitPush(ORCHESTRATION_WS_CHANNELS.domainEvent, orchestrationEvent);234 emitEvent(orchestrationEventListeners, 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 });
307235
308 expect(onTerminalEvent).toHaveBeenCalledTimes(1);
309 expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent);236 expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent);
310 expect(onDomainEvent).toHaveBeenCalledTimes(1);
311 expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent);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 });
322239
323 it("wraps orchestration dispatch commands in the command envelope", async () => {240 it("sends orchestration dispatch commands as the direct RPC payload", async () => {
324 requestMock.mockResolvedValue(undefined);241 rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 });
325 const { createWsNativeApi } = await import("./wsNativeApi");242 const { createWsNativeApi } = await import("./wsNativeApi");
326243
327 const api = createWsNativeApi();244 const api = createWsNativeApi();
...@@ -339,13 +256,11 @@ describe("wsNativeApi", () => {...@@ -339,13 +256,11 @@ describe("wsNativeApi", () => {
339 } as const;256 } as const;
340 await api.orchestration.dispatchCommand(command);257 await api.orchestration.dispatchCommand(command);
341258
342 expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.dispatchCommand, {259 expect(rpcClientMock.orchestration.dispatchCommand).toHaveBeenCalledWith(command);
343 command,
344 });
345 });260 });
346261
347 it("forwards workspace file writes to the websocket project method", async () => {262 it("forwards workspace file writes to the project RPC", async () => {
348 requestMock.mockResolvedValue({ relativePath: "plan.md" });263 rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" });
349 const { createWsNativeApi } = await import("./wsNativeApi");264 const { createWsNativeApi } = await import("./wsNativeApi");
350265
351 const api = createWsNativeApi();266 const api = createWsNativeApi();
...@@ -355,100 +270,83 @@ describe("wsNativeApi", () => {...@@ -355,100 +270,83 @@ describe("wsNativeApi", () => {
355 contents: "# Plan\n",270 contents: "# Plan\n",
356 });271 });
357272
358 expect(requestMock).toHaveBeenCalledWith(WS_METHODS.projectsWriteFile, {273 expect(rpcClientMock.projects.writeFile).toHaveBeenCalledWith({
359 cwd: "/tmp/project",274 cwd: "/tmp/project",
360 relativePath: "plan.md",275 relativePath: "plan.md",
361 contents: "# Plan\n",276 contents: "# Plan\n",
362 });277 });
363 });278 });
364279
365 it("uses no client timeout for git.runStackedAction", async () => {280 it("forwards full-thread diff requests to the orchestration RPC", async () => {
366 requestMock.mockResolvedValue({281 rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" });
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 });
373 const { createWsNativeApi } = await import("./wsNativeApi");282 const { createWsNativeApi } = await import("./wsNativeApi");
374283
375 const api = createWsNativeApi();284 const api = createWsNativeApi();
376 await api.git.runStackedAction({285 await api.orchestration.getFullThreadDiff({
377 actionId: "action-1",286 threadId: ThreadId.makeUnsafe("thread-1"),
378 cwd: "/repo",287 toTurnCount: 1,
379 action: "commit",
380 });288 });
381289
382 expect(requestMock).toHaveBeenCalledWith(290 expect(rpcClientMock.orchestration.getFullThreadDiff).toHaveBeenCalledWith({
383 WS_METHODS.gitRunStackedAction,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",299 ...defaultProviders[0]!,
386 cwd: "/repo",300 checkedAt: "2026-01-03T00:00:00.000Z",
387 action: "commit",
388 },301 },
389 { timeoutMs: null },302 ];
390 );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 });
392311
393 it("forwards full-thread diff requests to the orchestration websocket method", async () => {312 it("forwards server settings updates directly to the RPC client", async () => {
394 requestMock.mockResolvedValue({ diff: "patch" });313 const nextSettings = {
314 ...DEFAULT_SERVER_SETTINGS,
315 enableAssistantStreaming: true,
316 };
317 rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings);
395 const { createWsNativeApi } = await import("./wsNativeApi");318 const { createWsNativeApi } = await import("./wsNativeApi");
396319
397 const api = createWsNativeApi();320 const api = createWsNativeApi();
398 await api.orchestration.getFullThreadDiff({
399 threadId: ThreadId.makeUnsafe("thread-1"),
400 toTurnCount: 1,
401 });
402321
403 expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.getFullThreadDiff, {322 await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual(
404 threadId: "thread-1",323 nextSettings,
405 toTurnCount: 1,324 );
325 expect(rpcClientMock.server.updateSettings).toHaveBeenCalledWith({
326 enableAssistantStreaming: true,
406 });327 });
407 });328 });
408329
409 it("forwards context menu metadata to desktop bridge", async () => {330 it("forwards context menu metadata to the desktop bridge", async () => {
410 const showContextMenu = vi.fn().mockResolvedValue("delete");331 const showContextMenu = vi.fn().mockResolvedValue("delete");
411 Object.defineProperty(getWindowForTest(), "desktopBridge", {332 getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu });
412 configurable: true,
413 writable: true,
414 value: {
415 showContextMenu,
416 },
417 });
418333
419 const { createWsNativeApi } = await import("./wsNativeApi");334 const { createWsNativeApi } = await import("./wsNativeApi");
420 const api = createWsNativeApi();335 const api = createWsNativeApi();
421 await api.contextMenu.show(336 const items = [{ id: "delete", label: "Delete" }] as const;
422 [
423 { id: "rename", label: "Rename thread" },
424 { id: "delete", label: "Delete", destructive: true },
425 ],
426 { x: 200, y: 300 },
427 );
428337
429 expect(showContextMenu).toHaveBeenCalledWith(338 await expect(api.contextMenu.show(items)).resolves.toBe("delete");
430 [339 expect(showContextMenu).toHaveBeenCalledWith(items, undefined);
431 { id: "rename", label: "Rename thread" },
432 { id: "delete", label: "Delete", destructive: true },
433 ],
434 { x: 200, y: 300 },
435 );
436 });340 });
437341
438 it("uses fallback context menu when desktop bridge is unavailable", async () => {342 it("falls back to the browser context menu helper when the desktop bridge is missing", async () => {
439 showContextMenuFallbackMock.mockResolvedValue("delete");343 showContextMenuFallbackMock.mockResolvedValue("rename");
440 Reflect.deleteProperty(getWindowForTest(), "desktopBridge");
441
442 const { createWsNativeApi } = await import("./wsNativeApi");344 const { createWsNativeApi } = await import("./wsNativeApi");
345
443 const api = createWsNativeApi();346 const api = createWsNativeApi();
444 await api.contextMenu.show([{ id: "delete", label: "Delete", destructive: true }], {347 const items = [{ id: "rename", label: "Rename" }] as const;
445 x: 20,
446 y: 30,
447 });
448348
449 expect(showContextMenuFallbackMock).toHaveBeenCalledWith(349 await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename");
450 [{ id: "delete", label: "Delete", destructive: true }],350 expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 });
451 { x: 20, y: 30 },
452 );
453 });351 });
454});352});
apps/web/src/wsNativeApi.ts+46-175
...@@ -1,136 +1,23 @@...@@ -1,136 +1,23 @@
1import {1import { type ContextMenuItem, type NativeApi } from "@t3tools/contracts";
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";
132
14import { showContextMenuFallback } from "./contextMenuFallback";3import { showContextMenuFallback } from "./contextMenuFallback";
15import { WsTransport } from "./wsTransport";4import { resetServerStateForTests } from "./rpc/serverState";
5import { __resetWsRpcClientForTests, getWsRpcClient } from "./wsRpcClient";
166
17let instance: { api: NativeApi; transport: WsTransport } | null = null;7let instance: { api: NativeApi } | null = null;
18const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();
19const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>();
20const providersUpdatedListeners = new Set<(payload: ServerProviderUpdatedPayload) => void>();
21const gitActionProgressListeners = new Set<(payload: GitActionProgressEvent) => void>();
228
23/**9export function __resetWsNativeApiForTests() {
24 * Subscribe to the server welcome message. If a welcome was already received10 instance = null;
25 * before this call, the listener fires synchronously with the cached payload.11 __resetWsRpcClientForTests();
26 * This avoids the race between WebSocket connect and React effect registration.12 resetServerStateForTests();
27 */
28export 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 */
49export 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
69export 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 };
87}13}
8814
89export function createWsNativeApi(): NativeApi {15export function createWsNativeApi(): NativeApi {
90 if (instance) return instance.api;16 if (instance) {
9117 return instance.api;
92 const transport = new WsTransport();18 }
9319
94 transport.subscribe(WS_CHANNELS.serverWelcome, (message) => {20 const rpcClient = getWsRpcClient();
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 });
13421
135 const api: NativeApi = {22 const api: NativeApi = {
136 dialogs: {23 dialogs: {
...@@ -146,22 +33,20 @@ export function createWsNativeApi(): NativeApi {...@@ -146,22 +33,20 @@ export function createWsNativeApi(): NativeApi {
146 },33 },
147 },34 },
148 terminal: {35 terminal: {
149 open: (input) => transport.request(WS_METHODS.terminalOpen, input),36 open: (input) => rpcClient.terminal.open(input as never),
150 write: (input) => transport.request(WS_METHODS.terminalWrite, input),37 write: (input) => rpcClient.terminal.write(input as never),
151 resize: (input) => transport.request(WS_METHODS.terminalResize, input),38 resize: (input) => rpcClient.terminal.resize(input as never),
152 clear: (input) => transport.request(WS_METHODS.terminalClear, input),39 clear: (input) => rpcClient.terminal.clear(input as never),
153 restart: (input) => transport.request(WS_METHODS.terminalRestart, input),40 restart: (input) => rpcClient.terminal.restart(input as never),
154 close: (input) => transport.request(WS_METHODS.terminalClose, input),41 close: (input) => rpcClient.terminal.close(input as never),
155 onEvent: (callback) =>42 onEvent: (callback) => rpcClient.terminal.onEvent(callback),
156 transport.subscribe(WS_CHANNELS.terminalEvent, (message) => callback(message.data)),
157 },43 },
158 projects: {44 projects: {
159 searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input),45 searchEntries: rpcClient.projects.searchEntries,
160 writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input),46 writeFile: rpcClient.projects.writeFile,
161 },47 },
162 shell: {48 shell: {
163 openInEditor: (cwd, editor) =>49 openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }),
164 transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }),
165 openExternal: async (url) => {50 openExternal: async (url) => {
166 if (window.desktopBridge) {51 if (window.desktopBridge) {
167 const opened = await window.desktopBridge.openExternal(url);52 const opened = await window.desktopBridge.openExternal(url);
...@@ -171,31 +56,20 @@ export function createWsNativeApi(): NativeApi {...@@ -171,31 +56,20 @@ export function createWsNativeApi(): NativeApi {
171 return;56 return;
172 }57 }
17358
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 window.open(url, "_blank", "noopener,noreferrer");59 window.open(url, "_blank", "noopener,noreferrer");
177 },60 },
178 },61 },
179 git: {62 git: {
180 pull: (input) => transport.request(WS_METHODS.gitPull, input),63 pull: rpcClient.git.pull,
181 status: (input) => transport.request(WS_METHODS.gitStatus, input),64 status: rpcClient.git.status,
182 runStackedAction: (input) =>65 listBranches: rpcClient.git.listBranches,
183 transport.request(WS_METHODS.gitRunStackedAction, input, { timeoutMs: null }),66 createWorktree: rpcClient.git.createWorktree,
184 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),67 removeWorktree: rpcClient.git.removeWorktree,
185 createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),68 createBranch: rpcClient.git.createBranch,
186 removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),69 checkout: rpcClient.git.checkout,
187 createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input),70 init: rpcClient.git.init,
188 checkout: (input) => transport.request(WS_METHODS.gitCheckout, input),71 resolvePullRequest: rpcClient.git.resolvePullRequest,
189 init: (input) => transport.request(WS_METHODS.gitInit, input),72 preparePullRequestThread: rpcClient.git.preparePullRequestThread,
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 },
199 },73 },
200 contextMenu: {74 contextMenu: {
201 show: async <T extends string>(75 show: async <T extends string>(
...@@ -209,28 +83,25 @@ export function createWsNativeApi(): NativeApi {...@@ -209,28 +83,25 @@ export function createWsNativeApi(): NativeApi {
209 },83 },
210 },84 },
211 server: {85 server: {
212 getConfig: () => transport.request(WS_METHODS.serverGetConfig),86 getConfig: rpcClient.server.getConfig,
213 refreshProviders: () => transport.request(WS_METHODS.serverRefreshProviders),87 refreshProviders: rpcClient.server.refreshProviders,
214 upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input),88 upsertKeybinding: rpcClient.server.upsertKeybinding,
215 getSettings: () => transport.request(WS_METHODS.serverGetSettings),89 getSettings: rpcClient.server.getSettings,
216 updateSettings: (patch) => transport.request(WS_METHODS.serverUpdateSettings, { patch }),90 updateSettings: rpcClient.server.updateSettings,
217 },91 },
218 orchestration: {92 orchestration: {
219 getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot),93 getSnapshot: rpcClient.orchestration.getSnapshot,
220 dispatchCommand: (command) =>94 dispatchCommand: rpcClient.orchestration.dispatchCommand,
221 transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, { command }),95 getTurnDiff: rpcClient.orchestration.getTurnDiff,
222 getTurnDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getTurnDiff, input),96 getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff,
223 getFullThreadDiff: (input) =>
224 transport.request(ORCHESTRATION_WS_METHODS.getFullThreadDiff, input),
225 replayEvents: (fromSequenceExclusive) =>97 replayEvents: (fromSequenceExclusive) =>
226 transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }),98 rpcClient.orchestration
227 onDomainEvent: (callback) =>99 .replayEvents({ fromSequenceExclusive })
228 transport.subscribe(ORCHESTRATION_WS_CHANNELS.domainEvent, (message) =>100 .then((events) => [...events]),
229 callback(message.data),101 onDomainEvent: (callback) => rpcClient.orchestration.onDomainEvent(callback),
230 ),
231 },102 },
232 };103 };
233104
234 instance = { api, transport };105 instance = { api };
235 return api;106 return api;
236}107}
apps/web/src/wsRpcClient.ts created+207
...@@ -0,0 +1,207 @@
1import {
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";
10import { Effect, Stream } from "effect";
11
12import { type WsRpcProtocolClient } from "./rpc/protocol";
13import { WsTransport } from "./wsTransport";
14
15type RpcTag = keyof WsRpcProtocolClient & string;
16type RpcMethod<TTag extends RpcTag> = WsRpcProtocolClient[TTag];
17type RpcInput<TTag extends RpcTag> = Parameters<RpcMethod<TTag>>[0];
18
19type 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
24type RpcUnaryNoArgMethod<TTag extends RpcTag> =
25 RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any>
26 ? () => Promise<TSuccess>
27 : never;
28
29type 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
34interface GitRunStackedActionOptions {
35 readonly onProgress?: (event: GitActionProgressEvent) => void;
36}
37
38export 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
98let sharedWsRpcClient: WsRpcClient | null = null;
99
100export function getWsRpcClient(): WsRpcClient {
101 if (sharedWsRpcClient) {
102 return sharedWsRpcClient;
103 }
104 sharedWsRpcClient = createWsRpcClient();
105 return sharedWsRpcClient;
106}
107
108export async function __resetWsRpcClientForTests() {
109 await sharedWsRpcClient?.dispose();
110 sharedWsRpcClient = null;
111}
112
113export function createWsRpcClient(transport = new WsTransport()): WsRpcClient {
114 return {
115 dispose: () => transport.dispose(),
116 terminal: {
117 open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)),
118 write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)),
119 resize: (input) => transport.request((client) => client[WS_METHODS.terminalResize](input)),
120 clear: (input) => transport.request((client) => client[WS_METHODS.terminalClear](input)),
121 restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)),
122 close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)),
123 onEvent: (listener) =>
124 transport.subscribe((client) => client[WS_METHODS.subscribeTerminalEvents]({}), listener),
125 },
126 projects: {
127 searchEntries: (input) =>
128 transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)),
129 writeFile: (input) =>
130 transport.request((client) => client[WS_METHODS.projectsWriteFile](input)),
131 },
132 shell: {
133 openInEditor: (input) =>
134 transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)),
135 },
136 git: {
137 pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)),
138 status: (input) => transport.request((client) => client[WS_METHODS.gitStatus](input)),
139 runStackedAction: async (input, options) => {
140 let result: GitRunStackedActionResult | null = null;
141
142 await transport.requestStream(
143 (client) => client[WS_METHODS.gitRunStackedAction](input),
144 (event) => {
145 options?.onProgress?.(event);
146 if (event.kind === "action_finished") {
147 result = event.result;
148 }
149 },
150 );
151
152 if (result) {
153 return result;
154 }
155
156 throw new Error("Git action stream completed without a final result.");
157 },
158 listBranches: (input) =>
159 transport.request((client) => client[WS_METHODS.gitListBranches](input)),
160 createWorktree: (input) =>
161 transport.request((client) => client[WS_METHODS.gitCreateWorktree](input)),
162 removeWorktree: (input) =>
163 transport.request((client) => client[WS_METHODS.gitRemoveWorktree](input)),
164 createBranch: (input) =>
165 transport.request((client) => client[WS_METHODS.gitCreateBranch](input)),
166 checkout: (input) => transport.request((client) => client[WS_METHODS.gitCheckout](input)),
167 init: (input) => transport.request((client) => client[WS_METHODS.gitInit](input)),
168 resolvePullRequest: (input) =>
169 transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)),
170 preparePullRequestThread: (input) =>
171 transport.request((client) => client[WS_METHODS.gitPreparePullRequestThread](input)),
172 },
173 server: {
174 getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})),
175 refreshProviders: () =>
176 transport.request((client) => client[WS_METHODS.serverRefreshProviders]({})),
177 upsertKeybinding: (input) =>
178 transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)),
179 getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})),
180 updateSettings: (patch) =>
181 transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })),
182 subscribeConfig: (listener) =>
183 transport.subscribe((client) => client[WS_METHODS.subscribeServerConfig]({}), listener),
184 subscribeLifecycle: (listener) =>
185 transport.subscribe((client) => client[WS_METHODS.subscribeServerLifecycle]({}), listener),
186 },
187 orchestration: {
188 getSnapshot: () =>
189 transport.request((client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})),
190 dispatchCommand: (input) =>
191 transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)),
192 getTurnDiff: (input) =>
193 transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)),
194 getFullThreadDiff: (input) =>
195 transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)),
196 replayEvents: (input) =>
197 transport
198 .request((client) => client[ORCHESTRATION_WS_METHODS.replayEvents](input))
199 .then((events) => [...events]),
200 onDomainEvent: (listener) =>
201 transport.subscribe(
202 (client) => client[WS_METHODS.subscribeOrchestrationDomainEvents]({}),
203 listener,
204 ),
205 },
206 };
207}
apps/web/src/wsTransport.test.ts+297-110
...@@ -1,10 +1,11 @@...@@ -1,10 +1,11 @@
1import { WS_CHANNELS } from "@t3tools/contracts";1import { WS_METHODS } from "@t3tools/contracts";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33
4import { WsTransport } from "./wsTransport";4import { WsTransport } from "./wsTransport";
55
6type WsEventType = "open" | "message" | "close" | "error";6type WsEventType = "open" | "message" | "close" | "error";
7type WsListener = (event?: { data?: unknown }) => void;7type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string };
8type WsListener = (event?: WsEvent) => void;
89
9const sockets: MockWebSocket[] = [];10const sockets: MockWebSocket[] = [];
1011
...@@ -16,9 +17,11 @@ class MockWebSocket {...@@ -16,9 +17,11 @@ class MockWebSocket {
1617
17 readyState = MockWebSocket.CONNECTING;18 readyState = MockWebSocket.CONNECTING;
18 readonly sent: string[] = [];19 readonly sent: string[] = [];
20 readonly url: string;
19 private readonly listeners = new Map<WsEventType, Set<WsListener>>();21 private readonly listeners = new Map<WsEventType, Set<WsListener>>();
2022
21 constructor(_url: string) {23 constructor(url: string) {
24 this.url = url;
22 sockets.push(this);25 sockets.push(this);
23 }26 }
2427
...@@ -28,25 +31,29 @@ class MockWebSocket {...@@ -28,25 +31,29 @@ class MockWebSocket {
28 this.listeners.set(type, listeners);31 this.listeners.set(type, listeners);
29 }32 }
3033
34 removeEventListener(type: WsEventType, listener: WsListener) {
35 this.listeners.get(type)?.delete(listener);
36 }
37
31 send(data: string) {38 send(data: string) {
32 this.sent.push(data);39 this.sent.push(data);
33 }40 }
3441
35 close() {42 close(code = 1000, reason = "") {
36 this.readyState = MockWebSocket.CLOSED;43 this.readyState = MockWebSocket.CLOSED;
37 this.emit("close");44 this.emit("close", { code, reason, type: "close" });
38 }45 }
3946
40 open() {47 open() {
41 this.readyState = MockWebSocket.OPEN;48 this.readyState = MockWebSocket.OPEN;
42 this.emit("open");49 this.emit("open", { type: "open" });
43 }50 }
4451
45 serverMessage(data: unknown) {52 serverMessage(data: unknown) {
46 this.emit("message", { data });53 this.emit("message", { data, type: "message" });
47 }54 }
4855
49 private emit(type: WsEventType, event?: { data?: unknown }) {56 private emit(type: WsEventType, event?: WsEvent) {
50 const listeners = this.listeners.get(type);57 const listeners = this.listeners.get(type);
51 if (!listeners) return;58 if (!listeners) return;
52 for (const listener of listeners) {59 for (const listener of listeners) {
...@@ -65,13 +72,33 @@ function getSocket(): MockWebSocket {...@@ -65,13 +72,33 @@ function getSocket(): MockWebSocket {
65 return socket;72 return socket;
66}73}
6774
75async 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
68beforeEach(() => {90beforeEach(() => {
69 sockets.length = 0;91 sockets.length = 0;
7092
71 Object.defineProperty(globalThis, "window", {93 Object.defineProperty(globalThis, "window", {
72 configurable: true,94 configurable: true,
73 value: {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 desktopBridge: undefined,102 desktopBridge: undefined,
76 },103 },
77 });104 });
...@@ -85,172 +112,332 @@ afterEach(() => {...@@ -85,172 +112,332 @@ afterEach(() => {
85});112});
86113
87describe("WsTransport", () => {114describe("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 const transport = new WsTransport("ws://localhost:3020");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 const socket = getSocket();158 const socket = getSocket();
91 socket.open();159 socket.open();
92160
93 const listener = vi.fn();161 await waitFor(() => {
94 transport.subscribe(WS_CHANNELS.serverConfigUpdated, listener);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 });
95179
96 socket.serverMessage(180 socket.serverMessage(
97 JSON.stringify({181 JSON.stringify({
98 type: "push",182 _tag: "Exit",
99 sequence: 1,183 requestId: requestMessage.id,
100 channel: WS_CHANNELS.serverConfigUpdated,184 exit: {
101 data: { issues: [] },185 _tag: "Success",
186 value: {
187 keybindings: [],
188 issues: [],
189 },
190 },
102 }),191 }),
103 );192 );
104193
105 expect(listener).toHaveBeenCalledTimes(1);194 await expect(requestPromise).resolves.toEqual({
106 expect(listener).toHaveBeenCalledWith({195 keybindings: [],
107 type: "push",196 issues: [],
108 sequence: 1,
109 channel: WS_CHANNELS.serverConfigUpdated,
110 data: { issues: [] },
111 });197 });
112198
113 transport.dispose();199 await transport.dispose();
114 });200 });
115201
116 it("resolves pending requests for valid response envelopes", async () => {202 it("delivers stream chunks to subscribers", async () => {
117 const transport = new WsTransport("ws://localhost:3020");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 const socket = getSocket();214 const socket = getSocket();
119 socket.open();215 socket.open();
120216
121 const requestPromise = transport.request("projects.list");217 await waitFor(() => {
122 const sent = socket.sent.at(-1);218 expect(socket.sent).toHaveLength(1);
123 if (!sent) {219 });
124 throw new Error("Expected request envelope to be sent");220
125 }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 };
126233
127 const requestEnvelope = JSON.parse(sent) as { id: string };
128 socket.serverMessage(234 socket.serverMessage(
129 JSON.stringify({235 JSON.stringify({
130 id: requestEnvelope.id,236 _tag: "Chunk",
131 result: { projects: [] },237 requestId: requestMessage.id,
238 values: [welcomeEvent],
132 }),239 }),
133 );240 );
134241
135 await expect(requestPromise).resolves.toEqual({ projects: [] });242 await waitFor(() => {
243 expect(listener).toHaveBeenCalledWith(welcomeEvent);
244 });
136245
137 transport.dispose();246 unsubscribe();
247 await transport.dispose();
138 });248 });
139249
140 it("drops malformed envelopes without crashing transport", () => {250 it("re-subscribes stream listeners after the stream exits", async () => {
141 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
142 const transport = new WsTransport("ws://localhost:3020");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 const socket = getSocket();262 const socket = getSocket();
144 socket.open();263 socket.open();
145264
146 const listener = vi.fn();265 await waitFor(() => {
147 transport.subscribe(WS_CHANNELS.serverConfigUpdated, listener);266 expect(socket.sent).toHaveLength(1);
267 });
148268
149 socket.serverMessage("{ invalid-json");269 const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string };
150 socket.serverMessage(270 socket.serverMessage(
151 JSON.stringify({271 JSON.stringify({
152 type: "push",272 _tag: "Chunk",
153 sequence: 2,273 requestId: firstRequest.id,
154 channel: 42,274 values: [
155 data: { bad: true },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 socket.serverMessage(287 socket.serverMessage(
159 JSON.stringify({288 JSON.stringify({
160 type: "push",289 _tag: "Exit",
161 sequence: 3,290 requestId: firstRequest.id,
162 channel: WS_CHANNELS.serverConfigUpdated,291 exit: {
163 data: { issues: [] },292 _tag: "Success",
293 value: null,
294 },
164 }),295 }),
165 );296 );
166297
167 expect(listener).toHaveBeenCalledTimes(1);298 await waitFor(() => {
168 expect(listener).toHaveBeenCalledWith({299 const nextRequest = socket.sent
169 type: "push",300 .map((message) => JSON.parse(message) as { _tag?: string; id?: string })
170 sequence: 3,301 .find((message) => message._tag === "Request" && message.id !== firstRequest.id);
171 channel: WS_CHANNELS.serverConfigUpdated,302 expect(nextRequest).toBeDefined();
172 data: { issues: [] },
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 });
188304
189 it("queues requests until the websocket opens", async () => {305 const secondRequest = socket.sent
190 const transport = new WsTransport("ws://localhost:3020");306 .map((message) => JSON.parse(message) as { _tag?: string; id?: string; tag?: string })
191 const socket = getSocket();307 .find(
192308 (message): message is { _tag: "Request"; id: string; tag: string } =>
193 const requestPromise = transport.request("projects.list");309 message._tag === "Request" && message.id !== firstRequest.id,
194 expect(socket.sent).toHaveLength(0);310 );
195311 if (!secondRequest) {
196 socket.open();312 throw new Error("Expected a resubscribe request");
197 expect(socket.sent).toHaveLength(1);313 }
198 const requestEnvelope = JSON.parse(socket.sent[0] ?? "{}") as { id: string };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 socket.serverMessage(326 socket.serverMessage(
200 JSON.stringify({327 JSON.stringify({
201 id: requestEnvelope.id,328 _tag: "Chunk",
202 result: { projects: [] },329 requestId: secondRequest.id,
330 values: [secondEvent],
203 }),331 }),
204 );332 );
205333
206 await expect(requestPromise).resolves.toEqual({ projects: [] });334 await waitFor(() => {
207 transport.dispose();335 expect(listener).toHaveBeenLastCalledWith(secondEvent);
336 });
337
338 unsubscribe();
339 await transport.dispose();
208 });340 });
209341
210 it("does not create a timeout for requests with timeoutMs null", async () => {342 it("streams finite request events without re-subscribing", async () => {
211 const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
212 const transport = new WsTransport("ws://localhost:3020");343 const transport = new WsTransport("ws://localhost:3020");
344 const listener = vi.fn();
345
346 await waitFor(() => {
347 expect(sockets).toHaveLength(1);
348 });
213 const socket = getSocket();349 const socket = getSocket();
214 socket.open();350 socket.open();
215351
216 const requestPromise = transport.request(352 const requestPromise = transport.requestStream(
217 "git.runStackedAction",353 (client) =>
218 { cwd: "/repo" },354 client[WS_METHODS.gitRunStackedAction]({
219 { timeoutMs: null },355 actionId: "action-1",
356 cwd: "/repo",
357 action: "commit",
358 }),
359 listener,
220 );360 );
221 const sent = socket.sent.at(-1);361
222 if (!sent) {362 await waitFor(() => {
223 throw new Error("Expected request envelope to be sent");363 expect(socket.sent).toHaveLength(1);
224 }364 });
225 const requestEnvelope = JSON.parse(sent) as { id: string };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;
226375
227 socket.serverMessage(376 socket.serverMessage(
228 JSON.stringify({377 JSON.stringify({
229 id: requestEnvelope.id,378 _tag: "Chunk",
230 result: { ok: true },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 );
233393
234 await expect(requestPromise).resolves.toEqual({ ok: true });394 await expect(requestPromise).resolves.toBeUndefined();
235 expect(timeoutSpy.mock.calls.some(([callback]) => typeof callback === "function")).toBe(false);395 expect(listener).toHaveBeenCalledWith(progressEvent);
236396 expect(
237 transport.dispose();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 });
239404
240 it("rejects pending requests when the websocket closes", async () => {405 it("closes the client scope on the transport runtime before disposing the runtime", async () => {
241 const transport = new WsTransport("ws://localhost:3020");406 const callOrder: string[] = [];
242 const socket = getSocket();407 let resolveClose!: () => void;
243 socket.open();408 const closePromise = new Promise<void>((resolve) => {
409 resolveClose = resolve;
410 });
244411
245 const requestPromise = transport.request(412 const runtime = {
246 "git.runStackedAction",413 runPromise: vi.fn(async () => {
247 { cwd: "/repo" },414 callOrder.push("close:start");
248 { timeoutMs: null },415 await closePromise;
249 );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);
250434
251 socket.close();435 resolveClose();
436
437 await waitFor(() => {
438 expect(runtime.dispose).toHaveBeenCalledTimes(1);
439 });
252440
253 await expect(requestPromise).rejects.toThrow("WebSocket connection closed.");441 expect(callOrder).toEqual(["close:start", "close:done", "runtime:dispose"]);
254 transport.dispose();
255 });442 });
256});443});
apps/web/src/wsTransport.ts+93-271
...@@ -1,309 +1,131 @@...@@ -1,309 +1,131 @@
1import {1import { Duration, Effect, Exit, ManagedRuntime, Option, Scope, Stream } from "effect";
2 type WsPush,
3 type WsPushChannel,
4 type WsPushMessage,
5 WebSocketResponse,
6 type WsResponse as WsResponseMessage,
7 WsResponse as WsResponseSchema,
8} from "@t3tools/contracts";
9import { decodeUnknownJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";
10import { Result, Schema } from "effect";
11
12type PushListener<C extends WsPushChannel> = (message: WsPushMessage<C>) => void;
132
14interface PendingRequest {3import {
15 resolve: (result: unknown) => void;4 createWsRpcProtocolLayer,
16 reject: (error: Error) => void;5 makeWsRpcProtocolClient,
17 timeout: ReturnType<typeof setTimeout> | null;6 type WsRpcProtocolClient,
18}7} from "./rpc/protocol";
8import { RpcClient } from "effect/unstable/rpc";
199
20interface SubscribeOptions {10interface SubscribeOptions {
21 readonly replayLatest?: boolean;11 readonly retryDelay?: Duration.Input;
22}12}
2313
24interface RequestOptions {14interface RequestOptions {
25 readonly timeoutMs?: number | null;15 readonly timeout?: Option.Option<Duration.Input>;
26}16}
2717
28type TransportState = "connecting" | "open" | "reconnecting" | "closed" | "disposed";18const DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS = Duration.millis(250);
29
30const REQUEST_TIMEOUT_MS = 60_000;
31const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
32const decodeWsResponse = decodeUnknownJsonResult(WsResponseSchema);
33const isWebSocketResponseEnvelope = Schema.is(WebSocketResponse);
34
35const isWsPushMessage = (value: WsResponseMessage): value is WsPush =>
36 "type" in value && value.type === "push";
3719
38interface WsRequestEnvelope {20function formatErrorMessage(error: unknown): string {
39 id: string;21 if (error instanceof Error && error.message.trim().length > 0) {
40 body: {22 return error.message;
41 _tag: string;
42 [key: string]: unknown;
43 };
44}
45
46function asError(value: unknown, fallback: string): Error {
47 if (value instanceof Error) {
48 return value;
49 }23 }
50 return new Error(fallback);24 return String(error);
51}25}
5226
53export class WsTransport {27export class WsTransport {
54 private ws: WebSocket | null = null;28 private readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>;
55 private nextId = 1;29 private readonly clientScope: Scope.Closeable;
56 private readonly pending = new Map<string, PendingRequest>();30 private readonly clientPromise: Promise<WsRpcProtocolClient>;
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;
62 private disposed = false;31 private disposed = false;
63 private state: TransportState = "connecting";
64 private readonly url: string;
6532
66 constructor(url?: string) {33 constructor(url?: string) {
67 const bridgeUrl = window.desktopBridge?.getWsUrl();34 this.runtime = ManagedRuntime.make(createWsRpcProtocolLayer(url));
68 const envUrl = import.meta.env.VITE_WS_URL as string | undefined;35 this.clientScope = this.runtime.runSync(Scope.make());
69 this.url =36 this.clientPromise = this.runtime.runPromise(
70 url ??37 Scope.provide(this.clientScope)(makeWsRpcProtocolClient),
71 (bridgeUrl && bridgeUrl.length > 038 );
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();
77 }39 }
7840
79 async request<T = unknown>(41 async request<TSuccess>(
80 method: string,42 execute: (client: WsRpcProtocolClient) => Effect.Effect<TSuccess, Error, never>,
81 params?: unknown,43 _options?: RequestOptions,
82 options?: RequestOptions,44 ): Promise<TSuccess> {
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() {
173 if (this.disposed) {45 if (this.disposed) {
174 return;46 throw new Error("Transport disposed");
175 }47 }
17648
177 this.state = this.reconnectAttempt > 0 ? "reconnecting" : "connecting";49 const client = await this.clientPromise;
178 const ws = new WebSocket(this.url);50 return await this.runtime.runPromise(Effect.suspend(() => execute(client)));
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 });
215 }51 }
21652
217 private handleMessage(raw: unknown) {53 async requestStream<TValue>(
218 const result = decodeWsResponse(raw);54 connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>,
219 if (Result.isFailure(result)) {55 listener: (value: TValue) => void,
220 console.warn("Dropped inbound WebSocket envelope", formatSchemaError(result.failure));56 ): Promise<void> {
221 return;57 if (this.disposed) {
58 throw new Error("Transport disposed");
222 }59 }
22360
224 const message = result.success;61 const client = await this.clientPromise;
225 if (isWsPushMessage(message)) {62 await this.runtime.runPromise(
226 this.latestPushByChannel.set(message.channel, message);63 Stream.runForEach(connect(client), (value) =>
227 const channelListeners = this.listeners.get(message.channel);64 Effect.sync(() => {
228 if (channelListeners) {
229 for (const listener of channelListeners) {
230 try {65 try {
231 listener(message);66 listener(value);
232 } catch {67 } catch {
233 // Swallow listener errors68 // Swallow listener errors so the stream can finish cleanly.
234 }69 }
235 }70 }),
236 }71 ),
237 return;72 );
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);
260 }73 }
26174
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 if (this.disposed) {80 if (this.disposed) {
264 return;81 return () => undefined;
265 }82 }
26683
267 this.outboundQueue.push(encodedMessage);84 let active = true;
268 try {85 const retryDelayMs = options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS;
269 this.flushQueue();86 const cancel = this.runtime.runCallback(
270 } catch {87 Effect.promise(() => this.clientPromise).pipe(
271 // Swallow: flushQueue has queued the message for retry on reconnect88 Effect.flatMap((client) =>
272 }89 Stream.runForEach(connect(client), (value) =>
273 }90 Effect.sync(() => {
27491 if (!active) {
275 private flushQueue() {92 return;
276 if (this.ws?.readyState !== WebSocket.OPEN) {93 }
277 return;94 try {
278 }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 );
279115
280 while (this.outboundQueue.length > 0) {116 return () => {
281 const message = this.outboundQueue.shift();117 active = false;
282 if (!message) {118 cancel();
283 continue;119 };
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 }
292 }120 }
293121
294 private scheduleReconnect() {122 async dispose() {
295 if (this.disposed || this.reconnectTimer !== null) {123 if (this.disposed) {
296 return;124 return;
297 }125 }
298126 this.disposed = true;
299 const delay =127 await this.runtime.runPromise(Scope.close(this.clientScope, Exit.void)).finally(() => {
300 RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] ??128 this.runtime.dispose();
301 RECONNECT_DELAYS_MS[0]!;129 });
302
303 this.reconnectAttempt += 1;
304 this.reconnectTimer = setTimeout(() => {
305 this.reconnectTimer = null;
306 this.connect();
307 }, delay);
308 }130 }
309}131}
apps/web/test/wsRpcHarness.ts created+169
...@@ -0,0 +1,169 @@
1import { Effect, Exit, PubSub, Scope, Stream } from "effect";
2import { WS_METHODS, WsRpcGroup } from "@t3tools/contracts";
3import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
4
5type RpcServerInstance = RpcServer.RpcServer<any>;
6
7type BrowserWsClient = {
8 send: (data: string) => void;
9};
10
11export type NormalizedWsRpcRequestBody = {
12 _tag: string;
13 [key: string]: unknown;
14};
15
16type UnaryResolverResult = unknown | Promise<unknown>;
17
18interface BrowserWsRpcHarnessOptions {
19 readonly resolveUnary?: (request: NormalizedWsRpcRequestBody) => UnaryResolverResult;
20 readonly getInitialStreamValues?: (
21 request: NormalizedWsRpcRequestBody,
22 ) => ReadonlyArray<unknown> | undefined;
23}
24
25const 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
33const ALL_RPC_METHODS = Array.from(WsRpcGroup.requests.keys());
34
35function 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
45function asEffect(result: UnaryResolverResult): Effect.Effect<unknown> {
46 if (result instanceof Promise) {
47 return Effect.promise(() => result);
48 }
49 return Effect.succeed(result);
50}
51
52export class BrowserWsRpcHarness {
53 readonly requests: Array<NormalizedWsRpcRequestBody> = [];
54
55 private readonly parser = RpcSerialization.json.makeUnsafe();
56 private client: BrowserWsClient | null = null;
57 private scope: Scope.Closeable | null = null;
58 private serverReady: Promise<RpcServerInstance> | null = null;
59 private resolveUnary: NonNullable<BrowserWsRpcHarnessOptions["resolveUnary"]> = () => ({});
60 private getInitialStreamValues: NonNullable<
61 BrowserWsRpcHarnessOptions["getInitialStreamValues"]
62 > = () => [];
63 private streamPubSubs = new Map<string, PubSub.PubSub<unknown>>();
64
65 async reset(options?: BrowserWsRpcHarnessOptions): Promise<void> {
66 await this.disconnect();
67 this.requests.length = 0;
68 this.resolveUnary = options?.resolveUnary ?? (() => ({}));
69 this.getInitialStreamValues = options?.getInitialStreamValues ?? (() => []);
70 this.initializeStreamPubSubs();
71 }
72
73 connect(client: BrowserWsClient): void {
74 if (this.scope) {
75 void Effect.runPromise(Scope.close(this.scope, Exit.void)).catch(() => undefined);
76 }
77 if (this.streamPubSubs.size === 0) {
78 this.initializeStreamPubSubs();
79 }
80 this.client = client;
81 this.scope = Effect.runSync(Scope.make());
82 this.serverReady = Effect.runPromise(
83 Scope.provide(this.scope)(
84 RpcServer.makeNoSerialization(WsRpcGroup, this.makeServerOptions()),
85 ).pipe(Effect.provide(this.makeLayer())),
86 ) as Promise<RpcServerInstance>;
87 }
88
89 async disconnect(): Promise<void> {
90 if (this.scope) {
91 await Effect.runPromise(Scope.close(this.scope, Exit.void)).catch(() => undefined);
92 this.scope = null;
93 }
94 for (const pubsub of this.streamPubSubs.values()) {
95 Effect.runSync(PubSub.shutdown(pubsub));
96 }
97 this.streamPubSubs.clear();
98 this.serverReady = null;
99 this.client = null;
100 }
101
102 private initializeStreamPubSubs(): void {
103 this.streamPubSubs = new Map(
104 Array.from(STREAM_METHODS, (method) => [method, Effect.runSync(PubSub.unbounded<unknown>())]),
105 );
106 }
107
108 async onMessage(rawData: string): Promise<void> {
109 const server = await this.serverReady;
110 if (!server) {
111 throw new Error("RPC test server is not connected");
112 }
113 const messages = this.parser.decode(rawData);
114 for (const message of messages) {
115 await Effect.runPromise(server.write(0, message as never));
116 }
117 }
118
119 emitStreamValue(method: string, value: unknown): void {
120 const pubsub = this.streamPubSubs.get(method);
121 if (!pubsub) {
122 throw new Error(`No stream registered for ${method}`);
123 }
124 Effect.runSync(PubSub.publish(pubsub, value));
125 }
126
127 private makeLayer() {
128 const handlers: Record<string, (payload: unknown) => unknown> = {};
129 for (const method of ALL_RPC_METHODS) {
130 handlers[method] = STREAM_METHODS.has(method)
131 ? (payload) => this.handleStream(method, payload)
132 : (payload) => this.handleUnary(method, payload);
133 }
134 return WsRpcGroup.toLayer(handlers as never);
135 }
136
137 private makeServerOptions() {
138 return {
139 onFromServer: (response: unknown) =>
140 Effect.sync(() => {
141 if (!this.client) {
142 return;
143 }
144 const encoded = this.parser.encode(response);
145 if (typeof encoded === "string") {
146 this.client.send(encoded);
147 }
148 }),
149 };
150 }
151
152 private handleUnary(method: string, payload: unknown) {
153 const request = normalizeRequest(method, payload);
154 this.requests.push(request);
155 return asEffect(this.resolveUnary(request));
156 }
157
158 private handleStream(method: string, payload: unknown) {
159 const request = normalizeRequest(method, payload);
160 this.requests.push(request);
161 const pubsub = this.streamPubSubs.get(method);
162 if (!pubsub) {
163 throw new Error(`No stream registered for ${method}`);
164 }
165 return Stream.fromIterable(this.getInitialStreamValues(request) ?? []).pipe(
166 Stream.concat(Stream.fromPubSub(pubsub)),
167 );
168 }
169}
apps/web/tsconfig.json+1-1
...@@ -21,5 +21,5 @@...@@ -21,5 +21,5 @@
21 }21 }
22 ]22 ]
23 },23 },
24 "include": ["src", "vite.config.ts"]24 "include": ["src", "vite.config.ts", "test"]
25}25}
bun.lock+9-3
...@@ -44,17 +44,17 @@...@@ -44,17 +44,17 @@
44 "name": "t3",44 "name": "t3",
45 "version": "0.0.15",45 "version": "0.0.15",
46 "bin": {46 "bin": {
47 "t3": "./dist/index.mjs",47 "t3": "./dist/bin.mjs",
48 },48 },
49 "dependencies": {49 "dependencies": {
50 "@anthropic-ai/claude-agent-sdk": "^0.2.77",50 "@anthropic-ai/claude-agent-sdk": "^0.2.77",
51 "@effect/platform-bun": "catalog:",
51 "@effect/platform-node": "catalog:",52 "@effect/platform-node": "catalog:",
52 "@effect/sql-sqlite-bun": "catalog:",53 "@effect/sql-sqlite-bun": "catalog:",
53 "@pierre/diffs": "^1.1.0-beta.16",54 "@pierre/diffs": "^1.1.0-beta.16",
54 "effect": "catalog:",55 "effect": "catalog:",
55 "node-pty": "^1.1.0",56 "node-pty": "^1.1.0",
56 "open": "^10.1.0",57 "open": "^10.1.0",
57 "ws": "^8.18.0",
58 },58 },
59 "devDependencies": {59 "devDependencies": {
60 "@effect/language-service": "catalog:",60 "@effect/language-service": "catalog:",
...@@ -64,7 +64,6 @@...@@ -64,7 +64,6 @@
64 "@t3tools/web": "workspace:*",64 "@t3tools/web": "workspace:*",
65 "@types/bun": "catalog:",65 "@types/bun": "catalog:",
66 "@types/node": "catalog:",66 "@types/node": "catalog:",
67 "@types/ws": "^8.5.13",
68 "tsdown": "catalog:",67 "tsdown": "catalog:",
69 "typescript": "catalog:",68 "typescript": "catalog:",
70 "vitest": "catalog:",69 "vitest": "catalog:",
...@@ -79,6 +78,7 @@...@@ -79,6 +78,7 @@
79 "@dnd-kit/modifiers": "^9.0.0",78 "@dnd-kit/modifiers": "^9.0.0",
80 "@dnd-kit/sortable": "^10.0.0",79 "@dnd-kit/sortable": "^10.0.0",
81 "@dnd-kit/utilities": "^3.2.2",80 "@dnd-kit/utilities": "^3.2.2",
81 "@effect/atom-react": "catalog:",
82 "@formkit/auto-animate": "^0.9.0",82 "@formkit/auto-animate": "^0.9.0",
83 "@lexical/react": "^0.41.0",83 "@lexical/react": "^0.41.0",
84 "@pierre/diffs": "^1.1.0-beta.16",84 "@pierre/diffs": "^1.1.0-beta.16",
...@@ -175,7 +175,9 @@...@@ -175,7 +175,9 @@
175 "vite": "^8.0.0",175 "vite": "^8.0.0",
176 },176 },
177 "catalog": {177 "catalog": {
178 "@effect/atom-react": "4.0.0-beta.43",
178 "@effect/language-service": "0.84.2",179 "@effect/language-service": "0.84.2",
180 "@effect/platform-bun": "4.0.0-beta.43",
179 "@effect/platform-node": "4.0.0-beta.43",181 "@effect/platform-node": "4.0.0-beta.43",
180 "@effect/sql-sqlite-bun": "4.0.0-beta.43",182 "@effect/sql-sqlite-bun": "4.0.0-beta.43",
181 "@effect/vitest": "4.0.0-beta.43",183 "@effect/vitest": "4.0.0-beta.43",
...@@ -267,8 +269,12 @@...@@ -267,8 +269,12 @@
267269
268 "@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=="],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=="],
269271
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 "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="],274 "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="],
271275
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 "@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=="],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=="],
273279
274 "@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=="],280 "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.43", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-A9q0GEb61pYcQ06Dr6gXj1nKlDI3KHsar1sk3qb1ZY+kVSR64tBAylI8zGon23KY+NPtTUj/sEIToB7jc3Qt5w=="],
package.json+2
...@@ -9,6 +9,8 @@...@@ -9,6 +9,8 @@
9 ],9 ],
10 "catalog": {10 "catalog": {
11 "effect": "4.0.0-beta.43",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 "@effect/platform-node": "4.0.0-beta.43",14 "@effect/platform-node": "4.0.0-beta.43",
13 "@effect/sql-sqlite-bun": "4.0.0-beta.43",15 "@effect/sql-sqlite-bun": "4.0.0-beta.43",
14 "@effect/vitest": "4.0.0-beta.43",16 "@effect/vitest": "4.0.0-beta.43",
packages/contracts/src/editor.ts+5
...@@ -24,3 +24,8 @@ export const OpenInEditorInput = Schema.Struct({...@@ -24,3 +24,8 @@ export const OpenInEditorInput = Schema.Struct({
24 editor: EditorId,24 editor: EditorId,
25});25});
26export type OpenInEditorInput = typeof OpenInEditorInput.Type;26export type OpenInEditorInput = typeof OpenInEditorInput.Type;
27
28export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", {
29 message: Schema.String,
30 cause: Schema.optional(Schema.Defect),
31}) {}
packages/contracts/src/git.ts+54
...@@ -223,6 +223,60 @@ export const GitPullResult = Schema.Struct({...@@ -223,6 +223,60 @@ export const GitPullResult = Schema.Struct({
223});223});
224export type GitPullResult = typeof GitPullResult.Type;224export type GitPullResult = typeof GitPullResult.Type;
225225
226// RPC / domain errors
227export 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
239export 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
249export 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
262export 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
272export const GitManagerServiceError = Schema.Union([
273 GitManagerError,
274 GitCommandError,
275 GitHubCliError,
276 TextGenerationError,
277]);
278export type GitManagerServiceError = typeof GitManagerServiceError.Type;
279
226const GitActionProgressBase = Schema.Struct({280const GitActionProgressBase = Schema.Struct({
227 actionId: TrimmedNonEmptyStringSchema,281 actionId: TrimmedNonEmptyStringSchema,
228 cwd: TrimmedNonEmptyStringSchema,282 cwd: TrimmedNonEmptyStringSchema,
packages/contracts/src/index.ts+1-1
...@@ -4,7 +4,6 @@ export * from "./terminal";...@@ -4,7 +4,6 @@ export * from "./terminal";
4export * from "./provider";4export * from "./provider";
5export * from "./providerRuntime";5export * from "./providerRuntime";
6export * from "./model";6export * from "./model";
7export * from "./ws";
8export * from "./keybindings";7export * from "./keybindings";
9export * from "./server";8export * from "./server";
10export * from "./settings";9export * from "./settings";
...@@ -12,3 +11,4 @@ export * from "./git";...@@ -12,3 +11,4 @@ export * from "./git";
12export * from "./orchestration";11export * from "./orchestration";
13export * from "./editor";12export * from "./editor";
14export * from "./project";13export * from "./project";
14export * from "./rpc";
packages/contracts/src/ipc.ts-5
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1import type {1import type {
2 GitCheckoutInput,2 GitCheckoutInput,
3 GitActionProgressEvent,
4 GitCreateBranchInput,3 GitCreateBranchInput,
5 GitPreparePullRequestThreadInput,4 GitPreparePullRequestThreadInput,
6 GitPreparePullRequestThreadResult,5 GitPreparePullRequestThreadResult,
...@@ -14,8 +13,6 @@ import type {...@@ -14,8 +13,6 @@ import type {
14 GitPullResult,13 GitPullResult,
15 GitRemoveWorktreeInput,14 GitRemoveWorktreeInput,
16 GitResolvePullRequestResult,15 GitResolvePullRequestResult,
17 GitRunStackedActionInput,
18 GitRunStackedActionResult,
19 GitStatusInput,16 GitStatusInput,
20 GitStatusResult,17 GitStatusResult,
21} from "./git";18} from "./git";
...@@ -161,8 +158,6 @@ export interface NativeApi {...@@ -161,8 +158,6 @@ export interface NativeApi {
161 // Stacked action API158 // Stacked action API
162 pull: (input: GitPullInput) => Promise<GitPullResult>;159 pull: (input: GitPullInput) => Promise<GitPullResult>;
163 status: (input: GitStatusInput) => Promise<GitStatusResult>;160 status: (input: GitStatusInput) => Promise<GitStatusResult>;
164 runStackedAction: (input: GitRunStackedActionInput) => Promise<GitRunStackedActionResult>;
165 onActionProgress: (callback: (event: GitActionProgressEvent) => void) => () => void;
166 };161 };
167 contextMenu: {162 contextMenu: {
168 show: <T extends string>(163 show: <T extends string>(
packages/contracts/src/keybindings.ts+22-6
...@@ -85,24 +85,27 @@ export const KeybindingShortcut = Schema.Struct({...@@ -85,24 +85,27 @@ export const KeybindingShortcut = Schema.Struct({
85});85});
86export type KeybindingShortcut = typeof KeybindingShortcut.Type;86export type KeybindingShortcut = typeof KeybindingShortcut.Type;
8787
88export const KeybindingWhenNode: Schema.Schema<KeybindingWhenNode> = Schema.Union([88const KeybindingWhenNodeRef = Schema.suspend(
89 (): Schema.Codec<KeybindingWhenNode> => KeybindingWhenNode,
90);
91export const KeybindingWhenNode = Schema.Union([
89 Schema.Struct({92 Schema.Struct({
90 type: Schema.Literal("identifier"),93 type: Schema.Literal("identifier"),
91 name: Schema.NonEmptyString,94 name: Schema.NonEmptyString,
92 }),95 }),
93 Schema.Struct({96 Schema.Struct({
94 type: Schema.Literal("not"),97 type: Schema.Literal("not"),
95 node: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode),98 node: KeybindingWhenNodeRef,
96 }),99 }),
97 Schema.Struct({100 Schema.Struct({
98 type: Schema.Literal("and"),101 type: Schema.Literal("and"),
99 left: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode),102 left: KeybindingWhenNodeRef,
100 right: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode),103 right: KeybindingWhenNodeRef,
101 }),104 }),
102 Schema.Struct({105 Schema.Struct({
103 type: Schema.Literal("or"),106 type: Schema.Literal("or"),
104 left: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode),107 left: KeybindingWhenNodeRef,
105 right: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode),108 right: KeybindingWhenNodeRef,
106 }),109 }),
107]);110]);
108export type KeybindingWhenNode =111export type KeybindingWhenNode =
...@@ -122,3 +125,16 @@ export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).ch...@@ -122,3 +125,16 @@ export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).ch
122 Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),125 Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),
123);126);
124export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type;127export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type;
128
129export class KeybindingsConfigError extends Schema.TaggedErrorClass<KeybindingsConfigError>()(
130 "KeybindingsConfigParseError",
131 {
132 configPath: Schema.String,
133 detail: Schema.String,
134 cause: Schema.optional(Schema.Defect),
135 },
136) {
137 override get message(): string {
138 return `Unable to parse keybindings config at ${this.configPath}: ${this.detail}`;
139 }
140}
packages/contracts/src/orchestration.ts+40-4
...@@ -23,10 +23,6 @@ export const ORCHESTRATION_WS_METHODS = {...@@ -23,10 +23,6 @@ export const ORCHESTRATION_WS_METHODS = {
23 replayEvents: "orchestration.replayEvents",23 replayEvents: "orchestration.replayEvents",
24} as const;24} as const;
2525
26export const ORCHESTRATION_WS_CHANNELS = {
27 domainEvent: "orchestration.domainEvent",
28} as const;
29
30export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]);26export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]);
31export type ProviderKind = typeof ProviderKind.Type;27export type ProviderKind = typeof ProviderKind.Type;
32export const ProviderApprovalPolicy = Schema.Literals([28export const ProviderApprovalPolicy = Schema.Literals([
...@@ -1037,3 +1033,43 @@ export const OrchestrationRpcSchemas = {...@@ -1037,3 +1033,43 @@ export const OrchestrationRpcSchemas = {
1037 output: OrchestrationReplayEventsResult,1033 output: OrchestrationReplayEventsResult,
1038 },1034 },
1039} as const;1035} as const;
1036
1037export class OrchestrationGetSnapshotError extends Schema.TaggedErrorClass<OrchestrationGetSnapshotError>()(
1038 "OrchestrationGetSnapshotError",
1039 {
1040 message: TrimmedNonEmptyString,
1041 cause: Schema.optional(Schema.Defect),
1042 },
1043) {}
1044
1045export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass<OrchestrationDispatchCommandError>()(
1046 "OrchestrationDispatchCommandError",
1047 {
1048 message: TrimmedNonEmptyString,
1049 cause: Schema.optional(Schema.Defect),
1050 },
1051) {}
1052
1053export class OrchestrationGetTurnDiffError extends Schema.TaggedErrorClass<OrchestrationGetTurnDiffError>()(
1054 "OrchestrationGetTurnDiffError",
1055 {
1056 message: TrimmedNonEmptyString,
1057 cause: Schema.optional(Schema.Defect),
1058 },
1059) {}
1060
1061export class OrchestrationGetFullThreadDiffError extends Schema.TaggedErrorClass<OrchestrationGetFullThreadDiffError>()(
1062 "OrchestrationGetFullThreadDiffError",
1063 {
1064 message: TrimmedNonEmptyString,
1065 cause: Schema.optional(Schema.Defect),
1066 },
1067) {}
1068
1069export class OrchestrationReplayEventsError extends Schema.TaggedErrorClass<OrchestrationReplayEventsError>()(
1070 "OrchestrationReplayEventsError",
1071 {
1072 message: TrimmedNonEmptyString,
1073 cause: Schema.optional(Schema.Defect),
1074 },
1075) {}
packages/contracts/src/project.ts+16
...@@ -26,6 +26,14 @@ export const ProjectSearchEntriesResult = Schema.Struct({...@@ -26,6 +26,14 @@ export const ProjectSearchEntriesResult = Schema.Struct({
26});26});
27export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type;27export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type;
2828
29export class ProjectSearchEntriesError extends Schema.TaggedErrorClass<ProjectSearchEntriesError>()(
30 "ProjectSearchEntriesError",
31 {
32 message: TrimmedNonEmptyString,
33 cause: Schema.optional(Schema.Defect),
34 },
35) {}
36
29export const ProjectWriteFileInput = Schema.Struct({37export const ProjectWriteFileInput = Schema.Struct({
30 cwd: TrimmedNonEmptyString,38 cwd: TrimmedNonEmptyString,
31 relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH)),39 relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH)),
...@@ -37,3 +45,11 @@ export const ProjectWriteFileResult = Schema.Struct({...@@ -37,3 +45,11 @@ export const ProjectWriteFileResult = Schema.Struct({
37 relativePath: TrimmedNonEmptyString,45 relativePath: TrimmedNonEmptyString,
38});46});
39export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type;47export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type;
48
49export class ProjectWriteFileError extends Schema.TaggedErrorClass<ProjectWriteFileError>()(
50 "ProjectWriteFileError",
51 {
52 message: TrimmedNonEmptyString,
53 cause: Schema.optional(Schema.Defect),
54 },
55) {}
packages/contracts/src/rpc.ts created+359
...@@ -0,0 +1,359 @@
1import { Schema } from "effect";
2import * as Rpc from "effect/unstable/rpc/Rpc";
3import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
4
5import { OpenError, OpenInEditorInput } from "./editor";
6import {
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";
28import { KeybindingsConfigError } from "./keybindings";
29import {
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";
44import {
45 ProjectSearchEntriesError,
46 ProjectSearchEntriesInput,
47 ProjectSearchEntriesResult,
48 ProjectWriteFileError,
49 ProjectWriteFileInput,
50 ProjectWriteFileResult,
51} from "./project";
52import {
53 TerminalClearInput,
54 TerminalCloseInput,
55 TerminalError,
56 TerminalEvent,
57 TerminalOpenInput,
58 TerminalResizeInput,
59 TerminalRestartInput,
60 TerminalSessionSnapshot,
61 TerminalWriteInput,
62} from "./terminal";
63import {
64 ServerConfigStreamEvent,
65 ServerConfig,
66 ServerLifecycleStreamEvent,
67 ServerProviderUpdatedPayload,
68 ServerUpsertKeybindingInput,
69 ServerUpsertKeybindingResult,
70} from "./server";
71import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings";
72
73export 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
119export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, {
120 payload: ServerUpsertKeybindingInput,
121 success: ServerUpsertKeybindingResult,
122 error: KeybindingsConfigError,
123});
124
125export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, {
126 payload: Schema.Struct({}),
127 success: ServerConfig,
128 error: Schema.Union([KeybindingsConfigError, ServerSettingsError]),
129});
130
131export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, {
132 payload: Schema.Struct({}),
133 success: ServerProviderUpdatedPayload,
134});
135
136export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, {
137 payload: Schema.Struct({}),
138 success: ServerSettings,
139 error: ServerSettingsError,
140});
141
142export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, {
143 payload: Schema.Struct({ patch: ServerSettingsPatch }),
144 success: ServerSettings,
145 error: ServerSettingsError,
146});
147
148export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, {
149 payload: ProjectSearchEntriesInput,
150 success: ProjectSearchEntriesResult,
151 error: ProjectSearchEntriesError,
152});
153
154export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, {
155 payload: ProjectWriteFileInput,
156 success: ProjectWriteFileResult,
157 error: ProjectWriteFileError,
158});
159
160export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, {
161 payload: OpenInEditorInput,
162 error: OpenError,
163});
164
165export const WsGitStatusRpc = Rpc.make(WS_METHODS.gitStatus, {
166 payload: GitStatusInput,
167 success: GitStatusResult,
168 error: GitManagerServiceError,
169});
170
171export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, {
172 payload: GitPullInput,
173 success: GitPullResult,
174 error: GitCommandError,
175});
176
177export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, {
178 payload: GitRunStackedActionInput,
179 success: GitActionProgressEvent,
180 error: GitManagerServiceError,
181 stream: true,
182});
183
184export const WsGitResolvePullRequestRpc = Rpc.make(WS_METHODS.gitResolvePullRequest, {
185 payload: GitPullRequestRefInput,
186 success: GitResolvePullRequestResult,
187 error: GitManagerServiceError,
188});
189
190export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePullRequestThread, {
191 payload: GitPreparePullRequestThreadInput,
192 success: GitPreparePullRequestThreadResult,
193 error: GitManagerServiceError,
194});
195
196export const WsGitListBranchesRpc = Rpc.make(WS_METHODS.gitListBranches, {
197 payload: GitListBranchesInput,
198 success: GitListBranchesResult,
199 error: GitCommandError,
200});
201
202export const WsGitCreateWorktreeRpc = Rpc.make(WS_METHODS.gitCreateWorktree, {
203 payload: GitCreateWorktreeInput,
204 success: GitCreateWorktreeResult,
205 error: GitCommandError,
206});
207
208export const WsGitRemoveWorktreeRpc = Rpc.make(WS_METHODS.gitRemoveWorktree, {
209 payload: GitRemoveWorktreeInput,
210 error: GitCommandError,
211});
212
213export const WsGitCreateBranchRpc = Rpc.make(WS_METHODS.gitCreateBranch, {
214 payload: GitCreateBranchInput,
215 error: GitCommandError,
216});
217
218export const WsGitCheckoutRpc = Rpc.make(WS_METHODS.gitCheckout, {
219 payload: GitCheckoutInput,
220 error: GitCommandError,
221});
222
223export const WsGitInitRpc = Rpc.make(WS_METHODS.gitInit, {
224 payload: GitInitInput,
225 error: GitCommandError,
226});
227
228export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, {
229 payload: TerminalOpenInput,
230 success: TerminalSessionSnapshot,
231 error: TerminalError,
232});
233
234export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, {
235 payload: TerminalWriteInput,
236 error: TerminalError,
237});
238
239export const WsTerminalResizeRpc = Rpc.make(WS_METHODS.terminalResize, {
240 payload: TerminalResizeInput,
241 error: TerminalError,
242});
243
244export const WsTerminalClearRpc = Rpc.make(WS_METHODS.terminalClear, {
245 payload: TerminalClearInput,
246 error: TerminalError,
247});
248
249export const WsTerminalRestartRpc = Rpc.make(WS_METHODS.terminalRestart, {
250 payload: TerminalRestartInput,
251 success: TerminalSessionSnapshot,
252 error: TerminalError,
253});
254
255export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, {
256 payload: TerminalCloseInput,
257 error: TerminalError,
258});
259
260export const WsOrchestrationGetSnapshotRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getSnapshot, {
261 payload: OrchestrationGetSnapshotInput,
262 success: OrchestrationRpcSchemas.getSnapshot.output,
263 error: OrchestrationGetSnapshotError,
264});
265
266export const WsOrchestrationDispatchCommandRpc = Rpc.make(
267 ORCHESTRATION_WS_METHODS.dispatchCommand,
268 {
269 payload: ClientOrchestrationCommand,
270 success: OrchestrationRpcSchemas.dispatchCommand.output,
271 error: OrchestrationDispatchCommandError,
272 },
273);
274
275export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, {
276 payload: OrchestrationGetTurnDiffInput,
277 success: OrchestrationRpcSchemas.getTurnDiff.output,
278 error: OrchestrationGetTurnDiffError,
279});
280
281export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make(
282 ORCHESTRATION_WS_METHODS.getFullThreadDiff,
283 {
284 payload: OrchestrationGetFullThreadDiffInput,
285 success: OrchestrationRpcSchemas.getFullThreadDiff.output,
286 error: OrchestrationGetFullThreadDiffError,
287 },
288);
289
290export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.replayEvents, {
291 payload: OrchestrationReplayEventsInput,
292 success: OrchestrationRpcSchemas.replayEvents.output,
293 error: OrchestrationReplayEventsError,
294});
295
296export const WsSubscribeOrchestrationDomainEventsRpc = Rpc.make(
297 WS_METHODS.subscribeOrchestrationDomainEvents,
298 {
299 payload: Schema.Struct({}),
300 success: OrchestrationEvent,
301 stream: true,
302 },
303);
304
305export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, {
306 payload: Schema.Struct({}),
307 success: TerminalEvent,
308 stream: true,
309});
310
311export 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
318export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, {
319 payload: Schema.Struct({}),
320 success: ServerLifecycleStreamEvent,
321 stream: true,
322});
323
324export const WsRpcGroup = RpcGroup.make(
325 WsServerGetConfigRpc,
326 WsServerRefreshProvidersRpc,
327 WsServerUpsertKeybindingRpc,
328 WsServerGetSettingsRpc,
329 WsServerUpdateSettingsRpc,
330 WsProjectsSearchEntriesRpc,
331 WsProjectsWriteFileRpc,
332 WsShellOpenInEditorRpc,
333 WsGitStatusRpc,
334 WsGitPullRpc,
335 WsGitRunStackedActionRpc,
336 WsGitResolvePullRequestRpc,
337 WsGitPreparePullRequestThreadRpc,
338 WsGitListBranchesRpc,
339 WsGitCreateWorktreeRpc,
340 WsGitRemoveWorktreeRpc,
341 WsGitCreateBranchRpc,
342 WsGitCheckoutRpc,
343 WsGitInitRpc,
344 WsTerminalOpenRpc,
345 WsTerminalWriteRpc,
346 WsTerminalResizeRpc,
347 WsTerminalClearRpc,
348 WsTerminalRestartRpc,
349 WsTerminalCloseRpc,
350 WsSubscribeOrchestrationDomainEventsRpc,
351 WsSubscribeTerminalEventsRpc,
352 WsSubscribeServerConfigRpc,
353 WsSubscribeServerLifecycleRpc,
354 WsOrchestrationGetSnapshotRpc,
355 WsOrchestrationDispatchCommandRpc,
356 WsOrchestrationGetTurnDiffRpc,
357 WsOrchestrationGetFullThreadDiffRpc,
358 WsOrchestrationReplayEventsRpc,
359);
packages/contracts/src/server.ts+100-2
...@@ -1,5 +1,11 @@...@@ -1,5 +1,11 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas";2import {
3 IsoDateTime,
4 NonNegativeInt,
5 ProjectId,
6 ThreadId,
7 TrimmedNonEmptyString,
8} from "./baseSchemas";
3import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";9import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";
4import { EditorId } from "./editor";10import { EditorId } from "./editor";
5import { ModelCapabilities } from "./model";11import { ModelCapabilities } from "./model";
...@@ -63,7 +69,8 @@ export const ServerProvider = Schema.Struct({...@@ -63,7 +69,8 @@ export const ServerProvider = Schema.Struct({
63});69});
64export type ServerProvider = typeof ServerProvider.Type;70export type ServerProvider = typeof ServerProvider.Type;
6571
66const ServerProviders = Schema.Array(ServerProvider);72export const ServerProviders = Schema.Array(ServerProvider);
73export type ServerProviders = typeof ServerProviders.Type;
6774
68export const ServerConfig = Schema.Struct({75export const ServerConfig = Schema.Struct({
69 cwd: TrimmedNonEmptyString,76 cwd: TrimmedNonEmptyString,
...@@ -87,10 +94,101 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T...@@ -87,10 +94,101 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T
8794
88export const ServerConfigUpdatedPayload = Schema.Struct({95export const ServerConfigUpdatedPayload = Schema.Struct({
89 issues: ServerConfigIssues,96 issues: ServerConfigIssues,
97 providers: ServerProviders,
90 settings: Schema.optional(ServerSettings),98 settings: Schema.optional(ServerSettings),
91});99});
92export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type;100export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type;
93101
102export const ServerConfigKeybindingsUpdatedPayload = Schema.Struct({
103 issues: ServerConfigIssues,
104});
105export type ServerConfigKeybindingsUpdatedPayload =
106 typeof ServerConfigKeybindingsUpdatedPayload.Type;
107
108export const ServerConfigProviderStatusesPayload = Schema.Struct({
109 providers: ServerProviders,
110});
111export type ServerConfigProviderStatusesPayload = typeof ServerConfigProviderStatusesPayload.Type;
112
113export const ServerConfigSettingsUpdatedPayload = Schema.Struct({
114 settings: ServerSettings,
115});
116export type ServerConfigSettingsUpdatedPayload = typeof ServerConfigSettingsUpdatedPayload.Type;
117
118export const ServerConfigStreamSnapshotEvent = Schema.Struct({
119 version: Schema.Literal(1),
120 type: Schema.Literal("snapshot"),
121 config: ServerConfig,
122});
123export type ServerConfigStreamSnapshotEvent = typeof ServerConfigStreamSnapshotEvent.Type;
124
125export const ServerConfigStreamKeybindingsUpdatedEvent = Schema.Struct({
126 version: Schema.Literal(1),
127 type: Schema.Literal("keybindingsUpdated"),
128 payload: ServerConfigKeybindingsUpdatedPayload,
129});
130export type ServerConfigStreamKeybindingsUpdatedEvent =
131 typeof ServerConfigStreamKeybindingsUpdatedEvent.Type;
132
133export const ServerConfigStreamProviderStatusesEvent = Schema.Struct({
134 version: Schema.Literal(1),
135 type: Schema.Literal("providerStatuses"),
136 payload: ServerConfigProviderStatusesPayload,
137});
138export type ServerConfigStreamProviderStatusesEvent =
139 typeof ServerConfigStreamProviderStatusesEvent.Type;
140
141export const ServerConfigStreamSettingsUpdatedEvent = Schema.Struct({
142 version: Schema.Literal(1),
143 type: Schema.Literal("settingsUpdated"),
144 payload: ServerConfigSettingsUpdatedPayload,
145});
146export type ServerConfigStreamSettingsUpdatedEvent =
147 typeof ServerConfigStreamSettingsUpdatedEvent.Type;
148
149export const ServerConfigStreamEvent = Schema.Union([
150 ServerConfigStreamSnapshotEvent,
151 ServerConfigStreamKeybindingsUpdatedEvent,
152 ServerConfigStreamProviderStatusesEvent,
153 ServerConfigStreamSettingsUpdatedEvent,
154]);
155export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type;
156
157export const ServerLifecycleReadyPayload = Schema.Struct({
158 at: IsoDateTime,
159});
160export type ServerLifecycleReadyPayload = typeof ServerLifecycleReadyPayload.Type;
161
162export const ServerLifecycleWelcomePayload = Schema.Struct({
163 cwd: TrimmedNonEmptyString,
164 projectName: TrimmedNonEmptyString,
165 bootstrapProjectId: Schema.optional(ProjectId),
166 bootstrapThreadId: Schema.optional(ThreadId),
167});
168export type ServerLifecycleWelcomePayload = typeof ServerLifecycleWelcomePayload.Type;
169
170export const ServerLifecycleStreamWelcomeEvent = Schema.Struct({
171 version: Schema.Literal(1),
172 sequence: NonNegativeInt,
173 type: Schema.Literal("welcome"),
174 payload: ServerLifecycleWelcomePayload,
175});
176export type ServerLifecycleStreamWelcomeEvent = typeof ServerLifecycleStreamWelcomeEvent.Type;
177
178export const ServerLifecycleStreamReadyEvent = Schema.Struct({
179 version: Schema.Literal(1),
180 sequence: NonNegativeInt,
181 type: Schema.Literal("ready"),
182 payload: ServerLifecycleReadyPayload,
183});
184export type ServerLifecycleStreamReadyEvent = typeof ServerLifecycleStreamReadyEvent.Type;
185
186export const ServerLifecycleStreamEvent = Schema.Union([
187 ServerLifecycleStreamWelcomeEvent,
188 ServerLifecycleStreamReadyEvent,
189]);
190export type ServerLifecycleStreamEvent = typeof ServerLifecycleStreamEvent.Type;
191
94export const ServerProviderUpdatedPayload = Schema.Struct({192export const ServerProviderUpdatedPayload = Schema.Struct({
95 providers: ServerProviders,193 providers: ServerProviders,
96});194});
packages/contracts/src/settings.ts+13
...@@ -93,6 +93,19 @@ export type ServerSettings = typeof ServerSettings.Type;...@@ -93,6 +93,19 @@ export type ServerSettings = typeof ServerSettings.Type;
9393
94export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({});94export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({});
9595
96export 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// ── Unified type ─────────────────────────────────────────────────────109// ── Unified type ─────────────────────────────────────────────────────
97110
98export type UnifiedSettings = ServerSettings & ClientSettings;111export type UnifiedSettings = ServerSettings & ClientSettings;
packages/contracts/src/terminal.ts+71
...@@ -149,3 +149,74 @@ export const TerminalEvent = Schema.Union([...@@ -149,3 +149,74 @@ export const TerminalEvent = Schema.Union([
149 TerminalActivityEvent,149 TerminalActivityEvent,
150]);150]);
151export type TerminalEvent = typeof TerminalEvent.Type;151export type TerminalEvent = typeof TerminalEvent.Type;
152
153export 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
178export 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
192export 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
204export 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
216export const TerminalError = Schema.Union([
217 TerminalCwdError,
218 TerminalHistoryError,
219 TerminalSessionLookupError,
220 TerminalNotRunningError,
221]);
222export type TerminalError = typeof TerminalError.Type;
packages/contracts/src/ws.test.ts deleted-157
...@@ -1,157 +0,0 @@
1import { assert, it } from "@effect/vitest";
2import { Effect, Schema } from "effect";
3
4import { ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS } from "./orchestration";
5import { WebSocketRequest, WsResponse, WS_CHANNELS, WS_METHODS } from "./ws";
6
7const decodeWebSocketRequest = Schema.decodeUnknownEffect(WebSocketRequest);
8const decodeWsResponse = Schema.decodeUnknownEffect(WsResponse);
9
10it.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
25it.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
42it.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
61it.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
76it.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
98it.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
122it.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
141it.effect("rejects push envelopes when channel payload does not match the channel schema", () =>
142 Effect.gen(function* () {
143 const result = yield* Effect.exit(
144 decodeWsResponse({
145 type: "push",
146 sequence: 2,
147 channel: ORCHESTRATION_WS_CHANNELS.domainEvent,
148 data: {
149 cwd: "/tmp/workspace",
150 projectName: "workspace",
151 },
152 }),
153 );
154
155 assert.strictEqual(result._tag, "Failure");
156 }),
157);
packages/contracts/src/ws.ts deleted-257
...@@ -1,257 +0,0 @@
1import { Schema, Struct } from "effect";
2import { NonNegativeInt, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas";
3
4import {
5 ClientOrchestrationCommand,
6 OrchestrationEvent,
7 ORCHESTRATION_WS_CHANNELS,
8 OrchestrationGetFullThreadDiffInput,
9 ORCHESTRATION_WS_METHODS,
10 OrchestrationGetSnapshotInput,
11 OrchestrationGetTurnDiffInput,
12 OrchestrationReplayEventsInput,
13} from "./orchestration";
14import {
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";
28import {
29 TerminalClearInput,
30 TerminalCloseInput,
31 TerminalEvent,
32 TerminalOpenInput,
33 TerminalResizeInput,
34 TerminalRestartInput,
35 TerminalWriteInput,
36} from "./terminal";
37import { KeybindingRule } from "./keybindings";
38import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project";
39import { OpenInEditorInput } from "./editor";
40import { ServerConfigUpdatedPayload, ServerProviderUpdatedPayload } from "./server";
41import { ServerSettingsPatch } from "./settings";
42
43// ── WebSocket RPC Method Names ───────────────────────────────────────
44
45export 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
87export 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
97const 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
107const 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
154export const WebSocketRequest = Schema.Struct({
155 id: TrimmedNonEmptyString,
156 body: WebSocketRequestBody,
157});
158export type WebSocketRequest = typeof WebSocketRequest.Type;
159
160export 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});
169export type WebSocketResponse = typeof WebSocketResponse.Type;
170
171export const WsPushSequence = NonNegativeInt;
172export type WsPushSequence = typeof WsPushSequence.Type;
173
174export const WsWelcomePayload = Schema.Struct({
175 cwd: TrimmedNonEmptyString,
176 projectName: TrimmedNonEmptyString,
177 bootstrapProjectId: Schema.optional(ProjectId),
178 bootstrapThreadId: Schema.optional(ThreadId),
179});
180export type WsWelcomePayload = typeof WsWelcomePayload.Type;
181
182export 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
191export type WsPushChannel = keyof WsPushPayloadByChannel;
192export type WsPushData<C extends WsPushChannel> = WsPushPayloadByChannel[C];
193
194const 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
205export const WsPushServerWelcome = makeWsPushSchema(WS_CHANNELS.serverWelcome, WsWelcomePayload);
206export const WsPushServerConfigUpdated = makeWsPushSchema(
207 WS_CHANNELS.serverConfigUpdated,
208 ServerConfigUpdatedPayload,
209);
210export const WsPushServerProvidersUpdated = makeWsPushSchema(
211 WS_CHANNELS.serverProvidersUpdated,
212 ServerProviderUpdatedPayload,
213);
214export const WsPushGitActionProgress = makeWsPushSchema(
215 WS_CHANNELS.gitActionProgress,
216 GitActionProgressEvent,
217);
218export const WsPushTerminalEvent = makeWsPushSchema(WS_CHANNELS.terminalEvent, TerminalEvent);
219export const WsPushOrchestrationDomainEvent = makeWsPushSchema(
220 ORCHESTRATION_WS_CHANNELS.domainEvent,
221 OrchestrationEvent,
222);
223
224export 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]);
232export type WsPushChannelSchema = typeof WsPushChannelSchema.Type;
233
234export const WsPush = Schema.Union([
235 WsPushServerWelcome,
236 WsPushServerConfigUpdated,
237 WsPushServerProvidersUpdated,
238 WsPushGitActionProgress,
239 WsPushTerminalEvent,
240 WsPushOrchestrationDomainEvent,
241]);
242export type WsPush = typeof WsPush.Type;
243
244export type WsPushMessage<C extends WsPushChannel> = Extract<WsPush, { channel: C }>;
245
246export const WsPushEnvelopeBase = Schema.Struct({
247 type: Schema.Literal("push"),
248 sequence: WsPushSequence,
249 channel: WsPushChannelSchema,
250 data: Schema.Unknown,
251});
252export type WsPushEnvelopeBase = typeof WsPushEnvelopeBase.Type;
253
254// ── Union of all server → client messages ─────────────────────────────
255
256export const WsResponse = Schema.Union([WebSocketResponse, WsPush]);
257export type WsResponse = typeof WsResponse.Type;