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, "favicon", "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, "brand", "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, "brand-html-order", "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, "brand-obj-order", "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"), "favicon", "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("favicon"); - }); - }); - - 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, "brand", "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("brand"); - }); - }); - - 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, "brand-html-order", "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("brand-html-order"); - }); - }); - - 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, "brand-obj-order", "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("brand-obj-order"); - }); - }); - - 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"), + "router-project-favicon", + ); + + 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, "router-project-favicon"); + }).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
( + atom: import("effect/unstable/reactivity/Atom").Atom, + listener: (value: NonNullable) => void, +) { + useAtomSubscribe( + atom, + (value) => { + if (value === null) { + return; + } + listener(value as NonNullable); + }, + { immediate: true }, + ); +} + +function WsNativeApiAtomsBootstrap() { + const serverConfig = useServerConfig(); + + useEffect(() => { + if (serverConfig !== null) { + return; + } + + const api = readNativeApi(); + if (!api) { + return; + } + + void api.server.getConfig().catch(() => undefined); + }, [serverConfig]); + + return null; +} + +export function WsNativeApiAtomsProvider({ children }: { readonly children: ReactNode }) { + return ( + + + {children} + + ); +} + +export function useServerConfig(): ServerConfig | null { + return useAtomValue(serverConfigAtom); +} + +export function useServerSettings(): ServerSettings { + return useAtomValue(serverConfigAtom, selectSettings); +} + +export function useServerProviders(): ReadonlyArray { + return useAtomValue(serverConfigAtom, selectProviders); +} + +export function useServerKeybindings(): ResolvedKeybindingsConfig { + return useAtomValue(serverConfigAtom, selectKeybindings); +} + +export function useServerAvailableEditors(): ReadonlyArray { + return useAtomValue(serverConfigAtom, selectAvailableEditors); +} + +export function useServerKeybindingsConfigPath(): string | null { + return useAtomValue(serverConfigAtom, selectKeybindingsConfigPath); +} + +export function useServerWelcomeSubscription(listener: (payload: WsWelcomePayload) => void): void { + useLatestAtomSubscription(wsWelcomeAtom, listener); +} + +export function useServerConfigUpdatedSubscription( + listener: (notification: ServerConfigUpdatedNotification) => void, +): void { + useLatestAtomSubscription(serverConfigUpdatedAtom, listener); +} diff --git a/apps/web/src/wsNativeApiState.ts b/apps/web/src/wsNativeApiState.ts new file mode 100644 index 0000000000..f3b5cddfa3 --- /dev/null +++ b/apps/web/src/wsNativeApiState.ts @@ -0,0 +1,191 @@ +import { + type GitActionProgressEvent, + type ServerConfig, + type ServerConfigStreamEvent, + type ServerConfigUpdatedPayload, + type ServerProviderUpdatedPayload, + type ServerSettings, + type WsWelcomePayload, +} from "@t3tools/contracts"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; + +export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"]; + +export interface ServerConfigUpdatedNotification { + readonly payload: ServerConfigUpdatedPayload; + readonly source: ServerConfigUpdateSource; +} + +export interface GitActionProgressNotification { + readonly event: GitActionProgressEvent; +} + +function makeStateAtom(label: string, initialValue: A) { + return Atom.make(initialValue).pipe(Atom.keepAlive, Atom.withLabel(label)); +} + +function toServerConfigUpdatedPayload(config: ServerConfig): ServerConfigUpdatedPayload { + return { + issues: config.issues, + providers: config.providers, + settings: config.settings, + }; +} + +export let wsNativeApiRegistry = AtomRegistry.make(); + +export const wsWelcomeAtom = makeStateAtom("ws-server-welcome", null); +export const serverConfigAtom = makeStateAtom("ws-server-config", null); +export const serverConfigUpdatedAtom = makeStateAtom( + "ws-server-config-updated", + null, +); +export const providersUpdatedAtom = makeStateAtom( + "ws-server-providers-updated", + null, +); +export const gitActionProgressAtom = makeStateAtom( + "ws-git-action-progress", + null, +); + +export function getServerConfig(): ServerConfig | null { + return wsNativeApiRegistry.get(serverConfigAtom); +} + +export function setServerConfigSnapshot(config: ServerConfig): void { + resolveServerConfig(config); + emitProvidersUpdated({ providers: config.providers }); + emitServerConfigUpdated(toServerConfigUpdatedPayload(config), "snapshot"); +} + +export function applyServerConfigEvent(event: ServerConfigStreamEvent): void { + switch (event.type) { + case "snapshot": { + setServerConfigSnapshot(event.config); + return; + } + case "keybindingsUpdated": { + const latestServerConfig = getServerConfig(); + if (!latestServerConfig) { + return; + } + const nextConfig = { + ...latestServerConfig, + issues: event.payload.issues, + } satisfies ServerConfig; + resolveServerConfig(nextConfig); + emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), event.type); + return; + } + case "providerStatuses": { + applyProvidersUpdated(event.payload); + return; + } + case "settingsUpdated": { + applySettingsUpdated(event.payload.settings); + return; + } + } +} + +export function applyProvidersUpdated(payload: ServerProviderUpdatedPayload): void { + const latestServerConfig = getServerConfig(); + emitProvidersUpdated(payload); + + if (!latestServerConfig) { + return; + } + + const nextConfig = { + ...latestServerConfig, + providers: payload.providers, + } satisfies ServerConfig; + resolveServerConfig(nextConfig); + emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), "providerStatuses"); +} + +export function applySettingsUpdated(settings: ServerSettings): void { + const latestServerConfig = getServerConfig(); + if (!latestServerConfig) { + return; + } + + const nextConfig = { + ...latestServerConfig, + settings, + } satisfies ServerConfig; + resolveServerConfig(nextConfig); + emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), "settingsUpdated"); +} + +export function emitWelcome(payload: WsWelcomePayload): void { + wsNativeApiRegistry.set(wsWelcomeAtom, payload); +} + +export function emitGitActionProgress(event: GitActionProgressEvent): void { + wsNativeApiRegistry.set(gitActionProgressAtom, { event }); +} + +export function onWelcome(listener: (payload: WsWelcomePayload) => void): () => void { + return subscribeLatest(wsWelcomeAtom, listener); +} + +export function onServerConfigUpdated( + listener: (payload: ServerConfigUpdatedPayload, source: ServerConfigUpdateSource) => void, +): () => void { + return subscribeLatest(serverConfigUpdatedAtom, (notification) => { + listener(notification.payload, notification.source); + }); +} + +export function onProvidersUpdated( + listener: (payload: ServerProviderUpdatedPayload) => void, +): () => void { + return subscribeLatest(providersUpdatedAtom, listener); +} + +export function onGitActionProgress(listener: (event: GitActionProgressEvent) => void): () => void { + return wsNativeApiRegistry.subscribe(gitActionProgressAtom, (notification) => { + if (!notification) { + return; + } + listener(notification.event); + }); +} + +export function resetWsNativeApiStateForTests() { + wsNativeApiRegistry.dispose(); + wsNativeApiRegistry = AtomRegistry.make(); +} + +function resolveServerConfig(config: ServerConfig): void { + wsNativeApiRegistry.set(serverConfigAtom, config); +} + +function emitProvidersUpdated(payload: ServerProviderUpdatedPayload): void { + wsNativeApiRegistry.set(providersUpdatedAtom, payload); +} + +function emitServerConfigUpdated( + payload: ServerConfigUpdatedPayload, + source: ServerConfigUpdateSource, +): void { + wsNativeApiRegistry.set(serverConfigUpdatedAtom, { payload, source }); +} + +function subscribeLatest( + atom: Atom.Atom, + listener: (value: NonNullable) => void, +): () => void { + return wsNativeApiRegistry.subscribe( + atom, + (value) => { + if (value === null) { + return; + } + listener(value as NonNullable); + }, + { immediate: true }, + ); +} diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts new file mode 100644 index 0000000000..3befbca02a --- /dev/null +++ b/apps/web/src/wsRpcClient.ts @@ -0,0 +1,179 @@ +import { + type GitActionProgressEvent, + type NativeApi, + ORCHESTRATION_WS_METHODS, + type ServerSettingsPatch, + WS_METHODS, +} from "@t3tools/contracts"; +import { Effect, Stream } from "effect"; + +import { type WsRpcProtocolClient, WsTransport } from "./wsTransport"; + +type RpcTag = keyof WsRpcProtocolClient & string; +type RpcMethod = WsRpcProtocolClient[TTag]; +type RpcInput = Parameters>[0]; + +type RpcUnaryMethod = + RpcMethod extends (input: any, options?: any) => Effect.Effect + ? (input: RpcInput) => Promise + : never; + +type RpcUnaryNoArgMethod = + RpcMethod extends (input: any, options?: any) => Effect.Effect + ? () => Promise + : never; + +type RpcStreamMethod = + RpcMethod extends (input: any, options?: any) => Stream.Stream + ? (listener: (event: TEvent) => void) => () => void + : never; + +export interface WsRpcClient { + readonly dispose: () => void; + readonly terminal: { + readonly open: RpcUnaryMethod; + readonly write: RpcUnaryMethod; + readonly resize: RpcUnaryMethod; + readonly clear: RpcUnaryMethod; + readonly restart: RpcUnaryMethod; + readonly close: RpcUnaryMethod; + readonly onEvent: RpcStreamMethod; + }; + readonly projects: { + readonly searchEntries: RpcUnaryMethod; + readonly writeFile: RpcUnaryMethod; + }; + readonly shell: { + readonly openInEditor: (input: { + readonly cwd: Parameters[0]; + readonly editor: Parameters[1]; + }) => ReturnType; + }; + readonly git: { + readonly pull: RpcUnaryMethod; + readonly status: RpcUnaryMethod; + readonly runStackedAction: RpcUnaryMethod; + readonly listBranches: RpcUnaryMethod; + readonly createWorktree: RpcUnaryMethod; + readonly removeWorktree: RpcUnaryMethod; + readonly createBranch: RpcUnaryMethod; + readonly checkout: RpcUnaryMethod; + readonly init: RpcUnaryMethod; + readonly resolvePullRequest: RpcUnaryMethod; + readonly preparePullRequestThread: RpcUnaryMethod< + typeof WS_METHODS.gitPreparePullRequestThread + >; + readonly onActionProgress: RpcStreamMethod; + readonly subscribeActionProgress: ( + listener: (event: GitActionProgressEvent) => void, + ) => () => void; + }; + readonly server: { + readonly getConfig: RpcUnaryNoArgMethod; + readonly refreshProviders: RpcUnaryNoArgMethod; + readonly upsertKeybinding: RpcUnaryMethod; + readonly getSettings: RpcUnaryNoArgMethod; + readonly updateSettings: ( + patch: ServerSettingsPatch, + ) => ReturnType>; + readonly subscribeConfig: RpcStreamMethod; + readonly subscribeLifecycle: RpcStreamMethod; + }; + readonly orchestration: { + readonly getSnapshot: RpcUnaryNoArgMethod; + readonly dispatchCommand: RpcUnaryMethod; + readonly getTurnDiff: RpcUnaryMethod; + readonly getFullThreadDiff: RpcUnaryMethod; + readonly replayEvents: RpcUnaryMethod; + readonly onDomainEvent: RpcStreamMethod; + }; +} + +export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { + return { + dispose: () => transport.dispose(), + terminal: { + open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)), + write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)), + resize: (input) => transport.request((client) => client[WS_METHODS.terminalResize](input)), + clear: (input) => transport.request((client) => client[WS_METHODS.terminalClear](input)), + restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)), + close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)), + onEvent: (listener) => + transport.subscribe((client) => client[WS_METHODS.subscribeTerminalEvents]({}), listener), + }, + projects: { + searchEntries: (input) => + transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)), + writeFile: (input) => + transport.request((client) => client[WS_METHODS.projectsWriteFile](input)), + }, + shell: { + openInEditor: (input) => + transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), + }, + git: { + pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)), + status: (input) => transport.request((client) => client[WS_METHODS.gitStatus](input)), + runStackedAction: (input) => + transport.request((client) => client[WS_METHODS.gitRunStackedAction](input)), + listBranches: (input) => + transport.request((client) => client[WS_METHODS.gitListBranches](input)), + createWorktree: (input) => + transport.request((client) => client[WS_METHODS.gitCreateWorktree](input)), + removeWorktree: (input) => + transport.request((client) => client[WS_METHODS.gitRemoveWorktree](input)), + createBranch: (input) => + transport.request((client) => client[WS_METHODS.gitCreateBranch](input)), + checkout: (input) => transport.request((client) => client[WS_METHODS.gitCheckout](input)), + init: (input) => transport.request((client) => client[WS_METHODS.gitInit](input)), + resolvePullRequest: (input) => + transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)), + preparePullRequestThread: (input) => + transport.request((client) => client[WS_METHODS.gitPreparePullRequestThread](input)), + onActionProgress: (listener) => + transport.subscribe( + (client) => client[WS_METHODS.subscribeGitActionProgress]({}), + listener, + ), + subscribeActionProgress: (listener) => + transport.subscribe( + (client) => client[WS_METHODS.subscribeGitActionProgress]({}), + listener, + ), + }, + server: { + getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})), + refreshProviders: () => + transport.request((client) => client[WS_METHODS.serverRefreshProviders]({})), + upsertKeybinding: (input) => + transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)), + getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), + updateSettings: (patch) => + transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), + subscribeConfig: (listener) => + transport.subscribe((client) => client[WS_METHODS.subscribeServerConfig]({}), listener), + subscribeLifecycle: (listener) => + transport.subscribe((client) => client[WS_METHODS.subscribeServerLifecycle]({}), listener), + }, + orchestration: { + getSnapshot: () => + transport.request((client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})), + dispatchCommand: (input) => + transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), + getTurnDiff: (input) => + transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)), + getFullThreadDiff: (input) => + transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)), + replayEvents: (input) => + transport + .request((client) => client[ORCHESTRATION_WS_METHODS.replayEvents](input)) + .then((events) => [...events]), + onDomainEvent: (listener) => + transport.subscribe( + (client) => client[WS_METHODS.subscribeOrchestrationDomainEvents]({}), + listener, + ), + }, + }; +} diff --git a/apps/web/src/wsTransport.test.ts b/apps/web/src/wsTransport.test.ts index c9b963286b..993be6d7c0 100644 --- a/apps/web/src/wsTransport.test.ts +++ b/apps/web/src/wsTransport.test.ts @@ -1,10 +1,12 @@ -import { WS_CHANNELS } from "@t3tools/contracts"; +import { WS_METHODS } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { WsTransport } from "./wsTransport"; +import { Option } from "effect"; type WsEventType = "open" | "message" | "close" | "error"; -type WsListener = (event?: { data?: unknown }) => void; +type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; +type WsListener = (event?: WsEvent) => void; const sockets: MockWebSocket[] = []; @@ -16,9 +18,11 @@ class MockWebSocket { readyState = MockWebSocket.CONNECTING; readonly sent: string[] = []; + readonly url: string; private readonly listeners = new Map>(); - constructor(_url: string) { + constructor(url: string) { + this.url = url; sockets.push(this); } @@ -28,25 +32,29 @@ class MockWebSocket { this.listeners.set(type, listeners); } + removeEventListener(type: WsEventType, listener: WsListener) { + this.listeners.get(type)?.delete(listener); + } + send(data: string) { this.sent.push(data); } - close() { + close(code = 1000, reason = "") { this.readyState = MockWebSocket.CLOSED; - this.emit("close"); + this.emit("close", { code, reason, type: "close" }); } open() { this.readyState = MockWebSocket.OPEN; - this.emit("open"); + this.emit("open", { type: "open" }); } serverMessage(data: unknown) { - this.emit("message", { data }); + this.emit("message", { data, type: "message" }); } - private emit(type: WsEventType, event?: { data?: unknown }) { + private emit(type: WsEventType, event?: WsEvent) { const listeners = this.listeners.get(type); if (!listeners) return; for (const listener of listeners) { @@ -65,13 +73,28 @@ function getSocket(): MockWebSocket { return socket; } +async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise { + const startedAt = Date.now(); + for (;;) { + try { + assertion(); + return; + } catch (error) { + if (Date.now() - startedAt >= timeoutMs) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } +} + beforeEach(() => { sockets.length = 0; Object.defineProperty(globalThis, "window", { configurable: true, value: { - location: { hostname: "localhost", port: "3020" }, + location: { hostname: "localhost", port: "3020", protocol: "ws:" }, desktopBridge: undefined, }, }); @@ -85,172 +108,262 @@ afterEach(() => { }); describe("WsTransport", () => { - it("routes valid push envelopes to channel listeners", () => { + it("normalizes root websocket urls to /ws and preserves query params", async () => { + const transport = new WsTransport("ws://localhost:3020/?token=secret-token"); + + await waitFor(() => { + expect(sockets).toHaveLength(1); + }); + + expect(getSocket().url).toBe("ws://localhost:3020/ws?token=secret-token"); + transport.dispose(); + }); + + it("sends unary RPC requests and resolves successful exits", async () => { const transport = new WsTransport("ws://localhost:3020"); + + const requestPromise = transport.request((client) => + client[WS_METHODS.serverUpsertKeybinding]({ + command: "terminal.toggle", + key: "ctrl+k", + }), + ); + + await waitFor(() => { + expect(sockets).toHaveLength(1); + }); + const socket = getSocket(); socket.open(); - const listener = vi.fn(); - transport.subscribe(WS_CHANNELS.serverConfigUpdated, listener); + await waitFor(() => { + expect(socket.sent).toHaveLength(1); + }); + + const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { + _tag: string; + id: string; + payload: unknown; + tag: string; + }; + expect(requestMessage).toMatchObject({ + _tag: "Request", + tag: WS_METHODS.serverUpsertKeybinding, + payload: { + command: "terminal.toggle", + key: "ctrl+k", + }, + }); socket.serverMessage( JSON.stringify({ - type: "push", - sequence: 1, - channel: WS_CHANNELS.serverConfigUpdated, - data: { issues: [] }, + _tag: "Exit", + requestId: requestMessage.id, + exit: { + _tag: "Success", + value: { + keybindings: [], + issues: [], + }, + }, }), ); - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith({ - type: "push", - sequence: 1, - channel: WS_CHANNELS.serverConfigUpdated, - data: { issues: [] }, + await expect(requestPromise).resolves.toEqual({ + keybindings: [], + issues: [], }); transport.dispose(); }); - it("resolves pending requests for valid response envelopes", async () => { + it("delivers stream chunks to subscribers", async () => { const transport = new WsTransport("ws://localhost:3020"); + const listener = vi.fn(); + + const unsubscribe = transport.subscribe( + (client) => client[WS_METHODS.subscribeServerLifecycle]({}), + listener, + ); + await waitFor(() => { + expect(sockets).toHaveLength(1); + }); + const socket = getSocket(); socket.open(); - const requestPromise = transport.request("projects.list"); - const sent = socket.sent.at(-1); - if (!sent) { - throw new Error("Expected request envelope to be sent"); - } + await waitFor(() => { + expect(socket.sent).toHaveLength(1); + }); + + const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string }; + expect(requestMessage.tag).toBe(WS_METHODS.subscribeServerLifecycle); + + const welcomeEvent = { + version: 1, + sequence: 1, + type: "welcome", + payload: { + cwd: "/tmp/workspace", + projectName: "workspace", + }, + }; - const requestEnvelope = JSON.parse(sent) as { id: string }; socket.serverMessage( JSON.stringify({ - id: requestEnvelope.id, - result: { projects: [] }, + _tag: "Chunk", + requestId: requestMessage.id, + values: [welcomeEvent], }), ); - await expect(requestPromise).resolves.toEqual({ projects: [] }); + await waitFor(() => { + expect(listener).toHaveBeenCalledWith(welcomeEvent); + }); + unsubscribe(); transport.dispose(); }); - it("drops malformed envelopes without crashing transport", () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + it("re-subscribes stream listeners after the stream exits", async () => { const transport = new WsTransport("ws://localhost:3020"); + const listener = vi.fn(); + + const unsubscribe = transport.subscribe( + (client) => client[WS_METHODS.subscribeServerLifecycle]({}), + listener, + ); + await waitFor(() => { + expect(sockets).toHaveLength(1); + }); + const socket = getSocket(); socket.open(); - const listener = vi.fn(); - transport.subscribe(WS_CHANNELS.serverConfigUpdated, listener); + await waitFor(() => { + expect(socket.sent).toHaveLength(1); + }); - socket.serverMessage("{ invalid-json"); + const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; socket.serverMessage( JSON.stringify({ - type: "push", - sequence: 2, - channel: 42, - data: { bad: true }, + _tag: "Chunk", + requestId: firstRequest.id, + values: [ + { + version: 1, + sequence: 1, + type: "welcome", + payload: { + cwd: "/tmp/one", + projectName: "one", + }, + }, + ], }), ); socket.serverMessage( JSON.stringify({ - type: "push", - sequence: 3, - channel: WS_CHANNELS.serverConfigUpdated, - data: { issues: [] }, + _tag: "Exit", + requestId: firstRequest.id, + exit: { + _tag: "Success", + value: null, + }, }), ); - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith({ - type: "push", - sequence: 3, - channel: WS_CHANNELS.serverConfigUpdated, - data: { issues: [] }, + await waitFor(() => { + const nextRequest = socket.sent + .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) + .find((message) => message._tag === "Request" && message.id !== firstRequest.id); + expect(nextRequest).toBeDefined(); }); - expect(warnSpy).toHaveBeenCalledTimes(2); - expect(warnSpy).toHaveBeenNthCalledWith( - 1, - "Dropped inbound WebSocket envelope", - expect.stringMatching(/^SyntaxError:/), - ); - expect(warnSpy).toHaveBeenNthCalledWith( - 2, - "Dropped inbound WebSocket envelope", - expect.stringContaining('Expected "server.configUpdated"'), - ); - - transport.dispose(); - }); - - it("queues requests until the websocket opens", async () => { - const transport = new WsTransport("ws://localhost:3020"); - const socket = getSocket(); - - const requestPromise = transport.request("projects.list"); - expect(socket.sent).toHaveLength(0); - socket.open(); - expect(socket.sent).toHaveLength(1); - const requestEnvelope = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; + const secondRequest = socket.sent + .map((message) => JSON.parse(message) as { _tag?: string; id?: string; tag?: string }) + .find( + (message): message is { _tag: "Request"; id: string; tag: string } => + message._tag === "Request" && message.id !== firstRequest.id, + ); + if (!secondRequest) { + throw new Error("Expected a resubscribe request"); + } + expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle); + expect(secondRequest.id).not.toBe(firstRequest.id); + + const secondEvent = { + version: 1, + sequence: 2, + type: "welcome", + payload: { + cwd: "/tmp/two", + projectName: "two", + }, + }; socket.serverMessage( JSON.stringify({ - id: requestEnvelope.id, - result: { projects: [] }, + _tag: "Chunk", + requestId: secondRequest.id, + values: [secondEvent], }), ); - await expect(requestPromise).resolves.toEqual({ projects: [] }); + await waitFor(() => { + expect(listener).toHaveBeenLastCalledWith(secondEvent); + }); + + unsubscribe(); transport.dispose(); }); - it("does not create a timeout for requests with timeoutMs null", async () => { - const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); + it("accepts request options for long-running RPCs", async () => { const transport = new WsTransport("ws://localhost:3020"); + await waitFor(() => { + expect(sockets).toHaveLength(1); + }); const socket = getSocket(); socket.open(); const requestPromise = transport.request( - "git.runStackedAction", - { cwd: "/repo" }, - { timeoutMs: null }, + (client) => + client[WS_METHODS.gitRunStackedAction]({ + actionId: "action-1", + cwd: "/repo", + action: "commit", + }), + { timeout: Option.none() }, ); - const sent = socket.sent.at(-1); - if (!sent) { - throw new Error("Expected request envelope to be sent"); - } - const requestEnvelope = JSON.parse(sent) as { id: string }; + await waitFor(() => { + expect(socket.sent).toHaveLength(1); + }); + + const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; socket.serverMessage( JSON.stringify({ - id: requestEnvelope.id, - result: { ok: true }, + _tag: "Exit", + requestId: requestMessage.id, + exit: { + _tag: "Success", + value: { + 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" }, + }, + }, }), ); - await expect(requestPromise).resolves.toEqual({ ok: true }); - expect(timeoutSpy.mock.calls.some(([callback]) => typeof callback === "function")).toBe(false); - - transport.dispose(); - }); - - it("rejects pending requests when the websocket closes", async () => { - const transport = new WsTransport("ws://localhost:3020"); - const socket = getSocket(); - socket.open(); - - const requestPromise = transport.request( - "git.runStackedAction", - { cwd: "/repo" }, - { timeoutMs: null }, - ); - - socket.close(); - - await expect(requestPromise).rejects.toThrow("WebSocket connection closed."); + await expect(requestPromise).resolves.toEqual({ + 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" }, + }); transport.dispose(); }); }); diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index 12c9a6d958..3dc51171dd 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -1,309 +1,121 @@ -import { - type WsPush, - type WsPushChannel, - type WsPushMessage, - WebSocketResponse, - type WsResponse as WsResponseMessage, - WsResponse as WsResponseSchema, -} from "@t3tools/contracts"; -import { decodeUnknownJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; -import { Result, Schema } from "effect"; +import { Duration, Effect, Exit, Layer, ManagedRuntime, Option, Scope, Stream } from "effect"; +import { WsRpcGroup } from "@t3tools/contracts"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import * as Socket from "effect/unstable/socket/Socket"; +import { resolveServerUrl } from "./lib/utils"; -type PushListener = (message: WsPushMessage) => void; +const makeWsRpcClient = RpcClient.make(WsRpcGroup); -interface PendingRequest { - resolve: (result: unknown) => void; - reject: (error: Error) => void; - timeout: ReturnType | null; -} +type RpcClientFactory = typeof makeWsRpcClient; +export type WsRpcProtocolClient = + RpcClientFactory extends Effect.Effect ? Client : never; interface SubscribeOptions { - readonly replayLatest?: boolean; + readonly retryDelay?: Duration.Input; } interface RequestOptions { - readonly timeoutMs?: number | null; + readonly timeout?: Option.Option; } -type TransportState = "connecting" | "open" | "reconnecting" | "closed" | "disposed"; - -const REQUEST_TIMEOUT_MS = 60_000; -const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000]; -const decodeWsResponse = decodeUnknownJsonResult(WsResponseSchema); -const isWebSocketResponseEnvelope = Schema.is(WebSocketResponse); - -const isWsPushMessage = (value: WsResponseMessage): value is WsPush => - "type" in value && value.type === "push"; - -interface WsRequestEnvelope { - id: string; - body: { - _tag: string; - [key: string]: unknown; - }; -} +const DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS = Duration.millis(250); -function asError(value: unknown, fallback: string): Error { - if (value instanceof Error) { - return value; +function formatErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; } - return new Error(fallback); + return String(error); } export class WsTransport { - private ws: WebSocket | null = null; - private nextId = 1; - private readonly pending = new Map(); - private readonly listeners = new Map void>>(); - private readonly latestPushByChannel = new Map(); - private readonly outboundQueue: string[] = []; - private reconnectAttempt = 0; - private reconnectTimer: ReturnType | null = null; + private readonly runtime: ManagedRuntime.ManagedRuntime; + private readonly clientScope: Scope.Closeable; + private readonly clientPromise: Promise; private disposed = false; - private state: TransportState = "connecting"; - private readonly url: string; constructor(url?: string) { - const bridgeUrl = window.desktopBridge?.getWsUrl(); - const envUrl = import.meta.env.VITE_WS_URL as string | undefined; - this.url = - url ?? - (bridgeUrl && bridgeUrl.length > 0 - ? bridgeUrl - : envUrl && envUrl.length > 0 - ? envUrl - : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`); - this.connect(); + const resolvedUrl = resolveServerUrl({ + url, + protocol: "ws", + pathname: "/ws", + }); + const SocketLayer = Socket.layerWebSocket(resolvedUrl).pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + ); + const ProtocolLayer = RpcClient.layerProtocolSocket({ retryTransientErrors: true }).pipe( + Layer.provide(Layer.mergeAll(SocketLayer, RpcSerialization.layerJson)), + ); + + this.runtime = ManagedRuntime.make(ProtocolLayer); + this.clientScope = this.runtime.runSync(Scope.make()); + this.clientPromise = this.runtime.runPromise(Scope.provide(this.clientScope)(makeWsRpcClient)); } - async request( - method: string, - params?: unknown, - options?: RequestOptions, - ): Promise { - if (typeof method !== "string" || method.length === 0) { - throw new Error("Request method is required"); + async request( + execute: (client: WsRpcProtocolClient) => Effect.Effect, + _options?: RequestOptions, + ): Promise { + if (this.disposed) { + throw new Error("Transport disposed"); } - const id = String(this.nextId++); - const body = params != null ? { ...params, _tag: method } : { _tag: method }; - const message: WsRequestEnvelope = { id, body }; - const encoded = JSON.stringify(message); - - return new Promise((resolve, reject) => { - const timeoutMs = options?.timeoutMs === undefined ? REQUEST_TIMEOUT_MS : options.timeoutMs; - const timeout = - timeoutMs === null - ? null - : setTimeout(() => { - this.pending.delete(id); - reject(new Error(`Request timed out: ${method}`)); - }, timeoutMs); - - this.pending.set(id, { - resolve: resolve as (result: unknown) => void, - reject, - timeout, - }); - - this.send(encoded); - }); + const client = await this.clientPromise; + return await this.runtime.runPromise(Effect.suspend(() => execute(client))); } - subscribe( - channel: C, - listener: PushListener, + subscribe( + connect: (client: WsRpcProtocolClient) => Stream.Stream, + listener: (value: TValue) => void, options?: SubscribeOptions, ): () => void { - let channelListeners = this.listeners.get(channel); - if (!channelListeners) { - channelListeners = new Set<(message: WsPush) => void>(); - this.listeners.set(channel, channelListeners); - } - - const wrappedListener = (message: WsPush) => { - listener(message as WsPushMessage); - }; - channelListeners.add(wrappedListener); - - if (options?.replayLatest) { - const latest = this.latestPushByChannel.get(channel); - if (latest) { - wrappedListener(latest); - } - } + if (this.disposed) { + return () => undefined; + } + + let active = true; + const retryDelayMs = options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS; + const cancel = this.runtime.runCallback( + Effect.promise(() => this.clientPromise).pipe( + Effect.flatMap((client) => + Stream.runForEach(connect(client), (value) => + Effect.sync(() => { + if (!active) { + return; + } + try { + listener(value); + } catch { + // Swallow listener errors so the stream stays live. + } + }), + ), + ), + Effect.catch((error) => { + if (!active || this.disposed) { + return Effect.interrupt; + } + return Effect.sync(() => { + console.warn("WebSocket RPC subscription disconnected", { + error: formatErrorMessage(error), + }); + }).pipe(Effect.andThen(Effect.sleep(retryDelayMs))); + }), + Effect.forever, + ), + ); return () => { - channelListeners?.delete(wrappedListener); - if (channelListeners?.size === 0) { - this.listeners.delete(channel); - } + active = false; + cancel(); }; } - getLatestPush(channel: C): WsPushMessage | null { - const latest = this.latestPushByChannel.get(channel); - return latest ? (latest as WsPushMessage) : null; - } - - getState(): TransportState { - return this.state; - } - dispose() { - this.disposed = true; - this.state = "disposed"; - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - for (const pending of this.pending.values()) { - if (pending.timeout !== null) { - clearTimeout(pending.timeout); - } - pending.reject(new Error("Transport disposed")); - } - this.pending.clear(); - this.outboundQueue.length = 0; - this.ws?.close(); - this.ws = null; - } - - private connect() { if (this.disposed) { return; } - - this.state = this.reconnectAttempt > 0 ? "reconnecting" : "connecting"; - const ws = new WebSocket(this.url); - - ws.addEventListener("open", () => { - this.ws = ws; - this.state = "open"; - this.reconnectAttempt = 0; - this.flushQueue(); - }); - - ws.addEventListener("message", (event) => { - this.handleMessage(event.data); - }); - - ws.addEventListener("close", () => { - if (this.ws === ws) { - this.ws = null; - this.outboundQueue.length = 0; - for (const [id, pending] of this.pending.entries()) { - if (pending.timeout !== null) { - clearTimeout(pending.timeout); - } - this.pending.delete(id); - pending.reject(new Error("WebSocket connection closed.")); - } - } - if (this.disposed) { - this.state = "disposed"; - return; - } - this.state = "closed"; - this.scheduleReconnect(); - }); - - ws.addEventListener("error", (event) => { - // Log WebSocket errors for debugging (close event will follow) - console.warn("WebSocket connection error", { type: event.type, url: this.url }); - }); - } - - private handleMessage(raw: unknown) { - const result = decodeWsResponse(raw); - if (Result.isFailure(result)) { - console.warn("Dropped inbound WebSocket envelope", formatSchemaError(result.failure)); - return; - } - - const message = result.success; - if (isWsPushMessage(message)) { - this.latestPushByChannel.set(message.channel, message); - const channelListeners = this.listeners.get(message.channel); - if (channelListeners) { - for (const listener of channelListeners) { - try { - listener(message); - } catch { - // Swallow listener errors - } - } - } - return; - } - - if (!isWebSocketResponseEnvelope(message)) { - return; - } - - const pending = this.pending.get(message.id); - if (!pending) { - return; - } - - if (pending.timeout !== null) { - clearTimeout(pending.timeout); - } - this.pending.delete(message.id); - - if (message.error) { - pending.reject(new Error(message.error.message)); - return; - } - - pending.resolve(message.result); - } - - private send(encodedMessage: string) { - if (this.disposed) { - return; - } - - this.outboundQueue.push(encodedMessage); - try { - this.flushQueue(); - } catch { - // Swallow: flushQueue has queued the message for retry on reconnect - } - } - - private flushQueue() { - if (this.ws?.readyState !== WebSocket.OPEN) { - return; - } - - while (this.outboundQueue.length > 0) { - const message = this.outboundQueue.shift(); - if (!message) { - continue; - } - try { - this.ws.send(message); - } catch (error) { - this.outboundQueue.unshift(message); - throw asError(error, "Failed to send WebSocket request."); - } - } - } - - private scheduleReconnect() { - if (this.disposed || this.reconnectTimer !== null) { - return; - } - - const delay = - RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] ?? - RECONNECT_DELAYS_MS[0]!; - - this.reconnectAttempt += 1; - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null; - this.connect(); - }, delay); + this.disposed = true; + void Effect.runPromise(Scope.close(this.clientScope, Exit.void)); + void this.runtime.dispose(); } } diff --git a/bun.lock b/bun.lock index ee56cd52de..af243cf4eb 100644 --- a/bun.lock +++ b/bun.lock @@ -44,17 +44,17 @@ "name": "t3", "version": "0.0.15", "bin": { - "t3": "./dist/index.mjs", + "t3": "./dist/bin.mjs", }, "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", }, "devDependencies": { "@effect/language-service": "catalog:", @@ -64,7 +64,6 @@ "@t3tools/web": "workspace:*", "@types/bun": "catalog:", "@types/node": "catalog:", - "@types/ws": "^8.5.13", "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:", @@ -79,6 +78,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", @@ -175,7 +175,9 @@ "vite": "^8.0.0", }, "catalog": { + "@effect/atom-react": "4.0.0-beta.43", "@effect/language-service": "0.84.2", + "@effect/platform-bun": "4.0.0-beta.43", "@effect/platform-node": "4.0.0-beta.43", "@effect/sql-sqlite-bun": "4.0.0-beta.43", "@effect/vitest": "4.0.0-beta.43", @@ -267,8 +269,12 @@ "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@effect/atom-react": ["@effect/atom-react@4.0.0-beta.43", "", { "peerDependencies": { "effect": "^4.0.0-beta.43", "react": "^19.2.4", "scheduler": "*" } }, "sha512-xSrRbGXuo4d0g4ph66TQST1GNSjtQZrZj8V7OiAQFuzMYcZ0kwRIPUUFwOBtbWHK43/zNENdNWOnlXh/iYM1dw=="], + "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="], + "@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.43", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.43" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-nMZ9JsD6CzJNQ+5pDUFbPw7PSZdQdTQ092MbYrocVtvlf6qEFU/hji3ITvRIOX7eabyQ8AUyp55qFPQUeq+GIA=="], + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.43", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.43", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43", "ioredis": "^5.7.0" } }, "sha512-Uq6E1rjaIpjHauzjwoB2HzAg3battYt2Boy8XO50GoHiWCXKE6WapYZ0/AnaBx5v5qg2sOfqpuiLsUf9ZgxOkA=="], "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.43", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-A9q0GEb61pYcQ06Dr6gXj1nKlDI3KHsar1sk3qb1ZY+kVSR64tBAylI8zGon23KY+NPtTUj/sEIToB7jc3Qt5w=="], diff --git a/package.json b/package.json index aad78f7d4b..a26a359c03 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ ], "catalog": { "effect": "4.0.0-beta.43", + "@effect/atom-react": "4.0.0-beta.43", + "@effect/platform-bun": "4.0.0-beta.43", "@effect/platform-node": "4.0.0-beta.43", "@effect/sql-sqlite-bun": "4.0.0-beta.43", "@effect/vitest": "4.0.0-beta.43", diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 0bb8eb8f59..60ff5908c1 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -24,3 +24,8 @@ export const OpenInEditorInput = Schema.Struct({ editor: EditorId, }); export type OpenInEditorInput = typeof OpenInEditorInput.Type; + +export class OpenError extends Schema.TaggedErrorClass()("OpenError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index f8b65abf2c..65504fabc1 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -223,6 +223,60 @@ export const GitPullResult = Schema.Struct({ }); export type GitPullResult = typeof GitPullResult.Type; +// RPC / domain errors +export class GitCommandError extends Schema.TaggedErrorClass()("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}`; + } +} + +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}`; + } +} + +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}`; + } +} + +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}`; + } +} + +export const GitManagerServiceError = Schema.Union([ + GitManagerError, + GitCommandError, + GitHubCliError, + TextGenerationError, +]); +export type GitManagerServiceError = typeof GitManagerServiceError.Type; + const GitActionProgressBase = Schema.Struct({ actionId: TrimmedNonEmptyStringSchema, cwd: TrimmedNonEmptyStringSchema, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 248b3a04f9..94724ceb94 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -12,3 +12,4 @@ export * from "./git"; export * from "./orchestration"; export * from "./editor"; export * from "./project"; +export * from "./wsRpc"; diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 067cba8804..b08fff8679 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -85,24 +85,27 @@ export const KeybindingShortcut = Schema.Struct({ }); export type KeybindingShortcut = typeof KeybindingShortcut.Type; -export const KeybindingWhenNode: Schema.Schema = Schema.Union([ +const KeybindingWhenNodeRef = Schema.suspend( + (): Schema.Codec => KeybindingWhenNode, +); +export const KeybindingWhenNode = Schema.Union([ Schema.Struct({ type: Schema.Literal("identifier"), name: Schema.NonEmptyString, }), Schema.Struct({ type: Schema.Literal("not"), - node: Schema.suspend((): Schema.Schema => KeybindingWhenNode), + node: KeybindingWhenNodeRef, }), Schema.Struct({ type: Schema.Literal("and"), - left: Schema.suspend((): Schema.Schema => KeybindingWhenNode), - right: Schema.suspend((): Schema.Schema => KeybindingWhenNode), + left: KeybindingWhenNodeRef, + right: KeybindingWhenNodeRef, }), Schema.Struct({ type: Schema.Literal("or"), - left: Schema.suspend((): Schema.Schema => KeybindingWhenNode), - right: Schema.suspend((): Schema.Schema => KeybindingWhenNode), + left: KeybindingWhenNodeRef, + right: KeybindingWhenNodeRef, }), ]); export type KeybindingWhenNode = @@ -122,3 +125,16 @@ export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).ch Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), ); export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type; + +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}`; + } +} diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index a780a55c78..dd79ca2355 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1037,3 +1037,43 @@ export const OrchestrationRpcSchemas = { output: OrchestrationReplayEventsResult, }, } as const; + +export class OrchestrationGetSnapshotError extends Schema.TaggedErrorClass()( + "OrchestrationGetSnapshotError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} + +export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass()( + "OrchestrationDispatchCommandError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} + +export class OrchestrationGetTurnDiffError extends Schema.TaggedErrorClass()( + "OrchestrationGetTurnDiffError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} + +export class OrchestrationGetFullThreadDiffError extends Schema.TaggedErrorClass()( + "OrchestrationGetFullThreadDiffError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} + +export class OrchestrationReplayEventsError extends Schema.TaggedErrorClass()( + "OrchestrationReplayEventsError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 0903253301..2851120d1d 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -26,6 +26,14 @@ export const ProjectSearchEntriesResult = Schema.Struct({ }); export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; +export class ProjectSearchEntriesError extends Schema.TaggedErrorClass()( + "ProjectSearchEntriesError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} + export const ProjectWriteFileInput = Schema.Struct({ cwd: TrimmedNonEmptyString, relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH)), @@ -37,3 +45,11 @@ export const ProjectWriteFileResult = Schema.Struct({ relativePath: TrimmedNonEmptyString, }); export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type; + +export class ProjectWriteFileError extends Schema.TaggedErrorClass()( + "ProjectWriteFileError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 08910c3ecc..b35a2b89c0 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,5 +1,11 @@ import { Schema } from "effect"; -import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; +import { + IsoDateTime, + NonNegativeInt, + ProjectId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas"; import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; import { EditorId } from "./editor"; import { ModelCapabilities } from "./model"; @@ -63,7 +69,8 @@ export const ServerProvider = Schema.Struct({ }); export type ServerProvider = typeof ServerProvider.Type; -const ServerProviders = Schema.Array(ServerProvider); +export const ServerProviders = Schema.Array(ServerProvider); +export type ServerProviders = typeof ServerProviders.Type; export const ServerConfig = Schema.Struct({ cwd: TrimmedNonEmptyString, @@ -87,10 +94,98 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T export const ServerConfigUpdatedPayload = Schema.Struct({ issues: ServerConfigIssues, + providers: ServerProviders, settings: Schema.optional(ServerSettings), }); export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type; +export const ServerConfigKeybindingsUpdatedPayload = Schema.Struct({ + issues: ServerConfigIssues, +}); +export type ServerConfigKeybindingsUpdatedPayload = + typeof ServerConfigKeybindingsUpdatedPayload.Type; + +export const ServerConfigProviderStatusesPayload = Schema.Struct({ + providers: ServerProviders, +}); +export type ServerConfigProviderStatusesPayload = typeof ServerConfigProviderStatusesPayload.Type; + +export const ServerConfigSettingsUpdatedPayload = Schema.Struct({ + settings: ServerSettings, +}); +export type ServerConfigSettingsUpdatedPayload = typeof ServerConfigSettingsUpdatedPayload.Type; + +export const ServerConfigStreamSnapshotEvent = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("snapshot"), + config: ServerConfig, +}); +export type ServerConfigStreamSnapshotEvent = typeof ServerConfigStreamSnapshotEvent.Type; + +export const ServerConfigStreamKeybindingsUpdatedEvent = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("keybindingsUpdated"), + payload: ServerConfigKeybindingsUpdatedPayload, +}); +export type ServerConfigStreamKeybindingsUpdatedEvent = + typeof ServerConfigStreamKeybindingsUpdatedEvent.Type; + +export const ServerConfigStreamProviderStatusesEvent = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("providerStatuses"), + payload: ServerConfigProviderStatusesPayload, +}); +export type ServerConfigStreamProviderStatusesEvent = + typeof ServerConfigStreamProviderStatusesEvent.Type; + +export const ServerConfigStreamSettingsUpdatedEvent = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("settingsUpdated"), + payload: ServerConfigSettingsUpdatedPayload, +}); +export type ServerConfigStreamSettingsUpdatedEvent = + typeof ServerConfigStreamSettingsUpdatedEvent.Type; + +export const ServerConfigStreamEvent = Schema.Union([ + ServerConfigStreamSnapshotEvent, + ServerConfigStreamKeybindingsUpdatedEvent, + ServerConfigStreamProviderStatusesEvent, + ServerConfigStreamSettingsUpdatedEvent, +]); +export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; + +export const ServerLifecycleReadyPayload = Schema.Struct({ + at: IsoDateTime, +}); +export type ServerLifecycleReadyPayload = typeof ServerLifecycleReadyPayload.Type; + +export const ServerLifecycleStreamWelcomeEvent = Schema.Struct({ + version: Schema.Literal(1), + sequence: NonNegativeInt, + type: Schema.Literal("welcome"), + payload: Schema.Struct({ + cwd: TrimmedNonEmptyString, + projectName: TrimmedNonEmptyString, + bootstrapProjectId: Schema.optional(ProjectId), + bootstrapThreadId: Schema.optional(ThreadId), + }), +}); +export type ServerLifecycleStreamWelcomeEvent = typeof ServerLifecycleStreamWelcomeEvent.Type; + +export const ServerLifecycleStreamReadyEvent = Schema.Struct({ + version: Schema.Literal(1), + sequence: NonNegativeInt, + type: Schema.Literal("ready"), + payload: ServerLifecycleReadyPayload, +}); +export type ServerLifecycleStreamReadyEvent = typeof ServerLifecycleStreamReadyEvent.Type; + +export const ServerLifecycleStreamEvent = Schema.Union([ + ServerLifecycleStreamWelcomeEvent, + ServerLifecycleStreamReadyEvent, +]); +export type ServerLifecycleStreamEvent = typeof ServerLifecycleStreamEvent.Type; + export const ServerProviderUpdatedPayload = Schema.Struct({ providers: ServerProviders, }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 51fe683f99..c28b566daa 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -93,6 +93,19 @@ export type ServerSettings = typeof ServerSettings.Type; export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({}); +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}`; + } +} + // ── Unified type ───────────────────────────────────────────────────── export type UnifiedSettings = ServerSettings & ClientSettings; diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index f9729da66f..4344706bc2 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -149,3 +149,74 @@ export const TerminalEvent = Schema.Union([ TerminalActivityEvent, ]); export type TerminalEvent = typeof TerminalEvent.Type; + +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 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 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 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 const TerminalError = Schema.Union([ + TerminalCwdError, + TerminalHistoryError, + TerminalSessionLookupError, + TerminalNotRunningError, +]); +export type TerminalError = typeof TerminalError.Type; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 2bcc37f7c6..7b59f8025d 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -80,6 +80,13 @@ export const WS_METHODS = { serverUpsertKeybinding: "server.upsertKeybinding", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", + + // Streaming subscriptions + subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents", + subscribeTerminalEvents: "subscribeTerminalEvents", + subscribeServerConfig: "subscribeServerConfig", + subscribeServerLifecycle: "subscribeServerLifecycle", + subscribeGitActionProgress: "subscribeGitActionProgress", } as const; // ── Push Event Channels ────────────────────────────────────────────── @@ -149,6 +156,13 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), tagRequestBody(WS_METHODS.serverGetSettings, Schema.Struct({})), tagRequestBody(WS_METHODS.serverUpdateSettings, Schema.Struct({ patch: ServerSettingsPatch })), + + // Streaming subscriptions + tagRequestBody(WS_METHODS.subscribeOrchestrationDomainEvents, Schema.Struct({})), + tagRequestBody(WS_METHODS.subscribeTerminalEvents, Schema.Struct({})), + tagRequestBody(WS_METHODS.subscribeServerConfig, Schema.Struct({})), + tagRequestBody(WS_METHODS.subscribeServerLifecycle, Schema.Struct({})), + tagRequestBody(WS_METHODS.subscribeGitActionProgress, Schema.Struct({})), ]); export const WebSocketRequest = Schema.Struct({ @@ -251,6 +265,22 @@ export const WsPushEnvelopeBase = Schema.Struct({ }); export type WsPushEnvelopeBase = typeof WsPushEnvelopeBase.Type; +export const SubscribeOrchestrationDomainEventsInput = Schema.Struct({}); +export type SubscribeOrchestrationDomainEventsInput = + typeof SubscribeOrchestrationDomainEventsInput.Type; + +export const SubscribeTerminalEventsInput = Schema.Struct({}); +export type SubscribeTerminalEventsInput = typeof SubscribeTerminalEventsInput.Type; + +export const SubscribeServerConfigInput = Schema.Struct({}); +export type SubscribeServerConfigInput = typeof SubscribeServerConfigInput.Type; + +export const SubscribeServerLifecycleInput = Schema.Struct({}); +export type SubscribeServerLifecycleInput = typeof SubscribeServerLifecycleInput.Type; + +export const SubscribeGitActionProgressInput = Schema.Struct({}); +export type SubscribeGitActionProgressInput = typeof SubscribeGitActionProgressInput.Type; + // ── Union of all server → client messages ───────────────────────────── export const WsResponse = Schema.Union([WebSocketResponse, WsPush]); diff --git a/packages/contracts/src/wsRpc.ts b/packages/contracts/src/wsRpc.ts new file mode 100644 index 0000000000..4a52cc9546 --- /dev/null +++ b/packages/contracts/src/wsRpc.ts @@ -0,0 +1,328 @@ +import { Schema } from "effect"; +import * as Rpc from "effect/unstable/rpc/Rpc"; +import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; + +import { OpenError, OpenInEditorInput } from "./editor"; +import { + GitActionProgressEvent, + GitCheckoutInput, + GitCommandError, + GitCreateBranchInput, + GitCreateWorktreeInput, + GitCreateWorktreeResult, + GitInitInput, + GitListBranchesInput, + GitListBranchesResult, + GitManagerServiceError, + GitPreparePullRequestThreadInput, + GitPreparePullRequestThreadResult, + GitPullInput, + GitPullRequestRefInput, + GitPullResult, + GitRemoveWorktreeInput, + GitResolvePullRequestResult, + GitRunStackedActionInput, + GitRunStackedActionResult, + GitStatusInput, + GitStatusResult, +} from "./git"; +import { KeybindingsConfigError } from "./keybindings"; +import { + ClientOrchestrationCommand, + OrchestrationEvent, + ORCHESTRATION_WS_METHODS, + OrchestrationDispatchCommandError, + OrchestrationGetFullThreadDiffError, + OrchestrationGetFullThreadDiffInput, + OrchestrationGetSnapshotError, + OrchestrationGetSnapshotInput, + OrchestrationGetTurnDiffError, + OrchestrationGetTurnDiffInput, + OrchestrationReplayEventsError, + OrchestrationReplayEventsInput, + OrchestrationRpcSchemas, +} from "./orchestration"; +import { + ProjectSearchEntriesError, + ProjectSearchEntriesInput, + ProjectSearchEntriesResult, + ProjectWriteFileError, + ProjectWriteFileInput, + ProjectWriteFileResult, +} from "./project"; +import { + TerminalClearInput, + TerminalCloseInput, + TerminalError, + TerminalEvent, + TerminalOpenInput, + TerminalResizeInput, + TerminalRestartInput, + TerminalSessionSnapshot, + TerminalWriteInput, +} from "./terminal"; +import { + ServerConfigStreamEvent, + ServerConfig, + ServerLifecycleStreamEvent, + ServerProviderUpdatedPayload, + ServerUpsertKeybindingInput, + ServerUpsertKeybindingResult, +} from "./server"; +import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings"; +import { + SubscribeGitActionProgressInput, + SubscribeOrchestrationDomainEventsInput, + SubscribeServerConfigInput, + SubscribeServerLifecycleInput, + SubscribeTerminalEventsInput, + WS_METHODS, +} from "./ws"; + +export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { + payload: ServerUpsertKeybindingInput, + success: ServerUpsertKeybindingResult, + error: KeybindingsConfigError, +}); + +export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { + payload: Schema.Struct({}), + success: ServerConfig, + error: Schema.Union([KeybindingsConfigError, ServerSettingsError]), +}); + +export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { + payload: Schema.Struct({}), + success: ServerProviderUpdatedPayload, +}); + +export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { + payload: Schema.Struct({}), + success: ServerSettings, + error: ServerSettingsError, +}); + +export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, { + payload: Schema.Struct({ patch: ServerSettingsPatch }), + success: ServerSettings, + error: ServerSettingsError, +}); + +export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { + payload: ProjectSearchEntriesInput, + success: ProjectSearchEntriesResult, + error: ProjectSearchEntriesError, +}); + +export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { + payload: ProjectWriteFileInput, + success: ProjectWriteFileResult, + error: ProjectWriteFileError, +}); + +export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { + payload: OpenInEditorInput, + error: OpenError, +}); + +export const WsGitStatusRpc = Rpc.make(WS_METHODS.gitStatus, { + payload: GitStatusInput, + success: GitStatusResult, + error: GitManagerServiceError, +}); + +export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, { + payload: GitPullInput, + success: GitPullResult, + error: GitCommandError, +}); + +export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { + payload: GitRunStackedActionInput, + success: GitRunStackedActionResult, + error: GitManagerServiceError, +}); + +export const WsGitResolvePullRequestRpc = Rpc.make(WS_METHODS.gitResolvePullRequest, { + payload: GitPullRequestRefInput, + success: GitResolvePullRequestResult, + error: GitManagerServiceError, +}); + +export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePullRequestThread, { + payload: GitPreparePullRequestThreadInput, + success: GitPreparePullRequestThreadResult, + error: GitManagerServiceError, +}); + +export const WsGitListBranchesRpc = Rpc.make(WS_METHODS.gitListBranches, { + payload: GitListBranchesInput, + success: GitListBranchesResult, + error: GitCommandError, +}); + +export const WsGitCreateWorktreeRpc = Rpc.make(WS_METHODS.gitCreateWorktree, { + payload: GitCreateWorktreeInput, + success: GitCreateWorktreeResult, + error: GitCommandError, +}); + +export const WsGitRemoveWorktreeRpc = Rpc.make(WS_METHODS.gitRemoveWorktree, { + payload: GitRemoveWorktreeInput, + error: GitCommandError, +}); + +export const WsGitCreateBranchRpc = Rpc.make(WS_METHODS.gitCreateBranch, { + payload: GitCreateBranchInput, + error: GitCommandError, +}); + +export const WsGitCheckoutRpc = Rpc.make(WS_METHODS.gitCheckout, { + payload: GitCheckoutInput, + error: GitCommandError, +}); + +export const WsGitInitRpc = Rpc.make(WS_METHODS.gitInit, { + payload: GitInitInput, + error: GitCommandError, +}); + +export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { + payload: TerminalOpenInput, + success: TerminalSessionSnapshot, + error: TerminalError, +}); + +export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, { + payload: TerminalWriteInput, + error: TerminalError, +}); + +export const WsTerminalResizeRpc = Rpc.make(WS_METHODS.terminalResize, { + payload: TerminalResizeInput, + error: TerminalError, +}); + +export const WsTerminalClearRpc = Rpc.make(WS_METHODS.terminalClear, { + payload: TerminalClearInput, + error: TerminalError, +}); + +export const WsTerminalRestartRpc = Rpc.make(WS_METHODS.terminalRestart, { + payload: TerminalRestartInput, + success: TerminalSessionSnapshot, + error: TerminalError, +}); + +export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { + payload: TerminalCloseInput, + error: TerminalError, +}); + +export const WsOrchestrationGetSnapshotRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getSnapshot, { + payload: OrchestrationGetSnapshotInput, + success: OrchestrationRpcSchemas.getSnapshot.output, + error: OrchestrationGetSnapshotError, +}); + +export const WsOrchestrationDispatchCommandRpc = Rpc.make( + ORCHESTRATION_WS_METHODS.dispatchCommand, + { + payload: ClientOrchestrationCommand, + success: OrchestrationRpcSchemas.dispatchCommand.output, + error: OrchestrationDispatchCommandError, + }, +); + +export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, { + payload: OrchestrationGetTurnDiffInput, + success: OrchestrationRpcSchemas.getTurnDiff.output, + error: OrchestrationGetTurnDiffError, +}); + +export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make( + ORCHESTRATION_WS_METHODS.getFullThreadDiff, + { + payload: OrchestrationGetFullThreadDiffInput, + success: OrchestrationRpcSchemas.getFullThreadDiff.output, + error: OrchestrationGetFullThreadDiffError, + }, +); + +export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.replayEvents, { + payload: OrchestrationReplayEventsInput, + success: OrchestrationRpcSchemas.replayEvents.output, + error: OrchestrationReplayEventsError, +}); + +export const WsSubscribeOrchestrationDomainEventsRpc = Rpc.make( + WS_METHODS.subscribeOrchestrationDomainEvents, + { + payload: SubscribeOrchestrationDomainEventsInput, + success: OrchestrationEvent, + stream: true, + }, +); + +export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, { + payload: SubscribeTerminalEventsInput, + success: TerminalEvent, + stream: true, +}); + +export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerConfig, { + payload: SubscribeServerConfigInput, + success: ServerConfigStreamEvent, + error: Schema.Union([KeybindingsConfigError, ServerSettingsError]), + stream: true, +}); + +export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, { + payload: SubscribeServerLifecycleInput, + success: ServerLifecycleStreamEvent, + stream: true, +}); + +export const WsSubscribeGitActionProgressRpc = Rpc.make(WS_METHODS.subscribeGitActionProgress, { + payload: SubscribeGitActionProgressInput, + success: GitActionProgressEvent, + stream: true, +}); + +export const WsRpcGroup = RpcGroup.make( + WsServerGetConfigRpc, + WsServerRefreshProvidersRpc, + WsServerUpsertKeybindingRpc, + WsServerGetSettingsRpc, + WsServerUpdateSettingsRpc, + WsProjectsSearchEntriesRpc, + WsProjectsWriteFileRpc, + WsShellOpenInEditorRpc, + WsGitStatusRpc, + WsGitPullRpc, + WsGitRunStackedActionRpc, + WsGitResolvePullRequestRpc, + WsGitPreparePullRequestThreadRpc, + WsGitListBranchesRpc, + WsGitCreateWorktreeRpc, + WsGitRemoveWorktreeRpc, + WsGitCreateBranchRpc, + WsGitCheckoutRpc, + WsGitInitRpc, + WsTerminalOpenRpc, + WsTerminalWriteRpc, + WsTerminalResizeRpc, + WsTerminalClearRpc, + WsTerminalRestartRpc, + WsTerminalCloseRpc, + WsSubscribeOrchestrationDomainEventsRpc, + WsSubscribeTerminalEventsRpc, + WsSubscribeServerConfigRpc, + WsSubscribeServerLifecycleRpc, + WsSubscribeGitActionProgressRpc, + WsOrchestrationGetSnapshotRpc, + WsOrchestrationDispatchCommandRpc, + WsOrchestrationGetTurnDiffRpc, + WsOrchestrationGetFullThreadDiffRpc, + WsOrchestrationReplayEventsRpc, +);