diff --git a/.oxfmtrc.json b/.oxfmtrc.json
index ef2236d0f2..a3e32c9797 100644
--- a/.oxfmtrc.json
+++ b/.oxfmtrc.json
@@ -1,6 +1,7 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": [
+ ".reference",
".plans",
"dist",
"dist-electron",
diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs
index 1506d80e9f..5244d51dbf 100644
--- a/apps/desktop/scripts/dev-electron.mjs
+++ b/apps/desktop/scripts/dev-electron.mjs
@@ -10,11 +10,11 @@ const devServerUrl = `http://localhost:${port}`;
const requiredFiles = [
"dist-electron/main.js",
"dist-electron/preload.js",
- "../server/dist/index.mjs",
+ "../server/dist/bin.mjs",
];
const watchedDirectories = [
{ directory: "dist-electron", files: new Set(["main.js", "preload.js"]) },
- { directory: "../server/dist", files: new Set(["index.mjs"]) },
+ { directory: "../server/dist", files: new Set(["bin.mjs"]) },
];
const forcedShutdownTimeoutMs = 1_500;
const restartDebounceMs = 120;
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
index f1086e9c29..79f9a3d867 100644
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -390,7 +390,7 @@ function resolveAboutCommitHash(): string | null {
}
function resolveBackendEntry(): string {
- return Path.join(resolveAppRoot(), "apps/server/dist/index.mjs");
+ return Path.join(resolveAppRoot(), "apps/server/dist/bin.mjs");
}
function resolveBackendCwd(): string {
diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts
index 9340b9460d..24fa8b2438 100644
--- a/apps/server/integration/OrchestrationEngineHarness.integration.ts
+++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts
@@ -125,7 +125,7 @@ function waitFor(
read: Effect.Effect,
predicate: (value: A) => boolean,
description: string,
- timeoutMs = 10_000,
+ timeoutMs = 40_000,
): Effect.Effect {
const RETRY_SIGNAL = "wait_for_retry";
const retryIntervalMs = 10;
diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts
index d6b1004749..a5cc8f8cc6 100644
--- a/apps/server/integration/orchestrationEngine.integration.test.ts
+++ b/apps/server/integration/orchestrationEngine.integration.test.ts
@@ -57,7 +57,7 @@ function waitForSync(
read: () => A,
predicate: (value: A) => boolean,
description: string,
- timeoutMs = 3000,
+ timeoutMs = 10_000,
): Effect.Effect {
return Effect.gen(function* () {
const deadline = Date.now() + timeoutMs;
diff --git a/apps/server/package.json b/apps/server/package.json
index e473e21fd1..e59c7c208c 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -8,29 +8,29 @@
"directory": "apps/server"
},
"bin": {
- "t3": "./dist/index.mjs"
+ "t3": "./dist/bin.mjs"
},
"files": [
"dist"
],
"type": "module",
"scripts": {
- "dev": "bun run src/index.ts",
+ "dev": "bun run src/bin.ts",
"build": "node scripts/cli.ts build",
- "start": "node dist/index.mjs",
+ "start": "node dist/bin.mjs",
"prepare": "effect-language-service patch",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.77",
+ "@effect/platform-bun": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@pierre/diffs": "^1.1.0-beta.16",
"effect": "catalog:",
"node-pty": "^1.1.0",
- "open": "^10.1.0",
- "ws": "^8.18.0"
+ "open": "^10.1.0"
},
"devDependencies": {
"@effect/language-service": "catalog:",
@@ -40,7 +40,6 @@
"@t3tools/web": "workspace:*",
"@types/bun": "catalog:",
"@types/node": "catalog:",
- "@types/ws": "^8.5.13",
"tsdown": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts
new file mode 100644
index 0000000000..56113de4d7
--- /dev/null
+++ b/apps/server/src/bin.ts
@@ -0,0 +1,13 @@
+import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import { Command } from "effect/unstable/cli";
+
+import { NetService } from "@t3tools/shared/Net";
+import { cli } from "./cli";
+import { version } from "../package.json" with { type: "json" };
+
+const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
+
+Command.run(cli, { version }).pipe(Effect.provide(CliRuntimeLayer), NodeRuntime.runMain);
diff --git a/apps/server/src/checkpointing/Errors.ts b/apps/server/src/checkpointing/Errors.ts
index 782d0918e6..cb873559c1 100644
--- a/apps/server/src/checkpointing/Errors.ts
+++ b/apps/server/src/checkpointing/Errors.ts
@@ -1,6 +1,6 @@
import { Schema } from "effect";
import type { ProjectionRepositoryError } from "../persistence/Errors.ts";
-import { GitCommandError } from "../git/Errors.ts";
+import { GitCommandError } from "@t3tools/contracts";
/**
* CheckpointUnavailableError - Expected checkpoint does not exist.
diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts
index c430dbbde0..7df1c71c23 100644
--- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts
+++ b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts
@@ -10,7 +10,7 @@ import { CheckpointStoreLive } from "./CheckpointStore.ts";
import { CheckpointStore } from "../Services/CheckpointStore.ts";
import { GitCoreLive } from "../../git/Layers/GitCore.ts";
import { GitCore } from "../../git/Services/GitCore.ts";
-import { GitCommandError } from "../../git/Errors.ts";
+import { GitCommandError } from "@t3tools/contracts";
import { ServerConfig } from "../../config.ts";
import { ThreadId } from "@t3tools/contracts";
diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts
index b20204780c..0a1d7abb9d 100644
--- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts
+++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts
@@ -14,7 +14,7 @@ import { randomUUID } from "node:crypto";
import { Effect, Layer, FileSystem, Path } from "effect";
import { CheckpointInvariantError } from "../Errors.ts";
-import { GitCommandError } from "../../git/Errors.ts";
+import { GitCommandError } from "@t3tools/contracts";
import { GitCore } from "../../git/Services/GitCore.ts";
import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";
import { CheckpointRef } from "@t3tools/contracts";
diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts
new file mode 100644
index 0000000000..27bc60b1be
--- /dev/null
+++ b/apps/server/src/cli-config.test.ts
@@ -0,0 +1,318 @@
+import os from "node:os";
+
+import { assert, expect, it } from "@effect/vitest";
+import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect";
+
+import { NetService } from "@t3tools/shared/Net";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { deriveServerPaths } from "./config";
+import { resolveServerConfig } from "./cli";
+
+it.layer(NodeServices.layer)("cli config resolution", (it) => {
+ const openBootstrapFd = Effect.fn(function* (payload: Record) {
+ const fs = yield* FileSystem.FileSystem;
+ const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });
+ yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`);
+ const { fd } = yield* fs.open(filePath, { flag: "r" });
+ return fd;
+ });
+
+ it.effect("falls back to effect/config values when flags are omitted", () =>
+ Effect.gen(function* () {
+ const { join } = yield* Path.Path;
+ const baseDir = join(os.tmpdir(), "t3-cli-config-env-base");
+ const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173"));
+ const resolved = yield* resolveServerConfig(
+ {
+ mode: Option.none(),
+ port: Option.none(),
+ host: Option.none(),
+ baseDir: Option.none(),
+ devUrl: Option.none(),
+ noBrowser: Option.none(),
+ authToken: Option.none(),
+ bootstrapFd: Option.none(),
+ autoBootstrapProjectFromCwd: Option.none(),
+ logWebSocketEvents: Option.none(),
+ },
+ Option.none(),
+ ).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ ConfigProvider.layer(
+ ConfigProvider.fromEnv({
+ env: {
+ T3CODE_LOG_LEVEL: "Warn",
+ T3CODE_MODE: "desktop",
+ T3CODE_PORT: "4001",
+ T3CODE_HOST: "0.0.0.0",
+ T3CODE_HOME: baseDir,
+ VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
+ T3CODE_NO_BROWSER: "true",
+ T3CODE_AUTH_TOKEN: "env-token",
+ T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
+ T3CODE_LOG_WS_EVENTS: "true",
+ },
+ }),
+ ),
+ NetService.layer,
+ ),
+ ),
+ );
+
+ expect(resolved).toEqual({
+ logLevel: "Warn",
+ mode: "desktop",
+ port: 4001,
+ cwd: process.cwd(),
+ baseDir,
+ ...derivedPaths,
+ host: "0.0.0.0",
+ staticDir: undefined,
+ devUrl: new URL("http://127.0.0.1:5173"),
+ noBrowser: true,
+ authToken: "env-token",
+ autoBootstrapProjectFromCwd: false,
+ logWebSocketEvents: true,
+ });
+ }),
+ );
+
+ it.effect("uses CLI flags when provided", () =>
+ Effect.gen(function* () {
+ const { join } = yield* Path.Path;
+ const baseDir = join(os.tmpdir(), "t3-cli-config-flags-base");
+ const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
+ const resolved = yield* resolveServerConfig(
+ {
+ mode: Option.some("web"),
+ port: Option.some(8788),
+ host: Option.some("127.0.0.1"),
+ baseDir: Option.some(baseDir),
+ devUrl: Option.some(new URL("http://127.0.0.1:4173")),
+ noBrowser: Option.some(true),
+ authToken: Option.some("flag-token"),
+ bootstrapFd: Option.none(),
+ autoBootstrapProjectFromCwd: Option.some(true),
+ logWebSocketEvents: Option.some(true),
+ },
+ Option.some("Debug"),
+ ).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ ConfigProvider.layer(
+ ConfigProvider.fromEnv({
+ env: {
+ T3CODE_LOG_LEVEL: "Warn",
+ T3CODE_MODE: "desktop",
+ T3CODE_PORT: "4001",
+ T3CODE_HOST: "0.0.0.0",
+ T3CODE_HOME: join(os.tmpdir(), "ignored-base"),
+ VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
+ T3CODE_NO_BROWSER: "false",
+ T3CODE_AUTH_TOKEN: "ignored-token",
+ T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
+ T3CODE_LOG_WS_EVENTS: "false",
+ },
+ }),
+ ),
+ NetService.layer,
+ ),
+ ),
+ );
+
+ expect(resolved).toEqual({
+ logLevel: "Debug",
+ mode: "web",
+ port: 8788,
+ cwd: process.cwd(),
+ baseDir,
+ ...derivedPaths,
+ host: "127.0.0.1",
+ staticDir: undefined,
+ devUrl: new URL("http://127.0.0.1:4173"),
+ noBrowser: true,
+ authToken: "flag-token",
+ autoBootstrapProjectFromCwd: true,
+ logWebSocketEvents: true,
+ });
+ }),
+ );
+
+ it.effect("uses bootstrap envelope values as fallbacks when flags and env are absent", () =>
+ Effect.gen(function* () {
+ const { join } = yield* Path.Path;
+ const baseDir = "/tmp/t3-bootstrap-home";
+ const fd = yield* openBootstrapFd({
+ mode: "desktop",
+ port: 4888,
+ host: "127.0.0.2",
+ t3Home: baseDir,
+ devUrl: "http://127.0.0.1:5173",
+ noBrowser: true,
+ authToken: "bootstrap-token",
+ autoBootstrapProjectFromCwd: false,
+ logWebSocketEvents: true,
+ });
+ const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173"));
+
+ const resolved = yield* resolveServerConfig(
+ {
+ mode: Option.none(),
+ port: Option.none(),
+ host: Option.none(),
+ baseDir: Option.none(),
+ devUrl: Option.none(),
+ noBrowser: Option.none(),
+ authToken: Option.none(),
+ bootstrapFd: Option.none(),
+ autoBootstrapProjectFromCwd: Option.none(),
+ logWebSocketEvents: Option.none(),
+ },
+ Option.none(),
+ ).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ ConfigProvider.layer(
+ ConfigProvider.fromEnv({
+ env: {
+ T3CODE_BOOTSTRAP_FD: String(fd),
+ },
+ }),
+ ),
+ NetService.layer,
+ ),
+ ),
+ );
+
+ expect(resolved).toEqual({
+ logLevel: "Info",
+ mode: "desktop",
+ port: 4888,
+ cwd: process.cwd(),
+ baseDir,
+ ...derivedPaths,
+ host: "127.0.0.2",
+ staticDir: undefined,
+ devUrl: new URL("http://127.0.0.1:5173"),
+ noBrowser: true,
+ authToken: "bootstrap-token",
+ autoBootstrapProjectFromCwd: false,
+ logWebSocketEvents: true,
+ });
+ assert.equal(join(baseDir, "dev"), resolved.stateDir);
+ }),
+ );
+
+ it.effect("creates derived runtime directories during config resolution", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-dirs-" });
+
+ const resolved = yield* resolveServerConfig(
+ {
+ mode: Option.some("desktop"),
+ port: Option.some(4888),
+ host: Option.none(),
+ baseDir: Option.some(baseDir),
+ devUrl: Option.some(new URL("http://127.0.0.1:5173")),
+ noBrowser: Option.none(),
+ authToken: Option.none(),
+ bootstrapFd: Option.none(),
+ autoBootstrapProjectFromCwd: Option.none(),
+ logWebSocketEvents: Option.none(),
+ },
+ Option.none(),
+ ).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
+ NetService.layer,
+ ),
+ ),
+ );
+
+ for (const directory of [
+ resolved.stateDir,
+ resolved.logsDir,
+ resolved.providerLogsDir,
+ resolved.terminalLogsDir,
+ resolved.attachmentsDir,
+ resolved.worktreesDir,
+ path.dirname(resolved.serverLogPath),
+ ]) {
+ expect(yield* fs.exists(directory)).toBe(true);
+ }
+ }),
+ );
+
+ it.effect("applies flag then env precedence over bootstrap envelope values", () =>
+ Effect.gen(function* () {
+ const { join } = yield* Path.Path;
+ const baseDir = join(os.tmpdir(), "t3-cli-config-env-wins");
+ const fd = yield* openBootstrapFd({
+ mode: "desktop",
+ port: 4888,
+ host: "127.0.0.2",
+ t3Home: "/tmp/t3-bootstrap-home",
+ devUrl: "http://127.0.0.1:5173",
+ noBrowser: false,
+ authToken: "bootstrap-token",
+ autoBootstrapProjectFromCwd: false,
+ logWebSocketEvents: false,
+ });
+ const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
+
+ const resolved = yield* resolveServerConfig(
+ {
+ mode: Option.none(),
+ port: Option.some(8788),
+ host: Option.some("127.0.0.1"),
+ baseDir: Option.none(),
+ devUrl: Option.some(new URL("http://127.0.0.1:4173")),
+ noBrowser: Option.none(),
+ authToken: Option.some("flag-token"),
+ bootstrapFd: Option.none(),
+ autoBootstrapProjectFromCwd: Option.none(),
+ logWebSocketEvents: Option.none(),
+ },
+ Option.some("Debug"),
+ ).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ ConfigProvider.layer(
+ ConfigProvider.fromEnv({
+ env: {
+ T3CODE_MODE: "web",
+ T3CODE_BOOTSTRAP_FD: String(fd),
+ T3CODE_HOME: baseDir,
+ T3CODE_NO_BROWSER: "true",
+ T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
+ T3CODE_LOG_WS_EVENTS: "true",
+ },
+ }),
+ ),
+ NetService.layer,
+ ),
+ ),
+ );
+
+ expect(resolved).toEqual({
+ logLevel: "Debug",
+ mode: "web",
+ port: 8788,
+ cwd: process.cwd(),
+ baseDir,
+ ...derivedPaths,
+ host: "127.0.0.1",
+ staticDir: undefined,
+ devUrl: new URL("http://127.0.0.1:4173"),
+ noBrowser: true,
+ authToken: "flag-token",
+ autoBootstrapProjectFromCwd: true,
+ logWebSocketEvents: true,
+ });
+ }),
+ );
+});
diff --git a/apps/server/src/cli.test.ts b/apps/server/src/cli.test.ts
new file mode 100644
index 0000000000..db8885130d
--- /dev/null
+++ b/apps/server/src/cli.test.ts
@@ -0,0 +1,45 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { NetService } from "@t3tools/shared/Net";
+import { assert, it } from "@effect/vitest";
+import { Effect } from "effect";
+import * as Layer from "effect/Layer";
+import type { Layer as LayerShape } from "effect/Layer";
+import * as CliError from "effect/unstable/cli/CliError";
+import { Command } from "effect/unstable/cli";
+
+import { cli } from "./cli.ts";
+
+const CliRuntimeLayer: LayerShape<
+ Layer.Success | Layer.Success,
+ never,
+ never
+> = Layer.mergeAll(NodeServices.layer, NetService.layer);
+
+const provideCliRuntime = (effect: Effect.Effect) =>
+ effect.pipe(Effect.provide(CliRuntimeLayer));
+
+it.layer(NodeServices.layer)("cli log-level parsing", (it) => {
+ it.effect("accepts the built-in lowercase log-level flag values", () =>
+ Command.runWith(cli, { version: "0.0.0" })(["--log-level", "debug", "--version"]).pipe(
+ provideCliRuntime,
+ ),
+ );
+
+ it.effect("rejects invalid log-level casing before launching the server", () =>
+ Effect.gen(function* () {
+ const error = yield* Command.runWith(cli, { version: "0.0.0" })([
+ "--log-level",
+ "Debug",
+ ]).pipe(provideCliRuntime, Effect.flip);
+
+ if (!CliError.isCliError(error)) {
+ throw new Error(`Expected CliError, got ${String(error)}`);
+ }
+ if (error._tag !== "InvalidValue") {
+ throw new Error(`Expected InvalidValue, got ${error._tag}`);
+ }
+ assert.equal(error.option, "log-level");
+ assert.equal(error.value, "Debug");
+ }),
+ );
+});
diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts
new file mode 100644
index 0000000000..3588a4b1f8
--- /dev/null
+++ b/apps/server/src/cli.ts
@@ -0,0 +1,297 @@
+import { NetService } from "@t3tools/shared/Net";
+import { Config, Effect, LogLevel, Option, Schema } from "effect";
+import { Command, Flag, GlobalFlag } from "effect/unstable/cli";
+
+import {
+ DEFAULT_PORT,
+ deriveServerPaths,
+ ensureServerDirectories,
+ resolveStaticDir,
+ ServerConfig,
+ RuntimeMode,
+ type ServerConfigShape,
+} from "./config";
+import { readBootstrapEnvelope } from "./bootstrap";
+import { resolveBaseDir } from "./os-jank";
+import { runServer } from "./server";
+
+const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
+
+const BootstrapEnvelopeSchema = Schema.Struct({
+ mode: Schema.optional(RuntimeMode),
+ port: Schema.optional(PortSchema),
+ host: Schema.optional(Schema.String),
+ t3Home: Schema.optional(Schema.String),
+ devUrl: Schema.optional(Schema.URLFromString),
+ noBrowser: Schema.optional(Schema.Boolean),
+ authToken: Schema.optional(Schema.String),
+ autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),
+ logWebSocketEvents: Schema.optional(Schema.Boolean),
+});
+
+const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe(
+ Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
+ Flag.optional,
+);
+const portFlag = Flag.integer("port").pipe(
+ Flag.withSchema(PortSchema),
+ Flag.withDescription("Port for the HTTP/WebSocket server."),
+ Flag.optional,
+);
+const hostFlag = Flag.string("host").pipe(
+ Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."),
+ Flag.optional,
+);
+const baseDirFlag = Flag.string("base-dir").pipe(
+ Flag.withDescription("Base directory path (equivalent to T3CODE_HOME)."),
+ Flag.optional,
+);
+const devUrlFlag = Flag.string("dev-url").pipe(
+ Flag.withSchema(Schema.URLFromString),
+ Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."),
+ Flag.optional,
+);
+const noBrowserFlag = Flag.boolean("no-browser").pipe(
+ Flag.withDescription("Disable automatic browser opening."),
+ Flag.optional,
+);
+const authTokenFlag = Flag.string("auth-token").pipe(
+ Flag.withDescription("Auth token required for WebSocket connections."),
+ Flag.withAlias("token"),
+ Flag.optional,
+);
+const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe(
+ Flag.withSchema(Schema.Int),
+ Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."),
+ Flag.optional,
+);
+const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe(
+ Flag.withDescription(
+ "Create a project for the current working directory on startup when missing.",
+ ),
+ Flag.optional,
+);
+const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe(
+ Flag.withDescription(
+ "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).",
+ ),
+ Flag.withAlias("log-ws-events"),
+ Flag.optional,
+);
+
+const EnvServerConfig = Config.all({
+ logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")),
+ mode: Config.schema(RuntimeMode, "T3CODE_MODE").pipe(
+ Config.option,
+ Config.map(Option.getOrUndefined),
+ ),
+ port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)),
+ host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),
+ t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)),
+ devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)),
+ noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe(
+ Config.option,
+ Config.map(Option.getOrUndefined),
+ ),
+ authToken: Config.string("T3CODE_AUTH_TOKEN").pipe(
+ Config.option,
+ Config.map(Option.getOrUndefined),
+ ),
+ bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe(
+ Config.option,
+ Config.map(Option.getOrUndefined),
+ ),
+ autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe(
+ Config.option,
+ Config.map(Option.getOrUndefined),
+ ),
+ logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe(
+ Config.option,
+ Config.map(Option.getOrUndefined),
+ ),
+});
+
+interface CliServerFlags {
+ readonly mode: Option.Option;
+ readonly port: Option.Option;
+ readonly host: Option.Option;
+ readonly baseDir: Option.Option;
+ readonly devUrl: Option.Option;
+ readonly noBrowser: Option.Option;
+ readonly authToken: Option.Option;
+ readonly bootstrapFd: Option.Option;
+ readonly autoBootstrapProjectFromCwd: Option.Option;
+ readonly logWebSocketEvents: Option.Option;
+}
+
+const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) =>
+ Option.getOrElse(Option.filter(flag, Boolean), () => envValue);
+
+const resolveOptionPrecedence = (
+ ...values: ReadonlyArray>
+): Option.Option => Option.firstSomeOf(values);
+
+export const resolveServerConfig = (
+ flags: CliServerFlags,
+ cliLogLevel: Option.Option,
+) =>
+ Effect.gen(function* () {
+ const { findAvailablePort } = yield* NetService;
+ const env = yield* EnvServerConfig;
+ const bootstrapFd = Option.getOrUndefined(flags.bootstrapFd) ?? env.bootstrapFd;
+ const bootstrapEnvelope =
+ bootstrapFd !== undefined
+ ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd)
+ : Option.none();
+
+ const mode: RuntimeMode = Option.getOrElse(
+ resolveOptionPrecedence(
+ flags.mode,
+ Option.fromUndefinedOr(env.mode),
+ Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.mode)),
+ ),
+ () => "web",
+ );
+
+ const port = yield* Option.match(
+ resolveOptionPrecedence(
+ flags.port,
+ Option.fromUndefinedOr(env.port),
+ Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.port)),
+ ),
+ {
+ onSome: (value) => Effect.succeed(value),
+ onNone: () => {
+ if (mode === "desktop") {
+ return Effect.succeed(DEFAULT_PORT);
+ }
+ return findAvailablePort(DEFAULT_PORT);
+ },
+ },
+ );
+ const devUrl = Option.getOrElse(
+ resolveOptionPrecedence(
+ flags.devUrl,
+ Option.fromUndefinedOr(env.devUrl),
+ Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.devUrl)),
+ ),
+ () => undefined,
+ );
+ const baseDir = yield* resolveBaseDir(
+ Option.getOrUndefined(
+ resolveOptionPrecedence(
+ flags.baseDir,
+ Option.fromUndefinedOr(env.t3Home),
+ Option.flatMap(bootstrapEnvelope, (bootstrap) =>
+ Option.fromUndefinedOr(bootstrap.t3Home),
+ ),
+ ),
+ ),
+ );
+ const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
+ yield* ensureServerDirectories(derivedPaths);
+ const noBrowser = resolveBooleanFlag(
+ flags.noBrowser,
+ Option.getOrElse(
+ resolveOptionPrecedence(
+ Option.fromUndefinedOr(env.noBrowser),
+ Option.flatMap(bootstrapEnvelope, (bootstrap) =>
+ Option.fromUndefinedOr(bootstrap.noBrowser),
+ ),
+ ),
+ () => mode === "desktop",
+ ),
+ );
+ const authToken = Option.getOrUndefined(
+ resolveOptionPrecedence(
+ flags.authToken,
+ Option.fromUndefinedOr(env.authToken),
+ Option.flatMap(bootstrapEnvelope, (bootstrap) =>
+ Option.fromUndefinedOr(bootstrap.authToken),
+ ),
+ ),
+ );
+ const autoBootstrapProjectFromCwd = resolveBooleanFlag(
+ flags.autoBootstrapProjectFromCwd,
+ Option.getOrElse(
+ resolveOptionPrecedence(
+ Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd),
+ Option.flatMap(bootstrapEnvelope, (bootstrap) =>
+ Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd),
+ ),
+ ),
+ () => mode === "web",
+ ),
+ );
+ const logWebSocketEvents = resolveBooleanFlag(
+ flags.logWebSocketEvents,
+ Option.getOrElse(
+ resolveOptionPrecedence(
+ Option.fromUndefinedOr(env.logWebSocketEvents),
+ Option.flatMap(bootstrapEnvelope, (bootstrap) =>
+ Option.fromUndefinedOr(bootstrap.logWebSocketEvents),
+ ),
+ ),
+ () => Boolean(devUrl),
+ ),
+ );
+ const staticDir = devUrl ? undefined : yield* resolveStaticDir();
+ const host = Option.getOrElse(
+ resolveOptionPrecedence(
+ flags.host,
+ Option.fromUndefinedOr(env.host),
+ Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)),
+ ),
+ () => (mode === "desktop" ? "127.0.0.1" : undefined),
+ );
+ const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel);
+
+ const config: ServerConfigShape = {
+ logLevel,
+ mode,
+ port,
+ cwd: process.cwd(),
+ baseDir,
+ ...derivedPaths,
+ host,
+ staticDir,
+ devUrl,
+ noBrowser,
+ authToken,
+ autoBootstrapProjectFromCwd,
+ logWebSocketEvents,
+ };
+
+ return config;
+ });
+
+const commandFlags = {
+ mode: modeFlag,
+ port: portFlag,
+ host: hostFlag,
+ baseDir: baseDirFlag,
+ devUrl: devUrlFlag,
+ noBrowser: noBrowserFlag,
+ authToken: authTokenFlag,
+ bootstrapFd: bootstrapFdFlag,
+ autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
+ logWebSocketEvents: logWebSocketEventsFlag,
+} as const;
+
+const rootCommand = Command.make("t3", commandFlags).pipe(
+ Command.withDescription("Run the T3 Code server."),
+ Command.withHandler((flags) =>
+ Effect.gen(function* () {
+ const logLevel = yield* GlobalFlag.LogLevel;
+ const config = yield* resolveServerConfig(flags, logLevel);
+ return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
+ }),
+ ),
+);
+
+const resetCommand = Command.make("reset", {}).pipe(
+ Command.withDescription("Reset the T3 Code server."),
+ Command.withHandler(() => Effect.die("Not implemented")),
+);
+
+export const cli = rootCommand.pipe(Command.withSubcommands([resetCommand]));
diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
index 29f82bccf1..21cd6f3150 100644
--- a/apps/server/src/config.ts
+++ b/apps/server/src/config.ts
@@ -6,11 +6,12 @@
*
* @module ServerConfig
*/
-import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect";
+import { Effect, FileSystem, Layer, LogLevel, Path, Schema, ServiceMap } from "effect";
export const DEFAULT_PORT = 3773;
-export type RuntimeMode = "web" | "desktop";
+export const RuntimeMode = Schema.Literals(["web", "desktop"]);
+export type RuntimeMode = typeof RuntimeMode.Type;
/**
* ServerDerivedPaths - Derived paths from the base directory.
@@ -34,6 +35,7 @@ export interface ServerDerivedPaths {
* ServerConfigShape - Process/runtime configuration required by the server.
*/
export interface ServerConfigShape extends ServerDerivedPaths {
+ readonly logLevel: LogLevel.LogLevel;
readonly mode: RuntimeMode;
readonly port: number;
readonly host: string | undefined;
@@ -73,6 +75,26 @@ export const deriveServerPaths = Effect.fn(function* (
};
});
+export const ensureServerDirectories = Effect.fn(function* (derivedPaths: ServerDerivedPaths) {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+
+ yield* Effect.all(
+ [
+ fs.makeDirectory(derivedPaths.stateDir, { recursive: true }),
+ fs.makeDirectory(derivedPaths.logsDir, { recursive: true }),
+ fs.makeDirectory(derivedPaths.providerLogsDir, { recursive: true }),
+ fs.makeDirectory(derivedPaths.terminalLogsDir, { recursive: true }),
+ fs.makeDirectory(derivedPaths.attachmentsDir, { recursive: true }),
+ fs.makeDirectory(derivedPaths.worktreesDir, { recursive: true }),
+ fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }),
+ fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }),
+ fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }),
+ ],
+ { concurrency: "unbounded" },
+ );
+});
+
/**
* ServerConfig - Service tag for server runtime configuration.
*/
@@ -91,12 +113,10 @@ export class ServerConfig extends ServiceMap.Service()("GitCommandError", {
- operation: Schema.String,
- command: Schema.String,
- cwd: Schema.String,
- detail: Schema.String,
- cause: Schema.optional(Schema.Defect),
-}) {
- override get message(): string {
- return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`;
- }
-}
-
-/**
- * GitHubCliError - GitHub CLI execution or authentication failed.
- */
-export class GitHubCliError extends Schema.TaggedErrorClass()("GitHubCliError", {
- operation: Schema.String,
- detail: Schema.String,
- cause: Schema.optional(Schema.Defect),
-}) {
- override get message(): string {
- return `GitHub CLI failed in ${this.operation}: ${this.detail}`;
- }
-}
-
-/**
- * TextGenerationError - Commit or PR text generation failed.
- */
-export class TextGenerationError extends Schema.TaggedErrorClass()(
- "TextGenerationError",
- {
- operation: Schema.String,
- detail: Schema.String,
- cause: Schema.optional(Schema.Defect),
- },
-) {
- override get message(): string {
- return `Text generation failed in ${this.operation}: ${this.detail}`;
- }
-}
-
-/**
- * GitManagerError - Stacked Git workflow orchestration failed.
- */
-export class GitManagerError extends Schema.TaggedErrorClass()("GitManagerError", {
- operation: Schema.String,
- detail: Schema.String,
- cause: Schema.optional(Schema.Defect),
-}) {
- override get message(): string {
- return `Git manager failed in ${this.operation}: ${this.detail}`;
- }
-}
-
-/**
- * GitManagerServiceError - Errors emitted by stacked Git workflow orchestration.
- */
-export type GitManagerServiceError =
- | GitManagerError
- | GitCommandError
- | GitHubCliError
- | TextGenerationError;
diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts
index 19ed40e65b..7d620be3a6 100644
--- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts
+++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts
@@ -14,7 +14,7 @@ import { ClaudeModelSelection } from "@t3tools/contracts";
import { resolveApiModelId } from "@t3tools/shared/model";
import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
-import { TextGenerationError } from "../Errors.ts";
+import { TextGenerationError } from "@t3tools/contracts";
import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
import {
buildBranchNamePrompt,
diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts
index 21a97eec9c..a07505f025 100644
--- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts
+++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts
@@ -5,7 +5,7 @@ import { expect } from "vitest";
import { ServerConfig } from "../../config.ts";
import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
-import { TextGenerationError } from "../Errors.ts";
+import { TextGenerationError } from "@t3tools/contracts";
import { TextGeneration } from "../Services/TextGeneration.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts
index 8f0556ee34..9bd838773b 100644
--- a/apps/server/src/git/Layers/CodexTextGeneration.ts
+++ b/apps/server/src/git/Layers/CodexTextGeneration.ts
@@ -8,7 +8,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar
import { resolveAttachmentPath } from "../../attachmentStore.ts";
import { ServerConfig } from "../../config.ts";
-import { TextGenerationError } from "../Errors.ts";
+import { TextGenerationError } from "@t3tools/contracts";
import {
type BranchNameGenerationInput,
type ThreadTitleGenerationResult,
diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts
index 135834f891..6b2c73cf4d 100644
--- a/apps/server/src/git/Layers/GitCore.test.ts
+++ b/apps/server/src/git/Layers/GitCore.test.ts
@@ -8,7 +8,7 @@ import { describe, expect, vi } from "vitest";
import { GitCoreLive, makeGitCore } from "./GitCore.ts";
import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";
-import { GitCommandError } from "../Errors.ts";
+import { GitCommandError } from "@t3tools/contracts";
import { type ProcessRunResult, runProcess } from "../../processRunner.ts";
import { ServerConfig } from "../../config.ts";
diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts
index 81d6cbb549..ecfc6a37c8 100644
--- a/apps/server/src/git/Layers/GitCore.ts
+++ b/apps/server/src/git/Layers/GitCore.ts
@@ -18,7 +18,7 @@ import {
} from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
-import { GitCommandError } from "../Errors.ts";
+import { GitCommandError } from "@t3tools/contracts";
import {
GitCore,
type ExecuteGitProgress,
@@ -1941,8 +1941,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
});
// Refresh upstream refs in the background so checkout remains responsive.
- yield* Effect.forkScoped(
- refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })),
+ yield* refreshCheckedOutBranchUpstream(input.cwd).pipe(
+ Effect.ignoreCause({ log: true }),
+ Effect.forkDetach({ startImmediately: true }),
);
},
);
diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts
index 80ce43659e..76d7d30a47 100644
--- a/apps/server/src/git/Layers/GitHubCli.ts
+++ b/apps/server/src/git/Layers/GitHubCli.ts
@@ -2,7 +2,7 @@ import { Effect, Layer, Schema } from "effect";
import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
import { runProcess } from "../../processRunner";
-import { GitHubCliError } from "../Errors.ts";
+import { GitHubCliError } from "@t3tools/contracts";
import {
GitHubCli,
type GitHubRepositoryCloneUrls,
diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts
index db82ea4c72..e05fc30875 100644
--- a/apps/server/src/git/Layers/GitManager.test.ts
+++ b/apps/server/src/git/Layers/GitManager.test.ts
@@ -8,7 +8,7 @@ import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
import { expect } from "vitest";
import type { GitActionProgressEvent, ModelSelection } from "@t3tools/contracts";
-import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts";
+import { GitCommandError, GitHubCliError, TextGenerationError } from "@t3tools/contracts";
import { type GitManagerShape } from "../Services/GitManager.ts";
import {
type GitHubCliShape,
diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts
index dc082674b7..f8445cf09a 100644
--- a/apps/server/src/git/Layers/GitManager.ts
+++ b/apps/server/src/git/Layers/GitManager.ts
@@ -14,7 +14,7 @@ import {
sanitizeFeatureBranchName,
} from "@t3tools/shared/git";
-import { GitManagerError } from "../Errors.ts";
+import { GitManagerError } from "@t3tools/contracts";
import {
GitManager,
type GitActionProgressReporter,
@@ -25,7 +25,7 @@ import { GitCore } from "../Services/GitCore.ts";
import { GitHubCli } from "../Services/GitHubCli.ts";
import { TextGeneration } from "../Services/TextGeneration.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
-import type { GitManagerServiceError } from "../Errors.ts";
+import type { GitManagerServiceError } from "@t3tools/contracts";
const COMMIT_TIMEOUT_MS = 10 * 60_000;
const MAX_PROGRESS_TEXT_LENGTH = 500;
diff --git a/apps/server/src/git/Prompts.test.ts b/apps/server/src/git/Prompts.test.ts
index 7951e78b39..d8d079c0cf 100644
--- a/apps/server/src/git/Prompts.test.ts
+++ b/apps/server/src/git/Prompts.test.ts
@@ -7,7 +7,7 @@ import {
buildThreadTitlePrompt,
} from "./Prompts.ts";
import { normalizeCliError, sanitizeThreadTitle } from "./Utils.ts";
-import { TextGenerationError } from "./Errors.ts";
+import { TextGenerationError } from "@t3tools/contracts";
describe("buildCommitMessagePrompt", () => {
it("includes staged patch and summary in the prompt", () => {
diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts
index 3c30f17121..d7a28d1763 100644
--- a/apps/server/src/git/Services/GitCore.ts
+++ b/apps/server/src/git/Services/GitCore.ts
@@ -22,7 +22,7 @@ import type {
GitStatusResult,
} from "@t3tools/contracts";
-import type { GitCommandError } from "../Errors.ts";
+import type { GitCommandError } from "@t3tools/contracts";
export interface ExecuteGitInput {
readonly operation: string;
diff --git a/apps/server/src/git/Services/GitHubCli.ts b/apps/server/src/git/Services/GitHubCli.ts
index f10339af47..38afdd5f92 100644
--- a/apps/server/src/git/Services/GitHubCli.ts
+++ b/apps/server/src/git/Services/GitHubCli.ts
@@ -9,7 +9,7 @@ import { ServiceMap } from "effect";
import type { Effect } from "effect";
import type { ProcessRunResult } from "../../processRunner";
-import type { GitHubCliError } from "../Errors.ts";
+import type { GitHubCliError } from "@t3tools/contracts";
export interface GitHubPullRequestSummary {
readonly number: number;
diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts
index 2e83b78c3b..a99e4d3bc4 100644
--- a/apps/server/src/git/Services/GitManager.ts
+++ b/apps/server/src/git/Services/GitManager.ts
@@ -19,7 +19,7 @@ import {
} from "@t3tools/contracts";
import { ServiceMap } from "effect";
import type { Effect } from "effect";
-import type { GitManagerServiceError } from "../Errors.ts";
+import type { GitManagerServiceError } from "@t3tools/contracts";
export interface GitActionProgressReporter {
readonly publish: (event: GitActionProgressEvent) => Effect.Effect;
diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts
index 0df2fff62c..f4354c7a99 100644
--- a/apps/server/src/git/Services/TextGeneration.ts
+++ b/apps/server/src/git/Services/TextGeneration.ts
@@ -10,7 +10,7 @@ import { ServiceMap } from "effect";
import type { Effect } from "effect";
import type { ChatAttachment, ModelSelection } from "@t3tools/contracts";
-import type { TextGenerationError } from "../Errors.ts";
+import type { TextGenerationError } from "@t3tools/contracts";
/** Providers that support git text generation (commit messages, PR content, branch names). */
export type TextGenerationProvider = "codex" | "claudeAgent";
diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts
index 8f0321fd52..4a7931c74b 100644
--- a/apps/server/src/git/Utils.ts
+++ b/apps/server/src/git/Utils.ts
@@ -5,7 +5,7 @@
*/
import { Schema } from "effect";
-import { TextGenerationError } from "./Errors.ts";
+import { TextGenerationError } from "@t3tools/contracts";
import { existsSync } from "node:fs";
import { join } from "node:path";
diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts
new file mode 100644
index 0000000000..700ab8912b
--- /dev/null
+++ b/apps/server/src/http.ts
@@ -0,0 +1,208 @@
+import Mime from "@effect/platform-node/Mime";
+import { Effect, FileSystem, Option, Path } from "effect";
+import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
+
+import {
+ ATTACHMENTS_ROUTE_PREFIX,
+ normalizeAttachmentRelativePath,
+ resolveAttachmentRelativePath,
+} from "./attachmentPaths";
+import { resolveAttachmentPathById } from "./attachmentStore";
+import { ServerConfig } from "./config";
+import { FALLBACK_PROJECT_FAVICON_SVG, PROJECT_FAVICON_CACHE_CONTROL } from "./projectFavicon";
+import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver";
+
+const HEALTH_ROUTE_PATH = "/health";
+
+export const healthRouteLayer = HttpRouter.add(
+ "GET",
+ HEALTH_ROUTE_PATH,
+ HttpServerResponse.json({ ok: true }),
+);
+
+export const attachmentsRouteLayer = HttpRouter.add(
+ "GET",
+ `${ATTACHMENTS_ROUTE_PREFIX}/*`,
+ Effect.gen(function* () {
+ const request = yield* HttpServerRequest.HttpServerRequest;
+ const url = HttpServerRequest.toURL(request);
+ if (Option.isNone(url)) {
+ return HttpServerResponse.text("Bad Request", { status: 400 });
+ }
+
+ const config = yield* ServerConfig;
+ const rawRelativePath = url.value.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length);
+ const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath);
+ if (!normalizedRelativePath) {
+ return HttpServerResponse.text("Invalid attachment path", { status: 400 });
+ }
+
+ const isIdLookup =
+ !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes(".");
+ const filePath = isIdLookup
+ ? resolveAttachmentPathById({
+ attachmentsDir: config.attachmentsDir,
+ attachmentId: normalizedRelativePath,
+ })
+ : resolveAttachmentRelativePath({
+ attachmentsDir: config.attachmentsDir,
+ relativePath: normalizedRelativePath,
+ });
+ if (!filePath) {
+ return HttpServerResponse.text(isIdLookup ? "Not Found" : "Invalid attachment path", {
+ status: isIdLookup ? 404 : 400,
+ });
+ }
+
+ const fileSystem = yield* FileSystem.FileSystem;
+ const fileInfo = yield* fileSystem
+ .stat(filePath)
+ .pipe(Effect.catch(() => Effect.succeed(null)));
+ if (!fileInfo || fileInfo.type !== "File") {
+ return HttpServerResponse.text("Not Found", { status: 404 });
+ }
+
+ const contentType = Mime.getType(filePath) ?? "application/octet-stream";
+ const data = yield* fileSystem
+ .readFile(filePath)
+ .pipe(Effect.catch(() => Effect.succeed(null)));
+ if (!data) {
+ return HttpServerResponse.text("Internal Server Error", { status: 500 });
+ }
+
+ return HttpServerResponse.uint8Array(data, {
+ status: 200,
+ contentType,
+ headers: {
+ "Cache-Control": "public, max-age=31536000, immutable",
+ },
+ });
+ }),
+);
+
+export const projectFaviconRouteLayer = HttpRouter.add(
+ "GET",
+ "/api/project-favicon",
+ Effect.gen(function* () {
+ const request = yield* HttpServerRequest.HttpServerRequest;
+ const url = HttpServerRequest.toURL(request);
+ if (Option.isNone(url)) {
+ return HttpServerResponse.text("Bad Request", { status: 400 });
+ }
+
+ const projectCwd = url.value.searchParams.get("cwd");
+ if (!projectCwd) {
+ return HttpServerResponse.text("Missing cwd parameter", { status: 400 });
+ }
+
+ const faviconResolver = yield* ProjectFaviconResolver;
+ const faviconFilePath = yield* faviconResolver.resolvePath(projectCwd);
+ if (!faviconFilePath) {
+ return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, {
+ status: 200,
+ contentType: "image/svg+xml",
+ headers: {
+ "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL,
+ },
+ });
+ }
+
+ return yield* HttpServerResponse.file(faviconFilePath, {
+ status: 200,
+ headers: {
+ "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL,
+ },
+ }).pipe(
+ Effect.catch(() =>
+ Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
+ ),
+ );
+ }),
+);
+
+export const staticAndDevRouteLayer = HttpRouter.add(
+ "GET",
+ "*",
+ Effect.gen(function* () {
+ const request = yield* HttpServerRequest.HttpServerRequest;
+ const url = HttpServerRequest.toURL(request);
+ if (Option.isNone(url)) {
+ return HttpServerResponse.text("Bad Request", { status: 400 });
+ }
+
+ const config = yield* ServerConfig;
+ if (config.devUrl) {
+ return HttpServerResponse.redirect(config.devUrl.href, { status: 302 });
+ }
+
+ if (!config.staticDir) {
+ return HttpServerResponse.text("No static directory configured and no dev URL set.", {
+ status: 503,
+ });
+ }
+
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const staticRoot = path.resolve(config.staticDir);
+ const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname;
+ const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, "");
+ const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith("..");
+ const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, "");
+ const hasPathTraversalSegment = staticRelativePath.startsWith("..");
+ if (
+ staticRelativePath.length === 0 ||
+ hasRawLeadingParentSegment ||
+ hasPathTraversalSegment ||
+ staticRelativePath.includes("\0")
+ ) {
+ return HttpServerResponse.text("Invalid static file path", { status: 400 });
+ }
+
+ const isWithinStaticRoot = (candidate: string) =>
+ candidate === staticRoot ||
+ candidate.startsWith(staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`);
+
+ let filePath = path.resolve(staticRoot, staticRelativePath);
+ if (!isWithinStaticRoot(filePath)) {
+ return HttpServerResponse.text("Invalid static file path", { status: 400 });
+ }
+
+ const ext = path.extname(filePath);
+ if (!ext) {
+ filePath = path.resolve(filePath, "index.html");
+ if (!isWithinStaticRoot(filePath)) {
+ return HttpServerResponse.text("Invalid static file path", { status: 400 });
+ }
+ }
+
+ const fileInfo = yield* fileSystem
+ .stat(filePath)
+ .pipe(Effect.catch(() => Effect.succeed(null)));
+ if (!fileInfo || fileInfo.type !== "File") {
+ const indexPath = path.resolve(staticRoot, "index.html");
+ const indexData = yield* fileSystem
+ .readFile(indexPath)
+ .pipe(Effect.catch(() => Effect.succeed(null)));
+ if (!indexData) {
+ return HttpServerResponse.text("Not Found", { status: 404 });
+ }
+ return HttpServerResponse.uint8Array(indexData, {
+ status: 200,
+ contentType: "text/html; charset=utf-8",
+ });
+ }
+
+ const contentType = Mime.getType(filePath) ?? "application/octet-stream";
+ const data = yield* fileSystem
+ .readFile(filePath)
+ .pipe(Effect.catch(() => Effect.succeed(null)));
+ if (!data) {
+ return HttpServerResponse.text("Internal Server Error", { status: 500 });
+ }
+
+ return HttpServerResponse.uint8Array(data, {
+ status: 200,
+ contentType,
+ });
+ }),
+);
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
deleted file mode 100644
index 363a07ee38..0000000000
--- a/apps/server/src/index.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
-import * as NodeServices from "@effect/platform-node/NodeServices";
-import * as Effect from "effect/Effect";
-import * as Layer from "effect/Layer";
-
-import { CliConfig, t3Cli } from "./main";
-import { OpenLive } from "./open";
-import { Command } from "effect/unstable/cli";
-import { version } from "../package.json" with { type: "json" };
-import { ServerLive } from "./wsServer";
-import { NetService } from "@t3tools/shared/Net";
-import { FetchHttpClient } from "effect/unstable/http";
-
-const RuntimeLayer = Layer.empty.pipe(
- Layer.provideMerge(CliConfig.layer),
- Layer.provideMerge(ServerLive),
- Layer.provideMerge(OpenLive),
- Layer.provideMerge(NetService.layer),
- Layer.provideMerge(NodeServices.layer),
- Layer.provideMerge(FetchHttpClient.layer),
-);
-
-Command.run(t3Cli, { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain);
diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts
index 9cf0394142..8eda0ca85d 100644
--- a/apps/server/src/keybindings.test.ts
+++ b/apps/server/src/keybindings.test.ts
@@ -8,13 +8,13 @@ import { ServerConfig } from "./config";
import {
DEFAULT_KEYBINDINGS,
Keybindings,
- KeybindingsConfigError,
KeybindingsLive,
ResolvedKeybindingFromConfig,
compileResolvedKeybindingRule,
compileResolvedKeybindingsConfig,
parseKeybindingShortcut,
} from "./keybindings";
+import { KeybindingsConfigError } from "@t3tools/contracts";
const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);
const makeKeybindingsLayer = () => {
diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts
index 176e0300ad..086d795c0c 100644
--- a/apps/server/src/keybindings.ts
+++ b/apps/server/src/keybindings.ts
@@ -9,6 +9,7 @@
import {
KeybindingRule,
KeybindingsConfig,
+ KeybindingsConfigError,
KeybindingShortcut,
KeybindingWhenNode,
MAX_KEYBINDINGS_COUNT,
@@ -46,19 +47,6 @@ import * as Semaphore from "effect/Semaphore";
import { ServerConfig } from "./config";
import { fromLenientJson } from "@t3tools/shared/schemaJson";
-export class KeybindingsConfigError extends Schema.TaggedErrorClass()(
- "KeybindingsConfigParseError",
- {
- configPath: Schema.String,
- detail: Schema.String,
- cause: Schema.optional(Schema.Defect),
- },
-) {
- override get message(): string {
- return `Unable to parse keybindings config at ${this.configPath}: ${this.detail}`;
- }
-}
-
type WhenToken =
| { type: "identifier"; value: string }
| { type: "not" }
diff --git a/apps/server/src/logger.ts b/apps/server/src/logger.ts
deleted file mode 100644
index b9d18569cc..0000000000
--- a/apps/server/src/logger.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import util from "node:util";
-
-type LogLevel = "info" | "warn" | "error" | "event";
-
-type LogContext = Record;
-
-const ANSI = {
- reset: "\u001b[0m",
- dim: "\u001b[2m",
- cyan: "\u001b[36m",
- yellow: "\u001b[33m",
- red: "\u001b[31m",
- magenta: "\u001b[35m",
-} as const;
-
-const LEVEL_LABEL: Record = {
- info: "INFO",
- warn: "WARN",
- error: "ERROR",
- event: "EVENT",
-};
-
-const LEVEL_COLOR: Record = {
- info: ANSI.cyan,
- warn: ANSI.yellow,
- error: ANSI.red,
- event: ANSI.magenta,
-};
-
-function useColors() {
- return Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined;
-}
-
-function colorize(value: string, color: string, enabled: boolean) {
- return enabled ? `${color}${value}${ANSI.reset}` : value;
-}
-
-function timeStamp() {
- return new Date().toISOString().slice(11, 23);
-}
-
-function formatValue(value: unknown) {
- if (typeof value === "string") {
- return JSON.stringify(value);
- }
- if (
- typeof value === "number" ||
- typeof value === "boolean" ||
- value === null ||
- value === undefined
- ) {
- return String(value);
- }
- return util.inspect(value, {
- depth: 4,
- breakLength: Infinity,
- compact: true,
- maxArrayLength: 25,
- maxStringLength: 320,
- });
-}
-
-function formatContext(context: LogContext | undefined) {
- if (!context) return "";
- const entries = Object.entries(context).filter(([, value]) => value !== undefined);
- if (entries.length === 0) return "";
- return entries.map(([key, value]) => `${key}=${formatValue(value)}`).join(" ");
-}
-
-function write(level: LogLevel, scope: string, message: string, context?: LogContext) {
- const colorEnabled = useColors();
- const ts = colorize(timeStamp(), ANSI.dim, colorEnabled);
- const levelLabel = colorize(LEVEL_LABEL[level], LEVEL_COLOR[level], colorEnabled);
- const contextText = formatContext(context);
- const line = `${ts} ${levelLabel} [${scope}] ${message}${contextText ? ` ${contextText}` : ""}`;
-
- if (level === "warn") {
- console.warn(line);
- return;
- }
- if (level === "error") {
- console.error(line);
- return;
- }
- console.log(line);
-}
-
-export function createLogger(scope: string) {
- return {
- info(message: string, context?: LogContext) {
- write("info", scope, message, context);
- },
- warn(message: string, context?: LogContext) {
- write("warn", scope, message, context);
- },
- error(message: string, context?: LogContext) {
- write("error", scope, message, context);
- },
- event(message: string, context?: LogContext) {
- write("event", scope, message, context);
- },
- };
-}
diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts
deleted file mode 100644
index 0d990a81c8..0000000000
--- a/apps/server/src/main.test.ts
+++ /dev/null
@@ -1,413 +0,0 @@
-import * as Http from "node:http";
-import * as NodeServices from "@effect/platform-node/NodeServices";
-import { assert, it, vi } from "@effect/vitest";
-import type { OrchestrationReadModel } from "@t3tools/contracts";
-import * as ConfigProvider from "effect/ConfigProvider";
-import * as Effect from "effect/Effect";
-import * as FileSystem from "effect/FileSystem";
-import * as Layer from "effect/Layer";
-import * as Command from "effect/unstable/cli/Command";
-import { FetchHttpClient } from "effect/unstable/http";
-import { beforeEach } from "vitest";
-import { NetService } from "@t3tools/shared/Net";
-
-import { CliConfig, recordStartupHeartbeat, t3Cli, type CliConfigShape } from "./main";
-import { ServerConfig, type ServerConfigShape } from "./config";
-import { Open, type OpenShape } from "./open";
-import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
-import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
-import { Server, type ServerShape } from "./wsServer";
-import { ServerSettingsService } from "./serverSettings";
-
-const start = vi.fn(() => undefined);
-const stop = vi.fn(() => undefined);
-const fixPath = vi.fn(() => undefined);
-let resolvedConfig: ServerConfigShape | null = null;
-const serverStart = Effect.acquireRelease(
- Effect.gen(function* () {
- resolvedConfig = yield* ServerConfig;
- start();
- return {} as unknown as Http.Server;
- }),
- () => Effect.sync(() => stop()),
-);
-const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred));
-
-// Shared service layer used by this CLI test suite.
-const testLayer = Layer.mergeAll(
- Layer.succeed(CliConfig, {
- cwd: "/tmp/t3-test-workspace",
- fixPath: Effect.sync(fixPath),
- resolveStaticDir: Effect.undefined,
- } satisfies CliConfigShape),
- Layer.succeed(NetService, {
- canListenOnHost: () => Effect.succeed(true),
- isPortAvailableOnLoopback: () => Effect.succeed(true),
- reserveLoopbackPort: () => Effect.succeed(0),
- findAvailablePort,
- }),
- Layer.succeed(Server, {
- start: serverStart,
- stopSignal: Effect.void,
- } satisfies ServerShape),
- Layer.succeed(Open, {
- openBrowser: (_target: string) => Effect.void,
- openInEditor: () => Effect.void,
- } satisfies OpenShape),
- ServerSettingsService.layerTest(),
- AnalyticsService.layerTest,
- FetchHttpClient.layer,
- NodeServices.layer,
-);
-
-const runCli = (
- args: ReadonlyArray,
- env: Record = { T3CODE_NO_BROWSER: "true" },
-) => {
- return Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe(
- Effect.provide(
- ConfigProvider.layer(
- ConfigProvider.fromEnv({
- env: {
- ...env,
- },
- }),
- ),
- ),
- );
-};
-
-beforeEach(() => {
- vi.clearAllMocks();
- resolvedConfig = null;
- start.mockImplementation(() => undefined);
- stop.mockImplementation(() => undefined);
- fixPath.mockImplementation(() => undefined);
- findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred));
-});
-
-it.layer(testLayer)("server CLI command", (it) => {
- it.effect("parses all CLI flags and wires scoped start/stop", () =>
- Effect.gen(function* () {
- yield* runCli([
- "--mode",
- "desktop",
- "--port",
- "4010",
- "--host",
- "0.0.0.0",
- "--home-dir",
- "/tmp/t3-cli-home",
- "--dev-url",
- "http://127.0.0.1:5173",
- "--no-browser",
- "--auth-token",
- "auth-secret",
- ]);
-
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.mode, "desktop");
- assert.equal(resolvedConfig?.port, 4010);
- assert.equal(resolvedConfig?.host, "0.0.0.0");
- assert.equal(resolvedConfig?.baseDir, "/tmp/t3-cli-home");
- assert.equal(resolvedConfig?.stateDir, "/tmp/t3-cli-home/dev");
- assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/");
- assert.equal(resolvedConfig?.noBrowser, true);
- assert.equal(resolvedConfig?.authToken, "auth-secret");
- assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false);
- assert.equal(resolvedConfig?.logWebSocketEvents, true);
- assert.equal(stop.mock.calls.length, 1);
- }),
- );
-
- it.effect("supports --token as an alias for --auth-token", () =>
- Effect.gen(function* () {
- yield* runCli(["--token", "token-secret"]);
-
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.authToken, "token-secret");
- }),
- );
-
- it.effect("uses env fallbacks when flags are not provided", () =>
- Effect.gen(function* () {
- yield* runCli([], {
- T3CODE_MODE: "desktop",
- T3CODE_PORT: "4999",
- T3CODE_HOST: "100.88.10.4",
- T3CODE_HOME: "/tmp/t3-env-home",
- VITE_DEV_SERVER_URL: "http://localhost:5173",
- T3CODE_NO_BROWSER: "true",
- T3CODE_AUTH_TOKEN: "env-token",
- });
-
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.mode, "desktop");
- assert.equal(resolvedConfig?.port, 4999);
- assert.equal(resolvedConfig?.host, "100.88.10.4");
- assert.equal(resolvedConfig?.baseDir, "/tmp/t3-env-home");
- assert.equal(resolvedConfig?.stateDir, "/tmp/t3-env-home/dev");
- assert.equal(resolvedConfig?.devUrl?.toString(), "http://localhost:5173/");
- assert.equal(resolvedConfig?.noBrowser, true);
- assert.equal(resolvedConfig?.authToken, "env-token");
- assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false);
- assert.equal(resolvedConfig?.logWebSocketEvents, true);
- assert.equal(findAvailablePort.mock.calls.length, 0);
- }),
- );
-
- const openBootstrapFd = Effect.fn(function* (payload: Record) {
- const fs = yield* FileSystem.FileSystem;
- const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });
- yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`);
- const { fd } = yield* fs.open(filePath, { flag: "r" });
- return fd;
- });
-
- it.effect("recognizes bootstrap fd from environment config", () =>
- Effect.gen(function* () {
- const fd = yield* openBootstrapFd({ authToken: "bootstrap-token" });
-
- yield* runCli([], {
- T3CODE_MODE: "web",
- T3CODE_BOOTSTRAP_FD: String(fd),
- T3CODE_AUTH_TOKEN: "env-token",
- T3CODE_NO_BROWSER: "true",
- });
-
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.mode, "web");
- assert.equal(resolvedConfig?.authToken, "env-token");
- }),
- );
-
- it.effect("uses bootstrap envelope values as fallbacks when CLI and env are absent", () =>
- Effect.gen(function* () {
- const fd = yield* openBootstrapFd({
- mode: "desktop",
- port: 4888,
- host: "127.0.0.2",
- t3Home: "/tmp/t3-bootstrap-home",
- devUrl: "http://127.0.0.1:5173",
- noBrowser: true,
- authToken: "bootstrap-token",
- autoBootstrapProjectFromCwd: false,
- logWebSocketEvents: true,
- });
-
- yield* runCli([], {
- T3CODE_BOOTSTRAP_FD: String(fd),
- });
-
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.mode, "desktop");
- assert.equal(resolvedConfig?.port, 4888);
- assert.equal(resolvedConfig?.host, "127.0.0.2");
- assert.equal(resolvedConfig?.baseDir, "/tmp/t3-bootstrap-home");
- assert.equal(resolvedConfig?.stateDir, "/tmp/t3-bootstrap-home/dev");
- assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/");
- assert.equal(resolvedConfig?.noBrowser, true);
- assert.equal(resolvedConfig?.authToken, "bootstrap-token");
- assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false);
- assert.equal(resolvedConfig?.logWebSocketEvents, true);
- }),
- );
-
- it.effect("applies CLI then env precedence over bootstrap envelope values", () =>
- Effect.gen(function* () {
- const fd = yield* openBootstrapFd({
- mode: "desktop",
- port: 4888,
- host: "127.0.0.2",
- t3Home: "/tmp/t3-bootstrap-home",
- devUrl: "http://127.0.0.1:5173",
- noBrowser: false,
- authToken: "bootstrap-token",
- autoBootstrapProjectFromCwd: false,
- logWebSocketEvents: false,
- });
-
- yield* runCli(["--port", "4999", "--host", "0.0.0.0", "--auth-token", "cli-token"], {
- T3CODE_MODE: "web",
- T3CODE_BOOTSTRAP_FD: String(fd),
- T3CODE_HOME: "/tmp/t3-env-home",
- T3CODE_NO_BROWSER: "true",
- T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
- T3CODE_LOG_WS_EVENTS: "true",
- });
-
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.mode, "web");
- assert.equal(resolvedConfig?.port, 4999);
- assert.equal(resolvedConfig?.host, "0.0.0.0");
- assert.equal(resolvedConfig?.baseDir, "/tmp/t3-env-home");
- assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/");
- assert.equal(resolvedConfig?.noBrowser, true);
- assert.equal(resolvedConfig?.authToken, "cli-token");
- assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, true);
- assert.equal(resolvedConfig?.logWebSocketEvents, true);
- }),
- );
-
- it.effect("prefers --mode over T3CODE_MODE", () =>
- Effect.gen(function* () {
- findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(4666));
- yield* runCli(["--mode", "web"], {
- T3CODE_MODE: "desktop",
- T3CODE_NO_BROWSER: "true",
- });
-
- assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]);
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.mode, "web");
- assert.equal(resolvedConfig?.port, 4666);
- assert.equal(resolvedConfig?.host, undefined);
- }),
- );
-
- it.effect("prefers --no-browser over T3CODE_NO_BROWSER", () =>
- Effect.gen(function* () {
- yield* runCli(["--no-browser"], {
- T3CODE_NO_BROWSER: "false",
- });
-
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.noBrowser, true);
- }),
- );
-
- it.effect("uses dynamic port discovery in web mode when port is omitted", () =>
- Effect.gen(function* () {
- findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(5444));
- yield* runCli([]);
-
- assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]);
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.port, 5444);
- assert.equal(resolvedConfig?.mode, "web");
- }),
- );
-
- it.effect("uses fixed localhost defaults in desktop mode", () =>
- Effect.gen(function* () {
- yield* runCli([], {
- T3CODE_MODE: "desktop",
- T3CODE_NO_BROWSER: "true",
- });
-
- assert.equal(findAvailablePort.mock.calls.length, 0);
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.port, 3773);
- assert.equal(resolvedConfig?.host, "127.0.0.1");
- assert.equal(resolvedConfig?.mode, "desktop");
- }),
- );
-
- it.effect("allows overriding desktop host with --host", () =>
- Effect.gen(function* () {
- yield* runCli(["--host", "0.0.0.0"], {
- T3CODE_MODE: "desktop",
- T3CODE_NO_BROWSER: "true",
- });
-
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.mode, "desktop");
- assert.equal(resolvedConfig?.host, "0.0.0.0");
- }),
- );
-
- it.effect("supports CLI and env for bootstrap/log websocket toggles", () =>
- Effect.gen(function* () {
- yield* runCli(["--auto-bootstrap-project-from-cwd"], {
- T3CODE_MODE: "desktop",
- T3CODE_LOG_WS_EVENTS: "false",
- T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
- T3CODE_NO_BROWSER: "true",
- });
-
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, true);
- assert.equal(resolvedConfig?.logWebSocketEvents, false);
- }),
- );
-
- it.effect("hydrates PATH before server startup", () =>
- Effect.gen(function* () {
- yield* runCli([]);
-
- assert.equal(fixPath.mock.calls.length, 1);
- assert.equal(start.mock.calls.length, 1);
- const fixPathOrder = fixPath.mock.invocationCallOrder[0];
- const startOrder = start.mock.invocationCallOrder[0];
- if (typeof fixPathOrder !== "number" || typeof startOrder !== "number") {
- assert.fail("Expected fixPath and start to be called");
- }
- assert.isTrue(fixPathOrder < startOrder);
- }),
- );
-
- it.effect("records a startup heartbeat with thread/project counts", () =>
- Effect.gen(function* () {
- const recordTelemetry = vi.fn(
- (_event: string, _properties?: Readonly>) => Effect.void,
- );
- const getSnapshot = vi.fn(() =>
- Effect.succeed({
- snapshotSequence: 2,
- projects: [{} as OrchestrationReadModel["projects"][number]],
- threads: [
- {} as OrchestrationReadModel["threads"][number],
- {} as OrchestrationReadModel["threads"][number],
- ],
- updatedAt: new Date(1).toISOString(),
- } satisfies OrchestrationReadModel),
- );
-
- yield* recordStartupHeartbeat.pipe(
- Effect.provideService(ProjectionSnapshotQuery, {
- getSnapshot,
- }),
- Effect.provideService(AnalyticsService, {
- record: recordTelemetry,
- flush: Effect.void,
- }),
- );
-
- assert.deepEqual(recordTelemetry.mock.calls[0], [
- "server.boot.heartbeat",
- {
- threadCount: 2,
- projectCount: 1,
- },
- ]);
- }),
- );
-
- it.effect("does not start server for invalid --mode values", () =>
- Effect.gen(function* () {
- yield* runCli(["--mode", "invalid"]).pipe(Effect.catch(() => Effect.void));
-
- assert.equal(start.mock.calls.length, 0);
- assert.equal(stop.mock.calls.length, 0);
- }),
- );
-
- it.effect("does not start server for invalid --dev-url values", () =>
- Effect.gen(function* () {
- yield* runCli(["--dev-url", "not-a-url"]).pipe(Effect.catch(() => Effect.void));
-
- assert.equal(start.mock.calls.length, 0);
- assert.equal(stop.mock.calls.length, 0);
- }),
- );
-
- it.effect("does not start server for out-of-range --port values", () =>
- Effect.gen(function* () {
- yield* runCli(["--port", "70000"]).pipe(Effect.catch(() => Effect.void));
-
- // effect/unstable/cli renders help/errors for parse failures and returns success.
- assert.equal(start.mock.calls.length, 0);
- assert.equal(stop.mock.calls.length, 0);
- }),
- );
-});
diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
deleted file mode 100644
index 019783c253..0000000000
--- a/apps/server/src/main.ts
+++ /dev/null
@@ -1,456 +0,0 @@
-/**
- * CliConfig - CLI/runtime bootstrap service definitions.
- *
- * Defines startup-only service contracts used while resolving process config
- * and constructing server runtime layers.
- *
- * @module CliConfig
- */
-import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect";
-import { Command, Flag } from "effect/unstable/cli";
-import { NetService } from "@t3tools/shared/Net";
-import {
- DEFAULT_PORT,
- deriveServerPaths,
- resolveStaticDir,
- ServerConfig,
- type RuntimeMode,
- type ServerConfigShape,
-} from "./config";
-import { fixPath, resolveBaseDir } from "./os-jank";
-import { Open } from "./open";
-import * as SqlitePersistence from "./persistence/Layers/Sqlite";
-import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
-import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
-import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry";
-import { Server } from "./wsServer";
-import { ServerLoggerLive } from "./serverLogger";
-import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";
-import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
-import { readBootstrapEnvelope } from "./bootstrap";
-import { ServerSettingsLive } from "./serverSettings";
-
-export class StartupError extends Data.TaggedError("StartupError")<{
- readonly message: string;
- readonly cause?: unknown;
-}> {}
-
-const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
-
-const BootstrapEnvelopeSchema = Schema.Struct({
- mode: Schema.optional(Schema.String),
- port: Schema.optional(PortSchema),
- host: Schema.optional(Schema.String),
- t3Home: Schema.optional(Schema.String),
- devUrl: Schema.optional(Schema.URLFromString),
- noBrowser: Schema.optional(Schema.Boolean),
- authToken: Schema.optional(Schema.String),
- autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),
- logWebSocketEvents: Schema.optional(Schema.Boolean),
-});
-
-interface CliInput {
- readonly mode: Option.Option;
- readonly port: Option.Option;
- readonly host: Option.Option;
- readonly t3Home: Option.Option;
- readonly devUrl: Option.Option;
- readonly noBrowser: Option.Option;
- readonly authToken: Option.Option;
- readonly bootstrapFd: Option.Option;
- readonly autoBootstrapProjectFromCwd: Option.Option;
- readonly logWebSocketEvents: Option.Option;
-}
-
-/**
- * CliConfigShape - Startup helpers required while building server layers.
- */
-export interface CliConfigShape {
- /**
- * Current process working directory.
- */
- readonly cwd: string;
-
- /**
- * Apply OS-specific PATH normalization.
- */
- readonly fixPath: Effect.Effect;
-
- /**
- * Resolve static web asset directory for server mode.
- */
- readonly resolveStaticDir: Effect.Effect;
-}
-
-/**
- * CliConfig - Service tag for startup CLI/runtime helpers.
- */
-export class CliConfig extends ServiceMap.Service()(
- "t3/main/CliConfig",
-) {
- static readonly layer = Layer.effect(
- CliConfig,
- Effect.gen(function* () {
- const fileSystem = yield* FileSystem.FileSystem;
- const path = yield* Path.Path;
- return {
- cwd: process.cwd(),
- fixPath: Effect.sync(fixPath),
- resolveStaticDir: resolveStaticDir().pipe(
- Effect.provideService(FileSystem.FileSystem, fileSystem),
- Effect.provideService(Path.Path, path),
- ),
- } satisfies CliConfigShape;
- }),
- );
-}
-
-const CliEnvConfig = Config.all({
- mode: Config.string("T3CODE_MODE").pipe(
- Config.option,
- Config.map(Option.map((value) => (value === "desktop" ? "desktop" : "web"))),
- Config.map(Option.getOrUndefined),
- ),
- port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)),
- host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),
- t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)),
- devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)),
- noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe(
- Config.option,
- Config.map(Option.getOrUndefined),
- ),
- authToken: Config.string("T3CODE_AUTH_TOKEN").pipe(
- Config.option,
- Config.map(Option.getOrUndefined),
- ),
- bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe(
- Config.option,
- Config.map(Option.getOrUndefined),
- ),
- autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe(
- Config.option,
- Config.map(Option.getOrUndefined),
- ),
- logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe(
- Config.option,
- Config.map(Option.getOrUndefined),
- ),
-});
-
-const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) =>
- Option.getOrElse(Option.filter(flag, Boolean), () => envValue);
-
-const resolveOptionPrecedence = (
- ...values: ReadonlyArray>
-): Option.Option => Option.firstSomeOf(values);
-
-const isValidPort = (value: number): boolean => value >= 1 && value <= 65_535;
-const isRuntimeMode = (value: string): value is RuntimeMode =>
- value === "web" || value === "desktop";
-
-const ServerConfigLive = (input: CliInput) =>
- Layer.effect(
- ServerConfig,
- Effect.gen(function* () {
- const cliConfig = yield* CliConfig;
- const { findAvailablePort } = yield* NetService;
- const env = yield* CliEnvConfig.asEffect().pipe(
- Effect.mapError(
- (cause) =>
- new StartupError({ message: "Failed to read environment configuration", cause }),
- ),
- );
-
- const bootstrapFd = Option.getOrUndefined(input.bootstrapFd) ?? env.bootstrapFd;
- const bootstrapEnvelope =
- bootstrapFd !== undefined
- ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd)
- : Option.none();
-
- const mode: RuntimeMode = Option.getOrElse(
- resolveOptionPrecedence(
- input.mode,
- Option.fromUndefinedOr(env.mode),
- Option.flatMap(bootstrapEnvelope, (bootstrap) =>
- Option.filter(Option.fromUndefinedOr(bootstrap.mode), isRuntimeMode),
- ),
- ),
- () => "web",
- );
- const port = yield* Option.match(
- resolveOptionPrecedence(
- input.port,
- Option.fromUndefinedOr(env.port),
- Option.flatMap(bootstrapEnvelope, (bootstrap) =>
- Option.filter(Option.fromUndefinedOr(bootstrap.port), isValidPort),
- ),
- ),
- {
- onSome: (value) => Effect.succeed(value),
- onNone: () => {
- if (mode === "desktop") {
- return Effect.succeed(DEFAULT_PORT);
- }
- return findAvailablePort(DEFAULT_PORT);
- },
- },
- );
-
- const devUrl = Option.getOrElse(
- resolveOptionPrecedence(
- input.devUrl,
- Option.fromUndefinedOr(env.devUrl),
- Option.flatMap(bootstrapEnvelope, (bootstrap) =>
- Option.fromUndefinedOr(bootstrap.devUrl),
- ),
- ),
- () => undefined,
- );
- const baseDir = yield* resolveBaseDir(
- Option.getOrUndefined(
- resolveOptionPrecedence(
- input.t3Home,
- Option.fromUndefinedOr(env.t3Home),
- Option.flatMap(bootstrapEnvelope, (bootstrap) =>
- Option.fromUndefinedOr(bootstrap.t3Home),
- ),
- ),
- ),
- );
- const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
- const noBrowser = resolveBooleanFlag(
- input.noBrowser,
- Option.getOrElse(
- resolveOptionPrecedence(
- Option.fromUndefinedOr(env.noBrowser),
- Option.flatMap(bootstrapEnvelope, (bootstrap) =>
- Option.fromUndefinedOr(bootstrap.noBrowser),
- ),
- ),
- () => mode === "desktop",
- ),
- );
- const authToken = resolveOptionPrecedence(
- input.authToken,
- Option.fromUndefinedOr(env.authToken),
- Option.flatMap(bootstrapEnvelope, (bootstrap) =>
- Option.fromUndefinedOr(bootstrap.authToken),
- ),
- );
- const autoBootstrapProjectFromCwd = resolveBooleanFlag(
- input.autoBootstrapProjectFromCwd,
- Option.getOrElse(
- resolveOptionPrecedence(
- Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd),
- Option.flatMap(bootstrapEnvelope, (bootstrap) =>
- Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd),
- ),
- ),
- () => mode === "web",
- ),
- );
- const logWebSocketEvents = resolveBooleanFlag(
- input.logWebSocketEvents,
- Option.getOrElse(
- resolveOptionPrecedence(
- Option.fromUndefinedOr(env.logWebSocketEvents),
- Option.flatMap(bootstrapEnvelope, (bootstrap) =>
- Option.fromUndefinedOr(bootstrap.logWebSocketEvents),
- ),
- ),
- () => Boolean(devUrl),
- ),
- );
- const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;
- const host = Option.getOrElse(
- resolveOptionPrecedence(
- input.host,
- Option.fromUndefinedOr(env.host),
- Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)),
- ),
- () => (mode === "desktop" ? "127.0.0.1" : undefined),
- );
-
- const config: ServerConfigShape = {
- mode,
- port,
- cwd: cliConfig.cwd,
- host,
- baseDir,
- ...derivedPaths,
- staticDir,
- devUrl,
- noBrowser,
- authToken: Option.getOrUndefined(authToken),
- autoBootstrapProjectFromCwd,
- logWebSocketEvents,
- } satisfies ServerConfigShape;
-
- return config;
- }),
- );
-
-const LayerLive = (input: CliInput) =>
- Layer.empty.pipe(
- Layer.provideMerge(makeServerRuntimeServicesLayer()),
- Layer.provideMerge(makeServerProviderLayer()),
- Layer.provideMerge(ProviderRegistryLive),
- Layer.provideMerge(SqlitePersistence.layerConfig),
- Layer.provideMerge(ServerLoggerLive),
- Layer.provideMerge(AnalyticsServiceLayerLive),
- Layer.provideMerge(ServerSettingsLive),
- Layer.provideMerge(ServerConfigLive(input)),
- );
-
-const isWildcardHost = (host: string | undefined): boolean =>
- host === "0.0.0.0" || host === "::" || host === "[::]";
-
-const formatHostForUrl = (host: string): string =>
- host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
-
-export const recordStartupHeartbeat = Effect.gen(function* () {
- const analytics = yield* AnalyticsService;
- const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
-
- const { threadCount, projectCount } = yield* projectionSnapshotQuery.getSnapshot().pipe(
- Effect.map((snapshot) => ({
- threadCount: snapshot.threads.length,
- projectCount: snapshot.projects.length,
- })),
- Effect.catch((cause) =>
- Effect.logWarning("failed to gather startup snapshot for telemetry", { cause }).pipe(
- Effect.as({
- threadCount: 0,
- projectCount: 0,
- }),
- ),
- ),
- );
-
- yield* analytics.record("server.boot.heartbeat", {
- threadCount,
- projectCount,
- });
-});
-
-const makeServerRuntimeProgram = (input: CliInput) =>
- Effect.gen(function* () {
- const { start, stopSignal } = yield* Server;
- const openDeps = yield* Open;
-
- const config = yield* ServerConfig;
-
- if (!config.devUrl && !config.staticDir) {
- yield* Effect.logWarning(
- "web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable",
- {
- hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.",
- },
- );
- }
-
- yield* start;
- yield* Effect.forkChild(recordStartupHeartbeat);
-
- const localUrl = `http://localhost:${config.port}`;
- const bindUrl =
- config.host && !isWildcardHost(config.host)
- ? `http://${formatHostForUrl(config.host)}:${config.port}`
- : localUrl;
- const { authToken, devUrl, ...safeConfig } = config;
- yield* Effect.logInfo("T3 Code running", {
- ...safeConfig,
- devUrl: devUrl?.toString(),
- authEnabled: Boolean(authToken),
- });
-
- if (!config.noBrowser) {
- const target = config.devUrl?.toString() ?? bindUrl;
- yield* openDeps.openBrowser(target).pipe(
- Effect.catch(() =>
- Effect.logInfo("browser auto-open unavailable", {
- hint: `Open ${target} in your browser.`,
- }),
- ),
- );
- }
-
- return yield* stopSignal;
- }).pipe(Effect.provide(LayerLive(input)));
-
-const makeServerProgram = (input: CliInput) =>
- Effect.gen(function* () {
- const cliConfig = yield* CliConfig;
- yield* cliConfig.fixPath;
- return yield* makeServerRuntimeProgram(input);
- });
-
-/**
- * These flags mirrors the environment variables and the config shape.
- */
-
-const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe(
- Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
- Flag.optional,
-);
-const portFlag = Flag.integer("port").pipe(
- Flag.withSchema(PortSchema),
- Flag.withDescription("Port for the HTTP/WebSocket server."),
- Flag.optional,
-);
-const hostFlag = Flag.string("host").pipe(
- Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."),
- Flag.optional,
-);
-const t3HomeFlag = Flag.string("home-dir").pipe(
- Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."),
- Flag.optional,
-);
-const devUrlFlag = Flag.string("dev-url").pipe(
- Flag.withSchema(Schema.URLFromString),
- Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."),
- Flag.optional,
-);
-const noBrowserFlag = Flag.boolean("no-browser").pipe(
- Flag.withDescription("Disable automatic browser opening."),
- Flag.optional,
-);
-const authTokenFlag = Flag.string("auth-token").pipe(
- Flag.withDescription("Auth token required for WebSocket connections."),
- Flag.withAlias("token"),
- Flag.optional,
-);
-const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe(
- Flag.withSchema(Schema.Int),
- Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."),
- Flag.optional,
-);
-const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe(
- Flag.withDescription(
- "Create a project for the current working directory on startup when missing.",
- ),
- Flag.optional,
-);
-const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe(
- Flag.withDescription(
- "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).",
- ),
- Flag.withAlias("log-ws-events"),
- Flag.optional,
-);
-
-export const t3Cli = Command.make("t3", {
- mode: modeFlag,
- port: portFlag,
- host: hostFlag,
- t3Home: t3HomeFlag,
- devUrl: devUrlFlag,
- noBrowser: noBrowserFlag,
- authToken: authTokenFlag,
- bootstrapFd: bootstrapFdFlag,
- autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
- logWebSocketEvents: logWebSocketEventsFlag,
-}).pipe(
- Command.withDescription("Run the T3 Code server."),
- Command.withHandler((input) => Effect.scoped(makeServerProgram(input))),
-);
diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts
index 3fbfd1653d..212db36c01 100644
--- a/apps/server/src/open.ts
+++ b/apps/server/src/open.ts
@@ -10,17 +10,14 @@ import { spawn } from "node:child_process";
import { accessSync, constants, statSync } from "node:fs";
import { extname, join } from "node:path";
-import { EDITORS, type EditorId } from "@t3tools/contracts";
-import { ServiceMap, Schema, Effect, Layer } from "effect";
+import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts";
+import { ServiceMap, Effect, Layer } from "effect";
// ==============================
// Definitions
// ==============================
-export class OpenError extends Schema.TaggedErrorClass()("OpenError", {
- message: Schema.String,
- cause: Schema.optional(Schema.Defect),
-}) {}
+export { OpenError };
export interface OpenInEditorInput {
readonly cwd: string;
diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
index 7c201af375..fc33f0c10d 100644
--- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
+++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
@@ -115,7 +115,7 @@ async function waitForThread(
checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>;
activities: ReadonlyArray<{ kind: string }>;
}) => boolean,
- timeoutMs = 5000,
+ timeoutMs = 15_000,
) {
const deadline = Date.now() + timeoutMs;
const poll = async (): Promise<{
@@ -140,7 +140,7 @@ async function waitForThread(
async function waitForEvent(
engine: OrchestrationEngineShape,
predicate: (event: { type: string }) => boolean,
- timeoutMs = 5000,
+ timeoutMs = 15_000,
) {
const deadline = Date.now() + timeoutMs;
const poll = async () => {
@@ -191,7 +191,7 @@ function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string {
return runGit(cwd, ["show", `${ref}:${filePath}`]);
}
-async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 5000) {
+async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 15_000) {
const deadline = Date.now() + timeoutMs;
const poll = async (): Promise => {
if (gitRefExists(cwd, ref)) {
@@ -1007,18 +1007,7 @@ describe("CheckpointReactor", () => {
}),
);
- const deadline = Date.now() + 2000;
- const waitForRollbackCalls = async (): Promise => {
- if (harness.provider.rollbackConversation.mock.calls.length >= 2) {
- return;
- }
- if (Date.now() >= deadline) {
- throw new Error("Timed out waiting for rollbackConversation calls.");
- }
- await new Promise((resolve) => setTimeout(resolve, 10));
- return waitForRollbackCalls();
- };
- await waitForRollbackCalls();
+ await harness.drain();
expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2);
expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({
diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts
index 69b28b9d3c..5c52379f47 100644
--- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts
+++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts
@@ -205,7 +205,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope)));
yield* Effect.forkScoped(worker);
- yield* Effect.log("orchestration engine started").pipe(
+ yield* Effect.logDebug("orchestration engine started").pipe(
Effect.annotateLogs({ sequence: readModel.snapshotSequence }),
);
diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
index f0f2ccabee..49c04ea1ee 100644
--- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
@@ -3,7 +3,6 @@ import {
type ChatAttachment,
type OrchestrationEvent,
} from "@t3tools/contracts";
-import * as NodeServices from "@effect/platform-node/NodeServices";
import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect";
import * as SqlClient from "effect/unstable/sql/SqlClient";
@@ -1291,7 +1290,6 @@ export const OrchestrationProjectionPipelineLive = Layer.effect(
OrchestrationProjectionPipeline,
makeOrchestrationProjectionPipeline(),
).pipe(
- Layer.provideMerge(NodeServices.layer),
Layer.provideMerge(ProjectionProjectRepositoryLive),
Layer.provideMerge(ProjectionThreadRepositoryLive),
Layer.provideMerge(ProjectionThreadMessageRepositoryLive),
diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
index ed7037695f..506d6d2864 100644
--- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
+++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
@@ -17,7 +17,7 @@ import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effe
import { afterEach, describe, expect, it, vi } from "vitest";
import { deriveServerPaths, ServerConfig } from "../../config.ts";
-import { TextGenerationError } from "../../git/Errors.ts";
+import { TextGenerationError } from "@t3tools/contracts";
import { ProviderAdapterRequestError } from "../../provider/Errors.ts";
import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts
new file mode 100644
index 0000000000..7c9d68dc41
--- /dev/null
+++ b/apps/server/src/orchestration/Normalizer.ts
@@ -0,0 +1,129 @@
+import { Effect, FileSystem, Path } from "effect";
+import {
+ type ClientOrchestrationCommand,
+ type OrchestrationCommand,
+ OrchestrationDispatchCommandError,
+ PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
+} from "@t3tools/contracts";
+
+import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore";
+import { ServerConfig } from "../config";
+import { parseBase64DataUrl } from "../imageMime";
+import { expandHomePath } from "../os-jank";
+
+export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const serverConfig = yield* ServerConfig;
+
+ const normalizeProjectWorkspaceRoot = (workspaceRoot: string) =>
+ Effect.gen(function* () {
+ const normalizedWorkspaceRoot = path.resolve(yield* expandHomePath(workspaceRoot.trim()));
+ const workspaceStat = yield* fileSystem
+ .stat(normalizedWorkspaceRoot)
+ .pipe(Effect.catch(() => Effect.succeed(null)));
+ if (!workspaceStat) {
+ return yield* new OrchestrationDispatchCommandError({
+ message: `Project directory does not exist: ${normalizedWorkspaceRoot}`,
+ });
+ }
+ if (workspaceStat.type !== "Directory") {
+ return yield* new OrchestrationDispatchCommandError({
+ message: `Project path is not a directory: ${normalizedWorkspaceRoot}`,
+ });
+ }
+ return normalizedWorkspaceRoot;
+ });
+
+ if (command.type === "project.create") {
+ return {
+ ...command,
+ workspaceRoot: yield* normalizeProjectWorkspaceRoot(command.workspaceRoot),
+ } satisfies OrchestrationCommand;
+ }
+
+ if (command.type === "project.meta.update" && command.workspaceRoot !== undefined) {
+ return {
+ ...command,
+ workspaceRoot: yield* normalizeProjectWorkspaceRoot(command.workspaceRoot),
+ } satisfies OrchestrationCommand;
+ }
+
+ if (command.type !== "thread.turn.start") {
+ return command as OrchestrationCommand;
+ }
+
+ const normalizedAttachments = yield* Effect.forEach(
+ command.message.attachments,
+ (attachment) =>
+ Effect.gen(function* () {
+ const parsed = parseBase64DataUrl(attachment.dataUrl);
+ if (!parsed || !parsed.mimeType.startsWith("image/")) {
+ return yield* new OrchestrationDispatchCommandError({
+ message: `Invalid image attachment payload for '${attachment.name}'.`,
+ });
+ }
+
+ const bytes = Buffer.from(parsed.base64, "base64");
+ if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
+ return yield* new OrchestrationDispatchCommandError({
+ message: `Image attachment '${attachment.name}' is empty or too large.`,
+ });
+ }
+
+ const attachmentId = createAttachmentId(command.threadId);
+ if (!attachmentId) {
+ return yield* new OrchestrationDispatchCommandError({
+ message: "Failed to create a safe attachment id.",
+ });
+ }
+
+ const persistedAttachment = {
+ type: "image" as const,
+ id: attachmentId,
+ name: attachment.name,
+ mimeType: parsed.mimeType.toLowerCase(),
+ sizeBytes: bytes.byteLength,
+ };
+
+ const attachmentPath = resolveAttachmentPath({
+ attachmentsDir: serverConfig.attachmentsDir,
+ attachment: persistedAttachment,
+ });
+ if (!attachmentPath) {
+ return yield* new OrchestrationDispatchCommandError({
+ message: `Failed to resolve persisted path for '${attachment.name}'.`,
+ });
+ }
+
+ yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe(
+ Effect.mapError(
+ () =>
+ new OrchestrationDispatchCommandError({
+ message: `Failed to create attachment directory for '${attachment.name}'.`,
+ }),
+ ),
+ );
+ yield* fileSystem.writeFile(attachmentPath, bytes).pipe(
+ Effect.mapError(
+ () =>
+ new OrchestrationDispatchCommandError({
+ message: `Failed to persist attachment '${attachment.name}'.`,
+ }),
+ ),
+ );
+
+ return persistedAttachment;
+ }),
+ { concurrency: 1 },
+ );
+
+ return {
+ ...command,
+ message: {
+ ...command.message,
+ attachments: normalizedAttachments,
+ },
+ } satisfies OrchestrationCommand;
+ });
diff --git a/apps/server/src/projectFavicon.test.ts b/apps/server/src/projectFavicon.test.ts
new file mode 100644
index 0000000000..87e553f4f0
--- /dev/null
+++ b/apps/server/src/projectFavicon.test.ts
@@ -0,0 +1,78 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+import { afterEach, describe, expect, it } from "vitest";
+import { FALLBACK_PROJECT_FAVICON_SVG, resolveProjectFaviconFilePath } from "./projectFavicon";
+
+const tempDirs: string[] = [];
+
+function makeTempDir(prefix: string): string {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
+ tempDirs.push(dir);
+ return dir;
+}
+
+describe("resolveProjectFaviconFilePath", () => {
+ afterEach(() => {
+ for (const dir of tempDirs.splice(0, tempDirs.length)) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("finds a well-known favicon file from the project root", async () => {
+ const projectDir = makeTempDir("t3code-favicon-route-root-");
+ const faviconPath = path.join(projectDir, "favicon.svg");
+ fs.writeFileSync(faviconPath, "", "utf8");
+
+ await expect(resolveProjectFaviconFilePath(projectDir)).resolves.toBe(faviconPath);
+ });
+
+ it("resolves icon href from source files when no well-known favicon exists", async () => {
+ const projectDir = makeTempDir("t3code-favicon-route-source-");
+ const iconPath = path.join(projectDir, "public", "brand", "logo.svg");
+ fs.mkdirSync(path.dirname(iconPath), { recursive: true });
+ fs.writeFileSync(
+ path.join(projectDir, "index.html"),
+ '',
+ );
+ fs.writeFileSync(iconPath, "", "utf8");
+
+ await expect(resolveProjectFaviconFilePath(projectDir)).resolves.toBe(iconPath);
+ });
+
+ it("resolves icon link when href appears before rel in HTML", async () => {
+ const projectDir = makeTempDir("t3code-favicon-route-html-order-");
+ const iconPath = path.join(projectDir, "public", "brand", "logo.svg");
+ fs.mkdirSync(path.dirname(iconPath), { recursive: true });
+ fs.writeFileSync(
+ path.join(projectDir, "index.html"),
+ '',
+ );
+ fs.writeFileSync(iconPath, "", "utf8");
+
+ await expect(resolveProjectFaviconFilePath(projectDir)).resolves.toBe(iconPath);
+ });
+
+ it("resolves object-style icon metadata when href appears before rel", async () => {
+ const projectDir = makeTempDir("t3code-favicon-route-obj-order-");
+ const iconPath = path.join(projectDir, "public", "brand", "obj.svg");
+ fs.mkdirSync(path.dirname(iconPath), { recursive: true });
+ fs.mkdirSync(path.join(projectDir, "src"), { recursive: true });
+ fs.writeFileSync(
+ path.join(projectDir, "src", "root.tsx"),
+ 'const links = [{ href: "/brand/obj.svg", rel: "icon" }];',
+ "utf8",
+ );
+ fs.writeFileSync(iconPath, "", "utf8");
+
+ await expect(resolveProjectFaviconFilePath(projectDir)).resolves.toBe(iconPath);
+ });
+
+ it("returns null when no project icon exists so the route can use the inline fallback", async () => {
+ const projectDir = makeTempDir("t3code-favicon-route-fallback-");
+
+ await expect(resolveProjectFaviconFilePath(projectDir)).resolves.toBeNull();
+ expect(FALLBACK_PROJECT_FAVICON_SVG).toContain('data-fallback="project-favicon"');
+ });
+});
diff --git a/apps/server/src/projectFavicon.ts b/apps/server/src/projectFavicon.ts
new file mode 100644
index 0000000000..1122ea0814
--- /dev/null
+++ b/apps/server/src/projectFavicon.ts
@@ -0,0 +1,125 @@
+import fs from "node:fs";
+import path from "node:path";
+
+export const FALLBACK_PROJECT_FAVICON_SVG = ``;
+export const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";
+
+// Well-known favicon paths checked in order.
+const FAVICON_CANDIDATES = [
+ "favicon.svg",
+ "favicon.ico",
+ "favicon.png",
+ "public/favicon.svg",
+ "public/favicon.ico",
+ "public/favicon.png",
+ "app/favicon.ico",
+ "app/favicon.png",
+ "app/icon.svg",
+ "app/icon.png",
+ "app/icon.ico",
+ "src/favicon.ico",
+ "src/favicon.svg",
+ "src/app/favicon.ico",
+ "src/app/icon.svg",
+ "src/app/icon.png",
+ "assets/icon.svg",
+ "assets/icon.png",
+ "assets/logo.svg",
+ "assets/logo.png",
+];
+
+// Files that may contain a or icon metadata declaration.
+const ICON_SOURCE_FILES = [
+ "index.html",
+ "public/index.html",
+ "app/routes/__root.tsx",
+ "src/routes/__root.tsx",
+ "app/root.tsx",
+ "src/root.tsx",
+ "src/index.html",
+];
+
+// Matches tags or object-like icon metadata where rel/href can appear in any order.
+const LINK_ICON_HTML_RE =
+ /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i;
+const LINK_ICON_OBJ_RE =
+ /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i;
+
+function extractIconHref(source: string): string | null {
+ const htmlMatch = source.match(LINK_ICON_HTML_RE);
+ if (htmlMatch?.[1]) return htmlMatch[1];
+ const objMatch = source.match(LINK_ICON_OBJ_RE);
+ if (objMatch?.[1]) return objMatch[1];
+ return null;
+}
+
+function resolveIconHref(projectCwd: string, href: string): string[] {
+ const clean = href.replace(/^\//, "");
+ return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)];
+}
+
+function isPathWithinProject(projectCwd: string, candidatePath: string): boolean {
+ const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath));
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
+}
+
+async function statFile(filePath: string): Promise {
+ try {
+ return await fs.promises.stat(filePath);
+ } catch {
+ return null;
+ }
+}
+
+async function resolveFirstExistingFilePath(
+ projectCwd: string,
+ candidatePaths: ReadonlyArray,
+): Promise {
+ for (const candidatePath of candidatePaths) {
+ if (!isPathWithinProject(projectCwd, candidatePath)) {
+ continue;
+ }
+
+ const stats = await statFile(candidatePath);
+ if (stats?.isFile()) {
+ return candidatePath;
+ }
+ }
+
+ return null;
+}
+
+export async function resolveProjectFaviconFilePath(projectCwd: string): Promise {
+ for (const relativeCandidate of FAVICON_CANDIDATES) {
+ const candidatePath = path.join(projectCwd, relativeCandidate);
+ const resolvedPath = await resolveFirstExistingFilePath(projectCwd, [candidatePath]);
+ if (resolvedPath) {
+ return resolvedPath;
+ }
+ }
+
+ for (const sourceFileRelativePath of ICON_SOURCE_FILES) {
+ const sourceFilePath = path.join(projectCwd, sourceFileRelativePath);
+ let content: string;
+ try {
+ content = await fs.promises.readFile(sourceFilePath, "utf8");
+ } catch {
+ continue;
+ }
+
+ const href = extractIconHref(content);
+ if (!href) {
+ continue;
+ }
+
+ const resolvedPath = await resolveFirstExistingFilePath(
+ projectCwd,
+ resolveIconHref(projectCwd, href),
+ );
+ if (resolvedPath) {
+ return resolvedPath;
+ }
+ }
+
+ return null;
+}
diff --git a/apps/server/src/projectFaviconRoute.test.ts b/apps/server/src/projectFaviconRoute.test.ts
deleted file mode 100644
index 99b61aedc9..0000000000
--- a/apps/server/src/projectFaviconRoute.test.ts
+++ /dev/null
@@ -1,189 +0,0 @@
-import fs from "node:fs";
-import http from "node:http";
-import os from "node:os";
-import path from "node:path";
-
-import * as NodeServices from "@effect/platform-node/NodeServices";
-import { Effect } from "effect";
-import { afterEach, describe, expect, it } from "vitest";
-
-import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
-import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
-
-interface HttpResponse {
- statusCode: number;
- contentType: string | null;
- body: string;
-}
-
-const tempDirs: string[] = [];
-
-function makeTempDir(prefix: string): string {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
- tempDirs.push(dir);
- return dir;
-}
-
-async function withRouteServer(run: (baseUrl: string) => Promise): Promise {
- const server = http.createServer((req, res) => {
- const url = new URL(req.url ?? "/", "http://127.0.0.1");
- void Effect.runPromise(
- tryHandleProjectFaviconRequest(url, res).pipe(
- Effect.provide(ProjectFaviconResolverLive),
- Effect.provide(NodeServices.layer),
- Effect.flatMap((handled) =>
- handled
- ? Effect.void
- : Effect.sync(() => {
- res.writeHead(404, { "Content-Type": "text/plain" });
- res.end("Not Found");
- }),
- ),
- ),
- ).catch(() => {
- if (!res.headersSent) {
- res.writeHead(500, { "Content-Type": "text/plain" });
- res.end("Internal Server Error");
- }
- });
- });
-
- await new Promise((resolve, reject) => {
- server.listen(0, "127.0.0.1", (error?: Error) => {
- if (error) {
- reject(error);
- return;
- }
- resolve();
- });
- });
-
- const address = server.address();
- if (typeof address !== "object" || address === null) {
- throw new Error("Expected server address to be an object");
- }
- const baseUrl = `http://127.0.0.1:${address.port}`;
-
- try {
- await run(baseUrl);
- } finally {
- await new Promise((resolve, reject) => {
- server.close((error?: Error) => {
- if (error) {
- reject(error);
- return;
- }
- resolve();
- });
- });
- }
-}
-
-async function request(baseUrl: string, pathname: string): Promise {
- const response = await fetch(`${baseUrl}${pathname}`);
- return {
- statusCode: response.status,
- contentType: response.headers.get("content-type"),
- body: await response.text(),
- };
-}
-
-describe("tryHandleProjectFaviconRequest", () => {
- afterEach(() => {
- for (const dir of tempDirs.splice(0, tempDirs.length)) {
- fs.rmSync(dir, { recursive: true, force: true });
- }
- });
-
- it("returns 400 when cwd is missing", async () => {
- await withRouteServer(async (baseUrl) => {
- const response = await request(baseUrl, "/api/project-favicon");
- expect(response.statusCode).toBe(400);
- expect(response.body).toBe("Missing cwd parameter");
- });
- });
-
- it("serves a well-known favicon file from the project root", async () => {
- const projectDir = makeTempDir("t3code-favicon-route-root-");
- fs.writeFileSync(path.join(projectDir, "favicon.svg"), "", "utf8");
-
- await withRouteServer(async (baseUrl) => {
- const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
- const response = await request(baseUrl, pathname);
- expect(response.statusCode).toBe(200);
- expect(response.contentType).toContain("image/svg+xml");
- expect(response.body).toBe("");
- });
- });
-
- it("resolves icon href from source files when no well-known favicon exists", async () => {
- const projectDir = makeTempDir("t3code-favicon-route-source-");
- const iconPath = path.join(projectDir, "public", "brand", "logo.svg");
- fs.mkdirSync(path.dirname(iconPath), { recursive: true });
- fs.writeFileSync(
- path.join(projectDir, "index.html"),
- '',
- );
- fs.writeFileSync(iconPath, "", "utf8");
-
- await withRouteServer(async (baseUrl) => {
- const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
- const response = await request(baseUrl, pathname);
- expect(response.statusCode).toBe(200);
- expect(response.contentType).toContain("image/svg+xml");
- expect(response.body).toBe("");
- });
- });
-
- it("resolves icon link when href appears before rel in HTML", async () => {
- const projectDir = makeTempDir("t3code-favicon-route-html-order-");
- const iconPath = path.join(projectDir, "public", "brand", "logo.svg");
- fs.mkdirSync(path.dirname(iconPath), { recursive: true });
- fs.writeFileSync(
- path.join(projectDir, "index.html"),
- '',
- );
- fs.writeFileSync(iconPath, "", "utf8");
-
- await withRouteServer(async (baseUrl) => {
- const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
- const response = await request(baseUrl, pathname);
- expect(response.statusCode).toBe(200);
- expect(response.contentType).toContain("image/svg+xml");
- expect(response.body).toBe("");
- });
- });
-
- it("resolves object-style icon metadata when href appears before rel", async () => {
- const projectDir = makeTempDir("t3code-favicon-route-obj-order-");
- const iconPath = path.join(projectDir, "public", "brand", "obj.svg");
- fs.mkdirSync(path.dirname(iconPath), { recursive: true });
- fs.mkdirSync(path.join(projectDir, "src"), { recursive: true });
- fs.writeFileSync(
- path.join(projectDir, "src", "root.tsx"),
- 'const links = [{ href: "/brand/obj.svg", rel: "icon" }];',
- "utf8",
- );
- fs.writeFileSync(iconPath, "", "utf8");
-
- await withRouteServer(async (baseUrl) => {
- const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
- const response = await request(baseUrl, pathname);
- expect(response.statusCode).toBe(200);
- expect(response.contentType).toContain("image/svg+xml");
- expect(response.body).toBe("");
- });
- });
-
- it("serves a fallback favicon when no icon exists", async () => {
- const projectDir = makeTempDir("t3code-favicon-route-fallback-");
-
- await withRouteServer(async (baseUrl) => {
- const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
- const response = await request(baseUrl, pathname);
- expect(response.statusCode).toBe(200);
- expect(response.contentType).toContain("image/svg+xml");
- expect(response.body).toContain('data-fallback="project-favicon"');
- });
- });
-});
diff --git a/apps/server/src/projectFaviconRoute.ts b/apps/server/src/projectFaviconRoute.ts
deleted file mode 100644
index 56a9fc9035..0000000000
--- a/apps/server/src/projectFaviconRoute.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import http from "node:http";
-import path from "node:path";
-import { Effect, FileSystem } from "effect";
-
-import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";
-
-const FAVICON_MIME_TYPES: Record = {
- ".png": "image/png",
- ".jpg": "image/jpeg",
- ".svg": "image/svg+xml",
- ".ico": "image/x-icon",
-};
-
-const FALLBACK_FAVICON_SVG = ``;
-
-export const tryHandleProjectFaviconRequest = Effect.fn("tryHandleProjectFaviconRequest")(
- function* (
- url: URL,
- res: http.ServerResponse,
- ): Effect.fn.Return {
- const respond = (
- statusCode: number,
- headers: Record,
- body: string | Uint8Array,
- ) => {
- res.writeHead(statusCode, headers);
- res.end(body);
- };
-
- if (url.pathname !== "/api/project-favicon") {
- return false;
- }
-
- const projectCwd = url.searchParams.get("cwd");
- if (!projectCwd) {
- respond(400, { "Content-Type": "text/plain" }, "Missing cwd parameter");
- return true;
- }
-
- const fileSystem = yield* FileSystem.FileSystem;
- const faviconResolver = yield* ProjectFaviconResolver;
- const resolvedPath = yield* faviconResolver.resolvePath(projectCwd);
-
- if (!resolvedPath) {
- respond(
- 200,
- {
- "Content-Type": "image/svg+xml",
- "Cache-Control": "public, max-age=3600",
- },
- FALLBACK_FAVICON_SVG,
- );
- return true;
- }
-
- const data = yield* fileSystem
- .readFile(resolvedPath)
- .pipe(Effect.catch(() => Effect.succeed(null)));
- if (!data) {
- respond(500, { "Content-Type": "text/plain" }, "Read error");
- return true;
- }
-
- const ext = path.extname(resolvedPath).toLowerCase();
- const contentType = FAVICON_MIME_TYPES[ext] ?? "application/octet-stream";
- respond(
- 200,
- {
- "Content-Type": contentType,
- "Cache-Control": "public, max-age=3600",
- },
- data,
- );
- return true;
- },
-);
diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts
index d064a8239f..5a09d8b6ba 100644
--- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts
+++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts
@@ -208,6 +208,9 @@ async function readFirstPromptText(
if (next.done) {
return undefined;
}
+ if (typeof next.value.message.content === "string") {
+ return next.value.message.content;
+ }
const content = next.value.message.content[0];
if (!content || content.type !== "text") {
return undefined;
diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts
index 6b50bd4fbb..d99e2ad203 100644
--- a/apps/server/src/provider/Layers/ClaudeAdapter.ts
+++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts
@@ -533,7 +533,7 @@ function buildUserMessage(input: {
parent_tool_use_id: null,
message: {
role: "user",
- content: input.sdkContent,
+ content: input.sdkContent as unknown as SDKUserMessage["message"]["content"],
},
} as SDKUserMessage;
}
diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts
index f2243ef1b0..158a9ef25a 100644
--- a/apps/server/src/provider/Layers/ClaudeProvider.ts
+++ b/apps/server/src/provider/Layers/ClaudeProvider.ts
@@ -24,7 +24,8 @@ import {
} from "../providerSnapshot";
import { makeManagedServerProvider } from "../makeManagedServerProvider";
import { ClaudeProvider } from "../Services/ClaudeProvider";
-import { ServerSettingsError, ServerSettingsService } from "../../serverSettings";
+import { ServerSettingsService } from "../../serverSettings";
+import { ServerSettingsError } from "@t3tools/contracts";
const PROVIDER = "claudeAgent" as const;
const BUILT_IN_MODELS: ReadonlyArray = [
diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts
index 1fe98ea798..499d4c6ffd 100644
--- a/apps/server/src/provider/Layers/CodexProvider.ts
+++ b/apps/server/src/provider/Layers/CodexProvider.ts
@@ -46,7 +46,8 @@ import {
} from "../codexAccount";
import { probeCodexAccount } from "../codexAppServer";
import { CodexProvider } from "../Services/CodexProvider";
-import { ServerSettingsError, ServerSettingsService } from "../../serverSettings";
+import { ServerSettingsService } from "../../serverSettings";
+import { ServerSettingsError } from "@t3tools/contracts";
const PROVIDER = "codex" as const;
const OPENAI_AUTH_PROVIDERS = new Set(["openai"]);
diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts
index e519e82af5..4c1d0878fc 100644
--- a/apps/server/src/provider/makeManagedServerProvider.ts
+++ b/apps/server/src/provider/makeManagedServerProvider.ts
@@ -3,7 +3,7 @@ import { Duration, Effect, PubSub, Ref, Scope, Stream } from "effect";
import * as Semaphore from "effect/Semaphore";
import type { ServerProviderShape } from "./Services/ServerProvider";
-import { ServerSettingsError } from "../serverSettings";
+import { ServerSettingsError } from "@t3tools/contracts";
export function makeManagedServerProvider(input: {
readonly getSettings: Effect.Effect;
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
new file mode 100644
index 0000000000..d62df4440e
--- /dev/null
+++ b/apps/server/src/server.test.ts
@@ -0,0 +1,1322 @@
+import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
+import * as NodeSocket from "@effect/platform-node/NodeSocket";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import {
+ CommandId,
+ DEFAULT_SERVER_SETTINGS,
+ GitCommandError,
+ KeybindingRule,
+ OpenError,
+ TerminalNotRunningError,
+ type OrchestrationEvent,
+ ORCHESTRATION_WS_METHODS,
+ ProjectId,
+ ResolvedKeybindingRule,
+ ThreadId,
+ WS_METHODS,
+ WsRpcGroup,
+ EditorId,
+} from "@t3tools/contracts";
+import { assert, it } from "@effect/vitest";
+import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils";
+import { Effect, FileSystem, Layer, Path, Stream } from "effect";
+import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http";
+import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
+
+import type { ServerConfigShape } from "./config.ts";
+import { deriveServerPaths, ServerConfig } from "./config.ts";
+import { makeRoutesLayer } from "./server.ts";
+import { resolveAttachmentRelativePath } from "./attachmentPaths.ts";
+import {
+ CheckpointDiffQuery,
+ type CheckpointDiffQueryShape,
+} from "./checkpointing/Services/CheckpointDiffQuery.ts";
+import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts";
+import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
+import { Keybindings, type KeybindingsShape } from "./keybindings.ts";
+import { Open, type OpenShape } from "./open.ts";
+import {
+ OrchestrationEngineService,
+ type OrchestrationEngineShape,
+} from "./orchestration/Services/OrchestrationEngine.ts";
+import {
+ ProjectionSnapshotQuery,
+ type ProjectionSnapshotQueryShape,
+} from "./orchestration/Services/ProjectionSnapshotQuery.ts";
+import { PersistenceSqlError } from "./persistence/Errors.ts";
+import {
+ ProviderRegistry,
+ type ProviderRegistryShape,
+} from "./provider/Services/ProviderRegistry.ts";
+import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts";
+import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts";
+import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts";
+import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts";
+import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
+import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";
+import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
+import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
+
+const defaultProjectId = ProjectId.makeUnsafe("project-default");
+const defaultThreadId = ThreadId.makeUnsafe("thread-default");
+const defaultModelSelection = {
+ provider: "codex",
+ model: "gpt-5-codex",
+} as const;
+
+const makeDefaultOrchestrationReadModel = () => {
+ const now = new Date().toISOString();
+ return {
+ snapshotSequence: 0,
+ updatedAt: now,
+ projects: [
+ {
+ id: defaultProjectId,
+ title: "Default Project",
+ workspaceRoot: "/tmp/default-project",
+ defaultModelSelection,
+ scripts: [],
+ createdAt: now,
+ updatedAt: now,
+ deletedAt: null,
+ },
+ ],
+ threads: [
+ {
+ id: defaultThreadId,
+ projectId: defaultProjectId,
+ title: "Default Thread",
+ modelSelection: defaultModelSelection,
+ interactionMode: "default" as const,
+ runtimeMode: "full-access" as const,
+ branch: null,
+ worktreePath: null,
+ createdAt: now,
+ updatedAt: now,
+ archivedAt: null,
+ latestTurn: null,
+ messages: [],
+ session: null,
+ activities: [],
+ proposedPlans: [],
+ checkpoints: [],
+ deletedAt: null,
+ },
+ ],
+ };
+};
+
+const workspaceAndProjectServicesLayer = Layer.mergeAll(
+ WorkspacePathsLive,
+ WorkspaceEntriesLive,
+ WorkspaceFileSystemLive.pipe(
+ Layer.provide(WorkspacePathsLive),
+ Layer.provide(WorkspaceEntriesLive),
+ ),
+ ProjectFaviconResolverLive,
+);
+
+const buildAppUnderTest = (options?: {
+ config?: Partial;
+ layers?: {
+ keybindings?: Partial;
+ providerRegistry?: Partial;
+ serverSettings?: Partial;
+ open?: Partial;
+ gitCore?: Partial;
+ gitManager?: Partial;
+ terminalManager?: Partial;
+ orchestrationEngine?: Partial;
+ projectionSnapshotQuery?: Partial;
+ checkpointDiffQuery?: Partial;
+ serverLifecycleEvents?: Partial;
+ serverRuntimeStartup?: Partial;
+ };
+}) =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const tempBaseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-test-" });
+ const baseDir = options?.config?.baseDir ?? tempBaseDir;
+ const devUrl = options?.config?.devUrl;
+ const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
+ const config = {
+ logLevel: "Info",
+ mode: "web",
+ port: 0,
+ host: "127.0.0.1",
+ cwd: process.cwd(),
+ baseDir,
+ ...derivedPaths,
+ staticDir: undefined,
+ devUrl,
+ noBrowser: true,
+ authToken: undefined,
+ autoBootstrapProjectFromCwd: false,
+ logWebSocketEvents: false,
+ ...options?.config,
+ } satisfies ServerConfigShape;
+ const layerConfig = Layer.succeed(ServerConfig, config);
+
+ const appLayer = HttpRouter.serve(makeRoutesLayer, {
+ disableListenLog: true,
+ disableLogger: true,
+ }).pipe(
+ Layer.provide(
+ Layer.mock(Keybindings)({
+ streamChanges: Stream.empty,
+ ...options?.layers?.keybindings,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(ProviderRegistry)({
+ getProviders: Effect.succeed([]),
+ refresh: () => Effect.succeed([]),
+ streamChanges: Stream.empty,
+ ...options?.layers?.providerRegistry,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(ServerSettingsService)({
+ start: Effect.void,
+ ready: Effect.void,
+ getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS),
+ updateSettings: () => Effect.succeed(DEFAULT_SERVER_SETTINGS),
+ streamChanges: Stream.empty,
+ ...options?.layers?.serverSettings,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(Open)({
+ ...options?.layers?.open,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(GitCore)({
+ ...options?.layers?.gitCore,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(GitManager)({
+ ...options?.layers?.gitManager,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(TerminalManager)({
+ ...options?.layers?.terminalManager,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(OrchestrationEngineService)({
+ getReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()),
+ readEvents: () => Stream.empty,
+ dispatch: () => Effect.succeed({ sequence: 0 }),
+ streamDomainEvents: Stream.empty,
+ ...options?.layers?.orchestrationEngine,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(ProjectionSnapshotQuery)({
+ getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()),
+ ...options?.layers?.projectionSnapshotQuery,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(CheckpointDiffQuery)({
+ getTurnDiff: () =>
+ Effect.succeed({
+ threadId: defaultThreadId,
+ fromTurnCount: 0,
+ toTurnCount: 0,
+ diff: "",
+ }),
+ getFullThreadDiff: () =>
+ Effect.succeed({
+ threadId: defaultThreadId,
+ fromTurnCount: 0,
+ toTurnCount: 0,
+ diff: "",
+ }),
+ ...options?.layers?.checkpointDiffQuery,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(ServerLifecycleEvents)({
+ publish: (event) => Effect.succeed({ ...(event as any), sequence: 1 }),
+ snapshot: Effect.succeed({ sequence: 0, events: [] }),
+ stream: Stream.empty,
+ ...options?.layers?.serverLifecycleEvents,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(ServerRuntimeStartup)({
+ awaitCommandReady: Effect.void,
+ markHttpListening: Effect.void,
+ enqueueCommand: (effect) => effect,
+ ...options?.layers?.serverRuntimeStartup,
+ }),
+ ),
+ Layer.provide(workspaceAndProjectServicesLayer),
+ Layer.provide(layerConfig),
+ );
+
+ yield* Layer.build(appLayer);
+ return config;
+ });
+
+const wsRpcProtocolLayer = (wsUrl: string) =>
+ RpcClient.layerProtocolSocket().pipe(
+ Layer.provide(NodeSocket.layerWebSocket(wsUrl)),
+ Layer.provide(RpcSerialization.layerJson),
+ );
+
+const makeWsRpcClient = RpcClient.make(WsRpcGroup);
+type WsRpcClient =
+ typeof makeWsRpcClient extends Effect.Effect ? Client : never;
+
+const withWsRpcClient = (
+ wsUrl: string,
+ f: (client: WsRpcClient) => Effect.Effect,
+) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl)));
+
+const getHttpServerUrl = (pathname = "") =>
+ Effect.gen(function* () {
+ const server = yield* HttpServer.HttpServer;
+ const address = server.address as HttpServer.TcpAddress;
+ return `http://127.0.0.1:${address.port}${pathname}`;
+ });
+
+const getWsServerUrl = (pathname = "") =>
+ Effect.gen(function* () {
+ const server = yield* HttpServer.HttpServer;
+ const address = server.address as HttpServer.TcpAddress;
+ return `ws://127.0.0.1:${address.port}${pathname}`;
+ });
+
+it.layer(NodeServices.layer)("server router seam", (it) => {
+ it.effect("routes GET /health through HttpRouter", () =>
+ Effect.gen(function* () {
+ yield* buildAppUnderTest();
+
+ const response = yield* HttpClient.get("/health");
+ assert.equal(response.status, 200);
+ assert.deepEqual(yield* response.json, { ok: true });
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("serves static index content for GET / when staticDir is configured", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-static-" });
+ const indexPath = path.join(staticDir, "index.html");
+ yield* fileSystem.writeFileString(indexPath, "router-static-ok");
+
+ yield* buildAppUnderTest({ config: { staticDir } });
+
+ const response = yield* HttpClient.get("/");
+ assert.equal(response.status, 200);
+ assert.include(yield* response.text, "router-static-ok");
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("redirects to dev URL when configured", () =>
+ Effect.gen(function* () {
+ yield* buildAppUnderTest({
+ config: { devUrl: new URL("http://127.0.0.1:5173") },
+ });
+
+ const url = yield* getHttpServerUrl("/foo/bar");
+ const response = yield* Effect.promise(() => fetch(url, { redirect: "manual" }));
+
+ assert.equal(response.status, 302);
+ assert.equal(response.headers.get("location"), "http://127.0.0.1:5173/");
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("serves project favicon requests before the dev URL redirect", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const projectDir = yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "t3-router-project-favicon-",
+ });
+ yield* fileSystem.writeFileString(
+ path.join(projectDir, "favicon.svg"),
+ "",
+ );
+
+ yield* buildAppUnderTest({
+ config: { devUrl: new URL("http://127.0.0.1:5173") },
+ });
+
+ const response = yield* HttpClient.get(
+ `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`,
+ );
+
+ assert.equal(response.status, 200);
+ assert.equal(yield* response.text, "");
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("serves attachment files from state dir", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const attachmentId = "thread-11111111-1111-4111-8111-111111111111";
+
+ const config = yield* buildAppUnderTest();
+ const attachmentPath = resolveAttachmentRelativePath({
+ attachmentsDir: config.attachmentsDir,
+ relativePath: `${attachmentId}.bin`,
+ });
+ assert.isNotNull(attachmentPath, "Attachment path should be resolvable");
+
+ yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true });
+ yield* fileSystem.writeFileString(attachmentPath, "attachment-ok");
+
+ const response = yield* HttpClient.get(`/attachments/${attachmentId}`);
+ assert.equal(response.status, 200);
+ assert.equal(yield* response.text, "attachment-ok");
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("returns 404 for missing attachment id lookups", () =>
+ Effect.gen(function* () {
+ yield* buildAppUnderTest();
+
+ const response = yield* HttpClient.get(
+ "/attachments/missing-11111111-1111-4111-8111-111111111111",
+ );
+ assert.equal(response.status, 404);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc server.upsertKeybinding", () =>
+ Effect.gen(function* () {
+ const rule: KeybindingRule = {
+ command: "terminal.toggle",
+ key: "ctrl+k",
+ };
+ const resolved: ResolvedKeybindingRule = {
+ command: "terminal.toggle",
+ shortcut: {
+ key: "k",
+ metaKey: false,
+ ctrlKey: true,
+ shiftKey: false,
+ altKey: false,
+ modKey: true,
+ },
+ };
+
+ yield* buildAppUnderTest({
+ layers: {
+ keybindings: {
+ upsertKeybindingRule: () => Effect.succeed([resolved]),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const response = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverUpsertKeybinding](rule)),
+ );
+
+ assert.deepEqual(response.issues, []);
+ assert.deepEqual(response.keybindings, [resolved]);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("rejects websocket rpc handshake when auth token is missing", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-auth-required-" });
+ yield* fs.writeFileString(
+ path.join(workspaceDir, "needle-file.ts"),
+ "export const needle = 1;",
+ );
+
+ yield* buildAppUnderTest({
+ config: {
+ authToken: "secret-token",
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const result = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.projectsSearchEntries]({
+ cwd: workspaceDir,
+ query: "needle",
+ limit: 10,
+ }),
+ ).pipe(Effect.result),
+ );
+
+ assertTrue(result._tag === "Failure");
+ assertInclude(String(result.failure), "SocketOpenError");
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("accepts websocket rpc handshake when auth token is provided", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-auth-ok-" });
+ yield* fs.writeFileString(
+ path.join(workspaceDir, "needle-file.ts"),
+ "export const needle = 1;",
+ );
+
+ yield* buildAppUnderTest({
+ config: {
+ authToken: "secret-token",
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws?token=secret-token");
+ const response = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.projectsSearchEntries]({
+ cwd: workspaceDir,
+ query: "needle",
+ limit: 10,
+ }),
+ ),
+ );
+
+ assert.isAtLeast(response.entries.length, 1);
+ assert.equal(response.truncated, false);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () =>
+ Effect.gen(function* () {
+ const providers = [] as const;
+ const changeEvent = {
+ keybindings: [],
+ issues: [],
+ } as const;
+
+ yield* buildAppUnderTest({
+ layers: {
+ keybindings: {
+ loadConfigState: Effect.succeed({
+ keybindings: [],
+ issues: [],
+ }),
+ streamChanges: Stream.succeed(changeEvent),
+ },
+ providerRegistry: {
+ getProviders: Effect.succeed(providers),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const events = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect),
+ ),
+ );
+
+ const [first, second] = Array.from(events);
+ assert.equal(first?.type, "snapshot");
+ if (first?.type === "snapshot") {
+ assert.equal(first.version, 1);
+ assert.deepEqual(first.config.keybindings, []);
+ assert.deepEqual(first.config.issues, []);
+ assert.deepEqual(first.config.providers, providers);
+ assert.deepEqual(first.config.settings, DEFAULT_SERVER_SETTINGS);
+ }
+ assert.deepEqual(second, {
+ version: 1,
+ type: "keybindingsUpdated",
+ payload: { issues: [] },
+ });
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () =>
+ Effect.gen(function* () {
+ const providers = [] as const;
+
+ yield* buildAppUnderTest({
+ layers: {
+ keybindings: {
+ loadConfigState: Effect.succeed({
+ keybindings: [],
+ issues: [],
+ }),
+ streamChanges: Stream.empty,
+ },
+ providerRegistry: {
+ getProviders: Effect.succeed([]),
+ streamChanges: Stream.succeed(providers),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const events = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect),
+ ),
+ );
+
+ const [first, second] = Array.from(events);
+ assert.equal(first?.type, "snapshot");
+ assert.deepEqual(second, {
+ version: 1,
+ type: "providerStatuses",
+ payload: { providers },
+ });
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect(
+ "routes websocket rpc subscribeServerLifecycle replays snapshot and streams updates",
+ () =>
+ Effect.gen(function* () {
+ const lifecycleEvents = [
+ {
+ version: 1 as const,
+ sequence: 1,
+ type: "welcome" as const,
+ payload: {
+ cwd: "/tmp/project",
+ projectName: "project",
+ },
+ },
+ ] as const;
+ const liveEvents = Stream.make({
+ version: 1 as const,
+ sequence: 2,
+ type: "ready" as const,
+ payload: { at: new Date().toISOString() },
+ });
+
+ yield* buildAppUnderTest({
+ layers: {
+ serverLifecycleEvents: {
+ snapshot: Effect.succeed({
+ sequence: 1,
+ events: lifecycleEvents,
+ }),
+ stream: liveEvents,
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const events = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.subscribeServerLifecycle]({}).pipe(Stream.take(2), Stream.runCollect),
+ ),
+ );
+
+ const [first, second] = Array.from(events);
+ assert.equal(first?.type, "welcome");
+ assert.equal(first?.sequence, 1);
+ assert.equal(second?.type, "ready");
+ assert.equal(second?.sequence, 2);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc projects.searchEntries", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-search-" });
+ yield* fs.writeFileString(
+ path.join(workspaceDir, "needle-file.ts"),
+ "export const needle = 1;",
+ );
+
+ yield* buildAppUnderTest();
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const response = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.projectsSearchEntries]({
+ cwd: workspaceDir,
+ query: "needle",
+ limit: 10,
+ }),
+ ),
+ );
+
+ assert.isAtLeast(response.entries.length, 1);
+ assert.isTrue(response.entries.some((entry) => entry.path === "needle-file.ts"));
+ assert.equal(response.truncated, false);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc projects.searchEntries errors", () =>
+ Effect.gen(function* () {
+ yield* buildAppUnderTest();
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const result = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.projectsSearchEntries]({
+ cwd: "/definitely/not/a/real/workspace/path",
+ query: "needle",
+ limit: 10,
+ }),
+ ).pipe(Effect.result),
+ );
+
+ assertTrue(result._tag === "Failure");
+ assertTrue(result.failure._tag === "ProjectSearchEntriesError");
+ assertInclude(
+ result.failure.message,
+ "ENOENT: no such file or directory, scandir '/definitely/not/a/real/workspace/path'",
+ );
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc projects.writeFile", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-write-" });
+
+ yield* buildAppUnderTest();
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const response = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.projectsWriteFile]({
+ cwd: workspaceDir,
+ relativePath: "nested/created.txt",
+ contents: "written-by-rpc",
+ }),
+ ),
+ );
+
+ assert.equal(response.relativePath, "nested/created.txt");
+ const persisted = yield* fs.readFileString(path.join(workspaceDir, "nested", "created.txt"));
+ assert.equal(persisted, "written-by-rpc");
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc projects.writeFile errors", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-write-" });
+
+ yield* buildAppUnderTest();
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const result = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.projectsWriteFile]({
+ cwd: workspaceDir,
+ relativePath: "../escape.txt",
+ contents: "nope",
+ }),
+ ).pipe(Effect.result),
+ );
+
+ assertTrue(result._tag === "Failure");
+ assertTrue(result.failure._tag === "ProjectWriteFileError");
+ assert.equal(
+ result.failure.message,
+ "Workspace file path must stay within the project root.",
+ );
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc shell.openInEditor", () =>
+ Effect.gen(function* () {
+ let openedInput: { cwd: string; editor: EditorId } | null = null;
+ yield* buildAppUnderTest({
+ layers: {
+ open: {
+ openInEditor: (input) =>
+ Effect.sync(() => {
+ openedInput = input;
+ }),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.shellOpenInEditor]({
+ cwd: "/tmp/project",
+ editor: "cursor",
+ }),
+ ),
+ );
+
+ assert.deepEqual(openedInput, { cwd: "/tmp/project", editor: "cursor" });
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc shell.openInEditor errors", () =>
+ Effect.gen(function* () {
+ const openError = new OpenError({ message: "Editor command not found: cursor" });
+ yield* buildAppUnderTest({
+ layers: {
+ open: {
+ openInEditor: () => Effect.fail(openError),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const result = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.shellOpenInEditor]({
+ cwd: "/tmp/project",
+ editor: "cursor",
+ }),
+ ).pipe(Effect.result),
+ );
+
+ assertFailure(result, openError);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc git methods", () =>
+ Effect.gen(function* () {
+ yield* buildAppUnderTest({
+ layers: {
+ gitManager: {
+ status: () =>
+ Effect.succeed({
+ branch: "main",
+ hasWorkingTreeChanges: false,
+ workingTree: { files: [], insertions: 0, deletions: 0 },
+ hasUpstream: true,
+ aheadCount: 0,
+ behindCount: 0,
+ pr: null,
+ }),
+ runStackedAction: () =>
+ Effect.succeed({
+ action: "commit",
+ branch: { status: "skipped_not_requested" },
+ commit: { status: "created", commitSha: "abc123", subject: "feat: demo" },
+ push: { status: "skipped_not_requested" },
+ pr: { status: "skipped_not_requested" },
+ }),
+ resolvePullRequest: () =>
+ Effect.succeed({
+ pullRequest: {
+ number: 1,
+ title: "Demo PR",
+ url: "https://example.com/pr/1",
+ baseBranch: "main",
+ headBranch: "feature/demo",
+ state: "open",
+ },
+ }),
+ preparePullRequestThread: () =>
+ Effect.succeed({
+ pullRequest: {
+ number: 1,
+ title: "Demo PR",
+ url: "https://example.com/pr/1",
+ baseBranch: "main",
+ headBranch: "feature/demo",
+ state: "open",
+ },
+ branch: "feature/demo",
+ worktreePath: null,
+ }),
+ },
+ gitCore: {
+ pullCurrentBranch: () =>
+ Effect.succeed({
+ status: "pulled",
+ branch: "main",
+ upstreamBranch: "origin/main",
+ }),
+ listBranches: () =>
+ Effect.succeed({
+ branches: [
+ {
+ name: "main",
+ current: true,
+ isDefault: true,
+ worktreePath: null,
+ },
+ ],
+ isRepo: true,
+ hasOriginRemote: true,
+ }),
+ createWorktree: () =>
+ Effect.succeed({
+ worktree: { path: "/tmp/wt", branch: "feature/demo" },
+ }),
+ removeWorktree: () => Effect.void,
+ createBranch: () => Effect.void,
+ checkoutBranch: () => Effect.void,
+ initRepo: () => Effect.void,
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+
+ const status = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitStatus]({ cwd: "/tmp/repo" })),
+ );
+ assert.equal(status.branch, "main");
+
+ const pull = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })),
+ );
+ assert.equal(pull.status, "pulled");
+
+ const stacked = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.gitRunStackedAction]({
+ actionId: "action-1",
+ cwd: "/tmp/repo",
+ action: "commit",
+ }),
+ ),
+ );
+ assert.equal(stacked.action, "commit");
+
+ const resolvedPr = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.gitResolvePullRequest]({
+ cwd: "/tmp/repo",
+ reference: "1",
+ }),
+ ),
+ );
+ assert.equal(resolvedPr.pullRequest.number, 1);
+
+ const prepared = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.gitPreparePullRequestThread]({
+ cwd: "/tmp/repo",
+ reference: "1",
+ mode: "local",
+ }),
+ ),
+ );
+ assert.equal(prepared.branch, "feature/demo");
+
+ const branches = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.gitListBranches]({ cwd: "/tmp/repo" }),
+ ),
+ );
+ assert.equal(branches.branches[0]?.name, "main");
+
+ const worktree = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.gitCreateWorktree]({
+ cwd: "/tmp/repo",
+ branch: "main",
+ path: null,
+ }),
+ ),
+ );
+ assert.equal(worktree.worktree.branch, "feature/demo");
+
+ yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.gitRemoveWorktree]({
+ cwd: "/tmp/repo",
+ path: "/tmp/wt",
+ }),
+ ),
+ );
+
+ yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.gitCreateBranch]({
+ cwd: "/tmp/repo",
+ branch: "feature/new",
+ }),
+ ),
+ );
+
+ yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.gitCheckout]({
+ cwd: "/tmp/repo",
+ branch: "main",
+ }),
+ ),
+ );
+
+ yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.gitInit]({
+ cwd: "/tmp/repo",
+ }),
+ ),
+ );
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc git.pull errors", () =>
+ Effect.gen(function* () {
+ const gitError = new GitCommandError({
+ operation: "pull",
+ command: "git pull --ff-only",
+ cwd: "/tmp/repo",
+ detail: "upstream missing",
+ });
+ yield* buildAppUnderTest({
+ layers: {
+ gitCore: {
+ pullCurrentBranch: () => Effect.fail(gitError),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const result = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })).pipe(
+ Effect.result,
+ ),
+ );
+
+ assertFailure(result, gitError);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc orchestration methods", () =>
+ Effect.gen(function* () {
+ const now = new Date().toISOString();
+ const snapshot = {
+ snapshotSequence: 1,
+ updatedAt: now,
+ projects: [
+ {
+ id: ProjectId.makeUnsafe("project-a"),
+ title: "Project A",
+ workspaceRoot: "/tmp/project-a",
+ defaultModelSelection,
+ scripts: [],
+ createdAt: now,
+ updatedAt: now,
+ deletedAt: null,
+ },
+ ],
+ threads: [
+ {
+ id: ThreadId.makeUnsafe("thread-1"),
+ projectId: ProjectId.makeUnsafe("project-a"),
+ title: "Thread A",
+ modelSelection: defaultModelSelection,
+ interactionMode: "default" as const,
+ runtimeMode: "full-access" as const,
+ branch: null,
+ worktreePath: null,
+ createdAt: now,
+ updatedAt: now,
+ archivedAt: null,
+ latestTurn: null,
+ messages: [],
+ session: null,
+ activities: [],
+ proposedPlans: [],
+ checkpoints: [],
+ deletedAt: null,
+ },
+ ],
+ };
+
+ yield* buildAppUnderTest({
+ layers: {
+ projectionSnapshotQuery: {
+ getSnapshot: () => Effect.succeed(snapshot),
+ },
+ orchestrationEngine: {
+ dispatch: () => Effect.succeed({ sequence: 7 }),
+ readEvents: () => Stream.empty,
+ },
+ checkpointDiffQuery: {
+ getTurnDiff: () =>
+ Effect.succeed({
+ threadId: ThreadId.makeUnsafe("thread-1"),
+ fromTurnCount: 0,
+ toTurnCount: 1,
+ diff: "turn-diff",
+ }),
+ getFullThreadDiff: () =>
+ Effect.succeed({
+ threadId: ThreadId.makeUnsafe("thread-1"),
+ fromTurnCount: 0,
+ toTurnCount: 1,
+ diff: "full-diff",
+ }),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const snapshotResult = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})),
+ );
+ assert.equal(snapshotResult.snapshotSequence, 1);
+
+ const dispatchResult = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
+ type: "thread.session.stop",
+ commandId: CommandId.makeUnsafe("cmd-1"),
+ threadId: ThreadId.makeUnsafe("thread-1"),
+ createdAt: now,
+ }),
+ ),
+ );
+ assert.equal(dispatchResult.sequence, 7);
+
+ const turnDiffResult = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[ORCHESTRATION_WS_METHODS.getTurnDiff]({
+ threadId: ThreadId.makeUnsafe("thread-1"),
+ fromTurnCount: 0,
+ toTurnCount: 1,
+ }),
+ ),
+ );
+ assert.equal(turnDiffResult.diff, "turn-diff");
+
+ const fullDiffResult = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[ORCHESTRATION_WS_METHODS.getFullThreadDiff]({
+ threadId: ThreadId.makeUnsafe("thread-1"),
+ toTurnCount: 1,
+ }),
+ ),
+ );
+ assert.equal(fullDiffResult.diff, "full-diff");
+
+ const replayResult = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[ORCHESTRATION_WS_METHODS.replayEvents]({
+ fromSequenceExclusive: 0,
+ }),
+ ),
+ );
+ assert.deepEqual(replayResult, []);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect(
+ "routes websocket rpc subscribeOrchestrationDomainEvents with replay/live overlap resilience",
+ () =>
+ Effect.gen(function* () {
+ const now = new Date().toISOString();
+ const threadId = ThreadId.makeUnsafe("thread-1");
+ let replayCursor: number | null = null;
+ const makeEvent = (sequence: number): OrchestrationEvent =>
+ ({
+ sequence,
+ eventId: `event-${sequence}`,
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ occurredAt: now,
+ commandId: null,
+ causationEventId: null,
+ correlationId: null,
+ metadata: {},
+ type: "thread.reverted",
+ payload: {
+ threadId,
+ turnCount: sequence,
+ },
+ }) as OrchestrationEvent;
+
+ yield* buildAppUnderTest({
+ layers: {
+ orchestrationEngine: {
+ getReadModel: () =>
+ Effect.succeed({
+ ...makeDefaultOrchestrationReadModel(),
+ snapshotSequence: 1,
+ }),
+ readEvents: (fromSequenceExclusive) => {
+ replayCursor = fromSequenceExclusive;
+ return Stream.make(makeEvent(2), makeEvent(3));
+ },
+ streamDomainEvents: Stream.make(makeEvent(3), makeEvent(4)),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const events = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe(
+ Stream.take(3),
+ Stream.runCollect,
+ ),
+ ),
+ );
+
+ assert.equal(replayCursor, 1);
+ assert.deepEqual(
+ Array.from(events).map((event) => event.sequence),
+ [2, 3, 4],
+ );
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc orchestration.getSnapshot errors", () =>
+ Effect.gen(function* () {
+ yield* buildAppUnderTest({
+ layers: {
+ projectionSnapshotQuery: {
+ getSnapshot: () =>
+ Effect.fail(
+ new PersistenceSqlError({
+ operation: "ProjectionSnapshotQuery.getSnapshot",
+ detail: "projection unavailable",
+ }),
+ ),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const result = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})).pipe(
+ Effect.result,
+ ),
+ );
+
+ assertTrue(result._tag === "Failure");
+ assertTrue(result.failure._tag === "OrchestrationGetSnapshotError");
+ assertInclude(result.failure.message, "Failed to load orchestration snapshot");
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc terminal methods", () =>
+ Effect.gen(function* () {
+ const snapshot = {
+ threadId: "thread-1",
+ terminalId: "default",
+ cwd: "/tmp/project",
+ status: "running" as const,
+ pid: 1234,
+ history: "",
+ exitCode: null,
+ exitSignal: null,
+ updatedAt: new Date().toISOString(),
+ };
+
+ yield* buildAppUnderTest({
+ layers: {
+ terminalManager: {
+ open: () => Effect.succeed(snapshot),
+ write: () => Effect.void,
+ resize: () => Effect.void,
+ clear: () => Effect.void,
+ restart: () => Effect.succeed(snapshot),
+ close: () => Effect.void,
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+
+ const opened = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.terminalOpen]({
+ threadId: "thread-1",
+ terminalId: "default",
+ cwd: "/tmp/project",
+ }),
+ ),
+ );
+ assert.equal(opened.terminalId, "default");
+
+ yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.terminalWrite]({
+ threadId: "thread-1",
+ terminalId: "default",
+ data: "echo hi\n",
+ }),
+ ),
+ );
+
+ yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.terminalResize]({
+ threadId: "thread-1",
+ terminalId: "default",
+ cols: 120,
+ rows: 40,
+ }),
+ ),
+ );
+
+ yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.terminalClear]({
+ threadId: "thread-1",
+ terminalId: "default",
+ }),
+ ),
+ );
+
+ const restarted = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.terminalRestart]({
+ threadId: "thread-1",
+ terminalId: "default",
+ cwd: "/tmp/project",
+ cols: 120,
+ rows: 40,
+ }),
+ ),
+ );
+ assert.equal(restarted.terminalId, "default");
+
+ yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.terminalClose]({
+ threadId: "thread-1",
+ terminalId: "default",
+ }),
+ ),
+ );
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc terminal.write errors", () =>
+ Effect.gen(function* () {
+ const terminalError = new TerminalNotRunningError({
+ threadId: "thread-1",
+ terminalId: "default",
+ });
+ yield* buildAppUnderTest({
+ layers: {
+ terminalManager: {
+ write: () => Effect.fail(terminalError),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const result = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.terminalWrite]({
+ threadId: "thread-1",
+ terminalId: "default",
+ data: "echo fail\n",
+ }),
+ ).pipe(Effect.result),
+ );
+
+ assertFailure(result, terminalError);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+});
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
new file mode 100644
index 0000000000..3f9e7c9153
--- /dev/null
+++ b/apps/server/src/server.ts
@@ -0,0 +1,240 @@
+import { Effect, Layer } from "effect";
+import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http";
+
+import { ServerConfig } from "./config";
+import {
+ attachmentsRouteLayer,
+ healthRouteLayer,
+ projectFaviconRouteLayer,
+ staticAndDevRouteLayer,
+} from "./http";
+import { fixPath } from "./os-jank";
+import { websocketRpcRouteLayer } from "./ws";
+import { OpenLive } from "./open";
+import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite";
+import { ServerLifecycleEventsLive } from "./serverLifecycleEvents";
+import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";
+import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
+import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
+import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
+import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
+import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter";
+import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
+import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
+import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine";
+import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
+import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
+import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
+import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery";
+import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
+import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
+import { GitCoreLive } from "./git/Layers/GitCore";
+import { GitHubCliLive } from "./git/Layers/GitHubCli";
+import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration";
+import { TerminalManagerLive } from "./terminal/Layers/Manager";
+import { GitManagerLive } from "./git/Layers/GitManager";
+import { KeybindingsLive } from "./keybindings";
+import { ServerLoggerLive } from "./serverLogger";
+import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup";
+import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor";
+import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus";
+import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
+import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor";
+import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor";
+import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry";
+import { ServerSettingsLive } from "./serverSettings";
+import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver";
+import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries";
+import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem";
+import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths";
+
+const PtyAdapterLive = Layer.unwrap(
+ Effect.gen(function* () {
+ if (typeof Bun !== "undefined") {
+ const BunPTY = yield* Effect.promise(() => import("./terminal/Layers/BunPTY"));
+ return BunPTY.layer;
+ } else {
+ const NodePTY = yield* Effect.promise(() => import("./terminal/Layers/NodePTY"));
+ return NodePTY.layer;
+ }
+ }),
+);
+
+const HttpServerLive = Layer.unwrap(
+ Effect.gen(function* () {
+ const config = yield* ServerConfig;
+ if (typeof Bun !== "undefined") {
+ const BunHttpServer = yield* Effect.promise(
+ () => import("@effect/platform-bun/BunHttpServer"),
+ );
+ return BunHttpServer.layer({
+ port: config.port,
+ ...(config.host ? { hostname: config.host } : {}),
+ });
+ } else {
+ const [NodeHttpServer, NodeHttp] = yield* Effect.all([
+ Effect.promise(() => import("@effect/platform-node/NodeHttpServer")),
+ Effect.promise(() => import("node:http")),
+ ]);
+ return NodeHttpServer.layer(NodeHttp.createServer, {
+ host: config.host,
+ port: config.port,
+ });
+ }
+ }),
+);
+
+const PlatformServicesLive = Layer.unwrap(
+ Effect.gen(function* () {
+ if (typeof Bun !== "undefined") {
+ const { layer } = yield* Effect.promise(() => import("@effect/platform-bun/BunServices"));
+ return layer;
+ } else {
+ const { layer } = yield* Effect.promise(() => import("@effect/platform-node/NodeServices"));
+ return layer;
+ }
+ }),
+);
+
+const ReactorLayerLive = Layer.empty.pipe(
+ Layer.provideMerge(OrchestrationReactorLive),
+ Layer.provideMerge(ProviderRuntimeIngestionLive),
+ Layer.provideMerge(ProviderCommandReactorLive),
+ Layer.provideMerge(CheckpointReactorLive),
+ Layer.provideMerge(RuntimeReceiptBusLive),
+);
+
+const OrchestrationLayerLive = Layer.empty.pipe(
+ Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive),
+ Layer.provideMerge(OrchestrationEngineLive),
+ Layer.provideMerge(OrchestrationProjectionPipelineLive),
+ Layer.provideMerge(OrchestrationEventStoreLive),
+ Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive),
+);
+
+const CheckpointingLayerLive = Layer.empty.pipe(
+ Layer.provideMerge(CheckpointDiffQueryLive),
+ Layer.provideMerge(CheckpointStoreLive),
+);
+
+const ProviderLayerLive = Layer.unwrap(
+ Effect.gen(function* () {
+ const { providerEventLogPath } = yield* ServerConfig;
+ const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, {
+ stream: "native",
+ });
+ const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, {
+ stream: "canonical",
+ });
+ const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(
+ Layer.provide(ProviderSessionRuntimeRepositoryLive),
+ );
+ const codexAdapterLayer = makeCodexAdapterLive(
+ nativeEventLogger ? { nativeEventLogger } : undefined,
+ );
+ const claudeAdapterLayer = makeClaudeAdapterLive(
+ nativeEventLogger ? { nativeEventLogger } : undefined,
+ );
+ const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(
+ Layer.provide(codexAdapterLayer),
+ Layer.provide(claudeAdapterLayer),
+ Layer.provideMerge(providerSessionDirectoryLayer),
+ );
+ return makeProviderServiceLive(
+ canonicalEventLogger ? { canonicalEventLogger } : undefined,
+ ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer));
+ }),
+);
+
+const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive));
+
+const GitLayerLive = Layer.empty.pipe(
+ Layer.provideMerge(
+ GitManagerLive.pipe(
+ Layer.provideMerge(GitCoreLive),
+ Layer.provideMerge(GitHubCliLive),
+ Layer.provideMerge(RoutingTextGenerationLive),
+ ),
+ ),
+ Layer.provideMerge(GitCoreLive),
+);
+
+const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive));
+
+const WorkspaceLayerLive = Layer.mergeAll(
+ WorkspacePathsLive,
+ WorkspaceEntriesLive,
+ WorkspaceFileSystemLive.pipe(
+ Layer.provide(WorkspacePathsLive),
+ Layer.provide(WorkspaceEntriesLive),
+ ),
+);
+
+const RuntimeServicesLive = Layer.empty.pipe(
+ Layer.provideMerge(ServerRuntimeStartupLive),
+ Layer.provideMerge(ReactorLayerLive),
+
+ // Core Services
+ Layer.provideMerge(CheckpointingLayerLive),
+ Layer.provideMerge(OrchestrationLayerLive),
+ Layer.provideMerge(ProviderLayerLive),
+ Layer.provideMerge(GitLayerLive),
+ Layer.provideMerge(TerminalLayerLive),
+ Layer.provideMerge(PersistenceLayerLive),
+ Layer.provideMerge(KeybindingsLive),
+ Layer.provideMerge(ProviderRegistryLive),
+ Layer.provideMerge(ServerSettingsLive),
+ Layer.provideMerge(WorkspaceLayerLive),
+ Layer.provideMerge(ProjectFaviconResolverLive),
+
+ // Misc.
+ Layer.provideMerge(AnalyticsServiceLayerLive),
+ Layer.provideMerge(OpenLive),
+ Layer.provideMerge(ServerLifecycleEventsLive),
+);
+
+export const makeRoutesLayer = Layer.mergeAll(
+ healthRouteLayer,
+ attachmentsRouteLayer,
+ projectFaviconRouteLayer,
+ staticAndDevRouteLayer,
+ websocketRpcRouteLayer,
+);
+
+export const makeServerLayer = Layer.unwrap(
+ Effect.gen(function* () {
+ const config = yield* ServerConfig;
+
+ fixPath();
+
+ const httpListeningLayer = Layer.effectDiscard(
+ Effect.gen(function* () {
+ yield* HttpServer.HttpServer;
+ const startup = yield* ServerRuntimeStartup;
+ yield* startup.markHttpListening;
+ }),
+ );
+
+ const serverApplicationLayer = Layer.mergeAll(
+ HttpRouter.serve(makeRoutesLayer, {
+ disableLogger: !config.logWebSocketEvents,
+ }),
+ httpListeningLayer,
+ );
+
+ return serverApplicationLayer.pipe(
+ Layer.provideMerge(RuntimeServicesLive),
+ Layer.provideMerge(HttpServerLive),
+ Layer.provide(ServerLoggerLive),
+ Layer.provideMerge(FetchHttpClient.layer),
+ Layer.provideMerge(PlatformServicesLive),
+ );
+ }),
+);
+
+// Important: Only `ServerConfig` should be provided by the CLI layer!!! Don't let other requirements leak into the launch layer.
+export const runServer = Layer.launch(makeServerLayer) satisfies Effect.Effect<
+ never,
+ any,
+ ServerConfig
+>;
diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts
deleted file mode 100644
index f745d553a1..0000000000
--- a/apps/server/src/serverLayers.ts
+++ /dev/null
@@ -1,161 +0,0 @@
-import * as NodeServices from "@effect/platform-node/NodeServices";
-import { Effect, FileSystem, Layer, Path } from "effect";
-import * as SqlClient from "effect/unstable/sql/SqlClient";
-
-import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery";
-import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
-import { ServerConfig } from "./config";
-import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
-import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
-import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
-import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine";
-import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor";
-import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor";
-import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor";
-import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
-import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
-import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
-import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus";
-import { ProviderUnsupportedError } from "./provider/Errors";
-import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter";
-import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
-import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
-import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
-import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
-import { ProviderService } from "./provider/Services/ProviderService";
-import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
-import { ServerSettingsService } from "./serverSettings";
-
-import { TerminalManagerLive } from "./terminal/Layers/Manager";
-import { KeybindingsLive } from "./keybindings";
-import { GitManagerLive } from "./git/Layers/GitManager";
-import { GitCoreLive } from "./git/Layers/GitCore";
-import { GitHubCliLive } from "./git/Layers/GitHubCli";
-import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration";
-import { PtyAdapter } from "./terminal/Services/PTY";
-import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
-import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
-import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";
-import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
-import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
-
-type RuntimePtyAdapterLoader = {
- layer: Layer.Layer;
-};
-
-const runtimePtyAdapterLoaders = {
- bun: () => import("./terminal/Layers/BunPTY"),
- node: () => import("./terminal/Layers/NodePTY"),
-} satisfies Record Promise>;
-
-const makeRuntimePtyAdapterLayer = () =>
- Effect.gen(function* () {
- const runtime = process.versions.bun !== undefined ? "bun" : "node";
- const loader = runtimePtyAdapterLoaders[runtime];
- const ptyAdapterModule = yield* Effect.promise(loader);
- return ptyAdapterModule.layer;
- }).pipe(Layer.unwrap);
-
-export function makeServerProviderLayer(): Layer.Layer<
- ProviderService,
- ProviderUnsupportedError,
- | SqlClient.SqlClient
- | ServerConfig
- | ServerSettingsService
- | FileSystem.FileSystem
- | AnalyticsService
-> {
- return Effect.gen(function* () {
- const { providerEventLogPath } = yield* ServerConfig;
- const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, {
- stream: "native",
- });
- const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, {
- stream: "canonical",
- });
- const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(
- Layer.provide(ProviderSessionRuntimeRepositoryLive),
- );
- const codexAdapterLayer = makeCodexAdapterLive(
- nativeEventLogger ? { nativeEventLogger } : undefined,
- );
- const claudeAdapterLayer = makeClaudeAdapterLive(
- nativeEventLogger ? { nativeEventLogger } : undefined,
- );
- const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(
- Layer.provide(codexAdapterLayer),
- Layer.provide(claudeAdapterLayer),
- Layer.provideMerge(providerSessionDirectoryLayer),
- );
- return makeProviderServiceLive(
- canonicalEventLogger ? { canonicalEventLogger } : undefined,
- ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer));
- }).pipe(Layer.unwrap);
-}
-
-export function makeServerRuntimeServicesLayer() {
- const textGenerationLayer = RoutingTextGenerationLive;
- const gitCoreLayer = GitCoreLive;
- const checkpointStoreLayer = CheckpointStoreLive;
-
- const orchestrationLayer = OrchestrationEngineLive.pipe(
- Layer.provide(OrchestrationProjectionPipelineLive),
- Layer.provide(OrchestrationEventStoreLive),
- Layer.provide(OrchestrationCommandReceiptRepositoryLive),
- );
-
- const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe(
- Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive),
- Layer.provideMerge(checkpointStoreLayer),
- );
-
- const runtimeServicesLayer = Layer.mergeAll(
- orchestrationLayer,
- OrchestrationProjectionSnapshotQueryLive,
- checkpointStoreLayer,
- checkpointDiffQueryLayer,
- RuntimeReceiptBusLive,
- );
- const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
- Layer.provideMerge(runtimeServicesLayer),
- );
- const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
- Layer.provideMerge(runtimeServicesLayer),
- Layer.provideMerge(textGenerationLayer),
- );
- const checkpointReactorLayer = CheckpointReactorLive.pipe(
- Layer.provideMerge(runtimeServicesLayer),
- Layer.provideMerge(WorkspaceEntriesLive),
- );
- const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
- Layer.provideMerge(runtimeIngestionLayer),
- Layer.provideMerge(providerCommandReactorLayer),
- Layer.provideMerge(checkpointReactorLayer),
- );
-
- const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer()));
-
- const gitManagerLayer = GitManagerLive.pipe(
- Layer.provideMerge(GitHubCliLive),
- Layer.provideMerge(textGenerationLayer),
- );
-
- const workspacePathsLayer = WorkspacePathsLive;
- const workspaceEntriesLayer = WorkspaceEntriesLive;
- const workspaceFileSystemLayer = WorkspaceFileSystemLive.pipe(
- Layer.provide(workspacePathsLayer),
- Layer.provide(workspaceEntriesLayer),
- );
- const projectFaviconResolverLayer = ProjectFaviconResolverLive;
-
- return Layer.mergeAll(
- orchestrationReactorLayer,
- workspacePathsLayer,
- workspaceEntriesLayer,
- workspaceFileSystemLayer,
- projectFaviconResolverLayer,
- gitManagerLayer,
- terminalLayer,
- KeybindingsLive,
- ).pipe(Layer.provideMerge(gitCoreLayer), Layer.provideMerge(NodeServices.layer));
-}
diff --git a/apps/server/src/serverLifecycleEvents.test.ts b/apps/server/src/serverLifecycleEvents.test.ts
new file mode 100644
index 0000000000..1cd8c25c03
--- /dev/null
+++ b/apps/server/src/serverLifecycleEvents.test.ts
@@ -0,0 +1,42 @@
+import { assert, it } from "@effect/vitest";
+import { assertTrue } from "@effect/vitest/utils";
+import { Effect, Option } from "effect";
+
+import { ServerLifecycleEvents, ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts";
+
+it.effect(
+ "publishes lifecycle events without subscribers and snapshots the latest welcome/ready",
+ () =>
+ Effect.gen(function* () {
+ const lifecycleEvents = yield* ServerLifecycleEvents;
+
+ const welcome = yield* lifecycleEvents
+ .publish({
+ version: 1,
+ type: "welcome",
+ payload: {
+ cwd: "/tmp/project",
+ projectName: "project",
+ },
+ })
+ .pipe(Effect.timeoutOption("50 millis"));
+ assertTrue(Option.isSome(welcome));
+ assert.equal(welcome.value.sequence, 1);
+
+ const ready = yield* lifecycleEvents
+ .publish({
+ version: 1,
+ type: "ready",
+ payload: {
+ at: new Date().toISOString(),
+ },
+ })
+ .pipe(Effect.timeoutOption("50 millis"));
+ assertTrue(Option.isSome(ready));
+ assert.equal(ready.value.sequence, 2);
+
+ const snapshot = yield* lifecycleEvents.snapshot;
+ assert.equal(snapshot.sequence, 2);
+ assert.deepEqual(snapshot.events.map((event) => event.type).toSorted(), ["ready", "welcome"]);
+ }).pipe(Effect.provide(ServerLifecycleEventsLive)),
+);
diff --git a/apps/server/src/serverLifecycleEvents.ts b/apps/server/src/serverLifecycleEvents.ts
new file mode 100644
index 0000000000..4808a19d72
--- /dev/null
+++ b/apps/server/src/serverLifecycleEvents.ts
@@ -0,0 +1,53 @@
+import type { ServerLifecycleStreamEvent } from "@t3tools/contracts";
+import { Effect, Layer, PubSub, Ref, ServiceMap, Stream } from "effect";
+
+type LifecycleEventInput =
+ | Omit, "sequence">
+ | Omit, "sequence">;
+
+interface SnapshotState {
+ readonly sequence: number;
+ readonly events: ReadonlyArray;
+}
+
+export interface ServerLifecycleEventsShape {
+ readonly publish: (event: LifecycleEventInput) => Effect.Effect;
+ readonly snapshot: Effect.Effect;
+ readonly stream: Stream.Stream;
+}
+
+export class ServerLifecycleEvents extends ServiceMap.Service<
+ ServerLifecycleEvents,
+ ServerLifecycleEventsShape
+>()("t3/serverLifecycleEvents") {}
+
+export const ServerLifecycleEventsLive = Layer.effect(
+ ServerLifecycleEvents,
+ Effect.gen(function* () {
+ const pubsub = yield* PubSub.unbounded();
+ const state = yield* Ref.make({
+ sequence: 0,
+ events: [],
+ });
+
+ return {
+ publish: (event) =>
+ Ref.modify(state, (current) => {
+ const nextSequence = current.sequence + 1;
+ const nextEvent = {
+ ...event,
+ sequence: nextSequence,
+ } satisfies ServerLifecycleStreamEvent;
+ const nextEvents =
+ nextEvent.type === "welcome"
+ ? [nextEvent, ...current.events.filter((entry) => entry.type !== "welcome")]
+ : [nextEvent, ...current.events.filter((entry) => entry.type !== "ready")];
+ return [nextEvent, { sequence: nextSequence, events: nextEvents }] as const;
+ }).pipe(Effect.tap((event) => PubSub.publish(pubsub, event))),
+ snapshot: Ref.get(state),
+ get stream() {
+ return Stream.fromPubSub(pubsub);
+ },
+ } satisfies ServerLifecycleEventsShape;
+ }),
+);
diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts
index 1b90babaad..aea53aacfb 100644
--- a/apps/server/src/serverLogger.ts
+++ b/apps/server/src/serverLogger.ts
@@ -1,20 +1,16 @@
-import fs from "node:fs";
-
-import { Effect, Logger } from "effect";
-import * as Layer from "effect/Layer";
+import { Effect, Logger, References, Layer } from "effect";
import { ServerConfig } from "./config";
export const ServerLoggerLive = Effect.gen(function* () {
- const { logsDir, serverLogPath } = yield* ServerConfig;
-
- yield* Effect.sync(() => {
- fs.mkdirSync(logsDir, { recursive: true });
- });
+ const config = yield* ServerConfig;
+ const { serverLogPath } = config;
const fileLogger = Logger.formatSimple.pipe(Logger.toFile(serverLogPath));
-
- return Logger.layer([Logger.defaultLogger, fileLogger], {
+ const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel);
+ const loggerLayer = Logger.layer([Logger.consolePretty(), fileLogger], {
mergeWithExisting: false,
});
+
+ return Layer.mergeAll(loggerLayer, minimumLogLevelLayer);
}).pipe(Layer.unwrap);
diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts
new file mode 100644
index 0000000000..55700e3482
--- /dev/null
+++ b/apps/server/src/serverRuntimeStartup.test.ts
@@ -0,0 +1,49 @@
+import { assert, it } from "@effect/vitest";
+import { Deferred, Effect, Fiber, Ref } from "effect";
+import { TestClock } from "effect/testing";
+
+import { makeCommandGate, ServerRuntimeStartupError } from "./serverRuntimeStartup.ts";
+
+it.effect("enqueueCommand waits for readiness and then drains queued work", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const executionCount = yield* Ref.make(0);
+ const commandGate = yield* makeCommandGate;
+
+ const queuedCommandFiber = yield* commandGate
+ .enqueueCommand(Ref.updateAndGet(executionCount, (count) => count + 1))
+ .pipe(Effect.forkScoped);
+
+ yield* TestClock.adjust("50 millis");
+ assert.equal(yield* Ref.get(executionCount), 0);
+
+ yield* commandGate.signalCommandReady;
+
+ const result = yield* Fiber.join(queuedCommandFiber);
+ assert.equal(result, 1);
+ assert.equal(yield* Ref.get(executionCount), 1);
+ }),
+ ),
+);
+
+it.effect("enqueueCommand fails queued work when readiness fails", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const commandGate = yield* makeCommandGate;
+ const failure = yield* Deferred.make();
+
+ const queuedCommandFiber = yield* commandGate
+ .enqueueCommand(Deferred.await(failure).pipe(Effect.as("should-not-run")))
+ .pipe(Effect.forkScoped);
+
+ yield* commandGate.failCommandReady(
+ new ServerRuntimeStartupError({
+ message: "startup failed",
+ }),
+ );
+
+ const error = yield* Effect.flip(Fiber.join(queuedCommandFiber));
+ assert.equal(error.message, "startup failed");
+ }),
+ ),
+);
diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts
new file mode 100644
index 0000000000..1ef6b37005
--- /dev/null
+++ b/apps/server/src/serverRuntimeStartup.ts
@@ -0,0 +1,334 @@
+import {
+ CommandId,
+ DEFAULT_PROVIDER_INTERACTION_MODE,
+ type ModelSelection,
+ ProjectId,
+ ThreadId,
+} from "@t3tools/contracts";
+import { Data, Deferred, Effect, Exit, Layer, Path, Queue, Ref, Scope, ServiceMap } from "effect";
+
+import { ServerConfig } from "./config";
+import { Keybindings } from "./keybindings";
+import { Open } from "./open";
+import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
+import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
+import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
+import { ServerLifecycleEvents } from "./serverLifecycleEvents";
+import { ServerSettingsService } from "./serverSettings";
+import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
+
+const isWildcardHost = (host: string | undefined): boolean =>
+ host === "0.0.0.0" || host === "::" || host === "[::]";
+
+const formatHostForUrl = (host: string): string =>
+ host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
+
+export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{
+ readonly message: string;
+ readonly cause?: unknown;
+}> {}
+
+export interface ServerRuntimeStartupShape {
+ readonly awaitCommandReady: Effect.Effect;
+ readonly markHttpListening: Effect.Effect;
+ readonly enqueueCommand: (
+ effect: Effect.Effect,
+ ) => Effect.Effect;
+}
+
+export class ServerRuntimeStartup extends ServiceMap.Service<
+ ServerRuntimeStartup,
+ ServerRuntimeStartupShape
+>()("t3/serverRuntimeStartup") {}
+
+interface QueuedCommand {
+ readonly run: Effect.Effect;
+}
+
+type CommandReadinessState = "pending" | "ready" | ServerRuntimeStartupError;
+
+interface CommandGate {
+ readonly awaitCommandReady: Effect.Effect;
+ readonly signalCommandReady: Effect.Effect;
+ readonly failCommandReady: (error: ServerRuntimeStartupError) => Effect.Effect;
+ readonly enqueueCommand: (
+ effect: Effect.Effect,
+ ) => Effect.Effect;
+}
+
+const settleQueuedCommand = (deferred: Deferred.Deferred, exit: Exit.Exit) =>
+ Exit.isSuccess(exit)
+ ? Deferred.succeed(deferred, exit.value)
+ : Deferred.failCause(deferred, exit.cause);
+
+export const makeCommandGate = Effect.gen(function* () {
+ const commandReady = yield* Deferred.make();
+ const commandQueue = yield* Queue.unbounded();
+ const commandReadinessState = yield* Ref.make("pending");
+
+ const commandWorker = Effect.forever(
+ Queue.take(commandQueue).pipe(Effect.flatMap((command) => command.run)),
+ );
+ yield* Effect.forkScoped(commandWorker);
+
+ return {
+ awaitCommandReady: Deferred.await(commandReady),
+ signalCommandReady: Effect.gen(function* () {
+ yield* Ref.set(commandReadinessState, "ready");
+ yield* Deferred.succeed(commandReady, undefined).pipe(Effect.orDie);
+ }),
+ failCommandReady: (error) =>
+ Effect.gen(function* () {
+ yield* Ref.set(commandReadinessState, error);
+ yield* Deferred.fail(commandReady, error).pipe(Effect.orDie);
+ }),
+ enqueueCommand: (effect: Effect.Effect) =>
+ Effect.gen(function* () {
+ const readinessState = yield* Ref.get(commandReadinessState);
+ if (readinessState === "ready") {
+ return yield* effect;
+ }
+ if (readinessState !== "pending") {
+ return yield* readinessState;
+ }
+
+ const result = yield* Deferred.make();
+ yield* Queue.offer(commandQueue, {
+ run: Deferred.await(commandReady).pipe(
+ Effect.flatMap(() => effect),
+ Effect.exit,
+ Effect.flatMap((exit) => settleQueuedCommand(result, exit)),
+ ),
+ });
+ return yield* Deferred.await(result);
+ }),
+ } satisfies CommandGate;
+});
+
+const recordStartupHeartbeat = Effect.gen(function* () {
+ const analytics = yield* AnalyticsService;
+ const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
+
+ const { threadCount, projectCount } = yield* projectionSnapshotQuery.getSnapshot().pipe(
+ Effect.map((snapshot) => ({
+ threadCount: snapshot.threads.length,
+ projectCount: snapshot.projects.length,
+ })),
+ Effect.catch((cause) =>
+ Effect.logWarning("failed to gather startup snapshot for telemetry", { cause }).pipe(
+ Effect.as({
+ threadCount: 0,
+ projectCount: 0,
+ }),
+ ),
+ ),
+ );
+
+ yield* analytics.record("server.boot.heartbeat", {
+ threadCount,
+ projectCount,
+ });
+});
+
+const autoBootstrapWelcome = Effect.gen(function* () {
+ const serverConfig = yield* ServerConfig;
+ const projectionReadModelQuery = yield* ProjectionSnapshotQuery;
+ const orchestrationEngine = yield* OrchestrationEngineService;
+ const path = yield* Path.Path;
+
+ let bootstrapProjectId: ProjectId | undefined;
+ let bootstrapThreadId: ThreadId | undefined;
+
+ if (serverConfig.autoBootstrapProjectFromCwd) {
+ yield* Effect.gen(function* () {
+ const snapshot = yield* projectionReadModelQuery.getSnapshot();
+ const existingProject = snapshot.projects.find(
+ (project) => project.workspaceRoot === serverConfig.cwd && project.deletedAt === null,
+ );
+ let nextProjectId: ProjectId;
+ let nextProjectDefaultModelSelection: ModelSelection;
+
+ if (!existingProject) {
+ const createdAt = new Date().toISOString();
+ nextProjectId = ProjectId.makeUnsafe(crypto.randomUUID());
+ const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project";
+ nextProjectDefaultModelSelection = {
+ provider: "codex",
+ model: "gpt-5-codex",
+ };
+ yield* orchestrationEngine.dispatch({
+ type: "project.create",
+ commandId: CommandId.makeUnsafe(crypto.randomUUID()),
+ projectId: nextProjectId,
+ title: bootstrapProjectTitle,
+ workspaceRoot: serverConfig.cwd,
+ defaultModelSelection: nextProjectDefaultModelSelection,
+ createdAt,
+ });
+ } else {
+ nextProjectId = existingProject.id;
+ nextProjectDefaultModelSelection = existingProject.defaultModelSelection ?? {
+ provider: "codex",
+ model: "gpt-5-codex",
+ };
+ }
+
+ const existingThread = snapshot.threads.find(
+ (thread) => thread.projectId === nextProjectId && thread.deletedAt === null,
+ );
+ if (!existingThread) {
+ const createdAt = new Date().toISOString();
+ const createdThreadId = ThreadId.makeUnsafe(crypto.randomUUID());
+ yield* orchestrationEngine.dispatch({
+ type: "thread.create",
+ commandId: CommandId.makeUnsafe(crypto.randomUUID()),
+ threadId: createdThreadId,
+ projectId: nextProjectId,
+ title: "New thread",
+ modelSelection: nextProjectDefaultModelSelection,
+ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
+ runtimeMode: "full-access",
+ branch: null,
+ worktreePath: null,
+ createdAt,
+ });
+ bootstrapProjectId = nextProjectId;
+ bootstrapThreadId = createdThreadId;
+ } else {
+ bootstrapProjectId = nextProjectId;
+ bootstrapThreadId = existingThread.id;
+ }
+ });
+ }
+
+ const segments = serverConfig.cwd.split(/[/\\]/).filter(Boolean);
+ const projectName = segments[segments.length - 1] ?? "project";
+
+ return {
+ cwd: serverConfig.cwd,
+ projectName,
+ ...(bootstrapProjectId ? { bootstrapProjectId } : {}),
+ ...(bootstrapThreadId ? { bootstrapThreadId } : {}),
+ } as const;
+});
+
+const maybeOpenBrowser = Effect.gen(function* () {
+ const serverConfig = yield* ServerConfig;
+ if (serverConfig.noBrowser) {
+ return;
+ }
+ const { openBrowser } = yield* Open;
+ const localUrl = `http://localhost:${serverConfig.port}`;
+ const bindUrl =
+ serverConfig.host && !isWildcardHost(serverConfig.host)
+ ? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}`
+ : localUrl;
+ const target = serverConfig.devUrl?.toString() ?? bindUrl;
+
+ yield* openBrowser(target).pipe(
+ Effect.catch(() =>
+ Effect.logInfo("browser auto-open unavailable", {
+ hint: `Open ${target} in your browser.`,
+ }),
+ ),
+ );
+});
+
+const makeServerRuntimeStartup = Effect.gen(function* () {
+ const keybindings = yield* Keybindings;
+ const orchestrationReactor = yield* OrchestrationReactor;
+ const lifecycleEvents = yield* ServerLifecycleEvents;
+ const serverSettings = yield* ServerSettingsService;
+
+ const commandGate = yield* makeCommandGate;
+ const httpListening = yield* Deferred.make();
+ const reactorScope = yield* Scope.make("sequential");
+
+ yield* Effect.addFinalizer(() => Scope.close(reactorScope, Exit.void));
+
+ const startup = Effect.gen(function* () {
+ yield* Effect.logDebug("startup phase: starting keybindings runtime");
+ yield* keybindings.start.pipe(
+ Effect.catch((error) =>
+ Effect.logWarning("failed to start keybindings runtime", {
+ path: error.configPath,
+ detail: error.detail,
+ cause: error.cause,
+ }),
+ ),
+ Effect.forkScoped,
+ );
+
+ yield* Effect.logDebug("startup phase: starting server settings runtime");
+ yield* serverSettings.start.pipe(
+ Effect.catch((error) =>
+ Effect.logWarning("failed to start server settings runtime", {
+ path: error.settingsPath,
+ detail: error.detail,
+ cause: error.cause,
+ }),
+ ),
+ Effect.forkScoped,
+ );
+
+ yield* Effect.logDebug("startup phase: starting orchestration reactors");
+ yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope));
+
+ yield* Effect.logDebug("startup phase: preparing welcome payload");
+ const welcome = yield* autoBootstrapWelcome;
+ yield* Effect.logDebug("startup phase: publishing welcome event", {
+ cwd: welcome.cwd,
+ projectName: welcome.projectName,
+ bootstrapProjectId: welcome.bootstrapProjectId,
+ bootstrapThreadId: welcome.bootstrapThreadId,
+ });
+ yield* lifecycleEvents.publish({
+ version: 1,
+ type: "welcome",
+ payload: welcome,
+ });
+ });
+
+ yield* Effect.forkScoped(
+ Effect.gen(function* () {
+ const startupExit = yield* Effect.exit(startup);
+ if (Exit.isFailure(startupExit)) {
+ const error = new ServerRuntimeStartupError({
+ message: "Server runtime startup failed before command readiness.",
+ cause: startupExit.cause,
+ });
+ yield* Effect.logError("server runtime startup failed", { cause: startupExit.cause });
+ yield* commandGate.failCommandReady(error);
+ return;
+ }
+
+ yield* Effect.logInfo("Accepting commands");
+ yield* commandGate.signalCommandReady;
+ yield* Effect.logDebug("startup phase: waiting for http listener");
+ yield* Deferred.await(httpListening);
+ yield* Effect.logDebug("startup phase: publishing ready event");
+ yield* lifecycleEvents.publish({
+ version: 1,
+ type: "ready",
+ payload: { at: new Date().toISOString() },
+ });
+
+ yield* Effect.logDebug("startup phase: recording startup heartbeat");
+ yield* recordStartupHeartbeat;
+ yield* Effect.logDebug("startup phase: browser open check");
+ yield* maybeOpenBrowser;
+ yield* Effect.logDebug("startup phase: complete");
+ }),
+ );
+
+ return {
+ awaitCommandReady: commandGate.awaitCommandReady,
+ markHttpListening: Deferred.succeed(httpListening, undefined).pipe(Effect.orDie),
+ enqueueCommand: commandGate.enqueueCommand,
+ } satisfies ServerRuntimeStartupShape;
+});
+
+export const ServerRuntimeStartupLive = Layer.effect(
+ ServerRuntimeStartup,
+ makeServerRuntimeStartup,
+);
diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts
index a5b4345d50..7029017950 100644
--- a/apps/server/src/serverSettings.ts
+++ b/apps/server/src/serverSettings.ts
@@ -16,6 +16,7 @@ import {
type ModelSelection,
type ProviderKind,
ServerSettings,
+ ServerSettingsError,
type ServerSettingsPatch,
} from "@t3tools/contracts";
import {
@@ -41,19 +42,6 @@ import { ServerConfig } from "./config";
import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
import { fromLenientJson } from "@t3tools/shared/schemaJson";
-export class ServerSettingsError extends Schema.TaggedErrorClass()(
- "ServerSettingsError",
- {
- settingsPath: Schema.String,
- detail: Schema.String,
- cause: Schema.optional(Schema.Defect),
- },
-) {
- override get message(): string {
- return `Server settings error at ${this.settingsPath}: ${this.detail}`;
- }
-}
-
export interface ServerSettingsShape {
/** Start the settings runtime and attach file watching. */
readonly start: Effect.Effect;
diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts
index 067cd55438..bdfbc85cc5 100644
--- a/apps/server/src/terminal/Services/Manager.ts
+++ b/apps/server/src/terminal/Services/Manager.ts
@@ -10,85 +10,59 @@ import {
TerminalClearInput,
TerminalCloseInput,
TerminalEvent,
+ TerminalCwdError,
+ TerminalError,
+ TerminalHistoryError,
+ TerminalNotRunningError,
TerminalOpenInput,
TerminalResizeInput,
TerminalRestartInput,
TerminalSessionSnapshot,
+ TerminalSessionLookupError,
+ TerminalSessionStatus,
TerminalWriteInput,
} from "@t3tools/contracts";
-import { Effect, Schema, ServiceMap } from "effect";
+import { PtyProcess } from "./PTY";
+import { Effect, ServiceMap } from "effect";
-export class TerminalCwdError extends Schema.TaggedErrorClass()(
- "TerminalCwdError",
- {
- cwd: Schema.String,
- reason: Schema.Literals(["notFound", "notDirectory", "statFailed"]),
- cause: Schema.optional(Schema.Defect),
- },
-) {
- override get message() {
- if (this.reason === "notDirectory") {
- return `Terminal cwd is not a directory: ${this.cwd}`;
- }
- if (this.reason === "notFound") {
- return `Terminal cwd does not exist: ${this.cwd}`;
- }
- const causeMessage =
- this.cause && typeof this.cause === "object" && "message" in this.cause
- ? this.cause.message
- : undefined;
- return causeMessage
- ? `Failed to access terminal cwd: ${this.cwd} (${causeMessage})`
- : `Failed to access terminal cwd: ${this.cwd}`;
- }
-}
+export {
+ TerminalCwdError,
+ TerminalError,
+ TerminalHistoryError,
+ TerminalNotRunningError,
+ TerminalSessionLookupError,
+};
-export class TerminalHistoryError extends Schema.TaggedErrorClass()(
- "TerminalHistoryError",
- {
- operation: Schema.Literals(["read", "truncate", "migrate"]),
- threadId: Schema.String,
- terminalId: Schema.String,
- cause: Schema.optional(Schema.Defect),
- },
-) {
- override get message() {
- return `Failed to ${this.operation} terminal history for thread: ${this.threadId}, terminal: ${this.terminalId}`;
- }
+export interface TerminalSessionState {
+ threadId: string;
+ terminalId: string;
+ cwd: string;
+ status: TerminalSessionStatus;
+ pid: number | null;
+ history: string;
+ pendingHistoryControlSequence: string;
+ exitCode: number | null;
+ exitSignal: number | null;
+ updatedAt: string;
+ cols: number;
+ rows: number;
+ process: PtyProcess | null;
+ unsubscribeData: (() => void) | null;
+ unsubscribeExit: (() => void) | null;
+ hasRunningSubprocess: boolean;
+ runtimeEnv: Record | null;
}
-export class TerminalSessionLookupError extends Schema.TaggedErrorClass()(
- "TerminalSessionLookupError",
- {
- threadId: Schema.String,
- terminalId: Schema.String,
- },
-) {
- override get message() {
- return `Unknown terminal thread: ${this.threadId}, terminal: ${this.terminalId}`;
- }
+export interface ShellCandidate {
+ shell: string;
+ args?: string[];
}
-export class TerminalNotRunningError extends Schema.TaggedErrorClass()(
- "TerminalNotRunningError",
- {
- threadId: Schema.String,
- terminalId: Schema.String,
- },
-) {
- override get message() {
- return `Terminal is not running for thread: ${this.threadId}, terminal: ${this.terminalId}`;
- }
+export interface TerminalStartInput extends TerminalOpenInput {
+ cols: number;
+ rows: number;
}
-export const TerminalError = Schema.Union([
- TerminalCwdError,
- TerminalHistoryError,
- TerminalSessionLookupError,
- TerminalNotRunningError,
-]);
-export type TerminalError = typeof TerminalError.Type;
-
/**
* TerminalManagerShape - Service API for terminal session lifecycle operations.
*/
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
new file mode 100644
index 0000000000..a594979ac8
--- /dev/null
+++ b/apps/server/src/ws.ts
@@ -0,0 +1,339 @@
+import { Effect, Layer, Option, PubSub, Queue, Ref, Schema, Stream } from "effect";
+import {
+ type GitActionProgressEvent,
+ OrchestrationDispatchCommandError,
+ type OrchestrationEvent,
+ OrchestrationGetFullThreadDiffError,
+ OrchestrationGetSnapshotError,
+ OrchestrationGetTurnDiffError,
+ ORCHESTRATION_WS_METHODS,
+ ProjectSearchEntriesError,
+ ProjectWriteFileError,
+ OrchestrationReplayEventsError,
+ type TerminalEvent,
+ WS_METHODS,
+ WsRpcGroup,
+} from "@t3tools/contracts";
+import { clamp } from "effect/Number";
+import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
+import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
+
+import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
+import { ServerConfig } from "./config";
+import { GitCore } from "./git/Services/GitCore";
+import { GitManager } from "./git/Services/GitManager";
+import { Keybindings } from "./keybindings";
+import { Open, resolveAvailableEditors } from "./open";
+import { normalizeDispatchCommand } from "./orchestration/Normalizer";
+import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
+import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
+import { ProviderRegistry } from "./provider/Services/ProviderRegistry";
+import { ServerLifecycleEvents } from "./serverLifecycleEvents";
+import { ServerRuntimeStartup } from "./serverRuntimeStartup";
+import { ServerSettingsService } from "./serverSettings";
+import { TerminalManager } from "./terminal/Services/Manager";
+import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries";
+import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem";
+import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths";
+
+const WsRpcLayer = WsRpcGroup.toLayer(
+ Effect.gen(function* () {
+ const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
+ const orchestrationEngine = yield* OrchestrationEngineService;
+ const checkpointDiffQuery = yield* CheckpointDiffQuery;
+ const keybindings = yield* Keybindings;
+ const open = yield* Open;
+ const gitManager = yield* GitManager;
+ const git = yield* GitCore;
+ const terminalManager = yield* TerminalManager;
+ const providerRegistry = yield* ProviderRegistry;
+ const config = yield* ServerConfig;
+ const lifecycleEvents = yield* ServerLifecycleEvents;
+ const serverSettings = yield* ServerSettingsService;
+ const startup = yield* ServerRuntimeStartup;
+ const workspaceEntries = yield* WorkspaceEntries;
+ const workspaceFileSystem = yield* WorkspaceFileSystem;
+ const gitActionProgressPubSub = yield* Effect.acquireRelease(
+ PubSub.unbounded(),
+ PubSub.shutdown,
+ );
+
+ const loadServerConfig = Effect.gen(function* () {
+ const keybindingsConfig = yield* keybindings.loadConfigState;
+ const providers = yield* providerRegistry.getProviders;
+ const settings = yield* serverSettings.getSettings;
+
+ return {
+ cwd: config.cwd,
+ keybindingsConfigPath: config.keybindingsConfigPath,
+ keybindings: keybindingsConfig.keybindings,
+ issues: keybindingsConfig.issues,
+ providers,
+ availableEditors: resolveAvailableEditors(),
+ settings,
+ };
+ });
+
+ return WsRpcGroup.of({
+ [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) =>
+ projectionSnapshotQuery.getSnapshot().pipe(
+ Effect.mapError(
+ (cause) =>
+ new OrchestrationGetSnapshotError({
+ message: "Failed to load orchestration snapshot",
+ cause,
+ }),
+ ),
+ ),
+ [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) =>
+ Effect.gen(function* () {
+ const normalizedCommand = yield* normalizeDispatchCommand(command);
+ return yield* startup.enqueueCommand(orchestrationEngine.dispatch(normalizedCommand));
+ }).pipe(
+ Effect.mapError((cause) =>
+ Schema.is(OrchestrationDispatchCommandError)(cause)
+ ? cause
+ : new OrchestrationDispatchCommandError({
+ message: "Failed to dispatch orchestration command",
+ cause,
+ }),
+ ),
+ ),
+ [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) =>
+ checkpointDiffQuery.getTurnDiff(input).pipe(
+ Effect.mapError(
+ (cause) =>
+ new OrchestrationGetTurnDiffError({
+ message: "Failed to load turn diff",
+ cause,
+ }),
+ ),
+ ),
+ [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) =>
+ checkpointDiffQuery.getFullThreadDiff(input).pipe(
+ Effect.mapError(
+ (cause) =>
+ new OrchestrationGetFullThreadDiffError({
+ message: "Failed to load full thread diff",
+ cause,
+ }),
+ ),
+ ),
+ [ORCHESTRATION_WS_METHODS.replayEvents]: (input) =>
+ Stream.runCollect(
+ orchestrationEngine.readEvents(
+ clamp(input.fromSequenceExclusive, { maximum: Number.MAX_SAFE_INTEGER, minimum: 0 }),
+ ),
+ ).pipe(
+ Effect.map((events) => Array.from(events)),
+ Effect.mapError(
+ (cause) =>
+ new OrchestrationReplayEventsError({
+ message: "Failed to replay orchestration events",
+ cause,
+ }),
+ ),
+ ),
+ [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) =>
+ Stream.unwrap(
+ Effect.gen(function* () {
+ const snapshot = yield* orchestrationEngine.getReadModel();
+ const fromSequenceExclusive = snapshot.snapshotSequence;
+ const replayEvents: Array = yield* Stream.runCollect(
+ orchestrationEngine.readEvents(fromSequenceExclusive),
+ ).pipe(
+ Effect.map((events) => Array.from(events)),
+ Effect.catch(() => Effect.succeed([] as Array)),
+ );
+ const replayStream = Stream.fromIterable(replayEvents);
+ const source = Stream.merge(replayStream, orchestrationEngine.streamDomainEvents);
+ type SequenceState = {
+ readonly nextSequence: number;
+ readonly pendingBySequence: Map;
+ };
+ const state = yield* Ref.make({
+ nextSequence: fromSequenceExclusive + 1,
+ pendingBySequence: new Map(),
+ });
+
+ return source.pipe(
+ Stream.mapEffect((event) =>
+ Ref.modify(
+ state,
+ ({
+ nextSequence,
+ pendingBySequence,
+ }): [Array, SequenceState] => {
+ if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) {
+ return [[], { nextSequence, pendingBySequence }];
+ }
+
+ const updatedPending = new Map(pendingBySequence);
+ updatedPending.set(event.sequence, event);
+
+ const emit: Array = [];
+ let expected = nextSequence;
+ for (;;) {
+ const expectedEvent = updatedPending.get(expected);
+ if (!expectedEvent) {
+ break;
+ }
+ emit.push(expectedEvent);
+ updatedPending.delete(expected);
+ expected += 1;
+ }
+
+ return [emit, { nextSequence: expected, pendingBySequence: updatedPending }];
+ },
+ ),
+ ),
+ Stream.flatMap((events) => Stream.fromIterable(events)),
+ );
+ }),
+ ),
+ [WS_METHODS.serverGetConfig]: (_input) => loadServerConfig,
+ [WS_METHODS.serverRefreshProviders]: (_input) =>
+ providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))),
+ [WS_METHODS.serverUpsertKeybinding]: (rule) =>
+ Effect.gen(function* () {
+ const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule);
+ return { keybindings: keybindingsConfig, issues: [] };
+ }),
+ [WS_METHODS.serverGetSettings]: (_input) => serverSettings.getSettings,
+ [WS_METHODS.serverUpdateSettings]: ({ patch }) => serverSettings.updateSettings(patch),
+ [WS_METHODS.projectsSearchEntries]: (input) =>
+ workspaceEntries.search(input).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProjectSearchEntriesError({
+ message: `Failed to search workspace entries: ${cause.detail}`,
+ cause,
+ }),
+ ),
+ ),
+ [WS_METHODS.projectsWriteFile]: (input) =>
+ workspaceFileSystem.writeFile(input).pipe(
+ Effect.mapError((cause) => {
+ const message = Schema.is(WorkspacePathOutsideRootError)(cause)
+ ? "Workspace file path must stay within the project root."
+ : "Failed to write workspace file";
+ return new ProjectWriteFileError({
+ message,
+ cause,
+ });
+ }),
+ ),
+ [WS_METHODS.shellOpenInEditor]: (input) => open.openInEditor(input),
+ [WS_METHODS.gitStatus]: (input) => gitManager.status(input),
+ [WS_METHODS.gitPull]: (input) => git.pullCurrentBranch(input.cwd),
+ [WS_METHODS.gitRunStackedAction]: (input) =>
+ gitManager.runStackedAction(input, {
+ actionId: input.actionId,
+ progressReporter: {
+ publish: (event) => PubSub.publish(gitActionProgressPubSub, event).pipe(Effect.asVoid),
+ },
+ }),
+ [WS_METHODS.gitResolvePullRequest]: (input) => gitManager.resolvePullRequest(input),
+ [WS_METHODS.gitPreparePullRequestThread]: (input) =>
+ gitManager.preparePullRequestThread(input),
+ [WS_METHODS.gitListBranches]: (input) => git.listBranches(input),
+ [WS_METHODS.gitCreateWorktree]: (input) => git.createWorktree(input),
+ [WS_METHODS.gitRemoveWorktree]: (input) => git.removeWorktree(input),
+ [WS_METHODS.gitCreateBranch]: (input) => git.createBranch(input),
+ [WS_METHODS.gitCheckout]: (input) => Effect.scoped(git.checkoutBranch(input)),
+ [WS_METHODS.gitInit]: (input) => git.initRepo(input),
+ [WS_METHODS.terminalOpen]: (input) => terminalManager.open(input),
+ [WS_METHODS.terminalWrite]: (input) => terminalManager.write(input),
+ [WS_METHODS.terminalResize]: (input) => terminalManager.resize(input),
+ [WS_METHODS.terminalClear]: (input) => terminalManager.clear(input),
+ [WS_METHODS.terminalRestart]: (input) => terminalManager.restart(input),
+ [WS_METHODS.terminalClose]: (input) => terminalManager.close(input),
+ [WS_METHODS.subscribeTerminalEvents]: (_input) =>
+ Stream.callback((queue) =>
+ Effect.acquireRelease(
+ terminalManager.subscribe((event) => Queue.offer(queue, event)),
+ (unsubscribe) => Effect.sync(unsubscribe),
+ ),
+ ),
+ [WS_METHODS.subscribeGitActionProgress]: (_input) =>
+ Stream.fromPubSub(gitActionProgressPubSub),
+ [WS_METHODS.subscribeServerConfig]: (_input) =>
+ Stream.unwrap(
+ Effect.gen(function* () {
+ const keybindingsUpdates = keybindings.streamChanges.pipe(
+ Stream.map((event) => ({
+ version: 1 as const,
+ type: "keybindingsUpdated" as const,
+ payload: {
+ issues: event.issues,
+ },
+ })),
+ );
+ const providerStatuses = providerRegistry.streamChanges.pipe(
+ Stream.map((providers) => ({
+ version: 1 as const,
+ type: "providerStatuses" as const,
+ payload: { providers },
+ })),
+ );
+ const settingsUpdates = serverSettings.streamChanges.pipe(
+ Stream.map((settings) => ({
+ version: 1 as const,
+ type: "settingsUpdated" as const,
+ payload: { settings },
+ })),
+ );
+
+ return Stream.concat(
+ Stream.make({
+ version: 1 as const,
+ type: "snapshot" as const,
+ config: yield* loadServerConfig,
+ }),
+ Stream.merge(keybindingsUpdates, Stream.merge(providerStatuses, settingsUpdates)),
+ );
+ }),
+ ),
+ [WS_METHODS.subscribeServerLifecycle]: (_input) =>
+ Stream.unwrap(
+ Effect.gen(function* () {
+ const snapshot = yield* lifecycleEvents.snapshot;
+ const snapshotEvents = Array.from(snapshot.events).toSorted(
+ (left, right) => left.sequence - right.sequence,
+ );
+ const liveEvents = lifecycleEvents.stream.pipe(
+ Stream.filter((event) => event.sequence > snapshot.sequence),
+ );
+ return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents);
+ }),
+ ),
+ });
+ }),
+);
+
+export const websocketRpcRouteLayer = Layer.unwrap(
+ Effect.gen(function* () {
+ const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup).pipe(
+ Effect.provide(Layer.mergeAll(WsRpcLayer, RpcSerialization.layerJson)),
+ );
+ return HttpRouter.add(
+ "GET",
+ "/ws",
+ Effect.gen(function* () {
+ const request = yield* HttpServerRequest.HttpServerRequest;
+ const config = yield* ServerConfig;
+ if (config.authToken) {
+ const url = HttpServerRequest.toURL(request);
+ if (Option.isNone(url)) {
+ return HttpServerResponse.text("Invalid WebSocket URL", { status: 400 });
+ }
+ const token = url.value.searchParams.get("token");
+ if (token !== config.authToken) {
+ return HttpServerResponse.text("Unauthorized WebSocket connection", { status: 401 });
+ }
+ }
+ return yield* rpcWebSocketHttpEffect;
+ }),
+ );
+ }),
+);
diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts
deleted file mode 100644
index 4fa281d29f..0000000000
--- a/apps/server/src/wsServer.test.ts
+++ /dev/null
@@ -1,2069 +0,0 @@
-import * as Http from "node:http";
-import fs from "node:fs";
-import os from "node:os";
-import path from "node:path";
-
-import * as NodeServices from "@effect/platform-node/NodeServices";
-import {
- Effect,
- Exit,
- Fiber,
- Layer,
- ManagedRuntime,
- PlatformError,
- PubSub,
- Scope,
- Stream,
-} from "effect";
-import { describe, expect, it, afterEach, vi } from "vitest";
-import { createServer } from "./wsServer";
-import WebSocket from "ws";
-import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config";
-import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
-
-import {
- DEFAULT_TERMINAL_ID,
- DEFAULT_SERVER_SETTINGS,
- EDITORS,
- EventId,
- ORCHESTRATION_WS_CHANNELS,
- ORCHESTRATION_WS_METHODS,
- ProviderItemId,
- type ServerSettings,
- ThreadId,
- TurnId,
- WS_CHANNELS,
- WS_METHODS,
- type WebSocketResponse,
- type ProviderRuntimeEvent,
- type ServerProvider,
- type KeybindingsConfig,
- type ResolvedKeybindingsConfig,
- type WsPushChannel,
- type WsPushMessage,
- type WsPush,
-} from "@t3tools/contracts";
-import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";
-import type {
- TerminalClearInput,
- TerminalCloseInput,
- TerminalEvent,
- TerminalOpenInput,
- TerminalResizeInput,
- TerminalSessionSnapshot,
- TerminalWriteInput,
-} from "@t3tools/contracts";
-import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager";
-import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
-import { SqlClient, SqlError } from "effect/unstable/sql";
-import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
-import { ProviderRegistry, type ProviderRegistryShape } from "./provider/Services/ProviderRegistry";
-import { Open, type OpenShape } from "./open";
-import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
-import type { GitCoreShape } from "./git/Services/GitCore.ts";
-import { GitCore } from "./git/Services/GitCore.ts";
-import { GitCommandError, GitManagerError } from "./git/Errors.ts";
-import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";
-import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
-import { ServerSettingsService } from "./serverSettings.ts";
-
-const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
-const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
-const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
-const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
-
-const defaultOpenService: OpenShape = {
- openBrowser: () => Effect.void,
- openInEditor: () => Effect.void,
-};
-
-const defaultProviderStatuses: ReadonlyArray = [
- {
- provider: "codex",
- enabled: true,
- installed: true,
- version: "0.116.0",
- status: "ready",
- auth: { status: "authenticated" },
- checkedAt: "2026-01-01T00:00:00.000Z",
- models: [],
- },
-];
-
-const defaultProviderRegistryService: ProviderRegistryShape = {
- getProviders: Effect.succeed(defaultProviderStatuses),
- refresh: () => Effect.succeed(defaultProviderStatuses),
- streamChanges: Stream.empty,
-};
-
-const defaultServerSettings = DEFAULT_SERVER_SETTINGS;
-
-class MockTerminalManager implements TerminalManagerShape {
- private readonly sessions = new Map();
- private readonly eventPubSub = Effect.runSync(PubSub.unbounded());
- private activeSubscriptions = 0;
-
- private key(threadId: string, terminalId: string): string {
- return `${threadId}\u0000${terminalId}`;
- }
-
- emitEvent(event: TerminalEvent): void {
- Effect.runSync(PubSub.publish(this.eventPubSub, event));
- }
-
- subscriptionCount(): number {
- return this.activeSubscriptions;
- }
-
- readonly open: TerminalManagerShape["open"] = (input: TerminalOpenInput) =>
- Effect.sync(() => {
- const now = new Date().toISOString();
- const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
- const snapshot: TerminalSessionSnapshot = {
- threadId: input.threadId,
- terminalId,
- cwd: input.cwd,
- status: "running",
- pid: 4242,
- history: "",
- exitCode: null,
- exitSignal: null,
- updatedAt: now,
- };
- this.sessions.set(this.key(input.threadId, terminalId), snapshot);
- queueMicrotask(() => {
- this.emitEvent({
- type: "started",
- threadId: input.threadId,
- terminalId,
- createdAt: now,
- snapshot,
- });
- });
- return snapshot;
- });
-
- readonly write: TerminalManagerShape["write"] = (input: TerminalWriteInput) =>
- Effect.sync(() => {
- const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
- const existing = this.sessions.get(this.key(input.threadId, terminalId));
- if (!existing) {
- throw new Error(`Unknown terminal thread: ${input.threadId}`);
- }
- queueMicrotask(() => {
- this.emitEvent({
- type: "output",
- threadId: input.threadId,
- terminalId,
- createdAt: new Date().toISOString(),
- data: input.data,
- });
- });
- });
-
- readonly resize: TerminalManagerShape["resize"] = (_input: TerminalResizeInput) => Effect.void;
-
- readonly clear: TerminalManagerShape["clear"] = (input: TerminalClearInput) =>
- Effect.sync(() => {
- const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
- queueMicrotask(() => {
- this.emitEvent({
- type: "cleared",
- threadId: input.threadId,
- terminalId,
- createdAt: new Date().toISOString(),
- });
- });
- });
-
- readonly restart: TerminalManagerShape["restart"] = (input: TerminalOpenInput) =>
- Effect.sync(() => {
- const now = new Date().toISOString();
- const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
- const snapshot: TerminalSessionSnapshot = {
- threadId: input.threadId,
- terminalId,
- cwd: input.cwd,
- status: "running",
- pid: 5252,
- history: "",
- exitCode: null,
- exitSignal: null,
- updatedAt: now,
- };
- this.sessions.set(this.key(input.threadId, terminalId), snapshot);
- queueMicrotask(() => {
- this.emitEvent({
- type: "restarted",
- threadId: input.threadId,
- terminalId,
- createdAt: now,
- snapshot,
- });
- });
- return snapshot;
- });
-
- readonly close: TerminalManagerShape["close"] = (input: TerminalCloseInput) =>
- Effect.sync(() => {
- if (input.terminalId) {
- this.sessions.delete(this.key(input.threadId, input.terminalId));
- return;
- }
- for (const key of this.sessions.keys()) {
- if (key.startsWith(`${input.threadId}\u0000`)) {
- this.sessions.delete(key);
- }
- }
- });
-
- readonly subscribe: TerminalManagerShape["subscribe"] = (listener) =>
- Effect.sync(() => {
- this.activeSubscriptions += 1;
- const fiber = Effect.runFork(
- Stream.runForEach(Stream.fromPubSub(this.eventPubSub), (event) => listener(event)),
- );
- return () => {
- this.activeSubscriptions -= 1;
- Effect.runFork(Fiber.interrupt(fiber).pipe(Effect.ignore));
- };
- });
-}
-
-// ---------------------------------------------------------------------------
-// WebSocket test harness
-//
-// Incoming messages are split into two channels:
-// - pushChannel: server push envelopes (type === "push")
-// - responseChannel: request/response envelopes (have an "id" field)
-//
-// This means sendRequest never has to skip push messages and waitForPush
-// never has to skip response messages, eliminating a class of ordering bugs.
-// ---------------------------------------------------------------------------
-
-interface MessageChannel {
- queue: T[];
- waiters: Array<{
- resolve: (value: T) => void;
- reject: (error: Error) => void;
- timeoutId: ReturnType | null;
- }>;
-}
-
-interface SocketChannels {
- push: MessageChannel;
- response: MessageChannel;
-}
-
-const channelsBySocket = new WeakMap();
-
-function enqueue(channel: MessageChannel, item: T) {
- const waiter = channel.waiters.shift();
- if (waiter) {
- if (waiter.timeoutId !== null) clearTimeout(waiter.timeoutId);
- waiter.resolve(item);
- return;
- }
- channel.queue.push(item);
-}
-
-function dequeue(channel: MessageChannel, timeoutMs: number): Promise {
- const queued = channel.queue.shift();
- if (queued !== undefined) {
- return Promise.resolve(queued);
- }
-
- return new Promise((resolve, reject) => {
- const waiter = {
- resolve,
- reject,
- timeoutId: setTimeout(() => {
- const index = channel.waiters.indexOf(waiter);
- if (index >= 0) channel.waiters.splice(index, 1);
- reject(new Error(`Timed out waiting for WebSocket message after ${timeoutMs}ms`));
- }, timeoutMs) as ReturnType,
- };
- channel.waiters.push(waiter);
- });
-}
-
-function isWsPushEnvelope(message: unknown): message is WsPush {
- if (typeof message !== "object" || message === null) return false;
- if (!("type" in message) || !("channel" in message)) return false;
- return (message as { type?: unknown }).type === "push";
-}
-
-function asWebSocketResponse(message: unknown): WebSocketResponse | null {
- if (typeof message !== "object" || message === null) return null;
- if (!("id" in message)) return null;
- const id = (message as { id?: unknown }).id;
- if (typeof id !== "string") return null;
- return message as WebSocketResponse;
-}
-
-function connectWsOnce(port: number, token?: string): Promise {
- return new Promise((resolve, reject) => {
- const query = token ? `?token=${encodeURIComponent(token)}` : "";
- const ws = new WebSocket(`ws://127.0.0.1:${port}/${query}`);
- const channels: SocketChannels = {
- push: { queue: [], waiters: [] },
- response: { queue: [], waiters: [] },
- };
- channelsBySocket.set(ws, channels);
-
- ws.on("message", (raw) => {
- const parsed = JSON.parse(String(raw));
- if (isWsPushEnvelope(parsed)) {
- enqueue(channels.push, parsed);
- } else {
- const response = asWebSocketResponse(parsed);
- if (response) {
- enqueue(channels.response, response);
- }
- }
- });
-
- ws.once("open", () => resolve(ws));
- ws.once("error", () => reject(new Error("WebSocket connection failed")));
- });
-}
-
-async function connectWs(port: number, token?: string, attempts = 5): Promise {
- let lastError: unknown = new Error("WebSocket connection failed");
-
- for (let attempt = 0; attempt < attempts; attempt += 1) {
- try {
- return await connectWsOnce(port, token);
- } catch (error) {
- lastError = error;
- if (attempt < attempts - 1) {
- await new Promise((resolve) => setTimeout(resolve, 25));
- }
- }
- }
-
- throw lastError;
-}
-
-/** Connect and wait for the server.welcome push. Returns [ws, welcomeData]. */
-async function connectAndAwaitWelcome(
- port: number,
- token?: string,
-): Promise<[WebSocket, WsPushMessage]> {
- const ws = await connectWs(port, token);
- const welcome = await waitForPush(ws, WS_CHANNELS.serverWelcome);
- return [ws, welcome];
-}
-
-async function sendRequest(
- ws: WebSocket,
- method: string,
- params?: unknown,
-): Promise {
- const channels = channelsBySocket.get(ws);
- if (!channels) throw new Error("WebSocket not initialized");
-
- const id = crypto.randomUUID();
- const body =
- method === ORCHESTRATION_WS_METHODS.dispatchCommand
- ? { _tag: method, command: params }
- : params && typeof params === "object" && !Array.isArray(params)
- ? { _tag: method, ...(params as Record) }
- : { _tag: method };
- ws.send(JSON.stringify({ id, body }));
-
- // Response channel only contains responses — no push filtering needed
- while (true) {
- const response = await dequeue(channels.response, 60_000);
- if (response.id === id || response.id === "unknown") {
- return response;
- }
- }
-}
-
-async function waitForPush(
- ws: WebSocket,
- channel: C,
- predicate?: (push: WsPushMessage) => boolean,
- maxMessages = 120,
- idleTimeoutMs = 5_000,
-): Promise> {
- const channels = channelsBySocket.get(ws);
- if (!channels) throw new Error("WebSocket not initialized");
-
- for (let remaining = maxMessages; remaining > 0; remaining--) {
- const push = await dequeue(channels.push, idleTimeoutMs);
- if (push.channel !== channel) continue;
- const typed = push as WsPushMessage;
- if (!predicate || predicate(typed)) return typed;
- }
- throw new Error(`Timed out waiting for push on ${channel}`);
-}
-
-async function rewriteKeybindingsAndWaitForPush(
- ws: WebSocket,
- keybindingsPath: string,
- contents: string,
- predicate: (push: WsPushMessage) => boolean,
- attempts = 3,
-): Promise> {
- let lastError: unknown;
- for (let attempt = 0; attempt < attempts; attempt++) {
- fs.writeFileSync(keybindingsPath, contents, "utf8");
- try {
- return await waitForPush(ws, WS_CHANNELS.serverConfigUpdated, predicate, 20, 3_000);
- } catch (error) {
- lastError = error;
- }
- }
- throw lastError;
-}
-
-async function requestPath(
- port: number,
- requestPath: string,
-): Promise<{ statusCode: number; body: string }> {
- return new Promise((resolve, reject) => {
- const req = Http.request(
- {
- hostname: "127.0.0.1",
- port,
- path: requestPath,
- method: "GET",
- },
- (res) => {
- const chunks: Buffer[] = [];
- res.on("data", (chunk) => {
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
- });
- res.on("end", () => {
- resolve({
- statusCode: res.statusCode ?? 0,
- body: Buffer.concat(chunks).toString("utf8"),
- });
- });
- },
- );
- req.once("error", reject);
- req.end();
- });
-}
-
-function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {
- const resolved: Array = [];
- for (const binding of bindings) {
- const compiled = compileResolvedKeybindingRule(binding);
- if (!compiled) {
- throw new Error(`Unexpected invalid keybinding in test setup: ${binding.command}`);
- }
- resolved.push(compiled);
- }
- return resolved;
-}
-
-const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]);
-const VALID_EDITOR_IDS = new Set(EDITORS.map((editor) => editor.id));
-
-function expectAvailableEditors(value: unknown): void {
- expect(Array.isArray(value)).toBe(true);
- for (const editorId of value as unknown[]) {
- expect(typeof editorId).toBe("string");
- expect(VALID_EDITOR_IDS.has(editorId as (typeof EDITORS)[number]["id"])).toBe(true);
- }
-}
-
-function ensureParentDir(filePath: string): void {
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
-}
-
-function deriveServerPathsSync(baseDir: string, devUrl: URL | undefined) {
- return Effect.runSync(
- deriveServerPaths(baseDir, devUrl).pipe(Effect.provide(NodeServices.layer)),
- );
-}
-
-describe("WebSocket Server", () => {
- let server: Http.Server | null = null;
- let serverScope: Scope.Closeable | null = null;
- let disposeServerRuntime: (() => Promise) | null = null;
- const connections: WebSocket[] = [];
- const tempDirs: string[] = [];
-
- function makeTempDir(prefix: string): string {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
- tempDirs.push(dir);
- return dir;
- }
-
- async function createTestServer(
- options: {
- persistenceLayer?: Layer.Layer<
- SqlClient.SqlClient,
- SqlError.SqlError | MigrationError | PlatformError.PlatformError
- >;
- cwd?: string;
- autoBootstrapProjectFromCwd?: boolean;
- logWebSocketEvents?: boolean;
- devUrl?: string;
- authToken?: string;
- baseDir?: string;
- staticDir?: string;
- providerLayer?: Layer.Layer;
- providerRegistry?: ProviderRegistryShape;
- open?: OpenShape;
- gitManager?: GitManagerShape;
- gitCore?: Pick;
- terminalManager?: TerminalManagerShape;
- serverSettings?: Partial;
- } = {},
- ): Promise {
- if (serverScope) {
- throw new Error("Test server is already running");
- }
-
- const baseDir = options.baseDir ?? makeTempDir("t3code-ws-base-");
- const devUrl = options.devUrl ? new URL(options.devUrl) : undefined;
- const derivedPaths = deriveServerPathsSync(baseDir, devUrl);
- const scope = await Effect.runPromise(Scope.make("sequential"));
- const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory;
- const providerLayer = options.providerLayer ?? makeServerProviderLayer();
- const providerRegistryLayer = Layer.succeed(
- ProviderRegistry,
- options.providerRegistry ?? defaultProviderRegistryService,
- );
- const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService);
- const nodeServicesLayer = NodeServices.layer;
- const serverSettingsLayer = ServerSettingsService.layerTest(options.serverSettings);
- const serverSettingsRuntimeLayer = serverSettingsLayer.pipe(
- Layer.provideMerge(nodeServicesLayer),
- );
- const analyticsLayer = AnalyticsService.layerTest;
- const serverConfigLayer = Layer.succeed(ServerConfig, {
- mode: "web",
- port: 0,
- host: undefined,
- cwd: options.cwd ?? "/test/project",
- baseDir,
- ...derivedPaths,
- staticDir: options.staticDir,
- devUrl,
- noBrowser: true,
- authToken: options.authToken,
- autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false,
- logWebSocketEvents: options.logWebSocketEvents ?? Boolean(options.devUrl),
- } satisfies ServerConfigShape);
- const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer));
- const providerRuntimeLayer = infrastructureLayer.pipe(
- Layer.provideMerge(serverConfigLayer),
- Layer.provideMerge(serverSettingsRuntimeLayer),
- Layer.provideMerge(analyticsLayer),
- );
- const runtimeOverrides = Layer.mergeAll(
- options.gitManager ? Layer.succeed(GitManager, options.gitManager) : Layer.empty,
- options.gitCore
- ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape)
- : Layer.empty,
- options.terminalManager
- ? Layer.succeed(TerminalManager, options.terminalManager)
- : Layer.empty,
- );
-
- const runtimeLayer = Layer.merge(
- Layer.merge(
- makeServerRuntimeServicesLayer().pipe(
- Layer.provideMerge(providerRuntimeLayer),
- Layer.provideMerge(serverConfigLayer),
- Layer.provideMerge(serverSettingsRuntimeLayer),
- Layer.provideMerge(analyticsLayer),
- Layer.provideMerge(nodeServicesLayer),
- ),
- Layer.mergeAll(providerRuntimeLayer, serverSettingsRuntimeLayer, analyticsLayer),
- ),
- runtimeOverrides,
- );
- const dependenciesLayer = Layer.mergeAll(
- runtimeLayer,
- providerRegistryLayer,
- openLayer,
- serverConfigLayer,
- nodeServicesLayer,
- );
- const runtime = ManagedRuntime.make(dependenciesLayer);
- try {
- const httpServer = await runtime.runPromise(createServer().pipe(Scope.provide(scope)));
- disposeServerRuntime = () => runtime.dispose();
- serverScope = scope;
- return httpServer;
- } catch (error) {
- await runtime.dispose();
- await Effect.runPromise(Scope.close(scope, Exit.void));
- throw error;
- }
- }
-
- async function closeTestServer() {
- if (!serverScope && !disposeServerRuntime) return;
- const scope = serverScope;
- const disposeRuntime = disposeServerRuntime;
- serverScope = null;
- disposeServerRuntime = null;
- if (scope) {
- await Effect.runPromise(Scope.close(scope, Exit.void));
- }
- if (disposeRuntime) {
- await disposeRuntime();
- }
- }
-
- afterEach(async () => {
- for (const ws of connections) {
- ws.close();
- }
- connections.length = 0;
- await closeTestServer();
- server = null;
- for (const dir of tempDirs.splice(0, tempDirs.length)) {
- fs.rmSync(dir, { recursive: true, force: true });
- }
- vi.restoreAllMocks();
- });
-
- it("sends welcome message on connect", async () => {
- server = await createTestServer({ cwd: "/test/project" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
- expect(port).toBeGreaterThan(0);
-
- const [ws, welcome] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- expect(welcome.type).toBe("push");
- expect(welcome.data).toEqual({
- cwd: "/test/project",
- projectName: "project",
- });
- });
-
- it("serves persisted attachments from stateDir", async () => {
- const baseDir = makeTempDir("t3code-state-attachments-");
- const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined);
- const attachmentPath = path.join(attachmentsDir, "thread-a", "message-a", "0.png");
- fs.mkdirSync(path.dirname(attachmentPath), { recursive: true });
- fs.writeFileSync(attachmentPath, Buffer.from("hello-attachment"));
-
- server = await createTestServer({ cwd: "/test/project", baseDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
- expect(port).toBeGreaterThan(0);
-
- const response = await fetch(`http://127.0.0.1:${port}/attachments/thread-a/message-a/0.png`);
- expect(response.status).toBe(200);
- expect(response.headers.get("content-type")).toContain("image/png");
- const bytes = Buffer.from(await response.arrayBuffer());
- expect(bytes).toEqual(Buffer.from("hello-attachment"));
- });
-
- it("serves persisted attachments for URL-encoded paths", async () => {
- const baseDir = makeTempDir("t3code-state-attachments-encoded-");
- const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined);
- const attachmentPath = path.join(
- attachmentsDir,
- "thread%20folder",
- "message%20folder",
- "file%20name.png",
- );
- fs.mkdirSync(path.dirname(attachmentPath), { recursive: true });
- fs.writeFileSync(attachmentPath, Buffer.from("hello-encoded-attachment"));
-
- server = await createTestServer({ cwd: "/test/project", baseDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
- expect(port).toBeGreaterThan(0);
-
- const response = await fetch(
- `http://127.0.0.1:${port}/attachments/thread%20folder/message%20folder/file%20name.png`,
- );
- expect(response.status).toBe(200);
- expect(response.headers.get("content-type")).toContain("image/png");
- const bytes = Buffer.from(await response.arrayBuffer());
- expect(bytes).toEqual(Buffer.from("hello-encoded-attachment"));
- });
-
- it("serves static index for root path", async () => {
- const baseDir = makeTempDir("t3code-state-static-root-");
- const staticDir = makeTempDir("t3code-static-root-");
- fs.writeFileSync(path.join(staticDir, "index.html"), "static-root
", "utf8");
-
- server = await createTestServer({ cwd: "/test/project", baseDir, staticDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
- expect(port).toBeGreaterThan(0);
-
- const response = await fetch(`http://127.0.0.1:${port}/`);
- expect(response.status).toBe(200);
- expect(await response.text()).toContain("static-root");
- });
-
- it("rejects static path traversal attempts", async () => {
- const baseDir = makeTempDir("t3code-state-static-traversal-");
- const staticDir = makeTempDir("t3code-static-traversal-");
- fs.writeFileSync(path.join(staticDir, "index.html"), "safe
", "utf8");
-
- server = await createTestServer({ cwd: "/test/project", baseDir, staticDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
- expect(port).toBeGreaterThan(0);
-
- const response = await requestPath(port, "/..%2f..%2fetc/passwd");
- expect(response.statusCode).toBe(400);
- expect(response.body).toBe("Invalid static file path");
- });
-
- it("bootstraps the cwd project on startup when enabled", async () => {
- server = await createTestServer({
- cwd: "/test/bootstrap-workspace",
- autoBootstrapProjectFromCwd: true,
- });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
- expect(port).toBeGreaterThan(0);
-
- const [ws, welcome] = await connectAndAwaitWelcome(port);
- connections.push(ws);
- expect(welcome.data).toEqual(
- expect.objectContaining({
- cwd: "/test/bootstrap-workspace",
- projectName: "bootstrap-workspace",
- bootstrapProjectId: expect.any(String),
- bootstrapThreadId: expect.any(String),
- }),
- );
-
- const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot);
- expect(snapshotResponse.error).toBeUndefined();
- const snapshot = snapshotResponse.result as {
- projects: Array<{
- id: string;
- workspaceRoot: string;
- title: string;
- defaultModelSelection: {
- provider: string;
- model: string;
- } | null;
- }>;
- threads: Array<{
- id: string;
- projectId: string;
- title: string;
- modelSelection: {
- provider: string;
- model: string;
- };
- branch: string | null;
- worktreePath: string | null;
- }>;
- };
- const bootstrapProjectId = (welcome.data as { bootstrapProjectId?: string }).bootstrapProjectId;
- const bootstrapThreadId = (welcome.data as { bootstrapThreadId?: string }).bootstrapThreadId;
- expect(bootstrapProjectId).toBeDefined();
- expect(bootstrapThreadId).toBeDefined();
-
- expect(snapshot.projects).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- id: bootstrapProjectId,
- workspaceRoot: "/test/bootstrap-workspace",
- title: "bootstrap-workspace",
- defaultModelSelection: {
- provider: "codex",
- model: "gpt-5-codex",
- },
- }),
- ]),
- );
- expect(snapshot.threads).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- id: bootstrapThreadId,
- projectId: bootstrapProjectId,
- title: "New thread",
- modelSelection: {
- provider: "codex",
- model: "gpt-5-codex",
- },
- branch: null,
- worktreePath: null,
- }),
- ]),
- );
- });
-
- it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => {
- const baseDir = makeTempDir("t3code-state-bootstrap-existing-");
- const { dbPath } = deriveServerPathsSync(baseDir, undefined);
- const persistenceLayer = makeSqlitePersistenceLive(dbPath).pipe(
- Layer.provide(NodeServices.layer),
- );
- const cwd = "/test/bootstrap-existing";
-
- server = await createTestServer({
- cwd,
- baseDir,
- persistenceLayer,
- autoBootstrapProjectFromCwd: true,
- });
- let addr = server.address();
- let port = typeof addr === "object" && addr !== null ? addr.port : 0;
- expect(port).toBeGreaterThan(0);
-
- const [firstWs, firstWelcome] = await connectAndAwaitWelcome(port);
- connections.push(firstWs);
- const firstBootstrapProjectId = (firstWelcome.data as { bootstrapProjectId?: string })
- .bootstrapProjectId;
- const firstBootstrapThreadId = (firstWelcome.data as { bootstrapThreadId?: string })
- .bootstrapThreadId;
- expect(firstBootstrapProjectId).toBeDefined();
- expect(firstBootstrapThreadId).toBeDefined();
-
- firstWs.close();
- await closeTestServer();
- server = null;
-
- server = await createTestServer({
- cwd,
- baseDir,
- persistenceLayer,
- autoBootstrapProjectFromCwd: true,
- });
- addr = server.address();
- port = typeof addr === "object" && addr !== null ? addr.port : 0;
- expect(port).toBeGreaterThan(0);
-
- const [secondWs, secondWelcome] = await connectAndAwaitWelcome(port);
- connections.push(secondWs);
- expect(secondWelcome.data).toEqual(
- expect.objectContaining({
- cwd,
- projectName: "bootstrap-existing",
- bootstrapProjectId: firstBootstrapProjectId,
- bootstrapThreadId: firstBootstrapThreadId,
- }),
- );
- });
-
- it("logs outbound websocket push events in dev mode", async () => {
- const logSpy = vi.spyOn(console, "log").mockImplementation(() => {
- // Keep test output clean while verifying websocket logs.
- });
-
- server = await createTestServer({
- cwd: "/test/project",
- devUrl: "http://localhost:5173",
- });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
- expect(port).toBeGreaterThan(0);
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- expect(
- logSpy.mock.calls.some(([message]) => {
- if (typeof message !== "string") return false;
- return (
- message.includes("[ws]") &&
- message.includes("outgoing push") &&
- message.includes(`channel="${WS_CHANNELS.serverWelcome}"`)
- );
- }),
- ).toBe(true);
- });
-
- it("responds to server.getConfig", async () => {
- const baseDir = makeTempDir("t3code-state-get-config-");
- const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
- ensureParentDir(keybindingsPath);
- fs.writeFileSync(keybindingsPath, "[]", "utf8");
-
- server = await createTestServer({ cwd: "/my/workspace", baseDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
- expect(response.error).toBeUndefined();
- expect(response.result).toEqual({
- cwd: "/my/workspace",
- keybindingsConfigPath: keybindingsPath,
- keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
- issues: [],
- providers: defaultProviderStatuses,
- availableEditors: expect.any(Array),
- settings: defaultServerSettings,
- });
- expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
- });
-
- it("bootstraps default keybindings file when missing", async () => {
- const baseDir = makeTempDir("t3code-state-bootstrap-keybindings-");
- const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
- expect(fs.existsSync(keybindingsPath)).toBe(false);
-
- server = await createTestServer({ cwd: "/my/workspace", baseDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
- expect(response.error).toBeUndefined();
- expect(response.result).toEqual({
- cwd: "/my/workspace",
- keybindingsConfigPath: keybindingsPath,
- keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
- issues: [],
- providers: defaultProviderStatuses,
- availableEditors: expect.any(Array),
- settings: defaultServerSettings,
- });
- expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
-
- const persistedConfig = JSON.parse(
- fs.readFileSync(keybindingsPath, "utf8"),
- ) as KeybindingsConfig;
- expect(persistedConfig).toEqual(DEFAULT_KEYBINDINGS);
- });
-
- it("falls back to defaults and reports malformed keybindings config issues", async () => {
- const baseDir = makeTempDir("t3code-state-malformed-keybindings-");
- const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
- ensureParentDir(keybindingsPath);
- fs.writeFileSync(keybindingsPath, "{ not-json", "utf8");
-
- server = await createTestServer({ cwd: "/my/workspace", baseDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
- expect(response.error).toBeUndefined();
- expect(response.result).toEqual({
- cwd: "/my/workspace",
- keybindingsConfigPath: keybindingsPath,
- keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
- issues: [
- {
- kind: "keybindings.malformed-config",
- message: expect.stringContaining("expected JSON array"),
- },
- ],
- providers: defaultProviderStatuses,
- availableEditors: expect.any(Array),
- settings: defaultServerSettings,
- });
- expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
- expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json");
- });
-
- it("ignores invalid keybinding entries but keeps valid entries and reports issues", async () => {
- const baseDir = makeTempDir("t3code-state-partial-invalid-keybindings-");
- const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
- ensureParentDir(keybindingsPath);
- fs.writeFileSync(
- keybindingsPath,
- JSON.stringify([
- { key: "mod+j", command: "terminal.toggle" },
- { key: "mod+shift+d+o", command: "terminal.new" },
- { key: "mod+x", command: "not-a-real-command" },
- ]),
- "utf8",
- );
-
- server = await createTestServer({ cwd: "/my/workspace", baseDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
- expect(response.error).toBeUndefined();
- const result = response.result as {
- cwd: string;
- keybindingsConfigPath: string;
- keybindings: ResolvedKeybindingsConfig;
- issues: Array<{ kind: string; index?: number; message: string }>;
- providers: ReadonlyArray;
- availableEditors: unknown;
- };
- expect(result.cwd).toBe("/my/workspace");
- expect(result.keybindingsConfigPath).toBe(keybindingsPath);
- expect(result.issues).toEqual([
- {
- kind: "keybindings.invalid-entry",
- index: 1,
- message: expect.any(String),
- },
- {
- kind: "keybindings.invalid-entry",
- index: 2,
- message: expect.any(String),
- },
- ]);
- expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length);
- expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true);
- expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true);
- expect(result.providers).toEqual(defaultProviderStatuses);
- expectAvailableEditors(result.availableEditors);
- });
-
- it("pushes server.configUpdated issues when keybindings file changes", async () => {
- const baseDir = makeTempDir("t3code-state-keybindings-watch-");
- const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
- ensureParentDir(keybindingsPath);
- fs.writeFileSync(keybindingsPath, "[]", "utf8");
-
- server = await createTestServer({ cwd: "/my/workspace", baseDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const malformedPush = await rewriteKeybindingsAndWaitForPush(
- ws,
- keybindingsPath,
- "{ not-json",
- (push) =>
- Array.isArray(push.data.issues) &&
- Boolean(push.data.issues[0]) &&
- push.data.issues[0]!.kind === "keybindings.malformed-config",
- );
- expect(malformedPush.data).toEqual({
- issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }],
- });
-
- const successPush = await rewriteKeybindingsAndWaitForPush(
- ws,
- keybindingsPath,
- "[]",
- (push) => Array.isArray(push.data.issues) && push.data.issues.length === 0,
- );
- expect(successPush.data).toEqual({ issues: [] });
- });
-
- it("routes shell.openInEditor through the injected open service", async () => {
- const openCalls: Array<{ cwd: string; editor: string }> = [];
- const openService: OpenShape = {
- openBrowser: () => Effect.void,
- openInEditor: (input) => {
- openCalls.push({ cwd: input.cwd, editor: input.editor });
- return Effect.void;
- },
- };
-
- server = await createTestServer({ cwd: "/my/workspace", open: openService });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.shellOpenInEditor, {
- cwd: "/my/workspace",
- editor: "cursor",
- });
- expect(response.error).toBeUndefined();
- expect(openCalls).toEqual([{ cwd: "/my/workspace", editor: "cursor" }]);
- });
-
- it("reads keybindings from the configured state directory", async () => {
- const baseDir = makeTempDir("t3code-state-keybindings-");
- const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
- ensureParentDir(keybindingsPath);
- fs.writeFileSync(
- keybindingsPath,
- JSON.stringify([
- { key: "cmd+j", command: "terminal.toggle" },
- { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
- { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
- ]),
- "utf8",
- );
- server = await createTestServer({ cwd: "/my/workspace", baseDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
- expect(response.error).toBeUndefined();
- const persistedConfig = JSON.parse(
- fs.readFileSync(keybindingsPath, "utf8"),
- ) as KeybindingsConfig;
- expect(response.result).toEqual({
- cwd: "/my/workspace",
- keybindingsConfigPath: keybindingsPath,
- keybindings: compileKeybindings(persistedConfig),
- issues: [],
- providers: defaultProviderStatuses,
- availableEditors: expect.any(Array),
- settings: defaultServerSettings,
- });
- expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
- });
-
- it("upserts keybinding rules and updates cached server config", async () => {
- const baseDir = makeTempDir("t3code-state-upsert-keybinding-");
- const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
- ensureParentDir(keybindingsPath);
- fs.writeFileSync(
- keybindingsPath,
- JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]),
- "utf8",
- );
-
- server = await createTestServer({ cwd: "/my/workspace", baseDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const upsertResponse = await sendRequest(ws, WS_METHODS.serverUpsertKeybinding, {
- key: "mod+shift+r",
- command: "script.run-tests.run",
- });
- expect(upsertResponse.error).toBeUndefined();
- const persistedConfig = JSON.parse(
- fs.readFileSync(keybindingsPath, "utf8"),
- ) as KeybindingsConfig;
- const persistedCommands = new Set(persistedConfig.map((entry) => entry.command));
- for (const defaultRule of DEFAULT_KEYBINDINGS) {
- expect(persistedCommands.has(defaultRule.command)).toBe(true);
- }
- expect(persistedCommands.has("script.run-tests.run")).toBe(true);
- expect(upsertResponse.result).toEqual({
- keybindings: compileKeybindings(persistedConfig),
- issues: [],
- });
-
- const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig);
- expect(configResponse.error).toBeUndefined();
- expect(configResponse.result).toEqual({
- cwd: "/my/workspace",
- keybindingsConfigPath: keybindingsPath,
- keybindings: compileKeybindings(persistedConfig),
- issues: [],
- providers: defaultProviderStatuses,
- availableEditors: expect.any(Array),
- settings: defaultServerSettings,
- });
- expectAvailableEditors(
- (configResponse.result as { availableEditors: unknown }).availableEditors,
- );
- });
-
- it("returns error for unknown methods", async () => {
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, "nonexistent.method");
- expect(response.error).toBeDefined();
- expect(response.error!.message).toContain("Invalid request format");
- });
-
- it("returns error when requesting turn diff for unknown thread", async () => {
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, {
- threadId: "thread-missing",
- fromTurnCount: 1,
- toTurnCount: 2,
- });
- expect(response.result).toBeUndefined();
- expect(response.error?.message).toContain("Thread 'thread-missing' not found.");
- });
-
- it("returns error when requesting turn diff with an inverted range", async () => {
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, {
- threadId: "thread-any",
- fromTurnCount: 2,
- toTurnCount: 1,
- });
- expect(response.result).toBeUndefined();
- expect(response.error?.message).toContain(
- "fromTurnCount must be less than or equal to toTurnCount",
- );
- });
-
- it("returns error when requesting full thread diff for unknown thread", async () => {
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getFullThreadDiff, {
- threadId: "thread-missing",
- toTurnCount: 2,
- });
- expect(response.result).toBeUndefined();
- expect(response.error?.message).toContain("Thread 'thread-missing' not found.");
- });
-
- it("returns retryable error when requested turn exceeds current checkpoint turn count", async () => {
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const workspaceRoot = makeTempDir("t3code-ws-diff-project-");
- const createdAt = new Date().toISOString();
- const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
- type: "project.create",
- commandId: "cmd-diff-project-create",
- projectId: "project-diff",
- title: "Diff Project",
- workspaceRoot,
- defaultModelSelection: {
- provider: "codex",
- model: "gpt-5-codex",
- },
- createdAt,
- });
- expect(createProjectResponse.error).toBeUndefined();
- const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
- type: "thread.create",
- commandId: "cmd-diff-thread-create",
- threadId: "thread-diff",
- projectId: "project-diff",
- title: "Diff Thread",
- modelSelection: {
- provider: "codex",
- model: "gpt-5-codex",
- },
- runtimeMode: "full-access",
- interactionMode: "default",
- branch: null,
- worktreePath: null,
- createdAt,
- });
- expect(createThreadResponse.error).toBeUndefined();
-
- const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, {
- threadId: "thread-diff",
- fromTurnCount: 0,
- toTurnCount: 1,
- });
- expect(response.result).toBeUndefined();
- expect(response.error?.message).toContain("exceeds current turn count");
- });
-
- it("rejects project.create when the workspace root does not exist", async () => {
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const missingWorkspaceRoot = path.join(makeTempDir("t3code-ws-project-missing-"), "missing");
- const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
- type: "project.create",
- commandId: "cmd-ws-project-create-missing",
- projectId: "project-missing",
- title: "Missing Project",
- workspaceRoot: missingWorkspaceRoot,
- defaultModelSelection: {
- provider: "codex",
- model: "gpt-5-codex",
- },
- createdAt: new Date().toISOString(),
- });
-
- expect(response.result).toBeUndefined();
- expect(response.error?.message).toContain("Workspace root does not exist:");
- });
-
- it("keeps orchestration domain push behavior for provider runtime events", async () => {
- const runtimeEventPubSub = Effect.runSync(PubSub.unbounded());
- const emitRuntimeEvent = (event: ProviderRuntimeEvent) => {
- Effect.runSync(PubSub.publish(runtimeEventPubSub, event));
- };
- const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
- const providerService: ProviderServiceShape = {
- startSession: (threadId) =>
- Effect.succeed({
- provider: "codex",
- status: "ready",
- runtimeMode: "full-access",
- threadId,
- createdAt: new Date().toISOString(),
- updatedAt: new Date().toISOString(),
- }),
- sendTurn: ({ threadId }) =>
- Effect.succeed({
- threadId,
- turnId: asTurnId("provider-turn-1"),
- }),
- interruptTurn: () => unsupported(),
- respondToRequest: () => unsupported(),
- respondToUserInput: () => unsupported(),
- stopSession: () => unsupported(),
- listSessions: () => Effect.succeed([]),
- getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
- rollbackConversation: () => unsupported(),
- streamEvents: Stream.fromPubSub(runtimeEventPubSub),
- };
- const providerLayer = Layer.succeed(ProviderService, providerService);
-
- server = await createTestServer({
- cwd: "/test",
- providerLayer,
- serverSettings: { enableAssistantStreaming: true },
- });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const workspaceRoot = makeTempDir("t3code-ws-project-");
- const createdAt = new Date().toISOString();
- const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
- type: "project.create",
- commandId: "cmd-ws-project-create",
- projectId: "project-1",
- title: "WS Project",
- workspaceRoot,
- defaultModelSelection: {
- provider: "codex",
- model: "gpt-5-codex",
- },
- createdAt,
- });
- expect(createProjectResponse.error).toBeUndefined();
- const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
- type: "thread.create",
- commandId: "cmd-ws-runtime-thread-create",
- threadId: "thread-1",
- projectId: "project-1",
- title: "Thread 1",
- modelSelection: {
- provider: "codex",
- model: "gpt-5-codex",
- },
- runtimeMode: "full-access",
- interactionMode: "default",
- branch: null,
- worktreePath: null,
- createdAt,
- });
- expect(createThreadResponse.error).toBeUndefined();
-
- const startTurnResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
- type: "thread.turn.start",
- commandId: "cmd-ws-runtime-turn-start",
- threadId: "thread-1",
- message: {
- messageId: "msg-ws-runtime-1",
- role: "user",
- text: "hello",
- attachments: [],
- },
- runtimeMode: "approval-required",
- interactionMode: "default",
- createdAt,
- });
- expect(startTurnResponse.error).toBeUndefined();
-
- await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => {
- const event = push.data as { type?: string };
- return event.type === "thread.session-set";
- });
-
- emitRuntimeEvent({
- type: "content.delta",
- eventId: asEventId("evt-ws-runtime-message-delta"),
- provider: "codex",
- threadId: asThreadId("thread-1"),
- createdAt: new Date().toISOString(),
- turnId: asTurnId("turn-1"),
- itemId: asProviderItemId("item-1"),
- payload: {
- streamKind: "assistant_text",
- delta: "hello from runtime",
- },
- } as unknown as ProviderRuntimeEvent);
-
- const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => {
- const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } };
- return (
- event.type === "thread.message-sent" && event.payload?.messageId === "assistant:item-1"
- );
- });
-
- const domainEvent = domainPush.data as {
- type: string;
- payload: { messageId: string; text: string };
- };
- expect(domainEvent.type).toBe("thread.message-sent");
- expect(domainEvent.payload.messageId).toBe("assistant:item-1");
- expect(domainEvent.payload.text).toBe("hello from runtime");
- });
-
- it("routes terminal RPC methods and broadcasts terminal events", async () => {
- const cwd = makeTempDir("t3code-ws-terminal-cwd-");
- const terminalManager = new MockTerminalManager();
- server = await createTestServer({
- cwd: "/test",
- terminalManager,
- });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const open = await sendRequest(ws, WS_METHODS.terminalOpen, {
- threadId: "thread-1",
- cwd,
- cols: 100,
- rows: 24,
- });
- expect(open.error).toBeUndefined();
- expect((open.result as TerminalSessionSnapshot).threadId).toBe("thread-1");
- expect((open.result as TerminalSessionSnapshot).terminalId).toBe(DEFAULT_TERMINAL_ID);
-
- const write = await sendRequest(ws, WS_METHODS.terminalWrite, {
- threadId: "thread-1",
- data: "echo hello\n",
- });
- expect(write.error).toBeUndefined();
-
- const resize = await sendRequest(ws, WS_METHODS.terminalResize, {
- threadId: "thread-1",
- cols: 120,
- rows: 30,
- });
- expect(resize.error).toBeUndefined();
-
- const clear = await sendRequest(ws, WS_METHODS.terminalClear, {
- threadId: "thread-1",
- });
- expect(clear.error).toBeUndefined();
-
- const restart = await sendRequest(ws, WS_METHODS.terminalRestart, {
- threadId: "thread-1",
- cwd,
- cols: 120,
- rows: 30,
- });
- expect(restart.error).toBeUndefined();
-
- const close = await sendRequest(ws, WS_METHODS.terminalClose, {
- threadId: "thread-1",
- deleteHistory: true,
- });
- expect(close.error).toBeUndefined();
-
- const manualEvent: TerminalEvent = {
- type: "output",
- threadId: "thread-1",
- terminalId: DEFAULT_TERMINAL_ID,
- createdAt: new Date().toISOString(),
- data: "manual test output\n",
- };
- terminalManager.emitEvent(manualEvent);
-
- const push = await waitForPush(
- ws,
- WS_CHANNELS.terminalEvent,
- (candidate) => (candidate.data as TerminalEvent).type === "output",
- );
- expect(push.type).toBe("push");
- expect(push.channel).toBe(WS_CHANNELS.terminalEvent);
- });
-
- it("shuts down cleanly for injected terminal managers", async () => {
- const terminalManager = new MockTerminalManager();
- server = await createTestServer({
- cwd: "/test",
- terminalManager,
- });
-
- await closeTestServer();
- server = null;
-
- expect(() =>
- terminalManager.emitEvent({
- type: "output",
- threadId: "thread-1",
- terminalId: DEFAULT_TERMINAL_ID,
- createdAt: new Date().toISOString(),
- data: "after shutdown\n",
- }),
- ).not.toThrow();
- });
-
- it("returns validation errors for invalid terminal open params", async () => {
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.terminalOpen, {
- threadId: "",
- cwd: "",
- cols: 1,
- rows: 1,
- });
- expect(response.error).toBeDefined();
- });
-
- it("handles invalid JSON gracefully", async () => {
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- // Send garbage
- ws.send("not json at all");
-
- // Error response goes to the response channel
- const channels = channelsBySocket.get(ws)!;
- let response: WebSocketResponse | null = null;
- for (let attempt = 0; attempt < 5; attempt += 1) {
- const message = await dequeue(channels.response, 5_000);
- if (message.id === "unknown") {
- response = message;
- break;
- }
- if (message.error) {
- response = message;
- break;
- }
- }
- expect(response).toBeDefined();
- expect(response!.error).toBeDefined();
- expect(response!.error!.message).toContain("Invalid request format");
- });
-
- it("catches websocket message handler rejections and keeps the socket usable", async () => {
- const unhandledRejections: unknown[] = [];
- const onUnhandledRejection = (reason: unknown) => {
- unhandledRejections.push(reason);
- };
- process.on("unhandledRejection", onUnhandledRejection);
-
- const brokenOpenService: OpenShape = {
- openBrowser: () => Effect.void,
- openInEditor: () =>
- Effect.sync(() => BigInt(1)).pipe(Effect.map((result) => result as unknown as void)),
- };
-
- try {
- server = await createTestServer({ cwd: "/test", open: brokenOpenService });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- ws.send(
- JSON.stringify({
- id: "req-broken-open",
- body: {
- _tag: WS_METHODS.shellOpenInEditor,
- cwd: "/tmp",
- editor: "cursor",
- },
- }),
- );
-
- await new Promise((resolve) => setTimeout(resolve, 50));
- expect(unhandledRejections).toHaveLength(0);
-
- const workspace = makeTempDir("t3code-ws-handler-still-usable-");
- fs.writeFileSync(path.join(workspace, "file.txt"), "ok\n", "utf8");
- const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, {
- cwd: workspace,
- query: "file",
- limit: 5,
- });
- expect(response.error).toBeUndefined();
- expect(response.result).toEqual(
- expect.objectContaining({
- entries: expect.arrayContaining([
- expect.objectContaining({
- path: "file.txt",
- kind: "file",
- }),
- ]),
- }),
- );
- } finally {
- process.off("unhandledRejection", onUnhandledRejection);
- }
- });
-
- it("returns errors for removed projects CRUD methods", async () => {
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const listResponse = await sendRequest(ws, WS_METHODS.projectsList);
- expect(listResponse.result).toBeUndefined();
- expect(listResponse.error?.message).toContain("Invalid request format");
-
- const addResponse = await sendRequest(ws, WS_METHODS.projectsAdd, {
- cwd: "/tmp/project-a",
- });
- expect(addResponse.result).toBeUndefined();
- expect(addResponse.error?.message).toContain("Invalid request format");
-
- const removeResponse = await sendRequest(ws, WS_METHODS.projectsRemove, {
- id: "project-a",
- });
- expect(removeResponse.result).toBeUndefined();
- expect(removeResponse.error?.message).toContain("Invalid request format");
- });
-
- it("supports projects.searchEntries", async () => {
- const workspace = makeTempDir("t3code-ws-workspace-entries-");
- fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true });
- fs.writeFileSync(
- path.join(workspace, "src", "components", "Composer.tsx"),
- "export {};",
- "utf8",
- );
- fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8");
- fs.mkdirSync(path.join(workspace, ".git"), { recursive: true });
- fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8");
-
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, {
- cwd: workspace,
- query: "comp",
- limit: 10,
- });
- expect(response.error).toBeUndefined();
- expect(response.result).toEqual({
- entries: expect.arrayContaining([
- expect.objectContaining({ path: "src/components", kind: "directory" }),
- expect.objectContaining({ path: "src/components/Composer.tsx", kind: "file" }),
- ]),
- truncated: false,
- });
- });
-
- it("supports projects.writeFile within the workspace root", async () => {
- const workspace = makeTempDir("t3code-ws-write-file-");
-
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, {
- cwd: workspace,
- relativePath: "plans/effect-rpc.md",
- contents: "# Plan\n\n- step 1\n",
- });
-
- expect(response.error).toBeUndefined();
- expect(response.result).toEqual({
- relativePath: "plans/effect-rpc.md",
- });
- expect(fs.readFileSync(path.join(workspace, "plans", "effect-rpc.md"), "utf8")).toBe(
- "# Plan\n\n- step 1\n",
- );
- });
-
- it("invalidates workspace entry search cache after projects.writeFile", async () => {
- const workspace = makeTempDir("t3code-ws-write-file-invalidate-");
- fs.mkdirSync(path.join(workspace, "src"), { recursive: true });
- fs.writeFileSync(path.join(workspace, "src", "existing.ts"), "export {};\n", "utf8");
-
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const beforeWrite = await sendRequest(ws, WS_METHODS.projectsSearchEntries, {
- cwd: workspace,
- query: "rpc",
- limit: 10,
- });
- expect(beforeWrite.error).toBeUndefined();
- expect(beforeWrite.result).toEqual({
- entries: [],
- truncated: false,
- });
-
- const writeResponse = await sendRequest(ws, WS_METHODS.projectsWriteFile, {
- cwd: workspace,
- relativePath: "plans/effect-rpc.md",
- contents: "# Plan\n",
- });
- expect(writeResponse.error).toBeUndefined();
-
- const afterWrite = await sendRequest(ws, WS_METHODS.projectsSearchEntries, {
- cwd: workspace,
- query: "rpc",
- limit: 10,
- });
- expect(afterWrite.error).toBeUndefined();
- expect(afterWrite.result).toEqual({
- entries: expect.arrayContaining([
- expect.objectContaining({ path: "plans/effect-rpc.md", kind: "file" }),
- ]),
- truncated: false,
- });
- });
-
- it("rejects projects.writeFile paths outside the workspace root", async () => {
- const workspace = makeTempDir("t3code-ws-write-file-reject-");
-
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, {
- cwd: workspace,
- relativePath: "../escape.md",
- contents: "# no\n",
- });
-
- expect(response.result).toBeUndefined();
- expect(response.error?.message).toContain(
- "Workspace file path must be relative to the project root: ../escape.md",
- );
- expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false);
- });
-
- it("routes git core methods over websocket", async () => {
- const listBranches = vi.fn(() =>
- Effect.succeed({
- branches: [],
- isRepo: false,
- hasOriginRemote: false,
- }),
- );
- const initRepo = vi.fn(() => Effect.void);
- const pullCurrentBranch = vi.fn(() =>
- Effect.fail(
- new GitCommandError({
- operation: "GitCore.test.pullCurrentBranch",
- detail: "No upstream configured",
- command: "git pull",
- cwd: "/repo/path",
- }),
- ),
- );
-
- server = await createTestServer({
- cwd: "/test",
- gitCore: {
- listBranches,
- initRepo,
- pullCurrentBranch,
- },
- });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: "/repo/path" });
- expect(listResponse.error).toBeUndefined();
- expect(listResponse.result).toEqual({ branches: [], isRepo: false, hasOriginRemote: false });
- expect(listBranches).toHaveBeenCalledWith({ cwd: "/repo/path" });
-
- const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: "/repo/path" });
- expect(initResponse.error).toBeUndefined();
- expect(initRepo).toHaveBeenCalledWith({ cwd: "/repo/path" });
-
- const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { cwd: "/repo/path" });
- expect(pullResponse.result).toBeUndefined();
- expect(pullResponse.error?.message).toContain("No upstream configured");
- expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path");
- });
-
- it("supports git.status over websocket", async () => {
- const statusResult = {
- branch: "feature/test",
- hasWorkingTreeChanges: true,
- workingTree: {
- files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }],
- insertions: 7,
- deletions: 2,
- },
- hasUpstream: false,
- aheadCount: 0,
- behindCount: 0,
- pr: null,
- };
-
- const status = vi.fn(() => Effect.succeed(statusResult));
- const runStackedAction = vi.fn(() => Effect.void as any);
- const resolvePullRequest = vi.fn(() => Effect.void as any);
- const preparePullRequestThread = vi.fn(() => Effect.void as any);
- const gitManager: GitManagerShape = {
- status,
- resolvePullRequest,
- preparePullRequestThread,
- runStackedAction,
- };
-
- server = await createTestServer({ cwd: "/test", gitManager });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.gitStatus, {
- cwd: "/test",
- });
- expect(response.error).toBeUndefined();
- expect(response.result).toEqual(statusResult);
- expect(status).toHaveBeenCalledWith({ cwd: "/test" });
- });
-
- it("supports git pull request routing over websocket", async () => {
- const resolvePullRequestResult = {
- pullRequest: {
- number: 42,
- title: "PR thread flow",
- url: "https://github.com/pingdotgg/codething-mvp/pull/42",
- baseBranch: "main",
- headBranch: "feature/pr-threads",
- state: "open" as const,
- },
- };
- const preparePullRequestThreadResult = {
- ...resolvePullRequestResult,
- branch: "feature/pr-threads",
- worktreePath: "/tmp/pr-threads",
- };
-
- const gitManager: GitManagerShape = {
- status: vi.fn(() => Effect.void as any),
- resolvePullRequest: vi.fn(() => Effect.succeed(resolvePullRequestResult)),
- preparePullRequestThread: vi.fn(() => Effect.succeed(preparePullRequestThreadResult)),
- runStackedAction: vi.fn(() => Effect.void as any),
- };
-
- server = await createTestServer({ cwd: "/test", gitManager });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const resolveResponse = await sendRequest(ws, WS_METHODS.gitResolvePullRequest, {
- cwd: "/test",
- reference: "#42",
- });
- expect(resolveResponse.error).toBeUndefined();
- expect(resolveResponse.result).toEqual(resolvePullRequestResult);
-
- const prepareResponse = await sendRequest(ws, WS_METHODS.gitPreparePullRequestThread, {
- cwd: "/test",
- reference: "42",
- mode: "worktree",
- });
- expect(prepareResponse.error).toBeUndefined();
- expect(prepareResponse.result).toEqual(preparePullRequestThreadResult);
- expect(gitManager.resolvePullRequest).toHaveBeenCalledWith({
- cwd: "/test",
- reference: "#42",
- });
- expect(gitManager.preparePullRequestThread).toHaveBeenCalledWith({
- cwd: "/test",
- reference: "42",
- mode: "worktree",
- });
- });
-
- it("returns errors from git.runStackedAction", async () => {
- const runStackedAction = vi.fn(() =>
- Effect.fail(
- new GitManagerError({
- operation: "GitManager.test.runStackedAction",
- detail: "Cannot push from detached HEAD.",
- }),
- ),
- );
- const gitManager: GitManagerShape = {
- status: vi.fn(() => Effect.void as any),
- resolvePullRequest: vi.fn(() => Effect.void as any),
- preparePullRequestThread: vi.fn(() => Effect.void as any),
- runStackedAction,
- };
-
- server = await createTestServer({ cwd: "/test", gitManager });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, {
- actionId: "client-action-1",
- cwd: "/test",
- action: "commit_push",
- modelSelection: {
- provider: "codex",
- model: "gpt-5.4-mini",
- },
- });
- expect(response.result).toBeUndefined();
- expect(response.error?.message).toContain("detached HEAD");
- expect(runStackedAction).toHaveBeenCalledWith(
- {
- actionId: "client-action-1",
- cwd: "/test",
- action: "commit_push",
- },
- expect.objectContaining({
- actionId: "client-action-1",
- progressReporter: expect.any(Object),
- }),
- );
- });
-
- it("publishes git action progress only to the initiating websocket", async () => {
- const runStackedAction = vi.fn(
- (_input, options) =>
- options?.progressReporter
- ?.publish({
- actionId: options.actionId ?? "action-1",
- cwd: "/test",
- action: "commit",
- kind: "phase_started",
- phase: "commit",
- label: "Committing...",
- })
- .pipe(
- Effect.flatMap(() =>
- Effect.succeed({
- action: "commit" as const,
- branch: { status: "skipped_not_requested" as const },
- commit: {
- status: "created" as const,
- commitSha: "abc1234",
- subject: "Test commit",
- },
- push: { status: "skipped_not_requested" as const },
- pr: { status: "skipped_not_requested" as const },
- }),
- ),
- ) ?? Effect.void,
- );
- const gitManager: GitManagerShape = {
- status: vi.fn(() => Effect.void as any),
- resolvePullRequest: vi.fn(() => Effect.void as any),
- preparePullRequestThread: vi.fn(() => Effect.void as any),
- runStackedAction,
- };
-
- server = await createTestServer({ cwd: "/test", gitManager });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [initiatingWs] = await connectAndAwaitWelcome(port);
- const [otherWs] = await connectAndAwaitWelcome(port);
- connections.push(initiatingWs, otherWs);
-
- const responsePromise = sendRequest(initiatingWs, WS_METHODS.gitRunStackedAction, {
- actionId: "client-action-2",
- cwd: "/test",
- action: "commit",
- modelSelection: {
- provider: "codex",
- model: "gpt-5.4-mini",
- },
- });
- const progressPush = await waitForPush(initiatingWs, WS_CHANNELS.gitActionProgress);
-
- expect(progressPush.data).toEqual({
- actionId: "client-action-2",
- cwd: "/test",
- action: "commit",
- kind: "phase_started",
- phase: "commit",
- label: "Committing...",
- });
-
- await expect(
- waitForPush(otherWs, WS_CHANNELS.gitActionProgress, undefined, 10, 100),
- ).rejects.toThrow("Timed out waiting for WebSocket message after 100ms");
- await expect(responsePromise).resolves.toEqual(
- expect.objectContaining({
- result: expect.objectContaining({
- action: "commit",
- }),
- }),
- );
- });
-
- it("rejects websocket connections without a valid auth token", async () => {
- server = await createTestServer({ cwd: "/test", authToken: "secret-token" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed");
-
- const [authorizedWs] = await connectAndAwaitWelcome(port, "secret-token");
- connections.push(authorizedWs);
- });
-});
diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts
deleted file mode 100644
index b9fd7e58df..0000000000
--- a/apps/server/src/wsServer.ts
+++ /dev/null
@@ -1,998 +0,0 @@
-/**
- * Server - HTTP/WebSocket server service interface.
- *
- * Owns startup and shutdown lifecycle of the HTTP server, static asset serving,
- * and WebSocket request routing.
- *
- * @module Server
- */
-import http from "node:http";
-import type { Duplex } from "node:stream";
-
-import Mime from "@effect/platform-node/Mime";
-import {
- CommandId,
- DEFAULT_PROVIDER_INTERACTION_MODE,
- type ClientOrchestrationCommand,
- type OrchestrationCommand,
- ORCHESTRATION_WS_CHANNELS,
- ORCHESTRATION_WS_METHODS,
- PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
- ProjectId,
- ThreadId,
- WS_CHANNELS,
- WS_METHODS,
- WebSocketRequest,
- type WsResponse as WsResponseMessage,
- WsResponse,
- type WsPushEnvelopeBase,
-} from "@t3tools/contracts";
-import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
-import {
- Cause,
- Effect,
- Exit,
- FileSystem,
- Layer,
- Path,
- Ref,
- Result,
- Schema,
- Scope,
- ServiceMap,
- Stream,
- Struct,
-} from "effect";
-import { WebSocketServer, type WebSocket } from "ws";
-
-import { createLogger } from "./logger";
-import { GitManager } from "./git/Services/GitManager.ts";
-import { TerminalManager } from "./terminal/Services/Manager.ts";
-import { Keybindings } from "./keybindings";
-import { ServerSettingsService } from "./serverSettings";
-import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
-import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
-import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
-import { ProviderService } from "./provider/Services/ProviderService";
-import { ProviderRegistry } from "./provider/Services/ProviderRegistry";
-import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
-import { clamp } from "effect/Number";
-import { Open, resolveAvailableEditors } from "./open";
-import { ServerConfig } from "./config";
-import { GitCore } from "./git/Services/GitCore.ts";
-import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
-import {
- ATTACHMENTS_ROUTE_PREFIX,
- normalizeAttachmentRelativePath,
- resolveAttachmentRelativePath,
-} from "./attachmentPaths";
-
-import {
- createAttachmentId,
- resolveAttachmentPath,
- resolveAttachmentPathById,
-} from "./attachmentStore.ts";
-import { parseBase64DataUrl } from "./imageMime.ts";
-import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
-import { makeServerPushBus } from "./wsServer/pushBus.ts";
-import { makeServerReadiness } from "./wsServer/readiness.ts";
-import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";
-import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";
-import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";
-import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";
-import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts";
-
-/**
- * ServerShape - Service API for server lifecycle control.
- */
-export interface ServerShape {
- /**
- * Start HTTP and WebSocket listeners.
- */
- readonly start: Effect.Effect<
- http.Server,
- ServerLifecycleError,
- Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path
- >;
-
- /**
- * Wait for process shutdown signals.
- */
- readonly stopSignal: Effect.Effect;
-}
-
-/**
- * Server - Service tag for HTTP/WebSocket lifecycle management.
- */
-export class Server extends ServiceMap.Service()("t3/wsServer/Server") {}
-
-const isServerNotRunningError = (error: Error): boolean => {
- const maybeCode = (error as NodeJS.ErrnoException).code;
- return (
- maybeCode === "ERR_SERVER_NOT_RUNNING" || error.message.toLowerCase().includes("not running")
- );
-};
-
-function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void {
- socket.end(
- `HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` +
- "Connection: close\r\n" +
- "Content-Type: text/plain\r\n" +
- `Content-Length: ${Buffer.byteLength(message)}\r\n` +
- "\r\n" +
- message,
- );
-}
-
-function websocketRawToString(raw: unknown): string | null {
- if (typeof raw === "string") {
- return raw;
- }
- if (raw instanceof Uint8Array) {
- return Buffer.from(raw).toString("utf8");
- }
- if (raw instanceof ArrayBuffer) {
- return Buffer.from(new Uint8Array(raw)).toString("utf8");
- }
- if (Array.isArray(raw)) {
- const chunks: string[] = [];
- for (const chunk of raw) {
- if (typeof chunk === "string") {
- chunks.push(chunk);
- continue;
- }
- if (chunk instanceof Uint8Array) {
- chunks.push(Buffer.from(chunk).toString("utf8"));
- continue;
- }
- if (chunk instanceof ArrayBuffer) {
- chunks.push(Buffer.from(new Uint8Array(chunk)).toString("utf8"));
- continue;
- }
- return null;
- }
- return chunks.join("");
- }
- return null;
-}
-
-function stripRequestTag(body: T) {
- return Struct.omit(body, ["_tag"]);
-}
-
-const encodeWsResponse = Schema.encodeEffect(Schema.fromJsonString(WsResponse));
-const decodeWebSocketRequest = decodeJsonResult(WebSocketRequest);
-
-export type ServerCoreRuntimeServices =
- | OrchestrationEngineService
- | ProjectionSnapshotQuery
- | CheckpointDiffQuery
- | OrchestrationReactor
- | ProviderService
- | ProviderRegistry;
-
-export type ServerRuntimeServices =
- | ServerCoreRuntimeServices
- | GitManager
- | GitCore
- | TerminalManager
- | Keybindings
- | ServerSettingsService
- | ProjectFaviconResolver
- | WorkspaceEntries
- | WorkspaceFileSystem
- | WorkspacePaths
- | Open
- | AnalyticsService;
-
-export class ServerLifecycleError extends Schema.TaggedErrorClass()(
- "ServerLifecycleError",
- {
- operation: Schema.String,
- cause: Schema.optional(Schema.Defect),
- },
-) {}
-
-class RouteRequestError extends Schema.TaggedErrorClass()("RouteRequestError", {
- message: Schema.String,
-}) {}
-
-export const createServer = Effect.fn(function* (): Effect.fn.Return<
- http.Server,
- ServerLifecycleError,
- Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path
-> {
- const serverConfig = yield* ServerConfig;
- const {
- port,
- cwd,
- keybindingsConfigPath,
- staticDir,
- devUrl,
- authToken,
- host,
- logWebSocketEvents,
- autoBootstrapProjectFromCwd,
- } = serverConfig;
- const availableEditors = resolveAvailableEditors();
-
- const runtimeServices = yield* Effect.services<
- ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path
- >();
- const runPromise = Effect.runPromiseWith(runtimeServices);
-
- const gitManager = yield* GitManager;
- const terminalManager = yield* TerminalManager;
- const keybindingsManager = yield* Keybindings;
- const serverSettingsManager = yield* ServerSettingsService;
- const providerRegistry = yield* ProviderRegistry;
- const git = yield* GitCore;
- const workspaceEntries = yield* WorkspaceEntries;
- const workspaceFileSystem = yield* WorkspaceFileSystem;
- const workspacePaths = yield* WorkspacePaths;
- const fileSystem = yield* FileSystem.FileSystem;
- const path = yield* Path.Path;
-
- yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe(
- Effect.catch((error) =>
- Effect.logWarning("failed to sync keybindings defaults on startup", {
- path: error.configPath,
- detail: error.detail,
- cause: error.cause,
- }),
- ),
- );
-
- const providersRef = yield* Ref.make(yield* providerRegistry.getProviders);
-
- const clients = yield* Ref.make(new Set());
- const logger = createLogger("ws");
- const readiness = yield* makeServerReadiness;
-
- function logOutgoingPush(push: WsPushEnvelopeBase, recipients: number) {
- if (!logWebSocketEvents) return;
- logger.event("outgoing push", {
- channel: push.channel,
- sequence: push.sequence,
- recipients,
- payload: push.data,
- });
- }
-
- const pushBus = yield* makeServerPushBus({
- clients,
- logOutgoingPush,
- });
- yield* readiness.markPushBusReady;
- yield* keybindingsManager.start.pipe(
- Effect.mapError(
- (cause) => new ServerLifecycleError({ operation: "keybindingsRuntimeStart", cause }),
- ),
- );
- yield* readiness.markKeybindingsReady;
- yield* serverSettingsManager.start.pipe(
- Effect.mapError(
- (cause) => new ServerLifecycleError({ operation: "serverSettingsRuntimeStart", cause }),
- ),
- );
-
- const normalizeDispatchCommand = Effect.fnUntraced(function* (input: {
- readonly command: ClientOrchestrationCommand;
- }) {
- if (input.command.type === "project.create") {
- return {
- ...input.command,
- workspaceRoot: yield* workspacePaths
- .normalizeWorkspaceRoot(input.command.workspaceRoot)
- .pipe(Effect.mapError((cause) => new RouteRequestError({ message: cause.message }))),
- } satisfies OrchestrationCommand;
- }
-
- if (input.command.type === "project.meta.update" && input.command.workspaceRoot !== undefined) {
- return {
- ...input.command,
- workspaceRoot: yield* workspacePaths
- .normalizeWorkspaceRoot(input.command.workspaceRoot)
- .pipe(Effect.mapError((cause) => new RouteRequestError({ message: cause.message }))),
- } satisfies OrchestrationCommand;
- }
-
- if (input.command.type !== "thread.turn.start") {
- return input.command as OrchestrationCommand;
- }
- const turnStartCommand = input.command;
-
- const normalizedAttachments = yield* Effect.forEach(
- turnStartCommand.message.attachments,
- (attachment) =>
- Effect.gen(function* () {
- const parsed = parseBase64DataUrl(attachment.dataUrl);
- if (!parsed || !parsed.mimeType.startsWith("image/")) {
- return yield* new RouteRequestError({
- message: `Invalid image attachment payload for '${attachment.name}'.`,
- });
- }
-
- const bytes = Buffer.from(parsed.base64, "base64");
- if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
- return yield* new RouteRequestError({
- message: `Image attachment '${attachment.name}' is empty or too large.`,
- });
- }
-
- const attachmentId = createAttachmentId(turnStartCommand.threadId);
- if (!attachmentId) {
- return yield* new RouteRequestError({
- message: "Failed to create a safe attachment id.",
- });
- }
-
- const persistedAttachment = {
- type: "image" as const,
- id: attachmentId,
- name: attachment.name,
- mimeType: parsed.mimeType.toLowerCase(),
- sizeBytes: bytes.byteLength,
- };
-
- const attachmentPath = resolveAttachmentPath({
- attachmentsDir: serverConfig.attachmentsDir,
- attachment: persistedAttachment,
- });
- if (!attachmentPath) {
- return yield* new RouteRequestError({
- message: `Failed to resolve persisted path for '${attachment.name}'.`,
- });
- }
-
- yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe(
- Effect.mapError(
- () =>
- new RouteRequestError({
- message: `Failed to create attachment directory for '${attachment.name}'.`,
- }),
- ),
- );
- yield* fileSystem.writeFile(attachmentPath, bytes).pipe(
- Effect.mapError(
- () =>
- new RouteRequestError({
- message: `Failed to persist attachment '${attachment.name}'.`,
- }),
- ),
- );
-
- return persistedAttachment;
- }),
- { concurrency: 1 },
- );
-
- return {
- ...turnStartCommand,
- message: {
- ...turnStartCommand.message,
- attachments: normalizedAttachments,
- },
- } satisfies OrchestrationCommand;
- });
-
- // HTTP server — serves static files or redirects to Vite dev server
- const httpServer = http.createServer((req, res) => {
- const respond = (
- statusCode: number,
- headers: Record,
- body?: string | Uint8Array,
- ) => {
- res.writeHead(statusCode, headers);
- res.end(body);
- };
-
- void runPromise(
- Effect.gen(function* () {
- const url = new URL(req.url ?? "/", `http://localhost:${port}`);
- if (yield* tryHandleProjectFaviconRequest(url, res)) {
- return;
- }
-
- if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) {
- const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length);
- const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath);
- if (!normalizedRelativePath) {
- respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path");
- return;
- }
-
- const isIdLookup =
- !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes(".");
- const filePath = isIdLookup
- ? resolveAttachmentPathById({
- attachmentsDir: serverConfig.attachmentsDir,
- attachmentId: normalizedRelativePath,
- })
- : resolveAttachmentRelativePath({
- attachmentsDir: serverConfig.attachmentsDir,
- relativePath: normalizedRelativePath,
- });
- if (!filePath) {
- respond(
- isIdLookup ? 404 : 400,
- { "Content-Type": "text/plain" },
- isIdLookup ? "Not Found" : "Invalid attachment path",
- );
- return;
- }
-
- const fileInfo = yield* fileSystem
- .stat(filePath)
- .pipe(Effect.catch(() => Effect.succeed(null)));
- if (!fileInfo || fileInfo.type !== "File") {
- respond(404, { "Content-Type": "text/plain" }, "Not Found");
- return;
- }
-
- const contentType = Mime.getType(filePath) ?? "application/octet-stream";
- res.writeHead(200, {
- "Content-Type": contentType,
- "Cache-Control": "public, max-age=31536000, immutable",
- });
- const streamExit = yield* Stream.runForEach(fileSystem.stream(filePath), (chunk) =>
- Effect.sync(() => {
- if (!res.destroyed) {
- res.write(chunk);
- }
- }),
- ).pipe(Effect.exit);
- if (Exit.isFailure(streamExit)) {
- if (!res.destroyed) {
- res.destroy();
- }
- return;
- }
- if (!res.writableEnded) {
- res.end();
- }
- return;
- }
-
- // In dev mode, redirect to Vite dev server
- if (devUrl) {
- respond(302, { Location: devUrl.href });
- return;
- }
-
- // Serve static files from the web app build
- if (!staticDir) {
- respond(
- 503,
- { "Content-Type": "text/plain" },
- "No static directory configured and no dev URL set.",
- );
- return;
- }
-
- const staticRoot = path.resolve(staticDir);
- const staticRequestPath = url.pathname === "/" ? "/index.html" : url.pathname;
- const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, "");
- const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith("..");
- const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, "");
- const hasPathTraversalSegment = staticRelativePath.startsWith("..");
- if (
- staticRelativePath.length === 0 ||
- hasRawLeadingParentSegment ||
- hasPathTraversalSegment ||
- staticRelativePath.includes("\0")
- ) {
- respond(400, { "Content-Type": "text/plain" }, "Invalid static file path");
- return;
- }
-
- const isWithinStaticRoot = (candidate: string) =>
- candidate === staticRoot ||
- candidate.startsWith(
- staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`,
- );
-
- let filePath = path.resolve(staticRoot, staticRelativePath);
- if (!isWithinStaticRoot(filePath)) {
- respond(400, { "Content-Type": "text/plain" }, "Invalid static file path");
- return;
- }
-
- const ext = path.extname(filePath);
- if (!ext) {
- filePath = path.resolve(filePath, "index.html");
- if (!isWithinStaticRoot(filePath)) {
- respond(400, { "Content-Type": "text/plain" }, "Invalid static file path");
- return;
- }
- }
-
- const fileInfo = yield* fileSystem
- .stat(filePath)
- .pipe(Effect.catch(() => Effect.succeed(null)));
- if (!fileInfo || fileInfo.type !== "File") {
- const indexPath = path.resolve(staticRoot, "index.html");
- const indexData = yield* fileSystem
- .readFile(indexPath)
- .pipe(Effect.catch(() => Effect.succeed(null)));
- if (!indexData) {
- respond(404, { "Content-Type": "text/plain" }, "Not Found");
- return;
- }
- respond(200, { "Content-Type": "text/html; charset=utf-8" }, indexData);
- return;
- }
-
- const contentType = Mime.getType(filePath) ?? "application/octet-stream";
- const data = yield* fileSystem
- .readFile(filePath)
- .pipe(Effect.catch(() => Effect.succeed(null)));
- if (!data) {
- respond(500, { "Content-Type": "text/plain" }, "Internal Server Error");
- return;
- }
- respond(200, { "Content-Type": contentType }, data);
- }),
- ).catch(() => {
- if (!res.headersSent) {
- respond(500, { "Content-Type": "text/plain" }, "Internal Server Error");
- }
- });
- });
-
- // WebSocket server — upgrades from the HTTP server
- const wss = new WebSocketServer({ noServer: true });
-
- const closeWebSocketServer = Effect.callback((resume) => {
- wss.close((error) => {
- if (error && !isServerNotRunningError(error)) {
- resume(
- Effect.fail(
- new ServerLifecycleError({ operation: "closeWebSocketServer", cause: error }),
- ),
- );
- } else {
- resume(Effect.void);
- }
- });
- });
-
- const closeAllClients = Ref.get(clients).pipe(
- Effect.flatMap(Effect.forEach((client) => Effect.sync(() => client.close()))),
- Effect.flatMap(() => Ref.set(clients, new Set())),
- );
-
- const listenOptions = host ? { host, port } : { port };
-
- const orchestrationEngine = yield* OrchestrationEngineService;
- const projectionReadModelQuery = yield* ProjectionSnapshotQuery;
- const checkpointDiffQuery = yield* CheckpointDiffQuery;
- const orchestrationReactor = yield* OrchestrationReactor;
- const { openInEditor } = yield* Open;
-
- const subscriptionsScope = yield* Scope.make("sequential");
- yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void));
-
- yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) =>
- pushBus.publishAll(ORCHESTRATION_WS_CHANNELS.domainEvent, event),
- ).pipe(Effect.forkIn(subscriptionsScope));
-
- yield* Stream.runForEach(keybindingsManager.streamChanges, (event) =>
- pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, {
- issues: event.issues,
- }),
- ).pipe(Effect.forkIn(subscriptionsScope));
-
- yield* Stream.runForEach(serverSettingsManager.streamChanges, (settings) =>
- pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, {
- issues: [],
- settings,
- }),
- ).pipe(Effect.forkIn(subscriptionsScope));
-
- yield* Stream.runForEach(providerRegistry.streamChanges, (providers) =>
- Effect.gen(function* () {
- yield* Ref.set(providersRef, providers);
- yield* pushBus.publishAll(WS_CHANNELS.serverProvidersUpdated, {
- providers,
- });
- }),
- ).pipe(Effect.forkIn(subscriptionsScope));
-
- yield* Scope.provide(orchestrationReactor.start(), subscriptionsScope);
- yield* readiness.markOrchestrationSubscriptionsReady;
-
- let welcomeBootstrapProjectId: ProjectId | undefined;
- let welcomeBootstrapThreadId: ThreadId | undefined;
-
- if (autoBootstrapProjectFromCwd) {
- yield* Effect.gen(function* () {
- const snapshot = yield* projectionReadModelQuery.getSnapshot();
- const existingProject = snapshot.projects.find(
- (project) => project.workspaceRoot === cwd && project.deletedAt === null,
- );
- let bootstrapProjectId: ProjectId;
- let bootstrapProjectDefaultModelSelection;
-
- if (!existingProject) {
- const createdAt = new Date().toISOString();
- bootstrapProjectId = ProjectId.makeUnsafe(crypto.randomUUID());
- const bootstrapProjectTitle = path.basename(cwd) || "project";
- bootstrapProjectDefaultModelSelection = {
- provider: "codex" as const,
- model: "gpt-5-codex",
- };
- yield* orchestrationEngine.dispatch({
- type: "project.create",
- commandId: CommandId.makeUnsafe(crypto.randomUUID()),
- projectId: bootstrapProjectId,
- title: bootstrapProjectTitle,
- workspaceRoot: cwd,
- defaultModelSelection: bootstrapProjectDefaultModelSelection,
- createdAt,
- });
- } else {
- bootstrapProjectId = existingProject.id;
- bootstrapProjectDefaultModelSelection = existingProject.defaultModelSelection ?? {
- provider: "codex" as const,
- model: "gpt-5-codex",
- };
- }
-
- const existingThread = snapshot.threads.find(
- (thread) => thread.projectId === bootstrapProjectId && thread.deletedAt === null,
- );
- if (!existingThread) {
- const createdAt = new Date().toISOString();
- const threadId = ThreadId.makeUnsafe(crypto.randomUUID());
- yield* orchestrationEngine.dispatch({
- type: "thread.create",
- commandId: CommandId.makeUnsafe(crypto.randomUUID()),
- threadId,
- projectId: bootstrapProjectId,
- title: "New thread",
- modelSelection: bootstrapProjectDefaultModelSelection,
- interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
- runtimeMode: "full-access",
- branch: null,
- worktreePath: null,
- createdAt,
- });
- welcomeBootstrapProjectId = bootstrapProjectId;
- welcomeBootstrapThreadId = threadId;
- } else {
- welcomeBootstrapProjectId = bootstrapProjectId;
- welcomeBootstrapThreadId = existingThread.id;
- }
- }).pipe(
- Effect.mapError(
- (cause) => new ServerLifecycleError({ operation: "autoBootstrapProject", cause }),
- ),
- );
- }
-
- const unsubscribeTerminalEvents = yield* terminalManager.subscribe((event) =>
- pushBus.publishAll(WS_CHANNELS.terminalEvent, event),
- );
- yield* Scope.addFinalizer(subscriptionsScope, Effect.sync(unsubscribeTerminalEvents));
- yield* readiness.markTerminalSubscriptionsReady;
-
- yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe(
- Effect.mapError((cause) => new ServerLifecycleError({ operation: "httpServerListen", cause })),
- );
- yield* readiness.markHttpListening;
-
- yield* Effect.addFinalizer(() =>
- Effect.all([closeAllClients, closeWebSocketServer.pipe(Effect.ignoreCause({ log: true }))]),
- );
-
- const routeRequest = Effect.fnUntraced(function* (ws: WebSocket, request: WebSocketRequest) {
- switch (request.body._tag) {
- case ORCHESTRATION_WS_METHODS.getSnapshot:
- return yield* projectionReadModelQuery.getSnapshot();
-
- case ORCHESTRATION_WS_METHODS.dispatchCommand: {
- const { command } = request.body;
- const normalizedCommand = yield* normalizeDispatchCommand({ command });
- return yield* orchestrationEngine.dispatch(normalizedCommand);
- }
-
- case ORCHESTRATION_WS_METHODS.getTurnDiff: {
- const body = stripRequestTag(request.body);
- return yield* checkpointDiffQuery.getTurnDiff(body);
- }
-
- case ORCHESTRATION_WS_METHODS.getFullThreadDiff: {
- const body = stripRequestTag(request.body);
- return yield* checkpointDiffQuery.getFullThreadDiff(body);
- }
-
- case ORCHESTRATION_WS_METHODS.replayEvents: {
- const { fromSequenceExclusive } = request.body;
- return yield* Stream.runCollect(
- orchestrationEngine.readEvents(
- clamp(fromSequenceExclusive, {
- maximum: Number.MAX_SAFE_INTEGER,
- minimum: 0,
- }),
- ),
- ).pipe(Effect.map((events) => Array.from(events)));
- }
-
- case WS_METHODS.projectsSearchEntries: {
- const body = stripRequestTag(request.body);
- return yield* workspaceEntries.search(body).pipe(
- Effect.mapError(
- (cause) =>
- new RouteRequestError({
- message: `Failed to search workspace entries: ${cause.detail}`,
- }),
- ),
- );
- }
-
- case WS_METHODS.projectsWriteFile: {
- const body = stripRequestTag(request.body);
- return yield* workspaceFileSystem.writeFile(body).pipe(
- Effect.mapError(
- (cause) =>
- new RouteRequestError({
- message: `Failed to write workspace file: ${cause.message}`,
- }),
- ),
- );
- }
-
- case WS_METHODS.shellOpenInEditor: {
- const body = stripRequestTag(request.body);
- return yield* openInEditor(body);
- }
-
- case WS_METHODS.gitStatus: {
- const body = stripRequestTag(request.body);
- return yield* gitManager.status(body);
- }
-
- case WS_METHODS.gitPull: {
- const body = stripRequestTag(request.body);
- return yield* git.pullCurrentBranch(body.cwd);
- }
-
- case WS_METHODS.gitRunStackedAction: {
- const body = stripRequestTag(request.body);
- return yield* gitManager.runStackedAction(body, {
- actionId: body.actionId,
- progressReporter: {
- publish: (event) =>
- pushBus.publishClient(ws, WS_CHANNELS.gitActionProgress, event).pipe(Effect.asVoid),
- },
- });
- }
-
- case WS_METHODS.gitResolvePullRequest: {
- const body = stripRequestTag(request.body);
- return yield* gitManager.resolvePullRequest(body);
- }
-
- case WS_METHODS.gitPreparePullRequestThread: {
- const body = stripRequestTag(request.body);
- return yield* gitManager.preparePullRequestThread(body);
- }
-
- case WS_METHODS.gitListBranches: {
- const body = stripRequestTag(request.body);
- return yield* git.listBranches(body);
- }
-
- case WS_METHODS.gitCreateWorktree: {
- const body = stripRequestTag(request.body);
- return yield* git.createWorktree(body);
- }
-
- case WS_METHODS.gitRemoveWorktree: {
- const body = stripRequestTag(request.body);
- return yield* git.removeWorktree(body);
- }
-
- case WS_METHODS.gitCreateBranch: {
- const body = stripRequestTag(request.body);
- return yield* git.createBranch(body);
- }
-
- case WS_METHODS.gitCheckout: {
- const body = stripRequestTag(request.body);
- return yield* Effect.scoped(git.checkoutBranch(body));
- }
-
- case WS_METHODS.gitInit: {
- const body = stripRequestTag(request.body);
- return yield* git.initRepo(body);
- }
-
- case WS_METHODS.terminalOpen: {
- const body = stripRequestTag(request.body);
- return yield* terminalManager.open(body);
- }
-
- case WS_METHODS.terminalWrite: {
- const body = stripRequestTag(request.body);
- return yield* terminalManager.write(body);
- }
-
- case WS_METHODS.terminalResize: {
- const body = stripRequestTag(request.body);
- return yield* terminalManager.resize(body);
- }
-
- case WS_METHODS.terminalClear: {
- const body = stripRequestTag(request.body);
- return yield* terminalManager.clear(body);
- }
-
- case WS_METHODS.terminalRestart: {
- const body = stripRequestTag(request.body);
- return yield* terminalManager.restart(body);
- }
-
- case WS_METHODS.terminalClose: {
- const body = stripRequestTag(request.body);
- return yield* terminalManager.close(body);
- }
-
- case WS_METHODS.serverGetConfig: {
- const keybindingsConfig = yield* keybindingsManager.loadConfigState;
- const settings = yield* serverSettingsManager.getSettings;
- const providers = yield* Ref.get(providersRef);
- return {
- cwd,
- keybindingsConfigPath,
- keybindings: keybindingsConfig.keybindings,
- issues: keybindingsConfig.issues,
- providers,
- availableEditors,
- settings,
- };
- }
-
- case WS_METHODS.serverRefreshProviders: {
- const providers = yield* providerRegistry.refresh();
- yield* Ref.set(providersRef, providers);
- return { providers };
- }
-
- case WS_METHODS.serverUpsertKeybinding: {
- const body = stripRequestTag(request.body);
- const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body);
- return { keybindings: keybindingsConfig, issues: [] };
- }
-
- case WS_METHODS.serverGetSettings: {
- return yield* serverSettingsManager.getSettings;
- }
-
- case WS_METHODS.serverUpdateSettings: {
- const body = stripRequestTag(request.body);
- return yield* serverSettingsManager.updateSettings(body.patch);
- }
-
- default: {
- const _exhaustiveCheck: never = request.body;
- return yield* new RouteRequestError({
- message: `Unknown method: ${String(_exhaustiveCheck)}`,
- });
- }
- }
- });
-
- const handleMessage = Effect.fnUntraced(function* (ws: WebSocket, raw: unknown) {
- const sendWsResponse = (response: WsResponseMessage) =>
- encodeWsResponse(response).pipe(
- Effect.tap((encodedResponse) => Effect.sync(() => ws.send(encodedResponse))),
- Effect.asVoid,
- );
-
- const messageText = websocketRawToString(raw);
- if (messageText === null) {
- return yield* sendWsResponse({
- id: "unknown",
- error: { message: "Invalid request format: Failed to read message" },
- });
- }
-
- const request = decodeWebSocketRequest(messageText);
- if (Result.isFailure(request)) {
- return yield* sendWsResponse({
- id: "unknown",
- error: { message: `Invalid request format: ${formatSchemaError(request.failure)}` },
- });
- }
-
- const result = yield* Effect.exit(routeRequest(ws, request.success));
- if (Exit.isFailure(result)) {
- return yield* sendWsResponse({
- id: request.success.id,
- error: { message: Cause.pretty(result.cause) },
- });
- }
-
- return yield* sendWsResponse({
- id: request.success.id,
- result: result.value,
- });
- });
-
- httpServer.on("upgrade", (request, socket, head) => {
- socket.on("error", () => {}); // Prevent unhandled `EPIPE`/`ECONNRESET` from crashing the process if the client disconnects mid-handshake
-
- if (authToken) {
- let providedToken: string | null = null;
- try {
- const url = new URL(request.url ?? "/", `http://localhost:${port}`);
- providedToken = url.searchParams.get("token");
- } catch {
- rejectUpgrade(socket, 400, "Invalid WebSocket URL");
- return;
- }
-
- if (providedToken !== authToken) {
- rejectUpgrade(socket, 401, "Unauthorized WebSocket connection");
- return;
- }
- }
-
- wss.handleUpgrade(request, socket, head, (ws) => {
- wss.emit("connection", ws, request);
- });
- });
-
- wss.on("connection", (ws) => {
- const segments = cwd.split(/[/\\]/).filter(Boolean);
- const projectName = segments[segments.length - 1] ?? "project";
-
- const welcomeData = {
- cwd,
- projectName,
- ...(welcomeBootstrapProjectId ? { bootstrapProjectId: welcomeBootstrapProjectId } : {}),
- ...(welcomeBootstrapThreadId ? { bootstrapThreadId: welcomeBootstrapThreadId } : {}),
- };
- // Send welcome before adding to broadcast set so publishAll calls
- // cannot reach this client before the welcome arrives.
- void runPromise(
- readiness.awaitServerReady.pipe(
- Effect.flatMap(() => pushBus.publishClient(ws, WS_CHANNELS.serverWelcome, welcomeData)),
- Effect.flatMap((delivered) =>
- delivered ? Ref.update(clients, (clients) => clients.add(ws)) : Effect.void,
- ),
- ),
- );
-
- ws.on("message", (raw) => {
- void runPromise(handleMessage(ws, raw).pipe(Effect.ignoreCause({ log: true })));
- });
-
- ws.on("close", () => {
- void runPromise(
- Ref.update(clients, (clients) => {
- clients.delete(ws);
- return clients;
- }),
- );
- });
-
- ws.on("error", () => {
- void runPromise(
- Ref.update(clients, (clients) => {
- clients.delete(ws);
- return clients;
- }),
- );
- });
- });
-
- return httpServer;
-});
-
-export const ServerLive = Layer.succeed(Server, {
- start: createServer(),
- stopSignal: Effect.never,
-} satisfies ServerShape);
diff --git a/apps/server/src/wsServer/pushBus.test.ts b/apps/server/src/wsServer/pushBus.test.ts
deleted file mode 100644
index 80e8be2185..0000000000
--- a/apps/server/src/wsServer/pushBus.test.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import type { WebSocket } from "ws";
-import { it } from "@effect/vitest";
-import { describe, expect } from "vitest";
-import { Effect, Ref } from "effect";
-import { WS_CHANNELS } from "@t3tools/contracts";
-
-import { makeServerPushBus } from "./pushBus";
-
-class MockWebSocket {
- static readonly OPEN = 1;
-
- readonly OPEN = MockWebSocket.OPEN;
- readyState = MockWebSocket.OPEN;
- readonly sent: string[] = [];
- private readonly waiters = new Set<() => void>();
-
- send(message: string) {
- this.sent.push(message);
- for (const waiter of this.waiters) {
- waiter();
- }
- }
-
- waitForSentCount(count: number): Promise {
- if (this.sent.length >= count) {
- return Promise.resolve();
- }
-
- return new Promise((resolve) => {
- const check = () => {
- if (this.sent.length < count) {
- return;
- }
- this.waiters.delete(check);
- resolve();
- };
-
- this.waiters.add(check);
- });
- }
-}
-
-describe("makeServerPushBus", () => {
- it.live("waits for the welcome push before a new client joins broadcast delivery", () =>
- Effect.scoped(
- Effect.gen(function* () {
- const client = new MockWebSocket();
- const clients = yield* Ref.make(new Set());
- const pushBus = yield* makeServerPushBus({
- clients,
- logOutgoingPush: () => {},
- });
-
- yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, {
- issues: [{ kind: "keybindings.malformed-config", message: "queued-before-connect" }],
- });
-
- const delivered = yield* pushBus.publishClient(
- client as unknown as WebSocket,
- WS_CHANNELS.serverWelcome,
- {
- cwd: "/tmp/project",
- projectName: "project",
- },
- );
- expect(delivered).toBe(true);
-
- yield* Ref.update(clients, (current) => current.add(client as unknown as WebSocket));
-
- yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, {
- issues: [],
- });
-
- yield* Effect.promise(() => client.waitForSentCount(2));
-
- const messages = client.sent.map(
- (message) => JSON.parse(message) as { channel: string; data: unknown },
- );
-
- expect(messages).toHaveLength(2);
- expect(messages[0]).toEqual({
- type: "push",
- sequence: 2,
- channel: WS_CHANNELS.serverWelcome,
- data: {
- cwd: "/tmp/project",
- projectName: "project",
- },
- });
- expect(messages[1]).toEqual({
- type: "push",
- sequence: 3,
- channel: WS_CHANNELS.serverConfigUpdated,
- data: {
- issues: [],
- },
- });
- }),
- ),
- );
-});
diff --git a/apps/server/src/wsServer/pushBus.ts b/apps/server/src/wsServer/pushBus.ts
deleted file mode 100644
index c2cd302f23..0000000000
--- a/apps/server/src/wsServer/pushBus.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-import {
- WsPush,
- type WsPushChannel,
- type WsPushData,
- type WsPushEnvelopeBase,
-} from "@t3tools/contracts";
-import { Deferred, Effect, Queue, Ref, Schema } from "effect";
-import type { Scope } from "effect";
-import type { WebSocket } from "ws";
-
-type PushTarget =
- | { readonly kind: "all" }
- | { readonly kind: "client"; readonly client: WebSocket };
-
-interface PushJob {
- readonly channel: C;
- readonly data: WsPushData;
- readonly target: PushTarget;
- readonly delivered: Deferred.Deferred | null;
-}
-
-export interface ServerPushBus {
- readonly publishAll: (
- channel: C,
- data: WsPushData,
- ) => Effect.Effect;
- readonly publishClient: (
- client: WebSocket,
- channel: C,
- data: WsPushData,
- ) => Effect.Effect;
-}
-
-export const makeServerPushBus = (input: {
- readonly clients: Ref.Ref>;
- readonly logOutgoingPush: (push: WsPushEnvelopeBase, recipients: number) => void;
-}): Effect.Effect =>
- Effect.gen(function* () {
- const nextSequence = yield* Ref.make(0);
- const queue = yield* Queue.unbounded();
- const encodePush = Schema.encodeUnknownEffect(Schema.fromJsonString(WsPush));
-
- const settleDelivery = (job: PushJob, delivered: boolean) =>
- job.delivered === null
- ? Effect.void
- : Deferred.succeed(job.delivered, delivered).pipe(Effect.orDie);
-
- const send = Effect.fnUntraced(function* (job: PushJob) {
- const sequence = yield* Ref.updateAndGet(nextSequence, (current) => current + 1);
- const push: WsPushEnvelopeBase = {
- type: "push",
- sequence,
- channel: job.channel,
- data: job.data,
- };
- const recipients =
- job.target.kind === "all" ? yield* Ref.get(input.clients) : new Set([job.target.client]);
-
- return yield* encodePush(push).pipe(
- Effect.map((message) => {
- let recipientCount = 0;
- for (const client of recipients) {
- if (client.readyState !== client.OPEN) {
- continue;
- }
- client.send(message);
- recipientCount += 1;
- }
-
- input.logOutgoingPush(push, recipientCount);
- return recipientCount > 0;
- }),
- );
- });
-
- yield* Effect.forkScoped(
- Effect.forever(
- Queue.take(queue).pipe(
- Effect.flatMap((job) =>
- send(job).pipe(
- Effect.tap((delivered) => settleDelivery(job, delivered)),
- Effect.tapCause(() => settleDelivery(job, false)),
- Effect.ignoreCause({ log: true }),
- ),
- ),
- ),
- ),
- );
-
- const publish =
- (target: PushTarget) =>
- (channel: C, data: WsPushData) =>
- Queue.offer(queue, {
- channel,
- data,
- target,
- delivered: null,
- }).pipe(Effect.asVoid);
-
- return {
- publishAll: publish({ kind: "all" }),
- publishClient: (client, channel, data) =>
- Effect.gen(function* () {
- const delivered = yield* Deferred.make();
- yield* Queue.offer(queue, {
- channel,
- data,
- target: { kind: "client", client },
- delivered,
- }).pipe(Effect.asVoid);
- return yield* Deferred.await(delivered);
- }),
- } satisfies ServerPushBus;
- });
diff --git a/apps/server/src/wsServer/readiness.ts b/apps/server/src/wsServer/readiness.ts
deleted file mode 100644
index 2a973a8636..0000000000
--- a/apps/server/src/wsServer/readiness.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { Deferred, Effect } from "effect";
-
-export interface ServerReadiness {
- readonly awaitServerReady: Effect.Effect;
- readonly markHttpListening: Effect.Effect;
- readonly markPushBusReady: Effect.Effect;
- readonly markKeybindingsReady: Effect.Effect;
- readonly markTerminalSubscriptionsReady: Effect.Effect;
- readonly markOrchestrationSubscriptionsReady: Effect.Effect;
-}
-
-export const makeServerReadiness = Effect.gen(function* () {
- const httpListening = yield* Deferred.make();
- const pushBusReady = yield* Deferred.make();
- const keybindingsReady = yield* Deferred.make();
- const terminalSubscriptionsReady = yield* Deferred.make();
- const orchestrationSubscriptionsReady = yield* Deferred.make();
-
- const complete = (deferred: Deferred.Deferred) =>
- Deferred.succeed(deferred, undefined).pipe(Effect.orDie);
-
- return {
- awaitServerReady: Effect.all([
- Deferred.await(httpListening),
- Deferred.await(pushBusReady),
- Deferred.await(keybindingsReady),
- Deferred.await(terminalSubscriptionsReady),
- Deferred.await(orchestrationSubscriptionsReady),
- ]).pipe(Effect.asVoid),
- markHttpListening: complete(httpListening),
- markPushBusReady: complete(pushBusReady),
- markKeybindingsReady: complete(keybindingsReady),
- markTerminalSubscriptionsReady: complete(terminalSubscriptionsReady),
- markOrchestrationSubscriptionsReady: complete(orchestrationSubscriptionsReady),
- } satisfies ServerReadiness;
-});
diff --git a/apps/server/tsdown.config.ts b/apps/server/tsdown.config.ts
index f89bc7d3d7..f11dd37869 100644
--- a/apps/server/tsdown.config.ts
+++ b/apps/server/tsdown.config.ts
@@ -1,7 +1,7 @@
import { defineConfig } from "tsdown";
export default defineConfig({
- entry: ["src/index.ts"],
+ entry: ["src/bin.ts"],
format: ["esm", "cjs"],
checks: {
legacyCjs: false,
diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts
index cb86636fb6..1c5b2f0d38 100644
--- a/apps/server/vitest.config.ts
+++ b/apps/server/vitest.config.ts
@@ -6,8 +6,13 @@ export default mergeConfig(
baseConfig,
defineConfig({
test: {
- testTimeout: 15_000,
- hookTimeout: 15_000,
+ // The server suite exercises sqlite, git, temp worktrees, and orchestration
+ // runtimes heavily. Running files in parallel introduces load-sensitive flakes.
+ fileParallelism: false,
+ // Server integration tests exercise sqlite, git, and orchestration together.
+ // Under package-wide parallel runs they regularly exceed the default 15s budget.
+ testTimeout: 60_000,
+ hookTimeout: 60_000,
},
}),
);
diff --git a/apps/web/package.json b/apps/web/package.json
index bd0fb9e0ac..499943c3f0 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -19,6 +19,7 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
+ "@effect/atom-react": "catalog:",
"@formkit/auto-animate": "^0.9.0",
"@lexical/react": "^0.41.0",
"@pierre/diffs": "^1.1.0-beta.16",
diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx
index 20816d6935..327ebb2b07 100644
--- a/apps/web/src/components/ChatView.browser.tsx
+++ b/apps/web/src/components/ChatView.browser.tsx
@@ -4,7 +4,6 @@ import "../index.css";
import {
EventId,
ORCHESTRATION_WS_METHODS,
- ORCHESTRATION_WS_CHANNELS,
type MessageId,
type OrchestrationEvent,
type OrchestrationReadModel,
@@ -12,7 +11,6 @@ import {
type ServerConfig,
type ThreadId,
type WsWelcomePayload,
- WS_CHANNELS,
WS_METHODS,
OrchestrationSessionStatus,
DEFAULT_SERVER_SETTINGS,
@@ -31,8 +29,10 @@ import {
removeInlineTerminalContextPlaceholder,
} from "../lib/terminalContext";
import { isMacPlatform } from "../lib/utils";
+import { __resetNativeApiForTests } from "../nativeApi";
import { getRouter } from "../router";
import { useStore } from "../store";
+import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../test/wsRpcHarness";
import { estimateTimelineMessageHeight } from "./timelineHeight";
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
@@ -43,14 +43,6 @@ const NOW_ISO = "2026-03-04T12:00:00.000Z";
const BASE_TIME_MS = Date.parse(NOW_ISO);
const ATTACHMENT_SVG = "";
-interface WsRequestEnvelope {
- id: string;
- body: {
- _tag: string;
- [key: string]: unknown;
- };
-}
-
interface TestFixture {
snapshot: OrchestrationReadModel;
serverConfig: ServerConfig;
@@ -58,10 +50,9 @@ interface TestFixture {
}
let fixture: TestFixture;
-const wsRequests: WsRequestEnvelope["body"][] = [];
-let customWsRpcResolver: ((body: WsRequestEnvelope["body"]) => unknown | undefined) | null = null;
-let wsClient: { send: (message: string) => void } | null = null;
-let pushSequence = 1;
+const rpcHarness = new BrowserWsRpcHarness();
+const wsRequests = rpcHarness.requests;
+let customWsRpcResolver: ((body: NormalizedWsRpcRequestBody) => unknown | undefined) | null = null;
const wsLink = ws.link(/ws(s)?:\/\/.*/);
interface ViewportSpec {
@@ -372,32 +363,20 @@ function createThreadCreatedEvent(threadId: ThreadId, sequence: number): Orchest
}
function sendOrchestrationDomainEvent(event: OrchestrationEvent): void {
- if (!wsClient) {
- throw new Error("WebSocket client not connected");
- }
- wsClient.send(
- JSON.stringify({
- type: "push",
- sequence: pushSequence++,
- channel: ORCHESTRATION_WS_CHANNELS.domainEvent,
- data: event,
- }),
- );
+ rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event);
}
-async function waitForWsClient(): Promise<{ send: (message: string) => void }> {
- let client: { send: (message: string) => void } | null = null;
+async function waitForWsClient(): Promise {
await vi.waitFor(
() => {
- client = wsClient;
- expect(client).toBeTruthy();
+ expect(
+ wsRequests.some(
+ (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents,
+ ),
+ ).toBe(true);
},
{ timeout: 8_000, interval: 16 },
);
- if (!client) {
- throw new Error("WebSocket client not connected");
- }
- return client;
}
async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise {
@@ -513,7 +492,7 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
};
}
-function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
+function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {
const customResult = customWsRpcResolver?.(body);
if (customResult !== undefined) {
return customResult;
@@ -560,6 +539,9 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
truncated: false,
};
}
+ if (tag === WS_METHODS.shellOpenInEditor) {
+ return null;
+ }
if (tag === WS_METHODS.terminalOpen) {
return {
threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID,
@@ -578,34 +560,11 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
const worker = setupWorker(
wsLink.addEventListener("connection", ({ client }) => {
- wsClient = client;
- pushSequence = 1;
- client.send(
- JSON.stringify({
- type: "push",
- sequence: pushSequence++,
- channel: WS_CHANNELS.serverWelcome,
- data: fixture.welcome,
- }),
- );
+ void rpcHarness.connect(client);
client.addEventListener("message", (event) => {
const rawData = event.data;
if (typeof rawData !== "string") return;
- let request: WsRequestEnvelope;
- try {
- request = JSON.parse(rawData) as WsRequestEnvelope;
- } catch {
- return;
- }
- const method = request.body?._tag;
- if (typeof method !== "string") return;
- wsRequests.push(request.body);
- client.send(
- JSON.stringify({
- id: request.id,
- result: resolveWsRpc(request.body),
- }),
- );
+ void rpcHarness.onMessage(rawData);
});
}),
http.get("*/attachments/:attachmentId", () =>
@@ -716,7 +675,9 @@ async function waitForInteractionModeButton(
async function waitForServerConfigToApply(): Promise {
await vi.waitFor(
() => {
- expect(wsRequests.some((request) => request._tag === WS_METHODS.serverGetConfig)).toBe(true);
+ expect(wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerConfig)).toBe(
+ true,
+ );
},
{ timeout: 8_000, interval: 16 },
);
@@ -854,7 +815,7 @@ async function mountChatView(options: {
viewport: ViewportSpec;
snapshot: OrchestrationReadModel;
configureFixture?: (fixture: TestFixture) => void;
- resolveRpc?: (body: WsRequestEnvelope["body"]) => unknown | undefined;
+ resolveRpc?: (body: NormalizedWsRpcRequestBody) => unknown | undefined;
}): Promise {
fixture = buildFixture(options.snapshot);
options.configureFixture?.(fixture);
@@ -936,10 +897,37 @@ describe("ChatView timeline estimator parity (full app)", () => {
});
afterAll(async () => {
+ await rpcHarness.disconnect();
await worker.stop();
});
beforeEach(async () => {
+ await rpcHarness.reset({
+ resolveUnary: resolveWsRpc,
+ getInitialStreamValues: (request) => {
+ if (request._tag === WS_METHODS.subscribeServerLifecycle) {
+ return [
+ {
+ version: 1,
+ sequence: 1,
+ type: "welcome",
+ payload: fixture.welcome,
+ },
+ ];
+ }
+ if (request._tag === WS_METHODS.subscribeServerConfig) {
+ return [
+ {
+ version: 1,
+ type: "snapshot",
+ config: fixture.serverConfig,
+ },
+ ];
+ }
+ return [];
+ },
+ });
+ __resetNativeApiForTests();
await setViewport(DEFAULT_VIEWPORT);
localStorage.clear();
document.body.innerHTML = "";
@@ -1149,6 +1137,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
});
try {
+ await waitForServerConfigToApply();
const openButton = await waitForElement(
() =>
Array.from(document.querySelectorAll("button")).find(
@@ -1156,6 +1145,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
) as HTMLButtonElement | null,
"Unable to find Open button.",
);
+ await vi.waitFor(() => {
+ expect(openButton.disabled).toBe(false);
+ });
openButton.click();
await vi.waitFor(
@@ -1191,6 +1183,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
});
try {
+ await waitForServerConfigToApply();
const openButton = await waitForElement(
() =>
Array.from(document.querySelectorAll("button")).find(
@@ -1198,6 +1191,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
) as HTMLButtonElement | null,
"Unable to find Open button.",
);
+ await vi.waitFor(() => {
+ expect(openButton.disabled).toBe(false);
+ });
openButton.click();
await vi.waitFor(
@@ -1233,6 +1229,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
});
try {
+ await waitForServerConfigToApply();
const menuButton = await waitForElement(
() => document.querySelector('button[aria-label="Copy options"]'),
"Unable to find Open picker button.",
@@ -1281,7 +1278,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
});
it("falls back to the first installed editor when the stored favorite is unavailable", async () => {
- localStorage.setItem("t3code:last-editor", "vscodium");
+ localStorage.setItem("t3code:last-editor", JSON.stringify("vscodium"));
setDraftThreadWithoutWorktree();
const mounted = await mountChatView({
@@ -1296,6 +1293,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
});
try {
+ await waitForServerConfigToApply();
const openButton = await waitForElement(
() =>
Array.from(document.querySelectorAll("button")).find(
@@ -1303,6 +1301,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
) as HTMLButtonElement | null,
"Unable to find Open button.",
);
+ await vi.waitFor(() => {
+ expect(openButton.disabled).toBe(false);
+ });
openButton.click();
await vi.waitFor(
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 7562f845e2..7f83c94cba 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -11,11 +11,9 @@ import {
type ProviderApprovalDecision,
PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
- type ResolvedKeybindingsConfig,
type ServerProvider,
type ThreadId,
type TurnId,
- type EditorId,
type KeybindingCommand,
OrchestrationThreadActivity,
ProviderInteractionMode,
@@ -30,7 +28,6 @@ import { useDebouncedValue } from "@tanstack/react-pacer";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
-import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
import { isElectron } from "../env";
import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
import {
@@ -190,15 +187,18 @@ import {
waitForStartedServerThread,
} from "./ChatView.logic";
import { useLocalStorage } from "~/hooks/useLocalStorage";
+import {
+ useServerAvailableEditors,
+ useServerConfig,
+ useServerKeybindings,
+} from "~/wsNativeApiAtoms";
const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;
const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
const IMAGE_ONLY_BOOTSTRAP_PROMPT =
"[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
-const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
-const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];
const EMPTY_PROVIDERS: ServerProvider[] = [];
const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {};
@@ -785,8 +785,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
const lockedProvider: ProviderKind | null = hasThreadStarted
? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null)
: null;
- const serverConfigQuery = useQuery(serverConfigQueryOptions());
- const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDERS;
+ const serverConfig = useServerConfig();
+ const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
const unlockedSelectedProvider = resolveSelectableProvider(
providerStatuses,
selectedProviderByThreadId ?? threadProvider ?? "codex",
@@ -1171,8 +1171,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
);
const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd));
- const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS;
- const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS;
+ const keybindings = useServerKeybindings();
+ const availableEditors = useServerAvailableEditors();
const modelOptionsByProvider = useMemo(
() => ({
codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [],
@@ -1667,10 +1667,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
if (isElectron && keybindingRule) {
await api.server.upsertKeybinding(keybindingRule);
- await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all });
}
},
- [queryClient],
+ [],
);
const saveProjectScript = useCallback(
async (input: NewProjectScriptInput) => {
diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx
index 224bd2f887..88ab8b45c2 100644
--- a/apps/web/src/components/KeybindingsToast.browser.tsx
+++ b/apps/web/src/components/KeybindingsToast.browser.tsx
@@ -8,7 +8,6 @@ import {
type ServerConfig,
type ThreadId,
type WsWelcomePayload,
- WS_CHANNELS,
WS_METHODS,
} from "@t3tools/contracts";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
@@ -18,8 +17,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
import { render } from "vitest-browser-react";
import { useComposerDraftStore } from "../composerDraftStore";
+import { __resetNativeApiForTests } from "../nativeApi";
import { getRouter } from "../router";
import { useStore } from "../store";
+import { BrowserWsRpcHarness } from "../test/wsRpcHarness";
const THREAD_ID = "thread-kb-toast-test" as ThreadId;
const PROJECT_ID = "project-1" as ProjectId;
@@ -32,8 +33,7 @@ interface TestFixture {
}
let fixture: TestFixture;
-let wsClient: { send: (data: string) => void } | null = null;
-let pushSequence = 1;
+const rpcHarness = new BrowserWsRpcHarness();
const wsLink = ws.link(/ws(s)?:\/\/.*/);
@@ -179,52 +179,23 @@ function resolveWsRpc(tag: string): unknown {
const worker = setupWorker(
wsLink.addEventListener("connection", ({ client }) => {
- wsClient = client;
- pushSequence = 1;
- client.send(
- JSON.stringify({
- type: "push",
- sequence: pushSequence++,
- channel: WS_CHANNELS.serverWelcome,
- data: fixture.welcome,
- }),
- );
+ void rpcHarness.connect(client);
client.addEventListener("message", (event) => {
const rawData = event.data;
if (typeof rawData !== "string") return;
- let request: { id: string; body: { _tag: string; [key: string]: unknown } };
- try {
- request = JSON.parse(rawData);
- } catch {
- return;
- }
- const method = request.body?._tag;
- if (typeof method !== "string") return;
- client.send(
- JSON.stringify({
- id: request.id,
- result: resolveWsRpc(method),
- }),
- );
+ void rpcHarness.onMessage(rawData);
});
}),
http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })),
http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })),
);
-function sendServerConfigUpdatedPush(issues: Array<{ kind: string; message: string }>) {
- if (!wsClient) throw new Error("WebSocket client not connected");
- wsClient.send(
- JSON.stringify({
- type: "push",
- sequence: pushSequence++,
- channel: WS_CHANNELS.serverConfigUpdated,
- data: {
- issues,
- providers: fixture.serverConfig.providers,
- },
- }),
- );
+function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) {
+ rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, {
+ version: 1,
+ type: "keybindingsUpdated",
+ payload: { issues },
+ });
}
function queryToastTitles(): string[] {
@@ -308,13 +279,39 @@ describe("Keybindings update toast", () => {
});
afterAll(async () => {
+ await rpcHarness.disconnect();
await worker.stop();
});
- beforeEach(() => {
+ beforeEach(async () => {
+ await rpcHarness.reset({
+ resolveUnary: (request) => resolveWsRpc(request._tag),
+ getInitialStreamValues: (request) => {
+ if (request._tag === WS_METHODS.subscribeServerLifecycle) {
+ return [
+ {
+ version: 1,
+ sequence: 1,
+ type: "welcome",
+ payload: fixture.welcome,
+ },
+ ];
+ }
+ if (request._tag === WS_METHODS.subscribeServerConfig) {
+ return [
+ {
+ version: 1,
+ type: "snapshot",
+ config: fixture.serverConfig,
+ },
+ ];
+ }
+ return [];
+ },
+ });
+ __resetNativeApiForTests();
localStorage.clear();
document.body.innerHTML = "";
- pushSequence = 1;
useComposerDraftStore.setState({
draftsByThreadId: {},
draftThreadsByThreadId: {},
diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx
index bc0118120f..58426f50ba 100644
--- a/apps/web/src/components/ProjectFavicon.tsx
+++ b/apps/web/src/components/ProjectFavicon.tsx
@@ -1,30 +1,15 @@
import { FolderIcon } from "lucide-react";
import { useState } from "react";
-
-function getServerHttpOrigin(): string {
- const bridgeUrl = window.desktopBridge?.getWsUrl();
- const envUrl = import.meta.env.VITE_WS_URL as string | undefined;
- const wsUrl =
- bridgeUrl && bridgeUrl.length > 0
- ? bridgeUrl
- : envUrl && envUrl.length > 0
- ? envUrl
- : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`;
- // Parse to extract just the origin, dropping path/query (e.g. ?token=…)
- const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
- try {
- return new URL(httpUrl).origin;
- } catch {
- return httpUrl;
- }
-}
-
-const serverHttpOrigin = getServerHttpOrigin();
+import { resolveServerUrl } from "~/lib/utils";
const loadedProjectFaviconSrcs = new Set();
export function ProjectFavicon({ cwd, className }: { cwd: string; className?: string }) {
- const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(cwd)}`;
+ const src = resolveServerUrl({
+ protocol: "http",
+ pathname: "/api/project-favicon",
+ searchParams: { cwd },
+ });
const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",
);
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index efa5124288..ea70fe4e7d 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -43,9 +43,8 @@ import {
ProjectId,
ThreadId,
type GitStatusResult,
- type ResolvedKeybindingsConfig,
} from "@t3tools/contracts";
-import { useQueries, useQuery } from "@tanstack/react-query";
+import { useQueries } from "@tanstack/react-query";
import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
import {
type SidebarProjectSortOrder,
@@ -67,7 +66,6 @@ import {
} from "../keybindings";
import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
import { gitStatusQueryOptions } from "../lib/gitReactQuery";
-import { serverConfigQueryOptions } from "../lib/serverReactQuery";
import { readNativeApi } from "../nativeApi";
import { useComposerDraftStore } from "../composerDraftStore";
import { useHandleNewThread } from "../hooks/useHandleNewThread";
@@ -125,9 +123,8 @@ import {
import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
+import { useServerKeybindings } from "../wsNativeApiAtoms";
import type { Project, Thread } from "../types";
-
-const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
const THREAD_PREVIEW_LIMIT = 6;
const SIDEBAR_SORT_LABELS: Record = {
updated_at: "Last user message",
@@ -469,10 +466,7 @@ export default function Sidebar() {
strict: false,
select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
});
- const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({
- ...serverConfigQueryOptions(),
- select: (config) => config.keybindings,
- });
+ const keybindings = useServerKeybindings();
const [addingProject, setAddingProject] = useState(false);
const [newCwd, setNewCwd] = useState("");
const [isPickingFolder, setIsPickingFolder] = useState(false);
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index b7fde0c5f6..aaa37d221a 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -9,7 +9,7 @@ import {
Undo2Icon,
XIcon,
} from "lucide-react";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useQueryClient } from "@tanstack/react-query";
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
PROVIDER_DISPLAY_NAMES,
@@ -40,7 +40,6 @@ import {
setDesktopUpdateStateQueryData,
useDesktopUpdateState,
} from "../../lib/desktopUpdateReactQuery";
-import { serverConfigQueryOptions, serverQueryKeys } from "../../lib/serverReactQuery";
import {
MAX_CUSTOM_MODEL_LENGTH,
getCustomModelOptionsByProvider,
@@ -59,6 +58,11 @@ import { Switch } from "../ui/switch";
import { toastManager } from "../ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { ProjectFavicon } from "../ProjectFavicon";
+import {
+ useServerAvailableEditors,
+ useServerKeybindingsConfigPath,
+ useServerProviders,
+} from "../../wsNativeApiAtoms";
const THEME_OPTIONS = [
{
@@ -81,8 +85,6 @@ const TIMESTAMP_FORMAT_LABELS = {
"24-hour": "24-hour",
} as const;
-const EMPTY_SERVER_PROVIDERS: ReadonlyArray = [];
-
type InstallProviderSettings = {
provider: ProviderKind;
title: string;
@@ -516,7 +518,6 @@ export function GeneralSettingsPanel() {
const { theme, setTheme } = useTheme();
const settings = useSettings();
const { updateSettings } = useUpdateSettings();
- const serverConfigQuery = useQuery(serverConfigQueryOptions());
const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
const [openKeybindingsError, setOpenKeybindingsError] = useState(null);
const [openProviderDetails, setOpenProviderDetails] = useState>({
@@ -542,7 +543,6 @@ export function GeneralSettingsPanel() {
>({});
const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
const refreshingRef = useRef(false);
- const queryClient = useQueryClient();
const modelListRefs = useRef>>({});
const refreshProviders = useCallback(() => {
if (refreshingRef.current) return;
@@ -550,7 +550,6 @@ export function GeneralSettingsPanel() {
setIsRefreshingProviders(true);
void ensureNativeApi()
.server.refreshProviders()
- .then(() => queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }))
.catch((error: unknown) => {
console.warn("Failed to refresh providers", error);
})
@@ -558,11 +557,11 @@ export function GeneralSettingsPanel() {
refreshingRef.current = false;
setIsRefreshingProviders(false);
});
- }, [queryClient]);
+ }, []);
- const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
- const availableEditors = serverConfigQuery.data?.availableEditors;
- const serverProviders = serverConfigQuery.data?.providers ?? EMPTY_SERVER_PROVIDERS;
+ const keybindingsConfigPath = useServerKeybindingsConfigPath();
+ const availableEditors = useServerAvailableEditors();
+ const serverProviders = useServerProviders();
const codexHomePath = settings.providers.codex.homePath;
const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts
index 3f804bc48b..fd71f2a314 100644
--- a/apps/web/src/hooks/useSettings.ts
+++ b/apps/web/src/hooks/useSettings.ts
@@ -10,15 +10,12 @@
* store.
*/
import { useCallback, useMemo } from "react";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
ServerSettings,
ServerSettingsPatch,
- ServerConfig,
ModelSelection,
ThreadEnvMode,
} from "@t3tools/contracts";
-import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
import {
type ClientSettings,
ClientSettingsSchema,
@@ -29,13 +26,14 @@ import {
TimestampFormat,
UnifiedSettings,
} from "@t3tools/contracts/settings";
-import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
import { ensureNativeApi } from "~/nativeApi";
import { useLocalStorage } from "./useLocalStorage";
import { normalizeCustomModelSlugs } from "~/modelSelection";
import { Predicate, Schema, Struct } from "effect";
import { DeepMutable } from "effect/Types";
import { deepMerge } from "@t3tools/shared/Struct";
+import { useServerSettings } from "~/wsNativeApiAtoms";
+import { applySettingsUpdated, getServerConfig } from "~/wsNativeApiState";
const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1";
const OLD_SETTINGS_KEY = "t3code:app-settings:v1";
@@ -73,7 +71,7 @@ function splitPatch(patch: Partial): {
export function useSettings(
selector?: (s: UnifiedSettings) => T,
): T {
- const { data: serverConfig } = useQuery(serverConfigQueryOptions());
+ const serverSettings = useServerSettings();
const [clientSettings] = useLocalStorage(
CLIENT_SETTINGS_STORAGE_KEY,
DEFAULT_CLIENT_SETTINGS,
@@ -82,10 +80,10 @@ export function useSettings(
const merged = useMemo(
() => ({
- ...(serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS),
+ ...serverSettings,
...clientSettings,
}),
- [serverConfig?.settings, clientSettings],
+ [clientSettings, serverSettings],
);
return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]);
@@ -98,7 +96,6 @@ export function useSettings(
* persisted via RPC. Client keys go straight to localStorage.
*/
export function useUpdateSettings() {
- const queryClient = useQueryClient();
const [, setClientSettings] = useLocalStorage(
CLIENT_SETTINGS_STORAGE_KEY,
DEFAULT_CLIENT_SETTINGS,
@@ -110,14 +107,10 @@ export function useUpdateSettings() {
const { serverPatch, clientPatch } = splitPatch(patch);
if (Object.keys(serverPatch).length > 0) {
- // Optimistic update of the React Query cache
- queryClient.setQueryData(serverQueryKeys.config(), (old) => {
- if (!old) return old;
- return {
- ...old,
- settings: deepMerge(old.settings, serverPatch),
- };
- });
+ const currentServerConfig = getServerConfig();
+ if (currentServerConfig) {
+ applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch));
+ }
// Fire-and-forget RPC — push will reconcile on success
void ensureNativeApi().server.updateSettings(serverPatch);
}
@@ -126,7 +119,7 @@ export function useUpdateSettings() {
setClientSettings((prev) => ({ ...prev, ...clientPatch }));
}
},
- [queryClient, setClientSettings],
+ [setClientSettings],
);
const resetSettings = useCallback(() => {
diff --git a/apps/web/src/lib/serverReactQuery.ts b/apps/web/src/lib/serverReactQuery.ts
deleted file mode 100644
index 37029a3a3e..0000000000
--- a/apps/web/src/lib/serverReactQuery.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { queryOptions } from "@tanstack/react-query";
-import { ensureNativeApi } from "~/nativeApi";
-
-export const serverQueryKeys = {
- all: ["server"] as const,
- config: () => ["server", "config"] as const,
-};
-
-/**
- * Server config query options.
- *
- * `staleTime` is kept short so that push-driven `invalidateQueries` calls in
- * the EventRouter always trigger a refetch, and so the query re-fetches when
- * the component re-mounts (e.g. navigating away from settings and back).
- */
-export function serverConfigQueryOptions() {
- return queryOptions({
- queryKey: serverQueryKeys.config(),
- queryFn: async () => {
- const api = ensureNativeApi();
- return api.server.getConfig();
- },
- });
-}
diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts
index b5834606b1..e48f815461 100644
--- a/apps/web/src/lib/utils.ts
+++ b/apps/web/src/lib/utils.ts
@@ -1,4 +1,5 @@
import { CommandId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
+import { String, Predicate } from "effect";
import { type CxOptions, cx } from "class-variance-authority";
import { twMerge } from "tailwind-merge";
import * as Random from "effect/Random";
@@ -34,3 +35,41 @@ export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(randomUUID());
export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID());
export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID());
+
+const isNonEmptyString = Predicate.compose(Predicate.isString, String.isNonEmpty);
+const firstNonEmptyString = (...values: unknown[]): string => {
+ for (const value of values) {
+ if (isNonEmptyString(value)) {
+ return value;
+ }
+ }
+ throw new Error("No non-empty string provided");
+};
+
+export const resolveServerUrl = (options?: {
+ url?: string | undefined;
+ protocol?: "http" | "https" | "ws" | "wss" | undefined;
+ pathname?: string | undefined;
+ searchParams?: Record | undefined;
+}): string => {
+ const rawUrl = firstNonEmptyString(
+ options?.url,
+ window.desktopBridge?.getWsUrl(),
+ import.meta.env.VITE_WS_URL,
+ window.location.origin,
+ );
+
+ const parsedUrl = new URL(rawUrl);
+ if (options?.protocol) {
+ parsedUrl.protocol = options.protocol;
+ }
+ if (options?.pathname) {
+ parsedUrl.pathname = options.pathname;
+ } else {
+ parsedUrl.pathname = "/";
+ }
+ if (options?.searchParams) {
+ parsedUrl.search = new URLSearchParams(options.searchParams).toString();
+ }
+ return parsedUrl.toString();
+};
diff --git a/apps/web/src/nativeApi.ts b/apps/web/src/nativeApi.ts
index 40443f67e0..9f528b6342 100644
--- a/apps/web/src/nativeApi.ts
+++ b/apps/web/src/nativeApi.ts
@@ -1,6 +1,6 @@
import type { NativeApi } from "@t3tools/contracts";
-import { createWsNativeApi } from "./wsNativeApi";
+import { __resetWsNativeApiForTests, createWsNativeApi } from "./wsNativeApi";
let cachedApi: NativeApi | undefined;
@@ -24,3 +24,8 @@ export function ensureNativeApi(): NativeApi {
}
return api;
}
+
+export function __resetNativeApiForTests() {
+ cachedApi = undefined;
+ __resetWsNativeApiForTests();
+}
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index 4765b0a8e6..1d03f1f11d 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -1,12 +1,12 @@
-import { OrchestrationEvent, ThreadId } from "@t3tools/contracts";
+import { OrchestrationEvent, ThreadId, type WsWelcomePayload } from "@t3tools/contracts";
import {
Outlet,
createRootRouteWithContext,
type ErrorComponentProps,
useNavigate,
- useRouterState,
+ useLocation,
} from "@tanstack/react-router";
-import { useEffect, useRef } from "react";
+import { useEffect, useEffectEvent, useRef } from "react";
import { QueryClient, useQueryClient } from "@tanstack/react-query";
import { Throttler } from "@tanstack/react-pacer";
@@ -15,7 +15,6 @@ import { AppSidebarLayout } from "../components/AppSidebarLayout";
import { Button } from "../components/ui/button";
import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";
import { resolveAndPersistPreferredEditor } from "../editorPreferences";
-import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
import { readNativeApi } from "../nativeApi";
import {
clearPromotedDraftThread,
@@ -26,13 +25,18 @@ import { useStore } from "../store";
import { useUiStateStore } from "../uiStateStore";
import { useTerminalStateStore } from "../terminalStateStore";
import { terminalRunningSubprocessFromEvent } from "../terminalActivity";
-import { onServerConfigUpdated, onServerProvidersUpdated, onServerWelcome } from "../wsNativeApi";
import { migrateLocalSettingsToServer } from "../hooks/useSettings";
import { providerQueryKeys } from "../lib/providerReactQuery";
import { projectQueryKeys } from "../lib/projectReactQuery";
import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup";
import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects";
import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery";
+import {
+ useServerConfig,
+ useServerConfigUpdatedSubscription,
+ useServerWelcomeSubscription,
+ WsNativeApiAtomsProvider,
+} from "../wsNativeApiAtoms";
export const Route = createRootRouteWithContext<{
queryClient: QueryClient;
@@ -58,15 +62,17 @@ function RootRouteView() {
}
return (
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
);
}
@@ -154,16 +160,107 @@ function EventRouter() {
);
const queryClient = useQueryClient();
const navigate = useNavigate();
- const pathname = useRouterState({ select: (state) => state.location.pathname });
+ const pathname = useLocation({ select: (loc) => loc.pathname });
const pathnameRef = useRef(pathname);
const handledBootstrapThreadIdRef = useRef(null);
+ const handledConfigReplayRef = useRef(false);
+ const disposedRef = useRef(false);
+ const bootstrapFromSnapshotRef = useRef<() => Promise>(async () => undefined);
+ const serverConfig = useServerConfig();
pathnameRef.current = pathname;
+ const handleWelcome = useEffectEvent((payload: WsWelcomePayload) => {
+ migrateLocalSettingsToServer();
+ void (async () => {
+ await bootstrapFromSnapshotRef.current();
+ if (disposedRef.current) {
+ return;
+ }
+
+ if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {
+ return;
+ }
+ setProjectExpanded(payload.bootstrapProjectId, true);
+
+ if (pathnameRef.current !== "/") {
+ return;
+ }
+ if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) {
+ return;
+ }
+ await navigate({
+ to: "/$threadId",
+ params: { threadId: payload.bootstrapThreadId },
+ replace: true,
+ });
+ handledBootstrapThreadIdRef.current = payload.bootstrapThreadId;
+ })().catch(() => undefined);
+ });
+
+ const handleServerConfigUpdated = useEffectEvent(
+ ({
+ payload,
+ source,
+ }: {
+ readonly payload: import("@t3tools/contracts").ServerConfigUpdatedPayload;
+ readonly source: import("../wsNativeApiState").ServerConfigUpdateSource;
+ }) => {
+ const isReplay = !handledConfigReplayRef.current;
+ handledConfigReplayRef.current = true;
+ if (isReplay || source !== "keybindingsUpdated") {
+ return;
+ }
+
+ const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings."));
+ if (!issue) {
+ toastManager.add({
+ type: "success",
+ title: "Keybindings updated",
+ description: "Keybindings configuration reloaded successfully.",
+ });
+ return;
+ }
+
+ toastManager.add({
+ type: "warning",
+ title: "Invalid keybindings configuration",
+ description: issue.message,
+ actionProps: {
+ children: "Open keybindings.json",
+ onClick: () => {
+ const api = readNativeApi();
+ if (!api) {
+ return;
+ }
+
+ void Promise.resolve(serverConfig ?? api.server.getConfig())
+ .then((config) => {
+ const editor = resolveAndPersistPreferredEditor(config.availableEditors);
+ if (!editor) {
+ throw new Error("No available editors found.");
+ }
+ return api.shell.openInEditor(config.keybindingsConfigPath, editor);
+ })
+ .catch((error) => {
+ toastManager.add({
+ type: "error",
+ title: "Unable to open keybindings file",
+ description:
+ error instanceof Error ? error.message : "Unknown error opening file.",
+ });
+ });
+ },
+ },
+ });
+ },
+ );
+
useEffect(() => {
const api = readNativeApi();
if (!api) return;
let disposed = false;
+ disposedRef.current = false;
const recovery = createOrchestrationRecoveryCoordinator();
let needsProviderInvalidation = false;
@@ -299,11 +396,11 @@ function EventRouter() {
const bootstrapFromSnapshot = async (): Promise => {
await runSnapshotRecovery("bootstrap");
};
+ bootstrapFromSnapshotRef.current = bootstrapFromSnapshot;
const fallbackToSnapshotRecovery = async (): Promise => {
await runSnapshotRecovery("replay-failed");
};
-
const unsubDomainEvent = api.orchestration.onDomainEvent((event) => {
const action = recovery.classifyDomainEvent(event.sequence);
if (action === "apply") {
@@ -327,98 +424,13 @@ function EventRouter() {
hasRunningSubprocess,
);
});
- const unsubWelcome = onServerWelcome((payload) => {
- // Migrate old localStorage settings to server on first connect
- migrateLocalSettingsToServer();
- void (async () => {
- await bootstrapFromSnapshot();
- if (disposed) {
- return;
- }
-
- if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {
- return;
- }
- setProjectExpanded(payload.bootstrapProjectId, true);
-
- if (pathnameRef.current !== "/") {
- return;
- }
- if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) {
- return;
- }
- await navigate({
- to: "/$threadId",
- params: { threadId: payload.bootstrapThreadId },
- replace: true,
- });
- handledBootstrapThreadIdRef.current = payload.bootstrapThreadId;
- })().catch(() => undefined);
- });
- // onServerConfigUpdated replays the latest cached value synchronously
- // during subscribe. Skip the toast for that replay so effect re-runs
- // don't produce duplicate toasts.
- let subscribed = false;
- const unsubServerConfigUpdated = onServerConfigUpdated((payload) => {
- // Invalidate the config query so active observers refetch fresh data.
- void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() });
-
- if (!subscribed) return;
-
- // Only show keybindings toasts for keybindings changes (no settings in payload)
- if (payload.settings) return;
-
- const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings."));
- if (!issue) {
- toastManager.add({
- type: "success",
- title: "Keybindings updated",
- description: "Keybindings configuration reloaded successfully.",
- });
- return;
- }
-
- toastManager.add({
- type: "warning",
- title: "Invalid keybindings configuration",
- description: issue.message,
- actionProps: {
- children: "Open keybindings.json",
- onClick: () => {
- void queryClient
- .ensureQueryData(serverConfigQueryOptions())
- .then((config) => {
- const editor = resolveAndPersistPreferredEditor(config.availableEditors);
- if (!editor) {
- throw new Error("No available editors found.");
- }
- return api.shell.openInEditor(config.keybindingsConfigPath, editor);
- })
- .catch((error) => {
- toastManager.add({
- type: "error",
- title: "Unable to open keybindings file",
- description:
- error instanceof Error ? error.message : "Unknown error opening file.",
- });
- });
- },
- },
- });
- });
- const unsubProvidersUpdated = onServerProvidersUpdated(() => {
- void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() });
- });
- subscribed = true;
return () => {
disposed = true;
+ disposedRef.current = true;
needsProviderInvalidation = false;
queryInvalidationThrottler.cancel();
unsubDomainEvent();
unsubTerminalEvent();
- unsubWelcome();
- unsubServerConfigUpdated();
- unsubProvidersUpdated();
};
}, [
applyOrchestrationEvents,
@@ -433,6 +445,9 @@ function EventRouter() {
syncThreads,
]);
+ useServerWelcomeSubscription(handleWelcome);
+ useServerConfigUpdatedSubscription(handleServerConfigUpdated);
+
return null;
}
diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx
index 245ed9c576..9d3efe9561 100644
--- a/apps/web/src/routes/_chat.tsx
+++ b/apps/web/src/routes/_chat.tsx
@@ -1,26 +1,21 @@
-import { type ResolvedKeybindingsConfig } from "@t3tools/contracts";
-import { useQuery } from "@tanstack/react-query";
import { Outlet, createFileRoute } from "@tanstack/react-router";
import { useEffect } from "react";
import { useHandleNewThread } from "../hooks/useHandleNewThread";
import { isTerminalFocused } from "../lib/terminalFocus";
-import { serverConfigQueryOptions } from "../lib/serverReactQuery";
import { resolveShortcutCommand } from "../keybindings";
import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
import { useThreadSelectionStore } from "../threadSelectionStore";
import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";
import { useSettings } from "~/hooks/useSettings";
-
-const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
+import { useServerKeybindings } from "~/wsNativeApiAtoms";
function ChatRouteGlobalShortcuts() {
const clearSelection = useThreadSelectionStore((state) => state.clearSelection);
const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size);
const { activeDraftThread, activeThread, defaultProjectId, handleNewThread, routeThreadId } =
useHandleNewThread();
- const serverConfigQuery = useQuery(serverConfigQueryOptions());
- const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS;
+ const keybindings = useServerKeybindings();
const terminalOpen = useTerminalStateStore((state) =>
routeThreadId
? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen
diff --git a/apps/web/src/test/wsRpcHarness.ts b/apps/web/src/test/wsRpcHarness.ts
new file mode 100644
index 0000000000..cd066745a6
--- /dev/null
+++ b/apps/web/src/test/wsRpcHarness.ts
@@ -0,0 +1,169 @@
+import { Effect, Exit, PubSub, Scope, Stream } from "effect";
+import { WS_METHODS, WsRpcGroup } from "@t3tools/contracts";
+import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
+
+type RpcServerInstance = RpcServer.RpcServer;
+
+type BrowserWsClient = {
+ send: (data: string) => void;
+};
+
+export type NormalizedWsRpcRequestBody = {
+ _tag: string;
+ [key: string]: unknown;
+};
+
+type UnaryResolverResult = unknown | Promise;
+
+interface BrowserWsRpcHarnessOptions {
+ readonly resolveUnary?: (request: NormalizedWsRpcRequestBody) => UnaryResolverResult;
+ readonly getInitialStreamValues?: (
+ request: NormalizedWsRpcRequestBody,
+ ) => ReadonlyArray | undefined;
+}
+
+const STREAM_METHODS = new Set([
+ WS_METHODS.subscribeOrchestrationDomainEvents,
+ WS_METHODS.subscribeTerminalEvents,
+ WS_METHODS.subscribeServerConfig,
+ WS_METHODS.subscribeServerLifecycle,
+ WS_METHODS.subscribeGitActionProgress,
+]);
+
+const ALL_RPC_METHODS = Array.from(WsRpcGroup.requests.keys());
+
+function normalizeRequest(tag: string, payload: unknown): NormalizedWsRpcRequestBody {
+ if (payload && typeof payload === "object" && !Array.isArray(payload)) {
+ return {
+ _tag: tag,
+ ...(payload as Record),
+ };
+ }
+ return { _tag: tag, payload };
+}
+
+function asEffect(result: UnaryResolverResult): Effect.Effect {
+ if (result instanceof Promise) {
+ return Effect.promise(() => result);
+ }
+ return Effect.succeed(result);
+}
+
+export class BrowserWsRpcHarness {
+ readonly requests: Array = [];
+
+ private readonly parser = RpcSerialization.json.makeUnsafe();
+ private client: BrowserWsClient | null = null;
+ private scope: Scope.Closeable | null = null;
+ private serverReady: Promise | null = null;
+ private resolveUnary: NonNullable = () => ({});
+ private getInitialStreamValues: NonNullable<
+ BrowserWsRpcHarnessOptions["getInitialStreamValues"]
+ > = () => [];
+ private streamPubSubs = new Map>();
+
+ async reset(options?: BrowserWsRpcHarnessOptions): Promise {
+ await this.disconnect();
+ this.requests.length = 0;
+ this.resolveUnary = options?.resolveUnary ?? (() => ({}));
+ this.getInitialStreamValues = options?.getInitialStreamValues ?? (() => []);
+ this.initializeStreamPubSubs();
+ }
+
+ connect(client: BrowserWsClient): void {
+ if (this.scope) {
+ void Effect.runPromise(Scope.close(this.scope, Exit.void)).catch(() => undefined);
+ }
+ if (this.streamPubSubs.size === 0) {
+ this.initializeStreamPubSubs();
+ }
+ this.client = client;
+ this.scope = Effect.runSync(Scope.make());
+ this.serverReady = Effect.runPromise(
+ Scope.provide(this.scope)(
+ RpcServer.makeNoSerialization(WsRpcGroup, this.makeServerOptions()),
+ ).pipe(Effect.provide(this.makeLayer())),
+ ) as Promise;
+ }
+
+ async disconnect(): Promise {
+ if (this.scope) {
+ await Effect.runPromise(Scope.close(this.scope, Exit.void)).catch(() => undefined);
+ this.scope = null;
+ }
+ for (const pubsub of this.streamPubSubs.values()) {
+ Effect.runSync(PubSub.shutdown(pubsub));
+ }
+ this.streamPubSubs.clear();
+ this.serverReady = null;
+ this.client = null;
+ }
+
+ private initializeStreamPubSubs(): void {
+ this.streamPubSubs = new Map(
+ Array.from(STREAM_METHODS, (method) => [method, Effect.runSync(PubSub.unbounded())]),
+ );
+ }
+
+ async onMessage(rawData: string): Promise {
+ const server = await this.serverReady;
+ if (!server) {
+ throw new Error("RPC test server is not connected");
+ }
+ const messages = this.parser.decode(rawData);
+ for (const message of messages) {
+ await Effect.runPromise(server.write(0, message as never));
+ }
+ }
+
+ emitStreamValue(method: string, value: unknown): void {
+ const pubsub = this.streamPubSubs.get(method);
+ if (!pubsub) {
+ throw new Error(`No stream registered for ${method}`);
+ }
+ Effect.runSync(PubSub.publish(pubsub, value));
+ }
+
+ private makeLayer() {
+ const handlers: Record unknown> = {};
+ for (const method of ALL_RPC_METHODS) {
+ handlers[method] = STREAM_METHODS.has(method)
+ ? (payload) => this.handleStream(method, payload)
+ : (payload) => this.handleUnary(method, payload);
+ }
+ return WsRpcGroup.toLayer(handlers as never);
+ }
+
+ private makeServerOptions() {
+ return {
+ onFromServer: (response: unknown) =>
+ Effect.sync(() => {
+ if (!this.client) {
+ return;
+ }
+ const encoded = this.parser.encode(response);
+ if (typeof encoded === "string") {
+ this.client.send(encoded);
+ }
+ }),
+ };
+ }
+
+ private handleUnary(method: string, payload: unknown) {
+ const request = normalizeRequest(method, payload);
+ this.requests.push(request);
+ return asEffect(this.resolveUnary(request));
+ }
+
+ private handleStream(method: string, payload: unknown) {
+ const request = normalizeRequest(method, payload);
+ this.requests.push(request);
+ const pubsub = this.streamPubSubs.get(method);
+ if (!pubsub) {
+ throw new Error(`No stream registered for ${method}`);
+ }
+ return Stream.fromIterable(this.getInitialStreamValues(request) ?? []).pipe(
+ Stream.concat(Stream.fromPubSub(pubsub)),
+ );
+ }
+}
diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts
index be53eefd9b..a6d22b9f3c 100644
--- a/apps/web/src/wsNativeApi.test.ts
+++ b/apps/web/src/wsNativeApi.test.ts
@@ -1,23 +1,21 @@
import {
CommandId,
- type ContextMenuItem,
+ DEFAULT_SERVER_SETTINGS,
+ type DesktopBridge,
EventId,
- ORCHESTRATION_WS_CHANNELS,
- ORCHESTRATION_WS_METHODS,
- type OrchestrationEvent,
ProjectId,
- ThreadId,
- type WsPushChannel,
- type WsPushData,
- type WsPushMessage,
- WS_CHANNELS,
- WS_METHODS,
- type WsPush,
+ type OrchestrationEvent,
+ type ServerConfig,
+ type ServerConfigStreamEvent,
+ type ServerLifecycleStreamEvent,
type ServerProvider,
+ type TerminalEvent,
+ ThreadId,
} from "@t3tools/contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-const requestMock = vi.fn<(...args: Array) => Promise>();
+import type { ContextMenuItem } from "@t3tools/contracts";
+
const showContextMenuFallbackMock =
vi.fn<
(
@@ -25,39 +23,87 @@ const showContextMenuFallbackMock =
position?: { x: number; y: number },
) => Promise
>();
-const channelListeners = new Map void>>();
-const latestPushByChannel = new Map();
-const subscribeMock = vi.fn<
- (
- channel: string,
- listener: (message: WsPush) => void,
- options?: { replayLatest?: boolean },
- ) => () => void
->((channel, listener, options) => {
- const listeners = channelListeners.get(channel) ?? new Set<(message: WsPush) => void>();
+
+function registerListener(listeners: Set<(event: T) => void>, listener: (event: T) => void) {
listeners.add(listener);
- channelListeners.set(channel, listeners);
- const latest = latestPushByChannel.get(channel);
- if (latest && options?.replayLatest) {
- listener(latest);
- }
return () => {
listeners.delete(listener);
- if (listeners.size === 0) {
- channelListeners.delete(channel);
- }
};
-});
+}
+
+const lifecycleListeners = new Set<(event: ServerLifecycleStreamEvent) => void>();
+const configListeners = new Set<(event: ServerConfigStreamEvent) => void>();
+const gitProgressListeners = new Set<(event: unknown) => void>();
+const terminalEventListeners = new Set<(event: TerminalEvent) => void>();
+const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>();
+
+const rpcClientMock = {
+ dispose: vi.fn(),
+ terminal: {
+ open: vi.fn(),
+ write: vi.fn(),
+ resize: vi.fn(),
+ clear: vi.fn(),
+ restart: vi.fn(),
+ close: vi.fn(),
+ onEvent: vi.fn((listener: (event: TerminalEvent) => void) =>
+ registerListener(terminalEventListeners, listener),
+ ),
+ },
+ projects: {
+ searchEntries: vi.fn(),
+ writeFile: vi.fn(),
+ },
+ shell: {
+ openInEditor: vi.fn(),
+ },
+ git: {
+ pull: vi.fn(),
+ status: vi.fn(),
+ runStackedAction: vi.fn(),
+ listBranches: vi.fn(),
+ createWorktree: vi.fn(),
+ removeWorktree: vi.fn(),
+ createBranch: vi.fn(),
+ checkout: vi.fn(),
+ init: vi.fn(),
+ resolvePullRequest: vi.fn(),
+ preparePullRequestThread: vi.fn(),
+ onActionProgress: vi.fn((listener: (event: unknown) => void) =>
+ registerListener(gitProgressListeners, listener),
+ ),
+ subscribeActionProgress: vi.fn((listener: (event: unknown) => void) =>
+ registerListener(gitProgressListeners, listener),
+ ),
+ },
+ server: {
+ getConfig: vi.fn(),
+ refreshProviders: vi.fn(),
+ upsertKeybinding: vi.fn(),
+ getSettings: vi.fn(),
+ updateSettings: vi.fn(),
+ subscribeConfig: vi.fn((listener: (event: ServerConfigStreamEvent) => void) =>
+ registerListener(configListeners, listener),
+ ),
+ subscribeLifecycle: vi.fn((listener: (event: ServerLifecycleStreamEvent) => void) =>
+ registerListener(lifecycleListeners, listener),
+ ),
+ },
+ orchestration: {
+ getSnapshot: vi.fn(),
+ dispatchCommand: vi.fn(),
+ getTurnDiff: vi.fn(),
+ getFullThreadDiff: vi.fn(),
+ replayEvents: vi.fn(),
+ onDomainEvent: vi.fn((listener: (event: OrchestrationEvent) => void) =>
+ registerListener(orchestrationEventListeners, listener),
+ ),
+ },
+};
-vi.mock("./wsTransport", () => {
+vi.mock("./wsRpcClient", () => {
return {
- WsTransport: class MockWsTransport {
- request = requestMock;
- subscribe = subscribeMock;
- getLatestPush(channel: string) {
- return latestPushByChannel.get(channel) ?? null;
- }
- },
+ createWsRpcClient: () => rpcClientMock,
};
});
@@ -65,23 +111,20 @@ vi.mock("./contextMenuFallback", () => ({
showContextMenuFallback: showContextMenuFallbackMock,
}));
-let nextPushSequence = 1;
-
-function emitPush(channel: C, data: WsPushData): void {
- const listeners = channelListeners.get(channel);
- const message = {
- type: "push" as const,
- sequence: nextPushSequence++,
- channel,
- data,
- } as WsPushMessage;
- latestPushByChannel.set(channel, message);
- if (!listeners) return;
+function emitEvent(listeners: Set<(event: T) => void>, event: T) {
for (const listener of listeners) {
- listener(message);
+ listener(event);
}
}
+function emitLifecycleEvent(event: ServerLifecycleStreamEvent) {
+ emitEvent(lifecycleListeners, event);
+}
+
+function emitServerConfigEvent(event: ServerConfigStreamEvent) {
+ emitEvent(configListeners, event);
+}
+
function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unknown } {
const testGlobal = globalThis as typeof globalThis & {
window?: Window & typeof globalThis & { desktopBridge?: unknown };
@@ -92,6 +135,32 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn
return testGlobal.window;
}
+function makeDesktopBridge(overrides: Partial = {}): DesktopBridge {
+ return {
+ getWsUrl: () => null,
+ pickFolder: async () => null,
+ confirm: async () => true,
+ setTheme: async () => undefined,
+ showContextMenu: async () => null,
+ openExternal: async () => true,
+ onMenuAction: () => () => undefined,
+ getUpdateState: async () => {
+ throw new Error("getUpdateState not implemented in test");
+ },
+ checkForUpdate: async () => {
+ throw new Error("checkForUpdate not implemented in test");
+ },
+ downloadUpdate: async () => {
+ throw new Error("downloadUpdate not implemented in test");
+ },
+ installUpdate: async () => {
+ throw new Error("installUpdate not implemented in test");
+ },
+ onUpdateState: () => () => undefined,
+ ...overrides,
+ };
+}
+
const defaultProviders: ReadonlyArray = [
{
provider: "codex",
@@ -105,14 +174,25 @@ const defaultProviders: ReadonlyArray = [
},
];
+const baseServerConfig: ServerConfig = {
+ cwd: "/tmp/workspace",
+ keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json",
+ keybindings: [],
+ issues: [],
+ providers: defaultProviders,
+ availableEditors: ["cursor"],
+ settings: DEFAULT_SERVER_SETTINGS,
+};
+
beforeEach(() => {
vi.resetModules();
- requestMock.mockReset();
+ vi.clearAllMocks();
showContextMenuFallbackMock.mockReset();
- subscribeMock.mockClear();
- channelListeners.clear();
- latestPushByChannel.clear();
- nextPushSequence = 1;
+ lifecycleListeners.clear();
+ configListeners.clear();
+ gitProgressListeners.clear();
+ terminalEventListeners.clear();
+ orchestrationEventListeners.clear();
Reflect.deleteProperty(getWindowForTest(), "desktopBridge");
});
@@ -121,38 +201,58 @@ afterEach(() => {
});
describe("wsNativeApi", () => {
- it("delivers and caches valid server.welcome payloads", async () => {
+ it("delivers and caches welcome lifecycle events", async () => {
const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi");
+ const { wsNativeApiRegistry, wsWelcomeAtom } = await import("./wsNativeApiState");
createWsNativeApi();
const listener = vi.fn();
onServerWelcome(listener);
- const payload = { cwd: "/tmp/workspace", projectName: "t3-code" };
- emitPush(WS_CHANNELS.serverWelcome, payload);
+ emitLifecycleEvent({
+ version: 1,
+ sequence: 1,
+ type: "welcome",
+ payload: { cwd: "/tmp/workspace", projectName: "t3-code" },
+ });
expect(listener).toHaveBeenCalledTimes(1);
- expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload));
+ expect(listener).toHaveBeenCalledWith({
+ cwd: "/tmp/workspace",
+ projectName: "t3-code",
+ });
const lateListener = vi.fn();
onServerWelcome(lateListener);
expect(lateListener).toHaveBeenCalledTimes(1);
- expect(lateListener).toHaveBeenCalledWith(expect.objectContaining(payload));
+ expect(lateListener).toHaveBeenCalledWith({
+ cwd: "/tmp/workspace",
+ projectName: "t3-code",
+ });
+ expect(wsNativeApiRegistry.get(wsWelcomeAtom)).toEqual({
+ cwd: "/tmp/workspace",
+ projectName: "t3-code",
+ });
});
- it("preserves bootstrap ids from server.welcome payloads", async () => {
+ it("preserves bootstrap ids from welcome lifecycle events", async () => {
const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi");
createWsNativeApi();
const listener = vi.fn();
onServerWelcome(listener);
- emitPush(WS_CHANNELS.serverWelcome, {
- cwd: "/tmp/workspace",
- projectName: "t3-code",
- bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
- bootstrapThreadId: ThreadId.makeUnsafe("thread-1"),
+ emitLifecycleEvent({
+ version: 1,
+ sequence: 1,
+ type: "welcome",
+ payload: {
+ cwd: "/tmp/workspace",
+ projectName: "t3-code",
+ bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
+ bootstrapThreadId: ThreadId.makeUnsafe("thread-1"),
+ },
});
expect(listener).toHaveBeenCalledTimes(1);
@@ -166,94 +266,155 @@ describe("wsNativeApi", () => {
);
});
- it("delivers successive server.welcome payloads to active listeners", async () => {
- const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi");
+ it("delivers and caches current server config from the config stream snapshot", async () => {
+ const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi");
+ const { serverConfigAtom, wsNativeApiRegistry } = await import("./wsNativeApiState");
- createWsNativeApi();
+ const api = createWsNativeApi();
const listener = vi.fn();
- onServerWelcome(listener);
+ onServerConfigUpdated(listener);
- emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/one", projectName: "one" });
- emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/workspace", projectName: "t3-code" });
+ emitServerConfigEvent({
+ version: 1,
+ type: "snapshot",
+ config: baseServerConfig,
+ });
- expect(listener).toHaveBeenCalledTimes(2);
- expect(listener).toHaveBeenLastCalledWith(
- expect.objectContaining({
- cwd: "/tmp/workspace",
- projectName: "t3-code",
- }),
+ await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig);
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(listener).toHaveBeenCalledWith(
+ {
+ issues: [],
+ providers: defaultProviders,
+ settings: DEFAULT_SERVER_SETTINGS,
+ },
+ "snapshot",
);
+ expect(wsNativeApiRegistry.get(serverConfigAtom)).toEqual(baseServerConfig);
});
- it("delivers and caches valid server.configUpdated payloads", async () => {
+ it("falls back to server.getConfig before the stream cache is populated", async () => {
+ rpcClientMock.server.getConfig.mockResolvedValueOnce(baseServerConfig);
const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi");
- createWsNativeApi();
+ const api = createWsNativeApi();
const listener = vi.fn();
onServerConfigUpdated(listener);
- const payload = {
- issues: [
- {
- kind: "keybindings.invalid-entry",
- index: 1,
- message: "Entry at index 1 is invalid.",
- },
- ],
- } as const;
- emitPush(WS_CHANNELS.serverConfigUpdated, payload);
-
- expect(listener).toHaveBeenCalledTimes(1);
- expect(listener).toHaveBeenCalledWith(payload);
-
- const lateListener = vi.fn();
- onServerConfigUpdated(lateListener);
- expect(lateListener).toHaveBeenCalledTimes(1);
- expect(lateListener).toHaveBeenCalledWith(payload);
+ await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig);
+ expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith();
+ expect(listener).toHaveBeenCalledWith(
+ {
+ issues: [],
+ providers: defaultProviders,
+ settings: DEFAULT_SERVER_SETTINGS,
+ },
+ "snapshot",
+ );
});
- it("delivers successive server.configUpdated payloads to active listeners", async () => {
- const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi");
-
- createWsNativeApi();
- const listener = vi.fn();
- onServerConfigUpdated(listener);
+ it("merges config stream updates into the cached server config", async () => {
+ const { createWsNativeApi, onServerConfigUpdated, onServerProvidersUpdated } =
+ await import("./wsNativeApi");
+ const { providersUpdatedAtom, wsNativeApiRegistry } = await import("./wsNativeApiState");
- emitPush(WS_CHANNELS.serverConfigUpdated, {
- issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
+ const api = createWsNativeApi();
+ const configListener = vi.fn();
+ const providersListener = vi.fn();
+ onServerConfigUpdated(configListener);
+ onServerProvidersUpdated(providersListener);
+
+ emitServerConfigEvent({
+ version: 1,
+ type: "snapshot",
+ config: baseServerConfig,
});
- emitPush(WS_CHANNELS.serverConfigUpdated, {
- issues: [],
+ emitServerConfigEvent({
+ version: 1,
+ type: "keybindingsUpdated",
+ payload: {
+ issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
+ },
});
- expect(listener).toHaveBeenCalledTimes(2);
- expect(listener).toHaveBeenLastCalledWith({
- issues: [],
+ const nextProviders: ReadonlyArray = [
+ {
+ ...defaultProviders[0]!,
+ status: "warning",
+ checkedAt: "2026-01-02T00:00:00.000Z",
+ message: "rate limited",
+ },
+ ];
+ emitServerConfigEvent({
+ version: 1,
+ type: "providerStatuses",
+ payload: {
+ providers: nextProviders,
+ },
+ });
+ emitServerConfigEvent({
+ version: 1,
+ type: "settingsUpdated",
+ payload: {
+ settings: {
+ ...DEFAULT_SERVER_SETTINGS,
+ enableAssistantStreaming: true,
+ },
+ },
});
- });
-
- it("delivers and caches valid server.providersUpdated payloads", async () => {
- const { createWsNativeApi, onServerProvidersUpdated } = await import("./wsNativeApi");
-
- createWsNativeApi();
- const listener = vi.fn();
- onServerProvidersUpdated(listener);
-
- const payload = {
- providers: defaultProviders,
- } as const;
- emitPush(WS_CHANNELS.serverProvidersUpdated, payload);
-
- expect(listener).toHaveBeenCalledTimes(1);
- expect(listener).toHaveBeenCalledWith(payload);
- const lateListener = vi.fn();
- onServerProvidersUpdated(lateListener);
- expect(lateListener).toHaveBeenCalledTimes(1);
- expect(lateListener).toHaveBeenCalledWith(payload);
+ await expect(api.server.getConfig()).resolves.toEqual({
+ ...baseServerConfig,
+ issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
+ providers: nextProviders,
+ settings: {
+ ...DEFAULT_SERVER_SETTINGS,
+ enableAssistantStreaming: true,
+ },
+ });
+ expect(configListener).toHaveBeenNthCalledWith(
+ 1,
+ {
+ issues: [],
+ providers: defaultProviders,
+ settings: DEFAULT_SERVER_SETTINGS,
+ },
+ "snapshot",
+ );
+ expect(configListener).toHaveBeenNthCalledWith(
+ 2,
+ {
+ issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
+ providers: defaultProviders,
+ settings: DEFAULT_SERVER_SETTINGS,
+ },
+ "keybindingsUpdated",
+ );
+ expect(configListener).toHaveBeenNthCalledWith(
+ 3,
+ {
+ issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
+ providers: nextProviders,
+ settings: DEFAULT_SERVER_SETTINGS,
+ },
+ "providerStatuses",
+ );
+ expect(configListener).toHaveBeenLastCalledWith(
+ {
+ issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
+ providers: nextProviders,
+ settings: {
+ ...DEFAULT_SERVER_SETTINGS,
+ enableAssistantStreaming: true,
+ },
+ },
+ "settingsUpdated",
+ );
+ expect(providersListener).toHaveBeenLastCalledWith({ providers: nextProviders });
+ expect(wsNativeApiRegistry.get(providersUpdatedAtom)).toEqual({ providers: nextProviders });
});
- it("forwards valid terminal and orchestration events", async () => {
+ it("forwards terminal, orchestration, and git progress stream events", async () => {
const { createWsNativeApi } = await import("./wsNativeApi");
const api = createWsNativeApi();
@@ -272,7 +433,7 @@ describe("wsNativeApi", () => {
type: "output",
data: "hello",
} as const;
- emitPush(WS_CHANNELS.terminalEvent, terminalEvent);
+ emitEvent(terminalEventListeners, terminalEvent);
const orchestrationEvent = {
sequence: 1,
@@ -289,39 +450,34 @@ describe("wsNativeApi", () => {
projectId: ProjectId.makeUnsafe("project-1"),
title: "Project",
workspaceRoot: "/tmp/workspace",
- defaultModelSelection: null,
+ defaultModelSelection: {
+ provider: "codex",
+ model: "gpt-5-codex",
+ },
scripts: [],
createdAt: "2026-02-24T00:00:00.000Z",
updatedAt: "2026-02-24T00:00:00.000Z",
},
} satisfies Extract;
- emitPush(ORCHESTRATION_WS_CHANNELS.domainEvent, orchestrationEvent);
- emitPush(WS_CHANNELS.gitActionProgress, {
+ emitEvent(orchestrationEventListeners, orchestrationEvent);
+
+ const progressEvent = {
actionId: "action-1",
cwd: "/repo",
action: "commit",
kind: "phase_started",
phase: "commit",
label: "Committing...",
- });
+ } as const;
+ emitEvent(gitProgressListeners, progressEvent);
- expect(onTerminalEvent).toHaveBeenCalledTimes(1);
expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent);
- expect(onDomainEvent).toHaveBeenCalledTimes(1);
expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent);
- expect(onActionProgress).toHaveBeenCalledTimes(1);
- expect(onActionProgress).toHaveBeenCalledWith({
- actionId: "action-1",
- cwd: "/repo",
- action: "commit",
- kind: "phase_started",
- phase: "commit",
- label: "Committing...",
- });
+ expect(onActionProgress).toHaveBeenCalledWith(progressEvent);
});
- it("wraps orchestration dispatch commands in the command envelope", async () => {
- requestMock.mockResolvedValue(undefined);
+ it("sends orchestration dispatch commands as the direct RPC payload", async () => {
+ rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 });
const { createWsNativeApi } = await import("./wsNativeApi");
const api = createWsNativeApi();
@@ -339,13 +495,11 @@ describe("wsNativeApi", () => {
} as const;
await api.orchestration.dispatchCommand(command);
- expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.dispatchCommand, {
- command,
- });
+ expect(rpcClientMock.orchestration.dispatchCommand).toHaveBeenCalledWith(command);
});
- it("forwards workspace file writes to the websocket project method", async () => {
- requestMock.mockResolvedValue({ relativePath: "plan.md" });
+ it("forwards workspace file writes to the project RPC", async () => {
+ rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" });
const { createWsNativeApi } = await import("./wsNativeApi");
const api = createWsNativeApi();
@@ -355,7 +509,7 @@ describe("wsNativeApi", () => {
contents: "# Plan\n",
});
- expect(requestMock).toHaveBeenCalledWith(WS_METHODS.projectsWriteFile, {
+ expect(rpcClientMock.projects.writeFile).toHaveBeenCalledWith({
cwd: "/tmp/project",
relativePath: "plan.md",
contents: "# Plan\n",
@@ -363,7 +517,7 @@ describe("wsNativeApi", () => {
});
it("uses no client timeout for git.runStackedAction", async () => {
- requestMock.mockResolvedValue({
+ rpcClientMock.git.runStackedAction.mockResolvedValue({
action: "commit",
branch: { status: "skipped_not_requested" },
commit: { status: "created", commitSha: "abc1234", subject: "Test" },
@@ -379,19 +533,15 @@ describe("wsNativeApi", () => {
action: "commit",
});
- expect(requestMock).toHaveBeenCalledWith(
- WS_METHODS.gitRunStackedAction,
- {
- actionId: "action-1",
- cwd: "/repo",
- action: "commit",
- },
- { timeoutMs: null },
- );
+ expect(rpcClientMock.git.runStackedAction).toHaveBeenCalledWith({
+ actionId: "action-1",
+ cwd: "/repo",
+ action: "commit",
+ });
});
- it("forwards full-thread diff requests to the orchestration websocket method", async () => {
- requestMock.mockResolvedValue({ diff: "patch" });
+ it("forwards full-thread diff requests to the orchestration RPC", async () => {
+ rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" });
const { createWsNativeApi } = await import("./wsNativeApi");
const api = createWsNativeApi();
@@ -400,55 +550,99 @@ describe("wsNativeApi", () => {
toTurnCount: 1,
});
- expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.getFullThreadDiff, {
+ expect(rpcClientMock.orchestration.getFullThreadDiff).toHaveBeenCalledWith({
threadId: "thread-1",
toTurnCount: 1,
});
});
- it("forwards context menu metadata to desktop bridge", async () => {
- const showContextMenu = vi.fn().mockResolvedValue("delete");
- Object.defineProperty(getWindowForTest(), "desktopBridge", {
- configurable: true,
- writable: true,
- value: {
- showContextMenu,
+ it("refreshes providers and updates cached listeners", async () => {
+ const nextProviders: ReadonlyArray = [
+ {
+ ...defaultProviders[0]!,
+ checkedAt: "2026-01-03T00:00:00.000Z",
},
+ ];
+ rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders });
+ const { createWsNativeApi, onServerProvidersUpdated } = await import("./wsNativeApi");
+
+ const api = createWsNativeApi();
+ emitServerConfigEvent({
+ version: 1,
+ type: "snapshot",
+ config: baseServerConfig,
});
- const { createWsNativeApi } = await import("./wsNativeApi");
+ const listener = vi.fn();
+ onServerProvidersUpdated(listener);
+
+ await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders });
+ expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith();
+ expect(listener).toHaveBeenLastCalledWith({ providers: nextProviders });
+ await expect(api.server.getConfig()).resolves.toEqual({
+ ...baseServerConfig,
+ providers: nextProviders,
+ });
+ });
+
+ it("updates cached config when server settings are changed", async () => {
+ const nextSettings = {
+ ...DEFAULT_SERVER_SETTINGS,
+ enableAssistantStreaming: true,
+ };
+ rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings);
+ const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi");
+
const api = createWsNativeApi();
- await api.contextMenu.show(
- [
- { id: "rename", label: "Rename thread" },
- { id: "delete", label: "Delete", destructive: true },
- ],
- { x: 200, y: 300 },
- );
+ emitServerConfigEvent({
+ version: 1,
+ type: "snapshot",
+ config: baseServerConfig,
+ });
- expect(showContextMenu).toHaveBeenCalledWith(
- [
- { id: "rename", label: "Rename thread" },
- { id: "delete", label: "Delete", destructive: true },
- ],
- { x: 200, y: 300 },
+ const listener = vi.fn();
+ onServerConfigUpdated(listener);
+
+ await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual(
+ nextSettings,
+ );
+ expect(rpcClientMock.server.updateSettings).toHaveBeenCalledWith({
+ enableAssistantStreaming: true,
+ });
+ await expect(api.server.getConfig()).resolves.toEqual({
+ ...baseServerConfig,
+ settings: nextSettings,
+ });
+ expect(listener).toHaveBeenLastCalledWith(
+ {
+ issues: [],
+ providers: defaultProviders,
+ settings: nextSettings,
+ },
+ "settingsUpdated",
);
});
- it("uses fallback context menu when desktop bridge is unavailable", async () => {
- showContextMenuFallbackMock.mockResolvedValue("delete");
- Reflect.deleteProperty(getWindowForTest(), "desktopBridge");
+ it("forwards context menu metadata to the desktop bridge", async () => {
+ const showContextMenu = vi.fn().mockResolvedValue("delete");
+ getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu });
const { createWsNativeApi } = await import("./wsNativeApi");
const api = createWsNativeApi();
- await api.contextMenu.show([{ id: "delete", label: "Delete", destructive: true }], {
- x: 20,
- y: 30,
- });
+ const items = [{ id: "delete", label: "Delete" }] as const;
- expect(showContextMenuFallbackMock).toHaveBeenCalledWith(
- [{ id: "delete", label: "Delete", destructive: true }],
- { x: 20, y: 30 },
- );
+ await expect(api.contextMenu.show(items)).resolves.toBe("delete");
+ expect(showContextMenu).toHaveBeenCalledWith(items, undefined);
+ });
+
+ it("falls back to the browser context menu helper when the desktop bridge is missing", async () => {
+ showContextMenuFallbackMock.mockResolvedValue("rename");
+ const { createWsNativeApi } = await import("./wsNativeApi");
+
+ const api = createWsNativeApi();
+ const items = [{ id: "rename", label: "Rename" }] as const;
+
+ await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename");
+ expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 });
});
});
diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts
index 7024ffdb47..0f8bf6319e 100644
--- a/apps/web/src/wsNativeApi.ts
+++ b/apps/web/src/wsNativeApi.ts
@@ -1,45 +1,59 @@
import {
type GitActionProgressEvent,
- ORCHESTRATION_WS_CHANNELS,
- ORCHESTRATION_WS_METHODS,
type ContextMenuItem,
type NativeApi,
- ServerConfigUpdatedPayload,
- ServerProviderUpdatedPayload,
- WS_CHANNELS,
- WS_METHODS,
+ type ServerProviderUpdatedPayload,
type WsWelcomePayload,
} from "@t3tools/contracts";
import { showContextMenuFallback } from "./contextMenuFallback";
-import { WsTransport } from "./wsTransport";
+import { createWsRpcClient, type WsRpcClient } from "./wsRpcClient";
+import {
+ applyProvidersUpdated,
+ applyServerConfigEvent,
+ applySettingsUpdated,
+ emitGitActionProgress,
+ emitWelcome,
+ getServerConfig,
+ onGitActionProgress,
+ onProvidersUpdated,
+ onServerConfigUpdated as onServerConfigUpdatedState,
+ onWelcome,
+ resetWsNativeApiStateForTests,
+ ServerConfigUpdateSource,
+ setServerConfigSnapshot,
+} from "./wsNativeApiState";
+
+let instance: { api: NativeApi; rpcClient: WsRpcClient; cleanups: Array<() => void> } | null = null;
+
+export function __resetWsNativeApiForTests() {
+ if (instance) {
+ for (const cleanup of instance.cleanups) {
+ cleanup();
+ }
+ instance.rpcClient.dispose();
+ instance = null;
+ }
+ resetWsNativeApiStateForTests();
+}
-let instance: { api: NativeApi; transport: WsTransport } | null = null;
-const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();
-const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>();
-const providersUpdatedListeners = new Set<(payload: ServerProviderUpdatedPayload) => void>();
-const gitActionProgressListeners = new Set<(payload: GitActionProgressEvent) => void>();
+async function getServerConfigSnapshot(rpcClient: WsRpcClient) {
+ const latestServerConfig = getServerConfig();
+ if (latestServerConfig) {
+ return latestServerConfig;
+ }
+
+ const config = await rpcClient.server.getConfig();
+ setServerConfigSnapshot(config);
+ return getServerConfig() ?? config;
+}
/**
* Subscribe to the server welcome message. If a welcome was already received
* before this call, the listener fires synchronously with the cached payload.
- * This avoids the race between WebSocket connect and React effect registration.
*/
export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): () => void {
- welcomeListeners.add(listener);
-
- const latestWelcome = instance?.transport.getLatestPush(WS_CHANNELS.serverWelcome)?.data ?? null;
- if (latestWelcome) {
- try {
- listener(latestWelcome);
- } catch {
- // Swallow listener errors
- }
- }
-
- return () => {
- welcomeListeners.delete(listener);
- };
+ return onWelcome(listener);
}
/**
@@ -47,90 +61,39 @@ export function onServerWelcome(listener: (payload: WsWelcomePayload) => void):
* late subscribers to avoid missing config validation feedback.
*/
export function onServerConfigUpdated(
- listener: (payload: ServerConfigUpdatedPayload) => void,
+ listener: (
+ payload: import("@t3tools/contracts").ServerConfigUpdatedPayload,
+ source: ServerConfigUpdateSource,
+ ) => void,
): () => void {
- serverConfigUpdatedListeners.add(listener);
-
- const latestConfig =
- instance?.transport.getLatestPush(WS_CHANNELS.serverConfigUpdated)?.data ?? null;
- if (latestConfig) {
- try {
- listener(latestConfig);
- } catch {
- // Swallow listener errors
- }
- }
-
- return () => {
- serverConfigUpdatedListeners.delete(listener);
- };
+ return onServerConfigUpdatedState(listener);
}
export function onServerProvidersUpdated(
listener: (payload: ServerProviderUpdatedPayload) => void,
): () => void {
- providersUpdatedListeners.add(listener);
-
- const latestProviders =
- instance?.transport.getLatestPush(WS_CHANNELS.serverProvidersUpdated)?.data ?? null;
- if (latestProviders) {
- try {
- listener(latestProviders);
- } catch {
- // Swallow listener errors
- }
- }
-
- return () => {
- providersUpdatedListeners.delete(listener);
- };
+ return onProvidersUpdated(listener);
}
export function createWsNativeApi(): NativeApi {
- if (instance) return instance.api;
-
- const transport = new WsTransport();
+ if (instance) {
+ return instance.api;
+ }
- transport.subscribe(WS_CHANNELS.serverWelcome, (message) => {
- const payload = message.data;
- for (const listener of welcomeListeners) {
- try {
- listener(payload);
- } catch {
- // Swallow listener errors
+ const rpcClient = createWsRpcClient();
+ const cleanups = [
+ rpcClient.server.subscribeLifecycle((event) => {
+ if (event.type === "welcome") {
+ emitWelcome(event.payload);
}
- }
- });
- transport.subscribe(WS_CHANNELS.serverConfigUpdated, (message) => {
- const payload = message.data;
- for (const listener of serverConfigUpdatedListeners) {
- try {
- listener(payload);
- } catch {
- // Swallow listener errors
- }
- }
- });
- transport.subscribe(WS_CHANNELS.serverProvidersUpdated, (message) => {
- const payload = message.data;
- for (const listener of providersUpdatedListeners) {
- try {
- listener(payload);
- } catch {
- // Swallow listener errors
- }
- }
- });
- transport.subscribe(WS_CHANNELS.gitActionProgress, (message) => {
- const payload = message.data;
- for (const listener of gitActionProgressListeners) {
- try {
- listener(payload);
- } catch {
- // Swallow listener errors
- }
- }
- });
+ }),
+ rpcClient.server.subscribeConfig((event) => {
+ applyServerConfigEvent(event);
+ }),
+ rpcClient.git.subscribeActionProgress((event: GitActionProgressEvent) => {
+ emitGitActionProgress(event);
+ }),
+ ];
const api: NativeApi = {
dialogs: {
@@ -146,22 +109,20 @@ export function createWsNativeApi(): NativeApi {
},
},
terminal: {
- open: (input) => transport.request(WS_METHODS.terminalOpen, input),
- write: (input) => transport.request(WS_METHODS.terminalWrite, input),
- resize: (input) => transport.request(WS_METHODS.terminalResize, input),
- clear: (input) => transport.request(WS_METHODS.terminalClear, input),
- restart: (input) => transport.request(WS_METHODS.terminalRestart, input),
- close: (input) => transport.request(WS_METHODS.terminalClose, input),
- onEvent: (callback) =>
- transport.subscribe(WS_CHANNELS.terminalEvent, (message) => callback(message.data)),
+ open: (input) => rpcClient.terminal.open(input as never),
+ write: (input) => rpcClient.terminal.write(input as never),
+ resize: (input) => rpcClient.terminal.resize(input as never),
+ clear: (input) => rpcClient.terminal.clear(input as never),
+ restart: (input) => rpcClient.terminal.restart(input as never),
+ close: (input) => rpcClient.terminal.close(input as never),
+ onEvent: (callback) => rpcClient.terminal.onEvent(callback),
},
projects: {
- searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input),
- writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input),
+ searchEntries: rpcClient.projects.searchEntries,
+ writeFile: rpcClient.projects.writeFile,
},
shell: {
- openInEditor: (cwd, editor) =>
- transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }),
+ openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }),
openExternal: async (url) => {
if (window.desktopBridge) {
const opened = await window.desktopBridge.openExternal(url);
@@ -171,31 +132,22 @@ export function createWsNativeApi(): NativeApi {
return;
}
- // Some mobile browsers can return null here even when the tab opens.
- // Avoid false negatives and let the browser handle popup policy.
window.open(url, "_blank", "noopener,noreferrer");
},
},
git: {
- pull: (input) => transport.request(WS_METHODS.gitPull, input),
- status: (input) => transport.request(WS_METHODS.gitStatus, input),
- runStackedAction: (input) =>
- transport.request(WS_METHODS.gitRunStackedAction, input, { timeoutMs: null }),
- listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),
- createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),
- removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),
- createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input),
- checkout: (input) => transport.request(WS_METHODS.gitCheckout, input),
- init: (input) => transport.request(WS_METHODS.gitInit, input),
- resolvePullRequest: (input) => transport.request(WS_METHODS.gitResolvePullRequest, input),
- preparePullRequestThread: (input) =>
- transport.request(WS_METHODS.gitPreparePullRequestThread, input),
- onActionProgress: (callback) => {
- gitActionProgressListeners.add(callback);
- return () => {
- gitActionProgressListeners.delete(callback);
- };
- },
+ pull: rpcClient.git.pull,
+ status: rpcClient.git.status,
+ runStackedAction: rpcClient.git.runStackedAction,
+ listBranches: rpcClient.git.listBranches,
+ createWorktree: rpcClient.git.createWorktree,
+ removeWorktree: rpcClient.git.removeWorktree,
+ createBranch: rpcClient.git.createBranch,
+ checkout: rpcClient.git.checkout,
+ init: rpcClient.git.init,
+ resolvePullRequest: rpcClient.git.resolvePullRequest,
+ preparePullRequestThread: rpcClient.git.preparePullRequestThread,
+ onActionProgress: (callback) => onGitActionProgress(callback),
},
contextMenu: {
show: async (
@@ -209,28 +161,33 @@ export function createWsNativeApi(): NativeApi {
},
},
server: {
- getConfig: () => transport.request(WS_METHODS.serverGetConfig),
- refreshProviders: () => transport.request(WS_METHODS.serverRefreshProviders),
- upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input),
- getSettings: () => transport.request(WS_METHODS.serverGetSettings),
- updateSettings: (patch) => transport.request(WS_METHODS.serverUpdateSettings, { patch }),
+ getConfig: () => getServerConfigSnapshot(rpcClient),
+ refreshProviders: () =>
+ rpcClient.server.refreshProviders().then((payload) => {
+ applyProvidersUpdated(payload);
+ return payload;
+ }),
+ upsertKeybinding: rpcClient.server.upsertKeybinding,
+ getSettings: rpcClient.server.getSettings,
+ updateSettings: (patch) =>
+ rpcClient.server.updateSettings(patch).then((settings) => {
+ applySettingsUpdated(settings);
+ return settings;
+ }),
},
orchestration: {
- getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot),
- dispatchCommand: (command) =>
- transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, { command }),
- getTurnDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getTurnDiff, input),
- getFullThreadDiff: (input) =>
- transport.request(ORCHESTRATION_WS_METHODS.getFullThreadDiff, input),
+ getSnapshot: rpcClient.orchestration.getSnapshot,
+ dispatchCommand: rpcClient.orchestration.dispatchCommand,
+ getTurnDiff: rpcClient.orchestration.getTurnDiff,
+ getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff,
replayEvents: (fromSequenceExclusive) =>
- transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }),
- onDomainEvent: (callback) =>
- transport.subscribe(ORCHESTRATION_WS_CHANNELS.domainEvent, (message) =>
- callback(message.data),
- ),
+ rpcClient.orchestration
+ .replayEvents({ fromSequenceExclusive })
+ .then((events) => [...events]),
+ onDomainEvent: (callback) => rpcClient.orchestration.onDomainEvent(callback),
},
};
- instance = { api, transport };
+ instance = { api, rpcClient, cleanups };
return api;
}
diff --git a/apps/web/src/wsNativeApiAtoms.tsx b/apps/web/src/wsNativeApiAtoms.tsx
new file mode 100644
index 0000000000..07b350eaea
--- /dev/null
+++ b/apps/web/src/wsNativeApiAtoms.tsx
@@ -0,0 +1,112 @@
+import { RegistryContext, useAtomSubscribe, useAtomValue } from "@effect/atom-react";
+import {
+ DEFAULT_SERVER_SETTINGS,
+ type EditorId,
+ type ResolvedKeybindingsConfig,
+ type ServerConfig,
+ type ServerProvider,
+ type ServerSettings,
+ type WsWelcomePayload,
+} from "@t3tools/contracts";
+import { type ReactNode, useEffect } from "react";
+
+import { readNativeApi } from "./nativeApi";
+import {
+ serverConfigAtom,
+ serverConfigUpdatedAtom,
+ type ServerConfigUpdatedNotification,
+ wsNativeApiRegistry,
+ wsWelcomeAtom,
+} from "./wsNativeApiState";
+
+const EMPTY_AVAILABLE_EDITORS: ReadonlyArray = [];
+const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
+const EMPTY_SERVER_PROVIDERS: ReadonlyArray = [];
+
+const selectAvailableEditors = (config: ServerConfig | null): ReadonlyArray =>
+ config?.availableEditors ?? EMPTY_AVAILABLE_EDITORS;
+const selectKeybindings = (config: ServerConfig | null) => config?.keybindings ?? EMPTY_KEYBINDINGS;
+const selectKeybindingsConfigPath = (config: ServerConfig | null) =>
+ config?.keybindingsConfigPath ?? null;
+const selectProviders = (config: ServerConfig | null) =>
+ config?.providers ?? EMPTY_SERVER_PROVIDERS;
+const selectSettings = (config: ServerConfig | null): ServerSettings =>
+ config?.settings ?? DEFAULT_SERVER_SETTINGS;
+
+function useLatestAtomSubscription