Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .oxfmtrc.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": [
".reference",
".plans",
"dist",
"dist-electron",
Expand Down
163 changes: 163 additions & 0 deletions .plans/ws-rpc-endpoint-port-plan.md
Original file line number Diff line number Diff line change
@@ -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).
27 changes: 27 additions & 0 deletions .reference/server/package.json
Original file line number Diff line number Diff line change
@@ -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:"
}
}
32 changes: 32 additions & 0 deletions .reference/server/src/Migrations/001_TodoSchema.ts
Original file line number Diff line number Diff line change
@@ -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)
`
})
11 changes: 11 additions & 0 deletions .reference/server/src/bin.ts
Original file line number Diff line number Diff line change
@@ -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
)
125 changes: 125 additions & 0 deletions .reference/server/src/cli.ts
Original file line number Diff line number Diff line change
@@ -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<string>
readonly port: Option.Option<number>
readonly assets: Option.Option<string>
readonly db: Option.Option<string>
readonly requestLogging: Option.Option<boolean>
readonly frontendDevOrigin: Option.Option<string>
}

export const resolveServerConfig = (
flags: CliServerFlags
): Effect.Effect<ServerConfigData, Config.ConfigError> =>
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])
)
Loading
Loading