diff --git a/.oxfmtrc.json b/.oxfmtrc.json index ef2236d0f..a3e32c979 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/.plans/ws-rpc-endpoint-port-plan.md b/.plans/ws-rpc-endpoint-port-plan.md new file mode 100644 index 000000000..6f8608345 --- /dev/null +++ b/.plans/ws-rpc-endpoint-port-plan.md @@ -0,0 +1,163 @@ +# WebSocket RPC Port Plan + +Incrementally migrate WebSocket request handling from `apps/server/src/wsServer.ts` switch-cases to Effect RPC routes in `apps/server/src/ws.ts` with shared contracts in `packages/contracts`. + +## Porting Strategy (High Level) + +1. **Contract-first** + - Define each RPC in shared contracts (`packages/contracts`) so server and client use one schema source. + - Keep endpoint names identical to `WS_METHODS` / orchestration method names to avoid client churn. + +2. **Single endpoint slices** + - Port one endpoint at a time into `WsRpcGroup` in `apps/server/src/ws.ts`. + - Preserve current behavior and error semantics; avoid broad refactors in the same slice. + +3. **Prove wiring with tests** + - Add/extend integration tests in `apps/server/src/server.test.ts` (reference style: boot layer, connect WS RPC client, invoke method, assert result). + - Prefer lightweight assertions that prove route wiring + core behavior. + - Implementation details are often tested in each service's own tests. Server test only needs to prove high level behavior and error semantics. + +4. **Keep old path as fallback until parity** + - Leave legacy handler path in `wsServer.ts` for unmigrated methods. + - After each endpoint is migrated and tested, remove only that endpoint branch from legacy switch. + +5. **Quality gates per slice** + - Run `bun run test` (targeted), then `bun fmt`, `bun lint`, `bun typecheck`. + - Only proceed to next endpoint when checks are green. + +## Ordered Endpoint Checklist + +Legend: `[x]` done, `[ ]` not started. + +### Phase 1: Server metadata (smallest surface) + +- [x] `server.getConfig` (now retired in favor of `subscribeServerConfig` snapshot-first stream) +- [x] `server.upsertKeybinding` + +### Phase 2: Project + editor read/write (small inputs, bounded side effects) + +- [x] `projects.searchEntries` +- [x] `projects.writeFile` +- [x] `shell.openInEditor` + +### Phase 3: Git operations (broader side effects) + +- [x] `git.status` +- [x] `git.listBranches` +- [x] `git.pull` +- [x] `git.runStackedAction` +- [x] `git.resolvePullRequest` +- [x] `git.preparePullRequestThread` +- [x] `git.createWorktree` +- [x] `git.removeWorktree` +- [x] `git.createBranch` +- [x] `git.checkout` +- [x] `git.init` + +### Phase 4: Terminal lifecycle + IO (stateful and streaming-adjacent) + +- [x] `terminal.open` +- [x] `terminal.write` +- [x] `terminal.resize` +- [x] `terminal.clear` +- [x] `terminal.restart` +- [x] `terminal.close` + +### Phase 5: Orchestration RPC methods (domain-critical path) + +- [x] `orchestration.getSnapshot` +- [x] `orchestration.dispatchCommand` +- [x] `orchestration.getTurnDiff` +- [x] `orchestration.getFullThreadDiff` +- [x] `orchestration.replayEvents` + +### Phase 6: Streaming subscriptions via RPC (replace push-channel bridge) + +- [x] Define streaming RPC contracts for all server-driven event surfaces (reference pattern: `subscribeTodos`): + - [ ] `subscribeOrchestrationDomainEvents` + - [x] `subscribeTerminalEvents` + - [x] `subscribeServerConfig` (snapshot + keybindings updates + provider status heartbeat) + - [ ] `subscribeServerLifecycle` (welcome/readiness/bootstrap updates) +- [ ] Add stream payload schemas in `packages/contracts` with narrow tagged unions where needed. + - [ ] Include explicit event versioning strategy (`version` or schema evolution note). + - [ ] Ensure payload shape parity with existing `WS_CHANNELS` semantics. +- [ ] Implement streaming handlers in `apps/server/src/ws.ts` using `Effect.Stream`. + - [x] Wire first stream (`subscribeTerminalEvents`) to the correct source service/event bus. + - [x] Wire `subscribeServerConfig` to emit snapshot first, then live updates. + - [ ] Preserve ordering guarantees where currently expected. + - [ ] Preserve filtering/scoping rules (thread/session/worktree as applicable). +- [ ] Prove one full vertical slice first (recommended: terminal events), then fan out. + - [x] Contract + handler + client consumer. + - [x] Integration test: subscribe, receive at least one item, unsubscribe/interrupt cleanly. + - [x] Integration test: `subscribeServerConfig` emits initial snapshot and update event. + - [x] Integration test: provider-status heartbeat verified with Effect `TestClock.adjust`. +- [x] Remove superseded server-config RPCs that are now covered by stream semantics. + - [x] Remove `server.getConfig`. + - [x] Remove `subscribeServerConfigUpdates`. +- [ ] Subscription lifecycle semantics (must match or improve current behavior): + - [ ] reconnect + resubscribe behavior + - [ ] duplicate subscription protection on reconnect + - [ ] cancellation/unsubscribe finalizers + - [ ] cleanup when socket closes unexpectedly +- [ ] Reliability semantics: + - [ ] document and enforce backpressure strategy (buffer cap, drop policy, or disconnect) + - [ ] clarify delivery semantics (best-effort vs at-least-once) for each stream + - [ ] add metrics/logging for dropped/failed deliveries +- [ ] Security/auth parity: + - [ ] apply same auth gating as request/response RPC path + - [ ] enforce per-stream permission checks +- [ ] After parity, remove legacy push-channel publish paths and old envelope code paths for migrated streams. + +### Phase 7: Server startup/runtime side effects (move lifecycle out of legacy wsServer) + +- [ ] Move startup orchestration from `wsServer.ts` into layer-based runtime composition. + - [ ] keybindings startup + default sync behavior + - [ ] orchestration reactor startup + - [ ] terminal stream subscription lifecycle + - [ ] orchestration stream subscription lifecycle +- [ ] Move startup UX/ops side effects: + - [ ] open-in-browser behavior + - [ ] startup heartbeat analytics + - [ ] startup logs payload parity + - [ ] optional auto-bootstrap project/thread from cwd +- [ ] Preserve readiness and failure semantics: + - [ ] readiness gates for required subsystems + - [ ] startup failure behavior and error messages + - [ ] startup ordering guarantees and retry policy (if any) +- [ ] Preserve shutdown semantics: + - [ ] finalizers/unsubscribe behavior + - [ ] ws server close behavior + - [ ] in-flight stream cancellation handling +- [ ] Add lifecycle-focused integration tests (startup happy path + failure path + shutdown cleanup). + +### Phase 8: Client migration (full surface) + +- [ ] Migrate web client transport in `apps/web/src/ws.ts` to consume RPC contracts directly. + - [ ] Decide transport approach (custom adapter vs Effect `RpcClient`) and lock one path. +- [ ] Request/response parity migration: + - [ ] replace legacy websocket envelope call helpers with typed RPC client calls + - [ ] ensure domain-specific error decoding/parsing parity +- [ ] Streaming parity migration: + - [ ] consume new streaming RPC subscriptions for all migrated channels + - [ ] implement reconnect + resubscribe strategy + - [ ] enforce unsubscribe on route/session teardown +- [ ] UX behavior parity: + - [ ] loading/connected/disconnected state transitions + - [ ] terminal/orchestration live updates timing and ordering + - [ ] welcome/bootstrap/config update behavior +- [ ] Client tests: + - [ ] integration coverage for request calls + - [ ] subscription lifecycle tests (connect, receive, reconnect, teardown) + +### Phase 9: Final cleanup + deprecation removal + +- [ ] Delete legacy `wsServer.ts` transport path once server+client parity is proven. +- [ ] Remove old shared protocol artifacts no longer needed: + - [ ] legacy `WS_CHANNELS` usage + - [ ] legacy ws envelope request/response codecs where obsolete + - [ ] dead helpers/services only used by legacy transport path +- [ ] Run parity audit checklist before deletion: + - [ ] every old method mapped to RPC equivalent + - [ ] every old push channel mapped to streaming RPC equivalent + - [ ] auth/error/ordering semantics verified +- [ ] Add migration note/changelog entry for downstream consumers (if any). \ No newline at end of file diff --git a/.reference/server/package.json b/.reference/server/package.json new file mode 100644 index 000000000..8df16b676 --- /dev/null +++ b/.reference/server/package.json @@ -0,0 +1,27 @@ +{ + "name": "@effect-http-ws-cli/server", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + "./client": "./src/client.ts", + "./contracts": "./src/contracts.ts" + }, + "scripts": { + "dev": "node src/bin.ts", + "start": "node src/bin.ts", + "test": "vitest run", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@effect/platform-node": "catalog:", + "@effect/sql-sqlite-node": "catalog:", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "^24.10.0", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/.reference/server/src/Migrations/001_TodoSchema.ts b/.reference/server/src/Migrations/001_TodoSchema.ts new file mode 100644 index 000000000..aa6c1418e --- /dev/null +++ b/.reference/server/src/Migrations/001_TodoSchema.ts @@ -0,0 +1,32 @@ +import * as Effect from "effect/Effect" +import * as SqlClient from "effect/unstable/sql/SqlClient" + +export default Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient + + yield* sql` + CREATE TABLE IF NOT EXISTS todos ( + id TEXT PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + completed INTEGER NOT NULL, + archived INTEGER NOT NULL, + revision INTEGER NOT NULL, + updated_at TEXT NOT NULL + ) + ` + + yield* sql` + CREATE TABLE IF NOT EXISTS todo_events ( + event_offset INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + todo_json TEXT NOT NULL, + change_json TEXT NOT NULL, + archived INTEGER NOT NULL + ) + ` + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_todo_events_archived_offset + ON todo_events (archived, event_offset) + ` +}) diff --git a/.reference/server/src/bin.ts b/.reference/server/src/bin.ts new file mode 100644 index 000000000..647a33e51 --- /dev/null +++ b/.reference/server/src/bin.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node +import * as NodeRuntime from "@effect/platform-node/NodeRuntime" +import * as NodeServices from "@effect/platform-node/NodeServices" +import * as Effect from "effect/Effect" +import { cli } from "./cli.ts" +import { Command } from "effect/unstable/cli" + +Command.run(cli, { version: "0.1.0" }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain +) diff --git a/.reference/server/src/cli.ts b/.reference/server/src/cli.ts new file mode 100644 index 000000000..75806c2f4 --- /dev/null +++ b/.reference/server/src/cli.ts @@ -0,0 +1,125 @@ +import * as SqliteNode from "@effect/sql-sqlite-node" +import { Command, Flag } from "effect/unstable/cli" +import * as Config from "effect/Config" +import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" +import * as Option from "effect/Option" +import * as Path from "effect/Path" +import { fileURLToPath } from "node:url" +import { ServerConfig } from "./config.ts" +import { runMigrations } from "./migrations.ts" +import { runServer } from "./server.ts" +import type { ServerConfigData } from "./config.ts" + +const defaultAssetsDir = fileURLToPath(new URL("../../public", import.meta.url)) +const defaultDbFilename = fileURLToPath(new URL("../../todo.sqlite", import.meta.url)) + +const hostFlag = Flag.string("host").pipe( + Flag.withDescription("Host interface to bind"), + Flag.optional +) + +const portFlag = Flag.integer("port").pipe( + Flag.withDescription("Port to listen on"), + Flag.optional +) + +const assetsFlag = Flag.directory("assets").pipe( + Flag.withDescription("Directory of static assets"), + Flag.optional +) + +const dbFlag = Flag.string("db").pipe( + Flag.withDescription("SQLite database filename"), + Flag.optional +) + +const requestLoggingFlag = Flag.boolean("request-logging").pipe( + Flag.withDescription("Enable request logging"), + Flag.optional +) + +const frontendDevOriginFlag = Flag.string("frontend-dev-origin").pipe( + Flag.withDescription("Redirect frontend GET requests to a Vite dev server origin"), + Flag.optional +) + +const EnvServerConfig = Config.unwrap({ + host: Config.string("HOST").pipe(Config.withDefault("127.0.0.1")), + port: Config.port("PORT").pipe(Config.withDefault(8787)), + assetsDir: Config.string("ASSETS_DIR").pipe(Config.withDefault(defaultAssetsDir)), + dbFilename: Config.string("DB_FILENAME").pipe(Config.withDefault(defaultDbFilename)), + requestLogging: Config.boolean("REQUEST_LOGGING").pipe(Config.withDefault(true)), + frontendDevOrigin: Config.string("FRONTEND_DEV_ORIGIN").pipe( + Config.option, + Config.map(Option.getOrUndefined) + ) +}) + +export interface CliServerFlags { + readonly host: Option.Option + readonly port: Option.Option + readonly assets: Option.Option + readonly db: Option.Option + readonly requestLogging: Option.Option + readonly frontendDevOrigin: Option.Option +} + +export const resolveServerConfig = ( + flags: CliServerFlags +): Effect.Effect => + Effect.gen(function*() { + const env = yield* EnvServerConfig + return { + host: Option.getOrElse(flags.host, () => env.host), + port: Option.getOrElse(flags.port, () => env.port), + assetsDir: Option.getOrElse(flags.assets, () => env.assetsDir), + dbFilename: Option.getOrElse(flags.db, () => env.dbFilename), + requestLogging: Option.getOrElse(flags.requestLogging, () => env.requestLogging), + frontendDevOrigin: Option.getOrElse(flags.frontendDevOrigin, () => env.frontendDevOrigin) + } + }) + + +export const resetDatabase = (dbFilename: string) => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + if (dbFilename !== ":memory:") { + yield* fs.remove(path.resolve(dbFilename), { force: true }) + } + + const sqliteLayer = SqliteNode.SqliteClient.layer({ + filename: dbFilename + }) + + yield* runMigrations.pipe(Effect.provide(sqliteLayer)) + }) + +const commandFlags = { + host: hostFlag, + port: portFlag, + assets: assetsFlag, + db: dbFlag, + requestLogging: requestLoggingFlag, + frontendDevOrigin: frontendDevOriginFlag +} as const + +const rootCommand = Command.make("effect-http-ws-cli", commandFlags).pipe( + Command.withDescription("Run a unified Effect HTTP + WebSocket server"), + Command.withHandler((flags) => + Effect.flatMap(resolveServerConfig(flags), (config) => + runServer.pipe(Effect.provideService(ServerConfig, config)))), +) + +const resetCommand = Command.make("reset", commandFlags).pipe( + Command.withDescription("Delete the SQLite database file and rerun migrations"), + Command.withHandler((flags) => + Effect.flatMap(resolveServerConfig(flags), (config) => resetDatabase(config.dbFilename)) + ) +) + +export const cli = rootCommand.pipe( + Command.withSubcommands([resetCommand]) +) diff --git a/.reference/server/src/client.ts b/.reference/server/src/client.ts new file mode 100644 index 000000000..465080797 --- /dev/null +++ b/.reference/server/src/client.ts @@ -0,0 +1,58 @@ +import { NodeSocket } from "@effect/platform-node" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as RpcClient from "effect/unstable/rpc/RpcClient" +import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization" +import * as Stream from "effect/Stream" +import { WsRpcGroup } from "./contracts.ts" + +export const wsRpcProtocolLayer = (wsUrl: string) => + RpcClient.layerProtocolSocket().pipe( + Layer.provide(NodeSocket.layerWebSocket(wsUrl)), + Layer.provide(RpcSerialization.layerJson) + ) + +export const makeWsRpcClient = RpcClient.make(WsRpcGroup) +type WsRpcClient = typeof makeWsRpcClient extends Effect.Effect ? Client : never + +export const withWsRpcClient = ( + wsUrl: string, + f: (client: WsRpcClient) => Effect.Effect +) => + makeWsRpcClient.pipe( + Effect.flatMap(f), + Effect.provide(wsRpcProtocolLayer(wsUrl)) + ) + +export const runClientExample = (wsUrl: string) => + Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function*() { + const echoed = yield* client.echo({ text: "hello from client" }) + const summed = yield* client.sum({ left: 20, right: 22 }) + const time = yield* client.time(undefined) + return { echoed, summed, time } + }) + ) + ) + +export const runSubscriptionExample = (wsUrl: string, modelId: string) => + Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function*() { + const snapshot = yield* client.listTodos({ includeArchived: true }) + const todo = snapshot.todos[0] + if (!todo) { + return [] + } + + yield* client.renameTodo({ id: todo.id, title: `${modelId}: first` }) + yield* client.completeTodo({ id: todo.id, completed: true }) + + return yield* client.subscribeTodos({ fromOffset: snapshot.offset, includeArchived: true }).pipe( + Stream.take(2), + Stream.runCollect + ) + }) + ) + ) diff --git a/.reference/server/src/config.ts b/.reference/server/src/config.ts new file mode 100644 index 000000000..601135fef --- /dev/null +++ b/.reference/server/src/config.ts @@ -0,0 +1,14 @@ +import * as ServiceMap from "effect/ServiceMap" + +export interface ServerConfigData { + readonly host: string + readonly port: number + readonly assetsDir: string + readonly dbFilename: string + readonly requestLogging: boolean + readonly frontendDevOrigin: string | undefined +} + +export class ServerConfig extends ServiceMap.Service()( + "effect-http-ws-cli/ServerConfig" +) {} diff --git a/.reference/server/src/contracts.ts b/.reference/server/src/contracts.ts new file mode 100644 index 000000000..f2b2534f9 --- /dev/null +++ b/.reference/server/src/contracts.ts @@ -0,0 +1,132 @@ +import * as Schema from "effect/Schema" +import * as Rpc from "effect/unstable/rpc/Rpc" +import * as RpcGroup from "effect/unstable/rpc/RpcGroup" + +export const EchoPayload = Schema.Struct({ + text: Schema.String +}) + +export const EchoResult = Schema.Struct({ + text: Schema.String +}) + +export const SumPayload = Schema.Struct({ + left: Schema.Number, + right: Schema.Number +}) + +export const SumResult = Schema.Struct({ + total: Schema.Number +}) + +export const TimeResult = Schema.Struct({ + iso: Schema.String +}) + +export const Todo = Schema.Struct({ + id: Schema.String, + title: Schema.String, + completed: Schema.Boolean, + archived: Schema.Boolean, + revision: Schema.Number, + updatedAt: Schema.String +}) +export type Todo = Schema.Schema.Type + +export const TodoChange = Schema.Union([ + Schema.Struct({ + _tag: Schema.Literal("TodoCreated") + }), + Schema.Struct({ + _tag: Schema.Literal("TodoRenamed"), + title: Schema.String + }), + Schema.Struct({ + _tag: Schema.Literal("TodoCompleted"), + completed: Schema.Boolean + }), + Schema.Struct({ + _tag: Schema.Literal("TodoArchived"), + archived: Schema.Boolean + }) +]) +export type TodoChange = Schema.Schema.Type + +export const TodoEvent = Schema.Struct({ + offset: Schema.Number, + at: Schema.String, + todo: Todo, + change: TodoChange +}) +export type TodoEvent = Schema.Schema.Type + +export const TodoSnapshot = Schema.Struct({ + offset: Schema.Number, + todos: Schema.Array(Todo) +}) +export type TodoSnapshot = Schema.Schema.Type + +export const EchoRpc = Rpc.make("echo", { + payload: EchoPayload, + success: EchoResult +}) + +export const SumRpc = Rpc.make("sum", { + payload: SumPayload, + success: SumResult +}) + +export const TimeRpc = Rpc.make("time", { + success: TimeResult +}) + +export const ListTodosRpc = Rpc.make("listTodos", { + payload: Schema.Struct({ + includeArchived: Schema.Boolean + }), + success: TodoSnapshot +}) + +export const RenameTodoRpc = Rpc.make("renameTodo", { + payload: Schema.Struct({ + id: Schema.String, + title: Schema.String + }), + success: TodoEvent +}) + +export const CompleteTodoRpc = Rpc.make("completeTodo", { + payload: Schema.Struct({ + id: Schema.String, + completed: Schema.Boolean + }), + success: TodoEvent +}) + +export const ArchiveTodoRpc = Rpc.make("archiveTodo", { + payload: Schema.Struct({ + id: Schema.String, + archived: Schema.Boolean + }), + success: TodoEvent +}) + +export const SubscribeTodosRpc = Rpc.make("subscribeTodos", { + payload: Schema.Struct({ + fromOffset: Schema.Number, + includeArchived: Schema.Boolean + }), + success: TodoEvent, + stream: true +}) + +export const WsRpcGroup = RpcGroup.make( + EchoRpc, + SumRpc, + TimeRpc, + ListTodosRpc, + RenameTodoRpc, + CompleteTodoRpc, + ArchiveTodoRpc, + SubscribeTodosRpc +) diff --git a/.reference/server/src/messages.ts b/.reference/server/src/messages.ts new file mode 100644 index 000000000..75a67efac --- /dev/null +++ b/.reference/server/src/messages.ts @@ -0,0 +1,73 @@ +import * as Schema from "effect/Schema" +import * as SchemaTransformation from "effect/SchemaTransformation" + +export const ClientMessage = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("echo"), + text: Schema.String + }), + Schema.Struct({ + kind: Schema.Literal("sum"), + left: Schema.Number, + right: Schema.Number + }), + Schema.Struct({ + kind: Schema.Literal("time") + }) +]) + +export type ClientMessage = Schema.Schema.Type + +export const ServerMessage = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("echo"), + text: Schema.String + }), + Schema.Struct({ + kind: Schema.Literal("sumResult"), + total: Schema.Number + }), + Schema.Struct({ + kind: Schema.Literal("time"), + iso: Schema.String + }), + Schema.Struct({ + kind: Schema.Literal("error"), + error: Schema.String + }) +]) + + +export type ServerMessage = Schema.Schema.Type + +export const decodeClientMessage = Schema.decodeUnknownEffect(ClientMessage) + +const Utf8StringFromUint8Array = Schema.Uint8Array.pipe( + Schema.decodeTo( + Schema.String, + SchemaTransformation.transform({ + decode: (bytes) => new TextDecoder().decode(bytes), + encode: (text) => new TextEncoder().encode(text) + }) + ) +) + +const ClientMessageFromWire = Schema.Union([ + Schema.String, + Utf8StringFromUint8Array +]).pipe( + Schema.decodeTo(Schema.fromJsonString(ClientMessage)) +) + +export const decodeWireClientMessage = Schema.decodeUnknownEffect(ClientMessageFromWire) + +export const routeClientMessage = (message: ClientMessage): ServerMessage => { + switch (message.kind) { + case "echo": + return { kind: "echo", text: message.text } + case "sum": + return { kind: "sumResult", total: message.left + message.right } + case "time": + return { kind: "time", iso: new Date().toISOString() } + } +} diff --git a/.reference/server/src/migrations.ts b/.reference/server/src/migrations.ts new file mode 100644 index 000000000..65abf1a53 --- /dev/null +++ b/.reference/server/src/migrations.ts @@ -0,0 +1,41 @@ +/** + * MigrationsLive - Migration runner with inline loader + * + * Uses Migrator.make with fromRecord to define migrations inline. + * All migrations are statically imported - no dynamic file system loading. + * + * Migrations run automatically when the MigrationsLive layer is provided, + * ensuring the database schema is up-to-date before the application starts. + */ +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as ServiceMap from "effect/ServiceMap" +import * as Migrator from "effect/unstable/sql/Migrator" +import Migration0001 from "./Migrations/001_TodoSchema.ts" + +const loader = Migrator.fromRecord({ + "1_TodoSchema": Migration0001 +}) + +const run = Migrator.make({}) + +export const runMigrations = Effect.gen(function*() { + yield* Effect.log("Running migrations...") + yield* run({ loader }) + yield* Effect.log("Migrations ran successfully") +}) + +export interface MigrationsReadyApi { + readonly ready: true +} + +export class MigrationsReady extends ServiceMap.Service()( + "effect-http-ws-cli/MigrationsReady" +) {} + +export const MigrationsLive = Layer.effect( + MigrationsReady, + runMigrations.pipe( + Effect.as(MigrationsReady.of({ ready: true })) + ) +) diff --git a/.reference/server/src/model-store.ts b/.reference/server/src/model-store.ts new file mode 100644 index 000000000..56bcc0c05 --- /dev/null +++ b/.reference/server/src/model-store.ts @@ -0,0 +1,268 @@ +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Option from "effect/Option" +import * as PubSub from "effect/PubSub" +import * as Schema from "effect/Schema" +import * as ServiceMap from "effect/ServiceMap" +import * as Stream from "effect/Stream" +import * as SqlClient from "effect/unstable/sql/SqlClient" +import * as SqlSchema from "effect/unstable/sql/SqlSchema" +import type { Todo, TodoChange, TodoEvent, TodoSnapshot } from "./contracts.ts" +import { Todo as TodoSchema, TodoChange as TodoChangeSchema } from "./contracts.ts" +import { MigrationsReady } from "./migrations.ts" + +export interface TodoStoreApi { + readonly list: (input: { + readonly includeArchived: boolean + }) => Effect.Effect + readonly rename: (input: { + readonly id: string + readonly title: string + }) => Effect.Effect + readonly complete: (input: { + readonly id: string + readonly completed: boolean + }) => Effect.Effect + readonly archive: (input: { + readonly id: string + readonly archived: boolean + }) => Effect.Effect + readonly subscribe: (input: { + readonly fromOffset: number + readonly includeArchived: boolean + }) => Stream.Stream +} + +export class TodoStore extends ServiceMap.Service()( + "effect-http-ws-cli/TodoStore" +) {} + +const TodoRow = Schema.Struct({ + id: Schema.String, + title: Schema.String, + completed: Schema.BooleanFromBit, + archived: Schema.BooleanFromBit, + revision: Schema.Number, + updatedAt: Schema.String +}) + +const TodoEventRow = Schema.Struct({ + offset: Schema.Number, + at: Schema.String, + todo: Schema.fromJsonString(TodoSchema), + change: Schema.fromJsonString(TodoChangeSchema) +}) + +const EventInsertRequest = Schema.Struct({ + at: Schema.String, + todo: Schema.fromJsonString(TodoSchema), + change: Schema.fromJsonString(TodoChangeSchema), + archived: Schema.BooleanFromBit +}) + +const ListRequest = Schema.Struct({ + includeArchived: Schema.Boolean +}) + +const CatchupRequest = Schema.Struct({ + fromOffset: Schema.Number, + includeArchived: Schema.Boolean +}) + +const OffsetRow = Schema.Struct({ + offset: Schema.Number +}) + +const makeQueries = (sql: SqlClient.SqlClient) => { + const listTodoRows = SqlSchema.findAll({ + Request: ListRequest, + Result: TodoRow, + execute: (request) => + request.includeArchived + ? sql` + SELECT id, title, completed, archived, revision, updated_at AS updatedAt + FROM todos + ORDER BY id + ` + : sql` + SELECT id, title, completed, archived, revision, updated_at AS updatedAt + FROM todos + WHERE archived = 0 + ORDER BY id + ` + }) + + const findTodoById = SqlSchema.findOneOption({ + Request: Schema.String, + Result: TodoRow, + execute: (id) => sql` + SELECT id, title, completed, archived, revision, updated_at AS updatedAt + FROM todos + WHERE id = ${id} + ` + }) + + const upsertTodo = SqlSchema.void({ + Request: TodoRow, + execute: (todo) => sql` + INSERT INTO todos (id, title, completed, archived, revision, updated_at) + VALUES (${todo.id}, ${todo.title}, ${todo.completed}, ${todo.archived}, ${todo.revision}, ${todo.updatedAt}) + ON CONFLICT(id) DO UPDATE SET + title = excluded.title, + completed = excluded.completed, + archived = excluded.archived, + revision = excluded.revision, + updated_at = excluded.updated_at + ` + }) + + const loadEventsSince = SqlSchema.findAll({ + Request: CatchupRequest, + Result: TodoEventRow, + execute: (request) => + request.includeArchived + ? sql` + SELECT event_offset AS "offset", at, todo_json AS todo, change_json AS change + FROM todo_events + WHERE event_offset > ${request.fromOffset} + ORDER BY event_offset + ` + : sql` + SELECT event_offset AS "offset", at, todo_json AS todo, change_json AS change + FROM todo_events + WHERE event_offset > ${request.fromOffset} AND archived = 0 + ORDER BY event_offset + ` + }) + + const insertTodoEvent = SqlSchema.findOne({ + Request: EventInsertRequest, + Result: TodoEventRow, + execute: (request) => sql` + INSERT INTO todo_events (at, todo_json, change_json, archived) + VALUES (${request.at}, ${request.todo}, ${request.change}, ${request.archived}) + RETURNING event_offset AS "offset", at, todo_json AS todo, change_json AS change + ` + }) + + const currentOffset = SqlSchema.findOne({ + Request: Schema.Undefined, + Result: OffsetRow, + execute: () => sql<{ readonly offset: number }>` + SELECT COALESCE(MAX(event_offset), 0) AS "offset" + FROM todo_events + ` + }) + + return { + listTodoRows, + findTodoById, + upsertTodo, + loadEventsSince, + insertTodoEvent, + currentOffset + } as const +} + +export const layerTodoStore = Layer.effect( + TodoStore, + Effect.gen(function*() { + yield* MigrationsReady + const eventsPubSub = yield* PubSub.unbounded() + + const append = ( + todoId: string, + change: TodoChange, + update: (todo: Todo) => Todo + ): Effect.Effect => + Effect.flatMap(Effect.service(SqlClient.SqlClient), (sql) => { + const queries = makeQueries(sql) + return sql.withTransaction( + queries.findTodoById(todoId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.die(`Todo not found: ${todoId}`), + onSome: (todo) => { + const updated = update(todo) + return queries.upsertTodo(updated).pipe( + Effect.flatMap(() => + queries.insertTodoEvent({ + at: updated.updatedAt, + todo: updated, + change, + archived: updated.archived + }) + ) + ) + } + }) + ) + ) + ) + }).pipe(Effect.tap((event) => PubSub.publish(eventsPubSub, event)), Effect.orDie) + + const visible = (todo: Todo, includeArchived: boolean) => includeArchived || !todo.archived + + const subscribe = (input: { + readonly fromOffset: number + readonly includeArchived: boolean + }): Stream.Stream => { + const catchup = Stream.fromIterableEffect( + Effect.flatMap(Effect.service(SqlClient.SqlClient), (sql) => makeQueries(sql).loadEventsSince(input)) + ).pipe(Stream.orDie) + + const live = Stream.fromPubSub(eventsPubSub).pipe( + Stream.filter((event) => visible(event.todo, input.includeArchived)) + ) + + return Stream.concat(catchup, live) + } + + return TodoStore.of({ + list: ({ includeArchived }) => + Effect.flatMap(Effect.service(SqlClient.SqlClient), (sql) => { + const queries = makeQueries(sql) + return Effect.all({ + todos: queries.listTodoRows({ includeArchived }), + offset: queries.currentOffset(undefined).pipe(Effect.map(({ offset }) => offset)) + }).pipe( + Effect.map(({ offset, todos }) => ({ offset, todos })) + ) + }).pipe(Effect.orDie), + rename: ({ id, title }) => + append( + id, + { _tag: "TodoRenamed", title }, + (todo) => ({ + ...todo, + title, + revision: todo.revision + 1, + updatedAt: new Date().toISOString() + }) + ), + complete: ({ id, completed }) => + append( + id, + { _tag: "TodoCompleted", completed }, + (todo) => ({ + ...todo, + completed, + revision: todo.revision + 1, + updatedAt: new Date().toISOString() + }) + ), + archive: ({ id, archived }) => + append( + id, + { _tag: "TodoArchived", archived }, + (todo) => ({ + ...todo, + archived, + revision: todo.revision + 1, + updatedAt: new Date().toISOString() + }) + ), + subscribe + }) + }) +) diff --git a/.reference/server/src/server.ts b/.reference/server/src/server.ts new file mode 100644 index 000000000..0d1e7a16d --- /dev/null +++ b/.reference/server/src/server.ts @@ -0,0 +1,155 @@ +import { NodeHttpServer } from "@effect/platform-node" +import * as SqliteNode from "@effect/sql-sqlite-node" +import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" +import * as Layer from "effect/Layer" +import * as Path from "effect/Path" +import * as Http from "node:http" +import { + HttpRouter, + HttpServerRequest, + HttpServerResponse +} from "effect/unstable/http" +import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization" +import * as RpcServer from "effect/unstable/rpc/RpcServer" +import * as Stream from "effect/Stream" +import { + ClientMessage, + routeClientMessage, + ServerMessage +} from "./messages.ts" +import { ServerConfig } from "./config.ts" +import { WsRpcGroup } from "./contracts.ts" +import { TodoStore, layerTodoStore } from "./model-store.ts" +import { MigrationsLive } from "./migrations.ts" + +const respondMessage = HttpServerResponse.schemaJson(ServerMessage) + +const messageDispatchRoute = HttpRouter.add( + "POST", + "/api/dispatch", + HttpServerRequest.schemaBodyJson(ClientMessage).pipe( + Effect.flatMap((message) => respondMessage(routeClientMessage(message))), + Effect.catchTag( + "SchemaError", + () => Effect.succeed(HttpServerResponse.jsonUnsafe({ kind: "error", error: "Invalid message schema" }, { status: 400 })) + ), + Effect.catchTag( + "HttpServerError", + () => Effect.succeed(HttpServerResponse.jsonUnsafe({ kind: "error", error: "Invalid request body" }, { status: 400 })) + ) + ) +) + +const websocketRpcRoute = RpcServer.layerHttp({ + group: WsRpcGroup, + path: "/ws", + protocol: "websocket" + }).pipe( + Layer.provide(WsRpcGroup.toLayer({ + echo: ({ text }) => Effect.succeed({ text }), + sum: ({ left, right }) => Effect.succeed({ total: left + right }), + time: () => Effect.sync(() => ({ iso: new Date().toISOString() })), + listTodos: ({ includeArchived }) => + Effect.flatMap(Effect.service(TodoStore), (store) => store.list({ includeArchived })), + renameTodo: ({ id, title }) => + Effect.flatMap(Effect.service(TodoStore), (store) => store.rename({ id, title })), + completeTodo: ({ id, completed }) => + Effect.flatMap(Effect.service(TodoStore), (store) => store.complete({ id, completed })), + archiveTodo: ({ id, archived }) => + Effect.flatMap(Effect.service(TodoStore), (store) => store.archive({ id, archived })), + subscribeTodos: ({ fromOffset, includeArchived }) => + Stream.unwrap( + Effect.map( + Effect.service(TodoStore), + (store) => store.subscribe({ fromOffset, includeArchived }) + ) + ) + })), + Layer.provide(layerTodoStore), + Layer.provide(RpcSerialization.layerJson) + ) + +const staticRoute = HttpRouter.add( + "GET", + "*", + (request) => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const config = yield* ServerConfig + const url = HttpServerRequest.toURL(request) + if (!url) { + return HttpServerResponse.text("Bad Request", { status: 400 }) + } + + if (config.frontendDevOrigin) { + return HttpServerResponse.redirect( + new URL(`${url.pathname}${url.search}`, config.frontendDevOrigin), + { + status: 307, + headers: { "cache-control": "no-store" } + } + ) + } + + const root = path.resolve(config.assetsDir) + const decodedPath = decodeURIComponent(url.pathname) + const target = decodedPath === "/" + ? "index.html" + : decodedPath.endsWith("/") + ? `${decodedPath.slice(1)}index.html` + : decodedPath.slice(1) + + const normalizedTarget = path.normalize(target) + const absoluteTarget = path.resolve(root, normalizedTarget) + const relativeToRoot = path.relative(root, absoluteTarget) + + if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) { + return HttpServerResponse.text("Forbidden", { status: 403 }) + } + + const exists = yield* fs.exists(absoluteTarget) + if (!exists) { + return HttpServerResponse.text("Not Found", { status: 404 }) + } + + return yield* HttpServerResponse.file(absoluteTarget) + }).pipe(Effect.catchCause(() => Effect.succeed(HttpServerResponse.text("Bad Request", { status: 400 })))) +) + +export const makeRoutesLayer = + Layer.mergeAll( + HttpRouter.add( + "GET", + "/health", + HttpServerResponse.json({ ok: true }) + ), + messageDispatchRoute, + websocketRpcRoute, + staticRoute + ) + +export const makeServerLayer = Layer.unwrap( + Effect.gen(function*() { + const config = yield* ServerConfig + const sqliteLayer = SqliteNode.SqliteClient.layer({ + filename: config.dbFilename + }) + const persistenceLayer = MigrationsLive.pipe( + Layer.provideMerge(sqliteLayer) + ) + + return HttpRouter.serve(makeRoutesLayer, { + disableLogger: !config.requestLogging + }).pipe( + Layer.provideMerge(persistenceLayer), + Layer.provide(NodeHttpServer.layer(Http.createServer, { + host: config.host, + port: config.port + })) + ) + }) +) + +export const runServer = Layer.launch(makeServerLayer) diff --git a/.reference/server/test/cli-config.test.ts b/.reference/server/test/cli-config.test.ts new file mode 100644 index 000000000..90b75857a --- /dev/null +++ b/.reference/server/test/cli-config.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "@effect/vitest" +import * as ConfigProvider from "effect/ConfigProvider" +import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +import { resolveServerConfig } from "../src/cli.ts" + +describe("cli config resolution", () => { + it.effect("falls back to effect/config values when flags are omitted", () => + Effect.gen(function*() { + const resolved = yield* resolveServerConfig({ + host: Option.none(), + port: Option.none(), + assets: Option.none(), + db: Option.none(), + requestLogging: Option.none(), + frontendDevOrigin: Option.none() + }).pipe( + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ + env: { + HOST: "0.0.0.0", + PORT: "4001", + ASSETS_DIR: "public", + DB_FILENAME: "dev.sqlite", + REQUEST_LOGGING: "false", + FRONTEND_DEV_ORIGIN: "http://127.0.0.1:5173" + } + }) + ) + ) + + expect(resolved).toEqual({ + host: "0.0.0.0", + port: 4001, + assetsDir: "public", + dbFilename: "dev.sqlite", + requestLogging: false, + frontendDevOrigin: "http://127.0.0.1:5173" + }) + }) + ) + + it.effect("uses CLI flags when provided", () => + Effect.gen(function*() { + const resolved = yield* resolveServerConfig({ + host: Option.some("127.0.0.1"), + port: Option.some(8788), + assets: Option.some("public"), + db: Option.some("override.sqlite"), + requestLogging: Option.some(true), + frontendDevOrigin: Option.some("http://127.0.0.1:4173") + }).pipe( + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ + env: { + HOST: "0.0.0.0", + PORT: "4001", + ASSETS_DIR: "other", + DB_FILENAME: "ignored.sqlite", + REQUEST_LOGGING: "false", + FRONTEND_DEV_ORIGIN: "http://127.0.0.1:5173" + } + }) + ) + ) + + expect(resolved).toEqual({ + host: "127.0.0.1", + port: 8788, + assetsDir: "public", + dbFilename: "override.sqlite", + requestLogging: true, + frontendDevOrigin: "http://127.0.0.1:4173" + }) + }) + ) +}) diff --git a/.reference/server/test/reset.test.ts b/.reference/server/test/reset.test.ts new file mode 100644 index 000000000..b180538e3 --- /dev/null +++ b/.reference/server/test/reset.test.ts @@ -0,0 +1,90 @@ +import * as NodeServices from "@effect/platform-node/NodeServices" +import * as SqliteNode from "@effect/sql-sqlite-node" +import { describe, expect, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as SqlClient from "effect/unstable/sql/SqlClient" +import * as FileSystem from "node:fs" +import * as OS from "node:os" +import * as NodePath from "node:path" +import { resetDatabase } from "../src/cli.ts" +import { MigrationsLive } from "../src/migrations.ts" + +const countRows = (dbFilename: string, tableName: string) => + Effect.flatMap(Effect.service(SqlClient.SqlClient), (sql) => + sql<{ readonly count: number }>`SELECT COUNT(*) AS count FROM ${sql(tableName)}`.pipe( + Effect.map((rows) => rows[0]?.count ?? 0) + ) + ).pipe( + Effect.provide(SqliteNode.SqliteClient.layer({ filename: dbFilename })) + ) + +const insertTodo = (dbFilename: string) => + Effect.scoped( + Effect.flatMap(Effect.service(SqlClient.SqlClient), (sql) => { + const now = new Date().toISOString() + const id = "todo-reset-test" + const title = "before-reset" + return sql.withTransaction( + sql` + INSERT INTO todos (id, title, completed, archived, revision, updated_at) + VALUES (${id}, ${title}, 0, 0, 1, ${now}) + `.pipe( + Effect.flatMap(() => + sql` + INSERT INTO todo_events (at, todo_json, change_json, archived) + VALUES ( + ${now}, + ${JSON.stringify({ + id, + title, + completed: false, + archived: false, + revision: 1, + updatedAt: now + })}, + ${JSON.stringify({ _tag: "TodoCreated" })}, + 0 + ) + ` + ) + ) + ) + }).pipe( + Effect.provide(MigrationsLive.pipe(Layer.provideMerge(SqliteNode.SqliteClient.layer({ filename: dbFilename })))) + ) + ) + +describe("reset command", () => { + it.effect("deletes the database and reruns migrations without reseeding todos", () => + Effect.gen(function*() { + const dbFilename = NodePath.join( + OS.tmpdir(), + `effect-http-ws-cli-reset-${Date.now()}-${Math.random().toString(16).slice(2)}.sqlite` + ) + + yield* insertTodo(dbFilename) + + const beforeCount = yield* countRows(dbFilename, "todos") + expect(beforeCount).toBe(1) + + yield* resetDatabase(dbFilename).pipe( + Effect.provide(NodeServices.layer) + ) + + const todoCount = yield* countRows(dbFilename, "todos") + const migrationCount = yield* countRows(dbFilename, "effect_sql_migrations") + + expect(todoCount).toBe(0) + expect(migrationCount).toBe(1) + + yield* Effect.sync(() => { + try { + FileSystem.rmSync(dbFilename, { force: true }) + } catch { + // ignore cleanup failures in tests + } + }) + }) + ) +}) diff --git a/.reference/server/test/server.test.ts b/.reference/server/test/server.test.ts new file mode 100644 index 000000000..317e2a905 --- /dev/null +++ b/.reference/server/test/server.test.ts @@ -0,0 +1,310 @@ +import { NodeHttpServer } from "@effect/platform-node" +import * as SqliteNode from "@effect/sql-sqlite-node" +import { describe, expect, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Stream from "effect/Stream" +import * as FileSystem from "node:fs" +import * as OS from "node:os" +import * as NodePath from "node:path" +import { + HttpBody, + HttpClient, + HttpClientResponse, + HttpServer, + HttpRouter +} from "effect/unstable/http" +import * as SqlClient from "effect/unstable/sql/SqlClient" +import { ServerConfig } from "../src/config.ts" +import { MigrationsLive } from "../src/migrations.ts" +import { ServerMessage } from "../src/messages.ts" +import { withWsRpcClient } from "../src/client.ts" +import { makeRoutesLayer } from "../src/server.ts" + +const testServerConfig = { + host: "127.0.0.1", + port: 0, + assetsDir: new URL("../../public", import.meta.url).pathname, + dbFilename: ":memory:", + requestLogging: false, + frontendDevOrigin: undefined +} + +const AppUnderTest = HttpRouter.serve( + makeRoutesLayer, + { + disableListenLog: true, + disableLogger: true + } +) + +const persistenceLayer = (dbFilename: string) => { + const sqliteLayer = SqliteNode.SqliteClient.layer({ filename: dbFilename }) + return MigrationsLive.pipe(Layer.provideMerge(sqliteLayer)) +} + +const appLayer = (dbFilename: string) => + AppUnderTest.pipe(Layer.provideMerge(persistenceLayer(dbFilename))) + +const insertTodo = (dbFilename: string, id: string, title: string) => + Effect.scoped( + Effect.flatMap(Effect.service(SqlClient.SqlClient), (sql) => { + const now = new Date().toISOString() + return sql.withTransaction( + sql` + INSERT INTO todos (id, title, completed, archived, revision, updated_at) + VALUES (${id}, ${title}, 0, 0, 1, ${now}) + `.pipe( + Effect.flatMap(() => + sql` + INSERT INTO todo_events (at, todo_json, change_json, archived) + VALUES ( + ${now}, + ${JSON.stringify({ + id, + title, + completed: false, + archived: false, + revision: 1, + updatedAt: now + })}, + ${JSON.stringify({ _tag: "TodoCreated" })}, + 0 + ) + ` + ) + ) + ) + }).pipe( + Effect.provide(persistenceLayer(dbFilename)) + ) + ) + +describe("server", () => { + it.effect("routes HTTP messages validated by Schema", () => + Effect.gen(function*() { + yield* Layer.build(appLayer(testServerConfig.dbFilename)).pipe( + Effect.provideService(ServerConfig, testServerConfig) + ) + const client = yield* HttpClient.HttpClient + const response = yield* client.post("/api/dispatch", { + body: HttpBody.jsonUnsafe({ kind: "sum", left: 20, right: 22 }) + }) + + const parsed = yield* HttpClientResponse.schemaBodyJson(ServerMessage)(response) + expect(parsed).toEqual({ kind: "sumResult", total: 42 }) + }).pipe(Effect.provide(NodeHttpServer.layerTest)) + ) + + it.effect("serves static files from local filesystem", () => + Effect.gen(function*() { + yield* Layer.build(appLayer(testServerConfig.dbFilename)).pipe( + Effect.provideService(ServerConfig, testServerConfig) + ) + const text = yield* HttpClient.get("/").pipe( + Effect.flatMap((response) => response.text) + ) + + expect(text).toContain("effect-http-ws-cli") + }).pipe(Effect.provide(NodeHttpServer.layerTest)) + ) + + it.effect("redirects frontend requests to the Vite dev server when enabled", () => + Effect.gen(function*() { + yield* Layer.build(appLayer(testServerConfig.dbFilename)).pipe( + Effect.provideService(ServerConfig, { + ...testServerConfig, + frontendDevOrigin: "http://127.0.0.1:5173" + }) + ) + + const server = yield* HttpServer.HttpServer + const address = server.address as HttpServer.TcpAddress + const response = yield* Effect.promise(() => + fetch(`http://127.0.0.1:${address.port}/todos?filter=active`, { + redirect: "manual" + }) + ) + + expect(response.status).toBe(307) + expect(response.headers.get("location")).toBe("http://127.0.0.1:5173/todos?filter=active") + }).pipe(Effect.provide(NodeHttpServer.layerTest)) + ) + + it.effect("routes WebSocket RPC messages with shared contracts", () => + Effect.gen(function*() { + yield* Layer.build(appLayer(testServerConfig.dbFilename)).pipe( + Effect.provideService(ServerConfig, testServerConfig) + ) + const server = yield* HttpServer.HttpServer + const address = server.address as HttpServer.TcpAddress + const wsUrl = `ws://127.0.0.1:${address.port}/ws` + + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client.echo({ text: "hello from ws" })) + ) + + expect(response).toEqual({ text: "hello from ws" }) + }).pipe(Effect.provide(NodeHttpServer.layerTest)) + ) + + it.effect("routes WebSocket RPC calls for multiple procedures", () => + Effect.gen(function*() { + yield* Layer.build(appLayer(testServerConfig.dbFilename)).pipe( + Effect.provideService(ServerConfig, testServerConfig) + ) + const server = yield* HttpServer.HttpServer + const address = server.address as HttpServer.TcpAddress + const wsUrl = `ws://127.0.0.1:${address.port}/ws` + + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.all({ + sum: client.sum({ left: 1, right: 2 }), + time: client.time(undefined) + }) + ) + ) + + expect(result.sum).toEqual({ total: 3 }) + expect(typeof result.time.iso).toBe("string") + }).pipe(Effect.provide(NodeHttpServer.layerTest)) + ) + + it.effect("lists todos and streams todo updates from offset", () => + Effect.gen(function*() { + const dbFilename = NodePath.join( + OS.tmpdir(), + `effect-http-ws-cli-stream-${Date.now()}-${Math.random().toString(16).slice(2)}.sqlite` + ) + + yield* insertTodo(dbFilename, "todo-stream-test", "stream-seed") + + const events = yield* Effect.scoped( + Effect.gen(function*() { + yield* Layer.build(appLayer(dbFilename)).pipe( + Effect.provideService(ServerConfig, { + ...testServerConfig, + dbFilename + }) + ) + const server = yield* HttpServer.HttpServer + const address = server.address as HttpServer.TcpAddress + const wsUrl = `ws://127.0.0.1:${address.port}/ws` + + return yield* withWsRpcClient(wsUrl, (client) => + Effect.gen(function*() { + const snapshot = yield* client.listTodos({ includeArchived: true }) + const firstTodo = snapshot.todos[0] + if (!firstTodo) { + return yield* Effect.die("Expected a todo fixture") + } + + yield* client.renameTodo({ id: firstTodo.id, title: "alpha" }) + yield* client.completeTodo({ id: firstTodo.id, completed: true }) + yield* client.archiveTodo({ id: firstTodo.id, archived: true }) + + return yield* client.subscribeTodos({ + fromOffset: snapshot.offset, + includeArchived: true + }).pipe( + Stream.take(3), + Stream.runCollect + ) + }) + ) + }) + ).pipe( + Effect.ensuring( + Effect.sync(() => { + try { + FileSystem.rmSync(dbFilename, { force: true }) + } catch { + // ignore cleanup failures in tests + } + }) + ) + ) + + expect(events).toHaveLength(3) + expect(events[0]?.change).toEqual({ _tag: "TodoRenamed", title: "alpha" }) + expect(events[1]?.change).toEqual({ _tag: "TodoCompleted", completed: true }) + expect(events[2]?.change).toEqual({ _tag: "TodoArchived", archived: true }) + expect(events[0]?.todo.title).toBe("alpha") + expect(events[1]?.todo.completed).toBe(true) + expect(events[2]?.todo.archived).toBe(true) + }).pipe(Effect.provide(NodeHttpServer.layerTest)) + ) + + it.effect("persists todos across server restarts when using a file database", () => + Effect.gen(function*() { + const dbFilename = NodePath.join( + OS.tmpdir(), + `effect-http-ws-cli-${Date.now()}-${Math.random().toString(16).slice(2)}.sqlite` + ) + + yield* insertTodo(dbFilename, "todo-persist-test", "persist-seed") + + const withServer = (f: (wsUrl: string) => Effect.Effect) => + Effect.scoped( + Effect.gen(function*() { + yield* Layer.build(appLayer(dbFilename)).pipe( + Effect.provideService(ServerConfig, { + ...testServerConfig, + dbFilename + }) + ) + + const server = yield* HttpServer.HttpServer + const address = server.address as HttpServer.TcpAddress + const wsUrl = `ws://127.0.0.1:${address.port}/ws` + return yield* f(wsUrl) + }) + ) + + const renamedTodoId = yield* withServer((wsUrl) => + Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function*() { + const snapshot = yield* client.listTodos({ includeArchived: true }) + const firstTodo = snapshot.todos[0] + if (!firstTodo) { + return yield* Effect.die("Expected a todo fixture") + } + + yield* client.renameTodo({ id: firstTodo.id, title: "persisted-title" }) + return firstTodo.id + }) + ) + ) + ) + + const persistedTitle = yield* withServer((wsUrl) => + Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function*() { + const snapshot = yield* client.listTodos({ includeArchived: true }) + const persisted = snapshot.todos.find((todo) => todo.id === renamedTodoId) + if (!persisted) { + return yield* Effect.die("Expected persisted todo") + } + return persisted.title + }) + ) + ) + ).pipe( + Effect.ensuring( + Effect.sync(() => { + try { + FileSystem.rmSync(dbFilename, { force: true }) + } catch { + // ignore cleanup failures in tests + } + }) + ) + ) + + expect(persistedTitle).toBe("persisted-title") + }).pipe(Effect.provide(NodeHttpServer.layerTest)) + ) +}) diff --git a/.reference/server/tsconfig.json b/.reference/server/tsconfig.json new file mode 100644 index 000000000..de981b3bf --- /dev/null +++ b/.reference/server/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/.reference/server/vitest.config.ts b/.reference/server/vitest.config.ts new file mode 100644 index 000000000..7ef18d4fc --- /dev/null +++ b/.reference/server/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + environment: "node" + } +}) diff --git a/apps/server/package.json b/apps/server/package.json index b1c6b2667..10fd89b4d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -7,16 +7,16 @@ "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" diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts new file mode 100644 index 000000000..55bf4d04b --- /dev/null +++ b/apps/server/src/bin.ts @@ -0,0 +1,14 @@ +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" }; + +Command.run(cli, { version }).pipe( + Effect.provide(Layer.mergeAll(NetService.layer, NodeServices.layer)), + NodeRuntime.runMain, +); diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts new file mode 100644 index 000000000..e309f98da --- /dev/null +++ b/apps/server/src/cli-config.test.ts @@ -0,0 +1,118 @@ +import os from "node:os"; + +import { expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect, Layer, Option, Path } from "effect"; + +import { NetService } from "@t3tools/shared/Net"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { resolveServerConfig } from "./cli"; + +it.layer(NodeServices.layer)("cli config resolution", (it) => { + it.effect("falls back to effect/config values when flags are omitted", () => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const stateDir = join(os.tmpdir(), "t3-cli-config-env-state"); + const resolved = yield* resolveServerConfig({ + mode: Option.none(), + port: Option.none(), + host: Option.none(), + stateDir: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + authToken: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + }).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_MODE: "desktop", + T3CODE_PORT: "4001", + T3CODE_HOST: "0.0.0.0", + T3CODE_STATE_DIR: stateDir, + 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({ + mode: "desktop", + port: 4001, + cwd: process.cwd(), + keybindingsConfigPath: join(stateDir, "keybindings.json"), + host: "0.0.0.0", + stateDir, + 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 stateDir = join(os.tmpdir(), "t3-cli-config-flags-state"); + const resolved = yield* resolveServerConfig({ + mode: Option.some("web"), + port: Option.some(8788), + host: Option.some("127.0.0.1"), + stateDir: Option.some(stateDir), + devUrl: Option.some(new URL("http://127.0.0.1:4173")), + noBrowser: Option.some(true), + authToken: Option.some("flag-token"), + autoBootstrapProjectFromCwd: Option.some(true), + logWebSocketEvents: Option.some(true), + }).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_MODE: "desktop", + T3CODE_PORT: "4001", + T3CODE_HOST: "0.0.0.0", + T3CODE_STATE_DIR: join(os.tmpdir(), "ignored-state"), + 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({ + mode: "web", + port: 8788, + cwd: process.cwd(), + keybindingsConfigPath: join(stateDir, "keybindings.json"), + host: "127.0.0.1", + stateDir, + 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.ts b/apps/server/src/cli.ts new file mode 100644 index 000000000..236b083bc --- /dev/null +++ b/apps/server/src/cli.ts @@ -0,0 +1,193 @@ +import { NetService } from "@t3tools/shared/Net"; +import { Config, Effect, Option, Path, Schema } from "effect"; +import { Command, Flag } from "effect/unstable/cli"; + +import { + DEFAULT_PORT, + resolveStaticDir, + ServerConfig, + type RuntimeMode, + type ServerConfigShape, +} from "./config"; +import { resolveStateDir } from "./os-jank"; +import { runServer } from "./server"; + +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(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), + 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 stateDirFlag = Flag.string("state-dir").pipe( + Flag.withDescription("State directory path (equivalent to T3CODE_STATE_DIR)."), + 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 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({ + mode: Config.string("T3CODE_MODE").pipe( + Config.option, + Config.map( + Option.match({ + onNone: () => "web", + onSome: (value) => (value === "desktop" ? "desktop" : "web"), + }), + ), + ), + port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), + host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), + stateDir: Config.string("T3CODE_STATE_DIR").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), + ), + 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 stateDir: Option.Option; + readonly devUrl: Option.Option; + readonly noBrowser: Option.Option; + readonly authToken: Option.Option; + readonly autoBootstrapProjectFromCwd: Option.Option; + readonly logWebSocketEvents: Option.Option; +} + +const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => + Option.getOrElse(Option.filter(flag, Boolean), () => envValue); + +export const resolveServerConfig = (flags: CliServerFlags) => + Effect.gen(function* () { + const { findAvailablePort } = yield* NetService; + const env = yield* EnvServerConfig; + + const mode = Option.getOrElse(flags.mode, () => env.mode); + + const port = yield* Option.match(flags.port, { + onSome: (value) => Effect.succeed(value), + onNone: () => { + if (env.port) { + return Effect.succeed(env.port); + } + if (mode === "desktop") { + return Effect.succeed(DEFAULT_PORT); + } + return findAvailablePort(DEFAULT_PORT); + }, + }); + const stateDir = yield* resolveStateDir(Option.getOrUndefined(flags.stateDir) ?? env.stateDir); + const devUrl = Option.getOrElse(flags.devUrl, () => env.devUrl); + const noBrowser = resolveBooleanFlag(flags.noBrowser, env.noBrowser ?? mode === "desktop"); + const authToken = Option.getOrUndefined(flags.authToken) ?? env.authToken; + const autoBootstrapProjectFromCwd = resolveBooleanFlag( + flags.autoBootstrapProjectFromCwd, + env.autoBootstrapProjectFromCwd ?? mode === "web", + ); + const logWebSocketEvents = resolveBooleanFlag( + flags.logWebSocketEvents, + env.logWebSocketEvents ?? Boolean(devUrl), + ); + const staticDir = devUrl ? undefined : yield* resolveStaticDir(); + const { join } = yield* Path.Path; + const keybindingsConfigPath = join(stateDir, "keybindings.json"); + const host = + Option.getOrUndefined(flags.host) ?? + env.host ?? + (mode === "desktop" ? "127.0.0.1" : undefined); + + const config: ServerConfigShape = { + mode, + port, + cwd: process.cwd(), + keybindingsConfigPath, + host, + stateDir, + staticDir, + devUrl, + noBrowser, + authToken, + autoBootstrapProjectFromCwd, + logWebSocketEvents, + }; + + return config; + }); + +const commandFlags = { + mode: modeFlag, + port: portFlag, + host: hostFlag, + stateDir: stateDirFlag, + devUrl: devUrlFlag, + noBrowser: noBrowserFlag, + authToken: authTokenFlag, + autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, + logWebSocketEvents: logWebSocketEventsFlag, +} as const; + +const rootCommand = Command.make("t3", commandFlags).pipe( + Command.withDescription("Run the T3 Code server."), + Command.withHandler((flags) => + Effect.flatMap(resolveServerConfig(flags), (config) => + 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/git/Errors.ts b/apps/server/src/git/Errors.ts index 15bf482f7..4e1f763a9 100644 --- a/apps/server/src/git/Errors.ts +++ b/apps/server/src/git/Errors.ts @@ -1,67 +1,7 @@ -import { Schema } from "effect"; - -/** - * GitCommandError - Git command execution failed. - */ -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}`; - } -} - -/** - * 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; +export { + GitCommandError, + GitHubCliError, + GitManagerError, + TextGenerationError, + type GitManagerServiceError, +} from "@t3tools/contracts"; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts new file mode 100644 index 000000000..d73e3fd92 --- /dev/null +++ b/apps/server/src/http.ts @@ -0,0 +1,166 @@ +import Mime from "@effect/platform-node/Mime"; +import { Effect, FileSystem, 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"; + +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 (!url) { + return HttpServerResponse.text("Bad Request", { status: 400 }); + } + + const config = yield* ServerConfig; + const rawRelativePath = url.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({ + stateDir: config.stateDir, + attachmentId: normalizedRelativePath, + }) + : resolveAttachmentRelativePath({ + stateDir: config.stateDir, + 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 staticAndDevRouteLayer = HttpRouter.add( + "GET", + "*", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (!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.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") + ) { + 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 363a07ee3..000000000 --- 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.ts b/apps/server/src/keybindings.ts index bf5846782..9d22089f4 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, @@ -43,18 +44,7 @@ import { import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "./config"; -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}`; - } -} +export { KeybindingsConfigError }; type WhenToken = | { type: "identifier"; value: string } diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts deleted file mode 100644 index 83976e3d4..000000000 --- a/apps/server/src/main.test.ts +++ /dev/null @@ -1,300 +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 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"; - -const start = vi.fn(() => undefined); -const stop = 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.void, - 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), - AnalyticsService.layerTest, - FetchHttpClient.layer, - NodeServices.layer, -); - -const runCli = ( - args: ReadonlyArray, - env: Record = { T3CODE_NO_BROWSER: "true" }, -) => { - const uniqueStateDir = `/tmp/t3-cli-state-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - return Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe( - Effect.provide( - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - T3CODE_STATE_DIR: uniqueStateDir, - ...env, - }, - }), - ), - ), - ); -}; - -beforeEach(() => { - vi.clearAllMocks(); - resolvedConfig = null; - start.mockImplementation(() => undefined); - stop.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", - "--state-dir", - "/tmp/t3-cli-state", - "--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?.stateDir, "/tmp/t3-cli-state"); - 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_STATE_DIR: "/tmp/t3-env-state", - 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?.stateDir, "/tmp/t3-env-state"); - 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); - }), - ); - - 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("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"]); - - 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"]); - - // 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 0a33be0cb..000000000 --- a/apps/server/src/main.ts +++ /dev/null @@ -1,347 +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, - resolveStaticDir, - ServerConfig, - type RuntimeMode, - type ServerConfigShape, -} from "./config"; -import { fixPath, resolveStateDir } 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 { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; -import { Server } from "./wsServer"; -import { ServerLoggerLive } from "./serverLogger"; -import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; - -export class StartupError extends Data.TaggedError("StartupError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -interface CliInput { - readonly mode: Option.Option; - readonly port: Option.Option; - readonly host: Option.Option; - readonly stateDir: Option.Option; - readonly devUrl: Option.Option; - readonly noBrowser: Option.Option; - readonly authToken: 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.match({ - onNone: () => "web", - onSome: (value) => (value === "desktop" ? "desktop" : "web"), - }), - ), - ), - port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), - host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), - stateDir: Config.string("T3CODE_STATE_DIR").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), - ), - 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 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 mode = Option.getOrElse(input.mode, () => env.mode); - - const port = yield* Option.match(input.port, { - onSome: (value) => Effect.succeed(value), - onNone: () => { - if (env.port) { - return Effect.succeed(env.port); - } - if (mode === "desktop") { - return Effect.succeed(DEFAULT_PORT); - } - return findAvailablePort(DEFAULT_PORT); - }, - }); - const stateDir = yield* resolveStateDir( - Option.getOrUndefined(input.stateDir) ?? env.stateDir, - ); - const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); - const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); - const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; - const autoBootstrapProjectFromCwd = resolveBooleanFlag( - input.autoBootstrapProjectFromCwd, - env.autoBootstrapProjectFromCwd ?? mode === "web", - ); - const logWebSocketEvents = resolveBooleanFlag( - input.logWebSocketEvents, - env.logWebSocketEvents ?? Boolean(devUrl), - ); - const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; - const { join } = yield* Path.Path; - const keybindingsConfigPath = join(stateDir, "keybindings.json"); - const host = - Option.getOrUndefined(input.host) ?? - env.host ?? - (mode === "desktop" ? "127.0.0.1" : undefined); - - const config: ServerConfigShape = { - mode, - port, - cwd: cliConfig.cwd, - keybindingsConfigPath, - host, - stateDir, - staticDir, - devUrl, - noBrowser, - authToken, - autoBootstrapProjectFromCwd, - logWebSocketEvents, - } satisfies ServerConfigShape; - - return config; - }), - ); - -const LayerLive = (input: CliInput) => - Layer.empty.pipe( - Layer.provideMerge(makeServerRuntimeServicesLayer()), - Layer.provideMerge(makeServerProviderLayer()), - Layer.provideMerge(ProviderHealthLive), - Layer.provideMerge(SqlitePersistence.layerConfig), - Layer.provideMerge(ServerLoggerLive), - Layer.provideMerge(AnalyticsServiceLayerLive), - 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 makeServerProgram = (input: CliInput) => - Effect.gen(function* () { - const cliConfig = yield* CliConfig; - const { start, stopSignal } = yield* Server; - const openDeps = yield* Open; - yield* cliConfig.fixPath; - - 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))); - -/** - * 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(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), - 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 stateDirFlag = Flag.string("state-dir").pipe( - Flag.withDescription("State directory path (equivalent to T3CODE_STATE_DIR)."), - 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 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, - stateDir: stateDirFlag, - devUrl: devUrlFlag, - noBrowser: noBrowserFlag, - authToken: authTokenFlag, - 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 e7238c04b..3bd1dbcd4 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/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts new file mode 100644 index 000000000..c433be4f0 --- /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({ + stateDir: serverConfig.stateDir, + 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/server.test.ts b/apps/server/src/server.test.ts new file mode 100644 index 000000000..029ba54a3 --- /dev/null +++ b/apps/server/src/server.test.ts @@ -0,0 +1,1067 @@ +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, + GitCommandError, + KeybindingRule, + OpenError, + ORCHESTRATION_WS_METHODS, + ProjectId, + ResolvedKeybindingRule, + TerminalError, + ThreadId, + WS_METHODS, + WsRpcGroup, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; +import { Deferred, Effect, Fiber, FileSystem, Layer, Path, Stream } from "effect"; +import { TestClock } from "effect/testing"; +import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; + +import type { ServerConfigShape } from "./config.ts"; +import { 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 { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth.ts"; +import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; + +const defaultProjectId = ProjectId.makeUnsafe("project-default"); +const defaultThreadId = ThreadId.makeUnsafe("thread-default"); + +const makeDefaultOrchestrationReadModel = () => { + const now = new Date().toISOString(); + return { + snapshotSequence: 0, + updatedAt: now, + projects: [ + { + id: defaultProjectId, + title: "Default Project", + workspaceRoot: "/tmp/default-project", + defaultModel: "gpt-5-codex", + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + }, + ], + threads: [ + { + id: defaultThreadId, + projectId: defaultProjectId, + title: "Default Thread", + model: "gpt-5-codex", + interactionMode: "default" as const, + runtimeMode: "full-access" as const, + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + latestTurn: null, + messages: [], + session: null, + activities: [], + proposedPlans: [], + checkpoints: [], + deletedAt: null, + }, + ], + }; +}; + +const buildAppUnderTest = (options?: { + config?: Partial; + layers?: { + keybindings?: Partial; + providerHealth?: Partial; + open?: Partial; + gitCore?: Partial; + gitManager?: Partial; + terminalManager?: Partial; + orchestrationEngine?: Partial; + projectionSnapshotQuery?: Partial; + checkpointDiffQuery?: Partial; + }; +}) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempStateDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-test-" }); + const stateDir = options?.config?.stateDir ?? tempStateDir; + const layerConfig = Layer.succeed(ServerConfig, { + mode: "web", + port: 0, + host: "127.0.0.1", + cwd: process.cwd(), + keybindingsConfigPath: path.join(stateDir, "keybindings.json"), + stateDir, + staticDir: undefined, + devUrl: undefined, + noBrowser: true, + authToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + ...options?.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(ProviderHealth)({ + getStatuses: Effect.succeed([]), + ...options?.layers?.providerHealth, + }), + ), + 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(layerConfig), + ); + + yield* Layer.build(appLayer); + return stateDir; + }); + +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 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 stateDir = yield* buildAppUnderTest(); + const attachmentPath = resolveAttachmentRelativePath({ + stateDir, + 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("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), + }, + providerHealth: { + getStatuses: 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.deepEqual(first.config.keybindings, []); + assert.deepEqual(first.config.issues, []); + assert.deepEqual(first.config.providers, providers); + } + assert.deepEqual(second, { + type: "keybindingsUpdated", + payload: { issues: [] }, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc subscribeServerConfig emits providerStatuses heartbeat", () => + Effect.gen(function* () { + const providers = [] as const; + + yield* buildAppUnderTest({ + layers: { + keybindings: { + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), + streamChanges: Stream.empty, + }, + providerHealth: { + getStatuses: Effect.succeed(providers), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + Effect.gen(function* () { + const snapshotReceived = yield* Deferred.make(); + const eventsFiber = yield* withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({}).pipe( + Stream.tap((event) => + event.type === "snapshot" + ? Deferred.succeed(snapshotReceived, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Stream.take(2), + Stream.runCollect, + ), + ).pipe(Effect.forkScoped); + + yield* Deferred.await(snapshotReceived); + yield* TestClock.adjust("10 seconds"); + return yield* Fiber.join(eventsFiber); + }), + ); + + const [first, second] = Array.from(events); + assert.equal(first?.type, "snapshot"); + assert.deepEqual(second, { + type: "providerStatuses", + payload: { providers }, + }); + }).pipe(Effect.provide(Layer.mergeAll(NodeHttpServer.layerTest, TestClock.layer()))), + ); + + 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( + String(result.failure.cause), + "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: "cursor" | "vscode" | "zed" | "file-manager"; + } | 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]({ 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", + defaultModel: "gpt-5-codex", + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + }, + ], + threads: [ + { + id: ThreadId.makeUnsafe("thread-1"), + projectId: ProjectId.makeUnsafe("project-a"), + title: "Thread A", + model: "gpt-5-codex", + interactionMode: "default" as const, + runtimeMode: "full-access" as const, + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + 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 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 TerminalError({ message: "Terminal is not running" }); + 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 000000000..fa11dac27 --- /dev/null +++ b/apps/server/src/server.ts @@ -0,0 +1,56 @@ +import * as Net from "node:net"; +import * as Http from "node:http"; + +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import { Effect, Layer } from "effect"; +import { HttpRouter } from "effect/unstable/http"; + +import { ServerConfig } from "./config"; +import { attachmentsRouteLayer, healthRouteLayer, staticAndDevRouteLayer } from "./http"; +import { fixPath } from "./os-jank"; +import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY"; +import { TerminalManagerLive } from "./terminal/Layers/Manager"; +import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY"; +import { websocketRpcRouteLayer } from "./ws"; +import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; +import { KeybindingsLive } from "./keybindings"; + +const terminalManagerLayer = TerminalManagerLive.pipe( + Layer.provide( + typeof Bun !== "undefined" && process.platform !== "win32" + ? BunPtyAdapterLive + : NodePtyAdapterLive, + ), +); + +const runtimeServicesLayer = Layer.mergeAll( + terminalManagerLayer, + ProviderHealthLive, + KeybindingsLive, + /// other runtime services +); + +export const makeRoutesLayer = Layer.mergeAll( + healthRouteLayer, + attachmentsRouteLayer, + staticAndDevRouteLayer, + websocketRpcRouteLayer, +); + +export const makeServerLayer = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig; + const listenOptions: Net.ListenOptions = config.host + ? { host: config.host, port: config.port } + : { port: config.port }; + yield* Effect.sync(fixPath); + return HttpRouter.serve(makeRoutesLayer, { + disableLogger: !config.logWebSocketEvents, + }).pipe( + Layer.provide(runtimeServicesLayer), + Layer.provide(NodeHttpServer.layer(Http.createServer, listenOptions)), + ); + }), +); + +export const runServer = Layer.launch(makeServerLayer); diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index 8d8398c7a..6c122f1af 100644 --- a/apps/server/src/terminal/Services/Manager.ts +++ b/apps/server/src/terminal/Services/Manager.ts @@ -10,6 +10,7 @@ import { TerminalClearInput, TerminalCloseInput, TerminalEvent, + TerminalError, TerminalOpenInput, TerminalResizeInput, TerminalRestartInput, @@ -18,12 +19,8 @@ import { TerminalWriteInput, } from "@t3tools/contracts"; import { PtyProcess } from "./PTY"; -import { Effect, Schema, ServiceMap } from "effect"; - -export class TerminalError extends Schema.TaggedErrorClass()("TerminalError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect), -}) {} +import { Effect, ServiceMap } from "effect"; +export { TerminalError }; export interface TerminalSessionState { threadId: string; diff --git a/apps/server/src/workspaceEntries.ts b/apps/server/src/workspaceEntries.ts index dbce5c427..bb9f5609b 100644 --- a/apps/server/src/workspaceEntries.ts +++ b/apps/server/src/workspaceEntries.ts @@ -7,7 +7,9 @@ import { ProjectEntry, ProjectSearchEntriesInput, ProjectSearchEntriesResult, + ProjectWriteFileError, } from "@t3tools/contracts"; +import { Effect, Path } from "effect"; const WORKSPACE_CACHE_TTL_MS = 15_000; const WORKSPACE_CACHE_MAX_KEYS = 4; @@ -434,3 +436,40 @@ export async function searchWorkspaceEntries( truncated: index.truncated || ranked.length > input.limit, }; } + +function toPosixRelativePath(input: string): string { + return input.replaceAll("\\", "/"); +} + +export const resolveWorkspaceWritePath = Effect.fn(function* (params: { + workspaceRoot: string; + relativePath: string; +}) { + const path = yield* Path.Path; + + const normalizedInputPath = params.relativePath.trim(); + if (path.isAbsolute(normalizedInputPath)) { + return yield* new ProjectWriteFileError({ + message: "Workspace file path must be relative to the project root.", + }); + } + + const absolutePath = path.resolve(params.workspaceRoot, normalizedInputPath); + const relativeToRoot = toPosixRelativePath(path.relative(params.workspaceRoot, absolutePath)); + if ( + relativeToRoot.length === 0 || + relativeToRoot === "." || + relativeToRoot.startsWith("../") || + relativeToRoot === ".." || + path.isAbsolute(relativeToRoot) + ) { + return yield* new ProjectWriteFileError({ + message: "Workspace file path must stay within the project root.", + }); + } + + return { + absolutePath, + relativePath: relativeToRoot, + }; +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts new file mode 100644 index 000000000..af246686d --- /dev/null +++ b/apps/server/src/ws.ts @@ -0,0 +1,299 @@ +import { Effect, FileSystem, Layer, Path, Schema, Stream, PubSub } from "effect"; +import { + OrchestrationDispatchCommandError, + OrchestrationGetFullThreadDiffError, + OrchestrationGetSnapshotError, + OrchestrationGetTurnDiffError, + ORCHESTRATION_WS_METHODS, + ProjectSearchEntriesError, + ProjectWriteFileError, + OrchestrationReplayEventsError, + type TerminalEvent, + WS_METHODS, + WsRpcGroup, +} from "@t3tools/contracts"; +import { clamp } from "effect/Number"; +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 { ProviderHealth } from "./provider/Services/ProviderHealth"; +import { TerminalManager } from "./terminal/Services/Manager"; +import { resolveWorkspaceWritePath, searchWorkspaceEntries } from "./workspaceEntries"; + +const WsRpcLayer = WsRpcGroup.toLayer({ + [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => + Effect.gen(function* () { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + return yield* projectionSnapshotQuery.getSnapshot(); + }).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load orchestration snapshot", + cause, + }), + ), + ), + [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => + Effect.gen(function* () { + const orchestrationEngine = yield* OrchestrationEngineService; + const normalizedCommand = yield* normalizeDispatchCommand(command); + return yield* 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) => + Effect.gen(function* () { + const checkpointDiffQuery = yield* CheckpointDiffQuery; + return yield* checkpointDiffQuery.getTurnDiff(input); + }).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetTurnDiffError({ + message: "Failed to load turn diff", + cause, + }), + ), + ), + [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) => + Effect.gen(function* () { + const checkpointDiffQuery = yield* CheckpointDiffQuery; + return yield* checkpointDiffQuery.getFullThreadDiff(input); + }).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetFullThreadDiffError({ + message: "Failed to load full thread diff", + cause, + }), + ), + ), + [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => + Effect.gen(function* () { + const orchestrationEngine = yield* OrchestrationEngineService; + return yield* Stream.runCollect( + orchestrationEngine.readEvents( + clamp(input.fromSequenceExclusive, { maximum: Number.MAX_SAFE_INTEGER, minimum: 0 }), + ), + ).pipe(Effect.map((events) => Array.from(events))); + }).pipe( + Effect.mapError( + (cause) => + new OrchestrationReplayEventsError({ + message: "Failed to replay orchestration events", + cause, + }), + ), + ), + [WS_METHODS.serverUpsertKeybinding]: (rule) => + Effect.gen(function* () { + const keybindings = yield* Keybindings; + const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); + return { keybindings: keybindingsConfig, issues: [] }; + }), + [WS_METHODS.projectsSearchEntries]: (input) => + Effect.tryPromise({ + try: () => searchWorkspaceEntries(input), + catch: (cause) => + new ProjectSearchEntriesError({ + message: "Failed to search workspace entries", + cause, + }), + }), + [WS_METHODS.projectsWriteFile]: (input) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = yield* resolveWorkspaceWritePath({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( + Effect.mapError( + (cause) => + new ProjectWriteFileError({ + message: "Failed to prepare workspace path", + cause, + }), + ), + ); + yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( + Effect.mapError( + (cause) => + new ProjectWriteFileError({ + message: "Failed to write workspace file", + cause, + }), + ), + ); + return { relativePath: target.relativePath }; + }), + [WS_METHODS.shellOpenInEditor]: (input) => + Effect.gen(function* () { + const open = yield* Open; + return yield* open.openInEditor(input); + }), + [WS_METHODS.gitStatus]: (input) => + Effect.gen(function* () { + const gitManager = yield* GitManager; + return yield* gitManager.status(input); + }), + [WS_METHODS.gitPull]: (input) => + Effect.gen(function* () { + const git = yield* GitCore; + return yield* git.pullCurrentBranch(input.cwd); + }), + [WS_METHODS.gitRunStackedAction]: (input) => + Effect.gen(function* () { + const gitManager = yield* GitManager; + return yield* gitManager.runStackedAction(input); + }), + [WS_METHODS.gitResolvePullRequest]: (input) => + Effect.gen(function* () { + const gitManager = yield* GitManager; + return yield* gitManager.resolvePullRequest(input); + }), + [WS_METHODS.gitPreparePullRequestThread]: (input) => + Effect.gen(function* () { + const gitManager = yield* GitManager; + return yield* gitManager.preparePullRequestThread(input); + }), + [WS_METHODS.gitListBranches]: (input) => + Effect.gen(function* () { + const git = yield* GitCore; + return yield* git.listBranches(input); + }), + [WS_METHODS.gitCreateWorktree]: (input) => + Effect.gen(function* () { + const git = yield* GitCore; + return yield* git.createWorktree(input); + }), + [WS_METHODS.gitRemoveWorktree]: (input) => + Effect.gen(function* () { + const git = yield* GitCore; + return yield* git.removeWorktree(input); + }), + [WS_METHODS.gitCreateBranch]: (input) => + Effect.gen(function* () { + const git = yield* GitCore; + return yield* git.createBranch(input); + }), + [WS_METHODS.gitCheckout]: (input) => + Effect.gen(function* () { + const git = yield* GitCore; + return yield* Effect.scoped(git.checkoutBranch(input)); + }), + [WS_METHODS.gitInit]: (input) => + Effect.gen(function* () { + const git = yield* GitCore; + return yield* git.initRepo(input); + }), + [WS_METHODS.terminalOpen]: (input) => + Effect.gen(function* () { + const terminalManager = yield* TerminalManager; + return yield* terminalManager.open(input); + }), + [WS_METHODS.terminalWrite]: (input) => + Effect.gen(function* () { + const terminalManager = yield* TerminalManager; + return yield* terminalManager.write(input); + }), + [WS_METHODS.terminalResize]: (input) => + Effect.gen(function* () { + const terminalManager = yield* TerminalManager; + return yield* terminalManager.resize(input); + }), + [WS_METHODS.terminalClear]: (input) => + Effect.gen(function* () { + const terminalManager = yield* TerminalManager; + return yield* terminalManager.clear(input); + }), + [WS_METHODS.terminalRestart]: (input) => + Effect.gen(function* () { + const terminalManager = yield* TerminalManager; + return yield* terminalManager.restart(input); + }), + [WS_METHODS.terminalClose]: (input) => + Effect.gen(function* () { + const terminalManager = yield* TerminalManager; + return yield* terminalManager.close(input); + }), + [WS_METHODS.subscribeTerminalEvents]: (_input) => + Stream.unwrap( + Effect.gen(function* () { + const terminalManager = yield* TerminalManager; + const pubsub = yield* PubSub.unbounded(); + const unsubscribe = yield* terminalManager.subscribe((event) => { + PubSub.publishUnsafe(pubsub, event); + }); + return Stream.fromPubSub(pubsub).pipe(Stream.ensuring(Effect.sync(() => unsubscribe()))); + }), + ), + [WS_METHODS.subscribeServerConfig]: (_input) => + Stream.unwrap( + Effect.gen(function* () { + const keybindings = yield* Keybindings; + const providerHealth = yield* ProviderHealth; + const config = yield* ServerConfig; + const keybindingsConfig = yield* keybindings.loadConfigState; + const providers = yield* providerHealth.getStatuses; + + const keybindingsUpdates = keybindings.streamChanges.pipe( + Stream.mapEffect((event) => + Effect.succeed({ + type: "keybindingsUpdated" as const, + payload: { + issues: event.issues, + }, + }), + ), + ); + const providerStatuses = Stream.tick("10 seconds").pipe( + Stream.mapEffect(() => + Effect.gen(function* () { + const providers = yield* providerHealth.getStatuses; + return { + type: "providerStatuses" as const, + payload: { providers }, + }; + }), + ), + ); + return Stream.concat( + Stream.make({ + type: "snapshot" as const, + config: { + cwd: config.cwd, + keybindingsConfigPath: config.keybindingsConfigPath, + keybindings: keybindingsConfig.keybindings, + issues: keybindingsConfig.issues, + providers, + availableEditors: resolveAvailableEditors(), + }, + }), + Stream.merge(keybindingsUpdates, providerStatuses), + ); + }), + ), +}); + +export const websocketRpcRouteLayer = RpcServer.layerHttp({ + group: WsRpcGroup, + path: "/ws", + protocol: "websocket", +}).pipe(Layer.provide(WsRpcLayer), Layer.provide(RpcSerialization.layerJson)); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts deleted file mode 100644 index f12792a31..000000000 --- a/apps/server/src/wsServer.test.ts +++ /dev/null @@ -1,1815 +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, Layer, PlatformError, PubSub, Scope, Stream } from "effect"; -import { describe, expect, it, afterEach, vi } from "vitest"; -import { createServer } from "./wsServer"; -import WebSocket from "ws"; -import { ServerConfig, type ServerConfigShape } from "./config"; -import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; - -import { - DEFAULT_TERMINAL_ID, - EDITORS, - EventId, - ORCHESTRATION_WS_CHANNELS, - ORCHESTRATION_WS_METHODS, - ProviderItemId, - ThreadId, - TurnId, - WS_CHANNELS, - WS_METHODS, - type WebSocketResponse, - type ProviderRuntimeEvent, - type ServerProviderStatus, - 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 { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth"; -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"; - -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", - status: "ready", - available: true, - authStatus: "authenticated", - checkedAt: "2026-01-01T00:00:00.000Z", - }, -]; - -const defaultProviderHealthService: ProviderHealthShape = { - getStatuses: Effect.succeed(defaultProviderStatuses), -}; - -class MockTerminalManager implements TerminalManagerShape { - private readonly sessions = new Map(); - private readonly listeners = new Set<(event: TerminalEvent) => void>(); - - private key(threadId: string, terminalId: string): string { - return `${threadId}\u0000${terminalId}`; - } - - emitEvent(event: TerminalEvent): void { - for (const listener of this.listeners) { - listener(event); - } - } - - subscriptionCount(): number { - return this.listeners.size; - } - - 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.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - }); - - readonly dispose: TerminalManagerShape["dispose"] = Effect.void; -} - -// --------------------------------------------------------------------------- -// 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); - } -} - -describe("WebSocket Server", () => { - let server: Http.Server | null = null; - let serverScope: Scope.Closeable | 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; - stateDir?: string; - staticDir?: string; - providerLayer?: Layer.Layer; - providerHealth?: ProviderHealthShape; - open?: OpenShape; - gitManager?: GitManagerShape; - gitCore?: Pick; - terminalManager?: TerminalManagerShape; - } = {}, - ): Promise { - if (serverScope) { - throw new Error("Test server is already running"); - } - - const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-"); - const scope = await Effect.runPromise(Scope.make("sequential")); - const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; - const providerLayer = options.providerLayer ?? makeServerProviderLayer(); - const providerHealthLayer = Layer.succeed( - ProviderHealth, - options.providerHealth ?? defaultProviderHealthService, - ); - const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService); - const serverConfigLayer = Layer.succeed(ServerConfig, { - mode: "web", - port: 0, - host: undefined, - cwd: options.cwd ?? "/test/project", - keybindingsConfigPath: path.join(stateDir, "keybindings.json"), - stateDir, - staticDir: options.staticDir, - devUrl: options.devUrl ? new URL(options.devUrl) : undefined, - 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 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.provide(infrastructureLayer)), - infrastructureLayer, - ), - runtimeOverrides, - ); - const dependenciesLayer = Layer.empty.pipe( - Layer.provideMerge(runtimeLayer), - Layer.provideMerge(providerHealthLayer), - Layer.provideMerge(openLayer), - Layer.provideMerge(serverConfigLayer), - Layer.provideMerge(AnalyticsService.layerTest), - Layer.provideMerge(NodeServices.layer), - ); - const runtimeServices = await Effect.runPromise( - Layer.build(dependenciesLayer).pipe(Scope.provide(scope)), - ); - - try { - const runtime = await Effect.runPromise( - createServer().pipe(Effect.provide(runtimeServices), Scope.provide(scope)), - ); - serverScope = scope; - return runtime; - } catch (error) { - await Effect.runPromise(Scope.close(scope, Exit.void)); - throw error; - } - } - - async function closeTestServer() { - if (!serverScope) return; - const scope = serverScope; - serverScope = null; - await Effect.runPromise(Scope.close(scope, Exit.void)); - } - - 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 stateDir = makeTempDir("t3code-state-attachments-"); - const attachmentPath = path.join(stateDir, "attachments", "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", stateDir }); - 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 stateDir = makeTempDir("t3code-state-attachments-encoded-"); - const attachmentPath = path.join( - stateDir, - "attachments", - "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", stateDir }); - 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 stateDir = 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", stateDir, 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 stateDir = 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", stateDir, 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; - defaultModel: string | null; - }>; - threads: Array<{ - id: string; - projectId: string; - title: 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", - defaultModel: "gpt-5-codex", - }), - ]), - ); - expect(snapshot.threads).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: bootstrapThreadId, - projectId: bootstrapProjectId, - title: "New thread", - model: "gpt-5-codex", - branch: null, - worktreePath: null, - }), - ]), - ); - }); - - it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => { - const stateDir = makeTempDir("t3code-state-bootstrap-existing-"); - const persistenceLayer = makeSqlitePersistenceLive(path.join(stateDir, "state.sqlite")).pipe( - Layer.provide(NodeServices.layer), - ); - const cwd = "/test/bootstrap-existing"; - - server = await createTestServer({ - cwd, - stateDir, - 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, - stateDir, - 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 stateDir = makeTempDir("t3code-state-get-config-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); - fs.writeFileSync(keybindingsPath, "[]", "utf8"); - - server = await createTestServer({ cwd: "/my/workspace", stateDir }); - 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), - }); - expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); - }); - - it("bootstraps default keybindings file when missing", async () => { - const stateDir = makeTempDir("t3code-state-bootstrap-keybindings-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); - expect(fs.existsSync(keybindingsPath)).toBe(false); - - server = await createTestServer({ cwd: "/my/workspace", stateDir }); - 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), - }); - 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 stateDir = makeTempDir("t3code-state-malformed-keybindings-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); - fs.writeFileSync(keybindingsPath, "{ not-json", "utf8"); - - server = await createTestServer({ cwd: "/my/workspace", stateDir }); - 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), - }); - 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 stateDir = makeTempDir("t3code-state-partial-invalid-keybindings-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); - 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", stateDir }); - 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 stateDir = makeTempDir("t3code-state-keybindings-watch-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); - fs.writeFileSync(keybindingsPath, "[]", "utf8"); - - server = await createTestServer({ cwd: "/my/workspace", stateDir }); - 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) }], - providers: defaultProviderStatuses, - }); - - const successPush = await rewriteKeybindingsAndWaitForPush( - ws, - keybindingsPath, - "[]", - (push) => Array.isArray(push.data.issues) && push.data.issues.length === 0, - ); - expect(successPush.data).toEqual({ issues: [], providers: defaultProviderStatuses }); - }); - - 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 stateDir = makeTempDir("t3code-state-keybindings-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); - 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", stateDir }); - 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), - }); - expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); - }); - - it("upserts keybinding rules and updates cached server config", async () => { - const stateDir = makeTempDir("t3code-state-upsert-keybinding-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); - fs.writeFileSync( - keybindingsPath, - JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), - "utf8", - ); - - server = await createTestServer({ cwd: "/my/workspace", stateDir }); - 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), - }); - 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, - defaultModel: "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", - 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("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, - }); - 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, - defaultModel: "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", - 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: [], - }, - assistantDeliveryMode: "streaming", - 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("detaches terminal event listener on stop for injected manager", async () => { - const terminalManager = new MockTerminalManager(); - server = await createTestServer({ - cwd: "/test", - terminalManager, - }); - - expect(terminalManager.subscriptionCount()).toBe(1); - - await closeTestServer(); - server = null; - - expect(terminalManager.subscriptionCount()).toBe(0); - }); - - 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("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 stay within the project root.", - ); - 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, { - cwd: "/test", - action: "commit_push", - }); - expect(response.result).toBeUndefined(); - expect(response.error?.message).toContain("detached HEAD"); - expect(runStackedAction).toHaveBeenCalledWith({ - cwd: "/test", - action: "commit_push", - }); - }); - - 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 index 2e6ac51b7..ef02dad12 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -9,100 +9,37 @@ 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 { Cause, Effect, Exit, Layer, Path, Ref, Result, Schema, Scope, Stream } 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 { searchWorkspaceEntries } from "./workspaceEntries"; 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 { ProviderHealth } from "./provider/Services/ProviderHealth"; -import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; -import { clamp } from "effect/Number"; -import { Open, resolveAvailableEditors } from "./open"; +import { Open } 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 { expandHomePath } from "./os-jank.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; -/** - * 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 ( @@ -153,51 +90,11 @@ function websocketRawToString(raw: unknown): string | null { return null; } -function toPosixRelativePath(input: string): string { - return input.replaceAll("\\", "/"); -} +const isWildcardHost = (host: string | undefined): boolean => + host === "0.0.0.0" || host === "::" || host === "[::]"; -function resolveWorkspaceWritePath(params: { - workspaceRoot: string; - relativePath: string; - path: Path.Path; -}): Effect.Effect<{ absolutePath: string; relativePath: string }, RouteRequestError> { - const normalizedInputPath = params.relativePath.trim(); - if (params.path.isAbsolute(normalizedInputPath)) { - return Effect.fail( - new RouteRequestError({ - message: "Workspace file path must be relative to the project root.", - }), - ); - } - - const absolutePath = params.path.resolve(params.workspaceRoot, normalizedInputPath); - const relativeToRoot = toPosixRelativePath( - params.path.relative(params.workspaceRoot, absolutePath), - ); - if ( - relativeToRoot.length === 0 || - relativeToRoot === "." || - relativeToRoot.startsWith("../") || - relativeToRoot === ".." || - params.path.isAbsolute(relativeToRoot) - ) { - return Effect.fail( - new RouteRequestError({ - message: "Workspace file path must stay within the project root.", - }), - ); - } - - return Effect.succeed({ - absolutePath, - relativePath: relativeToRoot, - }); -} - -function stripRequestTag(body: T) { - return Struct.omit(body, ["_tag"]); -} +const formatHostForUrl = (host: string): string => + host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; const encodeWsResponse = Schema.encodeEffect(Schema.fromJsonString(WsResponse)); const decodeWebSocketRequest = decodeJsonResult(WebSocketRequest); @@ -205,15 +102,12 @@ const decodeWebSocketRequest = decodeJsonResult(WebSocketRequest); export type ServerCoreRuntimeServices = | OrchestrationEngineService | ProjectionSnapshotQuery - | CheckpointDiffQuery | OrchestrationReactor | ProviderService | ProviderHealth; export type ServerRuntimeServices = | ServerCoreRuntimeServices - | GitManager - | GitCore | TerminalManager | Keybindings | Open @@ -231,16 +125,40 @@ class RouteRequestError extends Schema.TaggedErrorClass()("Ro message: Schema.String, }) {} +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, + }); +}); + export const createServer = Effect.fn(function* (): Effect.fn.Return< http.Server, ServerLifecycleError, - Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path + Scope.Scope | ServerRuntimeServices | ServerConfig | Path.Path > { const serverConfig = yield* ServerConfig; const { port, cwd, - keybindingsConfigPath, staticDir, devUrl, authToken, @@ -248,14 +166,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< logWebSocketEvents, autoBootstrapProjectFromCwd, } = serverConfig; - const availableEditors = resolveAvailableEditors(); - const gitManager = yield* GitManager; const terminalManager = yield* TerminalManager; const keybindingsManager = yield* Keybindings; const providerHealth = yield* ProviderHealth; - const git = yield* GitCore; - const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe( @@ -296,282 +210,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ); yield* readiness.markKeybindingsReady; - const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { - readonly command: ClientOrchestrationCommand; - }) { - const normalizeProjectWorkspaceRoot = Effect.fnUntraced(function* (workspaceRoot: string) { - 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 RouteRequestError({ - message: `Project directory does not exist: ${normalizedWorkspaceRoot}`, - }); - } - if (workspaceStat.type !== "Directory") { - return yield* new RouteRequestError({ - message: `Project path is not a directory: ${normalizedWorkspaceRoot}`, - }); - } - return normalizedWorkspaceRoot; - }); - - if (input.command.type === "project.create") { - return { - ...input.command, - workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot), - } satisfies OrchestrationCommand; - } - - if (input.command.type === "project.meta.update" && input.command.workspaceRoot !== undefined) { - return { - ...input.command, - workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot), - } 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({ - stateDir: serverConfig.stateDir, - 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 Effect.runPromise( - Effect.gen(function* () { - const url = new URL(req.url ?? "/", `http://localhost:${port}`); - if (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({ - stateDir: serverConfig.stateDir, - attachmentId: normalizedRelativePath, - }) - : resolveAttachmentRelativePath({ - stateDir: serverConfig.stateDir, - 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"); - } - }); + // HTTP behavior migrated to `httpRouter.ts` + `server.ts`. + // wsServer remains focused on WebSocket lifecycle during migration. + const httpServer = http.createServer((_req, res) => { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("HTTP routes moved to HttpRouter runtime."); }); // WebSocket server — upgrades from the HTTP server @@ -600,9 +243,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const orchestrationEngine = yield* OrchestrationEngineService; const projectionReadModelQuery = yield* ProjectionSnapshotQuery; - const checkpointDiffQuery = yield* CheckpointDiffQuery; const orchestrationReactor = yield* OrchestrationReactor; - const { openInEditor } = yield* Open; + const { openBrowser } = yield* Open; const subscriptionsScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); @@ -685,7 +327,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< } const runtimeServices = yield* Effect.services< - ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path + ServerRuntimeServices | ServerConfig | Path.Path >(); const runPromise = Effect.runPromiseWith(runtimeServices); @@ -700,196 +342,43 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ); yield* readiness.markHttpListening; - yield* Effect.addFinalizer(() => - Effect.all([closeAllClients, closeWebSocketServer.pipe(Effect.ignoreCause({ log: true }))]), - ); - - const routeRequest = Effect.fnUntraced(function* (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* Effect.tryPromise({ - try: () => searchWorkspaceEntries(body), - catch: (cause) => - new RouteRequestError({ - message: `Failed to search workspace entries: ${String(cause)}`, - }), - }); - } - - case WS_METHODS.projectsWriteFile: { - const body = stripRequestTag(request.body); - const target = yield* resolveWorkspaceWritePath({ - workspaceRoot: body.cwd, - relativePath: body.relativePath, - path, - }); - yield* fileSystem - .makeDirectory(path.dirname(target.absolutePath), { recursive: true }) - .pipe( - Effect.mapError( - (cause) => - new RouteRequestError({ - message: `Failed to prepare workspace path: ${String(cause)}`, - }), - ), - ); - yield* fileSystem.writeFileString(target.absolutePath, body.contents).pipe( - Effect.mapError( - (cause) => - new RouteRequestError({ - message: `Failed to write workspace file: ${String(cause)}`, - }), - ), - ); - return { relativePath: target.relativePath }; - } - - 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); - } - - 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); - } + if (!devUrl && !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.", + }); + } - case WS_METHODS.terminalClear: { - const body = stripRequestTag(request.body); - return yield* terminalManager.clear(body); - } + const localUrl = `http://localhost:${port}`; + const bindUrl = + host && !isWildcardHost(host) ? `http://${formatHostForUrl(host)}:${port}` : localUrl; + const { authToken: _authToken, devUrl: configDevUrl, ...safeConfig } = serverConfig; + yield* Effect.logInfo("T3 Code running", { + ...safeConfig, + devUrl: configDevUrl?.toString(), + authEnabled: Boolean(authToken), + }); - case WS_METHODS.terminalRestart: { - const body = stripRequestTag(request.body); - return yield* terminalManager.restart(body); - } + if (!serverConfig.noBrowser) { + const target = configDevUrl?.toString() ?? bindUrl; + yield* openBrowser(target).pipe( + Effect.catch(() => + Effect.logInfo("browser auto-open unavailable", { + hint: `Open ${target} in your browser.`, + }), + ), + ); + } - case WS_METHODS.terminalClose: { - const body = stripRequestTag(request.body); - return yield* terminalManager.close(body); - } + yield* recordStartupHeartbeat; - case WS_METHODS.serverGetConfig: - const keybindingsConfig = yield* keybindingsManager.loadConfigState; - return { - cwd, - keybindingsConfigPath, - keybindings: keybindingsConfig.keybindings, - issues: keybindingsConfig.issues, - providers: providerStatuses, - availableEditors, - }; - - case WS_METHODS.serverUpsertKeybinding: { - const body = stripRequestTag(request.body); - const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body); - return { keybindings: keybindingsConfig, issues: [] }; - } + yield* Effect.addFinalizer(() => + Effect.all([closeAllClients, closeWebSocketServer.pipe(Effect.ignoreCause({ log: true }))]), + ); - default: { - const _exhaustiveCheck: never = request.body; - return yield* new RouteRequestError({ - message: `Unknown method: ${String(_exhaustiveCheck)}`, - }); - } - } + const routeRequest = Effect.fnUntraced(function* (request: WebSocketRequest) { + return yield* new RouteRequestError({ + message: `WebSocket method '${request.body._tag}' is now handled by RpcServer at /ws`, + }); }); const handleMessage = Effect.fnUntraced(function* (ws: WebSocket, raw: unknown) { @@ -1000,7 +489,4 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return httpServer; }); -export const ServerLive = Layer.succeed(Server, { - start: createServer(), - stopSignal: Effect.never, -} satisfies ServerShape); +export const ServerLayer = Layer.effectDiscard(createServer()); diff --git a/apps/server/tsdown.config.ts b/apps/server/tsdown.config.ts index f89bc7d3d..f11dd3786 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/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 8d7e02923..9ce17f5ac 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -16,3 +16,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 34ab11b16..1a8f3d20d 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -204,3 +204,41 @@ export const GitPullResult = Schema.Struct({ upstreamBranch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), }); 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), +}) {} + +export class GitHubCliError extends Schema.TaggedErrorClass()("GitHubCliError", { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +export class TextGenerationError extends Schema.TaggedErrorClass()( + "TextGenerationError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) {} + +export class GitManagerError extends Schema.TaggedErrorClass()("GitManagerError", { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +export const GitManagerServiceError = Schema.Union([ + GitManagerError, + GitCommandError, + GitHubCliError, + TextGenerationError, +]); +export type GitManagerServiceError = typeof GitManagerServiceError.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0f37a9351..7c47d02bd 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -11,3 +11,4 @@ export * from "./git"; export * from "./orchestration"; export * from "./editor"; export * from "./project"; +export * from "./wsRpc"; diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 1b99362c5..09ac0d175 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -9,39 +9,27 @@ import { ResolvedKeybindingsConfig, } from "./keybindings"; -const decode = ( - schema: S, - input: unknown, -): Effect.Effect, Schema.SchemaError, never> => - Schema.decodeUnknownEffect(schema as never)(input) as Effect.Effect< - Schema.Schema.Type, - Schema.SchemaError, - never - >; - -const decodeResolvedRule = Schema.decodeUnknownEffect(ResolvedKeybindingRule as never); - it.effect("parses keybinding rules", () => Effect.gen(function* () { - const parsed = yield* decode(KeybindingRule, { + const parsed = yield* Schema.decodeUnknownEffect(KeybindingRule)({ key: "mod+j", command: "terminal.toggle", }); assert.strictEqual(parsed.command, "terminal.toggle"); - const parsedClose = yield* decode(KeybindingRule, { + const parsedClose = yield* Schema.decodeUnknownEffect(KeybindingRule)({ key: "mod+w", command: "terminal.close", }); assert.strictEqual(parsedClose.command, "terminal.close"); - const parsedDiffToggle = yield* decode(KeybindingRule, { + const parsedDiffToggle = yield* Schema.decodeUnknownEffect(KeybindingRule)({ key: "mod+d", command: "diff.toggle", }); assert.strictEqual(parsedDiffToggle.command, "diff.toggle"); - const parsedLocal = yield* decode(KeybindingRule, { + const parsedLocal = yield* Schema.decodeUnknownEffect(KeybindingRule)({ key: "mod+shift+n", command: "chat.newLocal", }); @@ -50,20 +38,19 @@ it.effect("parses keybinding rules", () => ); it.effect("rejects invalid command values", () => + // oxlint-disable-next-line require-yield Effect.gen(function* () { - const result = yield* Effect.exit( - decode(KeybindingRule, { - key: "mod+j", - command: "script.Test.run", - }), - ); + const result = Schema.decodeUnknownExit(KeybindingRule)({ + key: "mod+j", + command: "script.Test.run", + }); assert.strictEqual(result._tag, "Failure"); }), ); it.effect("accepts dynamic script run commands", () => Effect.gen(function* () { - const parsed = yield* decode(KeybindingRule, { + const parsed = yield* Schema.decodeUnknownExit(KeybindingRule)({ key: "mod+r", command: "script.setup.run", }); @@ -73,7 +60,7 @@ it.effect("accepts dynamic script run commands", () => it.effect("parses keybindings array payload", () => Effect.gen(function* () { - const parsed = yield* decode(KeybindingsConfig, [ + const parsed = yield* Schema.decodeUnknownExit(KeybindingsConfig)([ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, ]); @@ -83,7 +70,7 @@ it.effect("parses keybindings array payload", () => it.effect("parses resolved keybinding rules", () => Effect.gen(function* () { - const parsed = yield* decode(ResolvedKeybindingRule, { + const parsed = yield* Schema.decodeUnknownExit(ResolvedKeybindingRule)({ command: "terminal.split", shortcut: { key: "d", @@ -108,7 +95,7 @@ it.effect("parses resolved keybinding rules", () => it.effect("parses resolved keybindings arrays", () => Effect.gen(function* () { - const parsed = yield* decode(ResolvedKeybindingsConfig, [ + const parsed = yield* Schema.decodeUnknownExit(ResolvedKeybindingsConfig)([ { command: "terminal.toggle", shortcut: { @@ -126,7 +113,7 @@ it.effect("parses resolved keybindings arrays", () => ); it.effect("drops unknown fields in resolved keybinding rules", () => - decodeResolvedRule({ + Schema.decodeUnknownExit(ResolvedKeybindingRule)({ command: "terminal.toggle", shortcut: { key: "j", @@ -139,9 +126,8 @@ it.effect("drops unknown fields in resolved keybinding rules", () => key: "mod+j", }).pipe( Effect.map((parsed) => { - const view = parsed as Record; - assert.strictEqual("key" in view, false); - assert.strictEqual(view.command, "terminal.toggle"); + assert.strictEqual("key" in parsed, false); + assert.strictEqual(parsed.command, "terminal.toggle"); }), ), ); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 48821b182..baf92e338 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -64,24 +64,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 = @@ -101,3 +104,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 17c5eb21d..cbdcbcc1d 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -983,3 +983,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 090325330..2851120d1 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 96ea90c1f..99ceb45bb 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -69,3 +69,41 @@ export const ServerConfigUpdatedPayload = Schema.Struct({ providers: ServerProviderStatuses, }); 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: ServerProviderStatuses, +}); +export type ServerConfigProviderStatusesPayload = typeof ServerConfigProviderStatusesPayload.Type; + +export const ServerConfigStreamSnapshotEvent = Schema.Struct({ + type: Schema.Literal("snapshot"), + config: ServerConfig, +}); +export type ServerConfigStreamSnapshotEvent = typeof ServerConfigStreamSnapshotEvent.Type; + +export const ServerConfigStreamKeybindingsUpdatedEvent = Schema.Struct({ + type: Schema.Literal("keybindingsUpdated"), + payload: ServerConfigKeybindingsUpdatedPayload, +}); +export type ServerConfigStreamKeybindingsUpdatedEvent = + typeof ServerConfigStreamKeybindingsUpdatedEvent.Type; + +export const ServerConfigStreamProviderStatusesEvent = Schema.Struct({ + type: Schema.Literal("providerStatuses"), + payload: ServerConfigProviderStatusesPayload, +}); +export type ServerConfigStreamProviderStatusesEvent = + typeof ServerConfigStreamProviderStatusesEvent.Type; + +export const ServerConfigStreamEvent = Schema.Union([ + ServerConfigStreamSnapshotEvent, + ServerConfigStreamKeybindingsUpdatedEvent, + ServerConfigStreamProviderStatusesEvent, +]); +export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index b0493d95c..e51eaefaa 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -149,3 +149,8 @@ export const TerminalEvent = Schema.Union([ TerminalActivityEvent, ]); export type TerminalEvent = typeof TerminalEvent.Type; + +export class TerminalError extends Schema.TaggedErrorClass()("TerminalError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index ebb76138b..58c437510 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -75,6 +75,13 @@ export const WS_METHODS = { // Server meta serverGetConfig: "server.getConfig", serverUpsertKeybinding: "server.upsertKeybinding", + + // Streaming subscriptions + subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents", + subscribeTerminalEvents: "subscribeTerminalEvents", + subscribeServerConfig: "subscribeServerConfig", + subscribeServerConfigUpdates: "subscribeServerConfigUpdates", + subscribeServerLifecycle: "subscribeServerLifecycle", } as const; // ── Push Event Channels ────────────────────────────────────────────── @@ -227,6 +234,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 SubscribeServerConfigUpdatesInput = Schema.Struct({}); +export type SubscribeServerConfigUpdatesInput = typeof SubscribeServerConfigUpdatesInput.Type; + +export const SubscribeServerLifecycleInput = Schema.Struct({}); +export type SubscribeServerLifecycleInput = typeof SubscribeServerLifecycleInput.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 000000000..558af9e0b --- /dev/null +++ b/packages/contracts/src/wsRpc.ts @@ -0,0 +1,286 @@ +import * as Rpc from "effect/unstable/rpc/Rpc"; +import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; + +import { OpenError, OpenInEditorInput } from "./editor"; +import { + 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, + ServerUpsertKeybindingInput, + ServerUpsertKeybindingResult, +} from "./server"; +import { + SubscribeOrchestrationDomainEventsInput, + SubscribeServerConfigInput, + SubscribeServerLifecycleInput, + SubscribeTerminalEventsInput, + WS_METHODS, + WsWelcomePayload, +} from "./ws"; + +export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { + payload: ServerUpsertKeybindingInput, + success: ServerUpsertKeybindingResult, + error: KeybindingsConfigError, +}); + +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: KeybindingsConfigError, + stream: true, +}); + +export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, { + payload: SubscribeServerLifecycleInput, + success: WsWelcomePayload, + stream: true, +}); + +export const WsRpcGroup = RpcGroup.make( + WsServerUpsertKeybindingRpc, + WsProjectsSearchEntriesRpc, + WsProjectsWriteFileRpc, + WsShellOpenInEditorRpc, + WsGitStatusRpc, + WsGitPullRpc, + WsGitRunStackedActionRpc, + WsGitResolvePullRequestRpc, + WsGitPreparePullRequestThreadRpc, + WsGitListBranchesRpc, + WsGitCreateWorktreeRpc, + WsGitRemoveWorktreeRpc, + WsGitCreateBranchRpc, + WsGitCheckoutRpc, + WsGitInitRpc, + WsTerminalOpenRpc, + WsTerminalWriteRpc, + WsTerminalResizeRpc, + WsTerminalClearRpc, + WsTerminalRestartRpc, + WsTerminalCloseRpc, + WsSubscribeTerminalEventsRpc, + WsSubscribeServerConfigRpc, + WsOrchestrationGetSnapshotRpc, + WsOrchestrationDispatchCommandRpc, + WsOrchestrationGetTurnDiffRpc, + WsOrchestrationGetFullThreadDiffRpc, + WsOrchestrationReplayEventsRpc, +);