Skip to content

chore: effect rpc and layer based startup#929

Open
juliusmarminge wants to merge 38 commits intomainfrom
effect-http-router
Open

chore: effect rpc and layer based startup#929
juliusmarminge wants to merge 38 commits intomainfrom
effect-http-router

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Mar 11, 2026

What Changed

Why

UI Changes

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes
  • I included a video for animation/interaction changes

Note

High Risk
Large refactor of server boot, HTTP routing, and WebSocket protocol handling; mistakes could break client connectivity, auth gating, static/attachment serving, or startup sequencing across Bun/Node runtimes.

Overview
Refactors the server to a new layer-launched runtime: introduces src/server.ts as the composition root (Bun/Node service selection, PTY + HTTP server selection, runtime services wiring) and replaces the previous CLI entrypoint with src/bin.ts + a new src/cli.ts that resolves config from flags/env (including new logLevel on ServerConfig).

Replaces ad-hoc WebSocket request handling with a typed Effect RPC endpoint at GET /ws (src/ws.ts), including token-based handshake auth and handlers/streams for orchestration, git, terminal, settings, keybindings, and workspace file ops; adds command normalization (orchestration/Normalizer.ts) to validate workspace roots and persist inbound image attachments.

Splits HTTP concerns into dedicated router layers (src/http.ts) for GET /health, attachment serving (by id or relative path), and static-file serving or dev-URL redirect with path traversal guards; updates logging (serverLogger.ts) to honor configured minimum log level. Also centralizes several error types into @t3tools/contracts, removes local git error definitions, and adjusts multiple integration-test timeouts to reduce flakiness.

Written by Cursor Bugbot for commit 849a39c. This will update automatically on new commits. Configure here.

Note

Replace WebSocket push-based transport with Effect RPC layer and refactor server startup

  • Replaces the manual WebSocket message handling in wsTransport.ts and wsNativeApi.ts with an Effect-based RPC client (WsRpcGroup) using typed request/response/stream contracts defined in wsRpc.ts.
  • Adds a new ws.ts server-side WS RPC layer that wires orchestration, git, terminal, settings, keybindings, and lifecycle services into typed RPC handlers with mapped domain errors.
  • Introduces server.ts and bin.ts to replace the deleted wsServer.ts, composing all application layers and HTTP/WS routes with Node/Bun platform adapters.
  • Adds serverRuntimeStartup.ts to gate command execution on server readiness, queuing commands until the server is ready and propagating startup failures.
  • Moves error types (GitCommandError, TerminalError, KeybindingsConfigError, ServerSettingsError, OpenError, etc.) from local server files into @t3tools/contracts so they are shared across client and server.
  • Adds structured stream events for server config and lifecycle (ServerConfigStreamEvent, ServerLifecycleStreamEvent) with subscription RPC methods, replacing ad-hoc push channels.
  • Risk: Behavioral change — the previous channel-based push/replay transport API (getLatestPush, WS_CHANNELS) is removed; all callers must use the new RPC stream subscription model.

Macroscope summarized 849a39c.

@juliusmarminge juliusmarminge marked this pull request as draft March 11, 2026 18:53
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 11, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a666171a-21f6-4e4e-b722-d977f1cda767

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch effect-http-router

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions bot added the vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. label Mar 11, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Dev URL redirect drops request path and query
    • Fixed the redirect to construct a new URL from the request's pathname and search params against the dev origin, and updated the test to assert the correct behavior.

Create PR

Or push these changes by commenting:

@cursor push 089fb595df
Preview (089fb595df)
diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts
--- a/apps/server/src/http.ts
+++ b/apps/server/src/http.ts
@@ -90,7 +90,8 @@
 
     const config = yield* ServerConfig;
     if (config.devUrl) {
-      return HttpServerResponse.redirect(config.devUrl.href, { status: 302 });
+      const devTarget = new URL(`${url.pathname}${url.search}`, config.devUrl);
+      return HttpServerResponse.redirect(devTarget.href, { status: 302 });
     }
 
     if (!config.staticDir) {

diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -266,7 +266,7 @@
       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/");
+      assert.equal(response.headers.get("location"), "http://127.0.0.1:5173/foo/bar");
     }).pipe(Effect.provide(NodeHttpServer.layerTest)),
   );


const config = yield* ServerConfig;
if (config.devUrl) {
return HttpServerResponse.redirect(config.devUrl.href, { status: 302 });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dev URL redirect drops request path and query

High Severity

The dev URL redirect uses config.devUrl.href without appending the incoming request's pathname or search params. Every GET request (e.g. /settings?tab=general) redirects to the dev server root (http://127.0.0.1:5173/) instead of the matching route (http://127.0.0.1:5173/settings?tab=general). The reference implementation correctly constructs new URL(url.pathname + url.search, origin). The test at line 269 asserts the broken behavior, masking the regression.

Additional Locations (1)
Fix in Cursor Fix in Web

@github-actions github-actions bot added the size:XXL 1,000+ changed lines (additions + deletions). label Mar 13, 2026
? Deferred.succeed(deferred, exit.value)
: Deferred.failCause(deferred, exit.cause);

export const makeCommandGate = Effect.gen(function* () {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low src/serverRuntimeStartup.ts:62

Once signalCommandReady sets commandReadinessState to "ready", enqueueCommand bypasses the queue and runs effects directly (line 86-87), while commands already in the queue are still being processed by commandWorker. This allows commands arriving after the state transition to execute concurrently with queued commands, breaking the sequential ordering guarantee that the queue was designed to provide.

Also found in 1 other location(s)

.reference/server/src/model-store.ts:218

Race condition in subscribe: Events published to eventsPubSub during the catchup query execution will be missed. Stream.concat(catchup, live) only subscribes to the PubSub after catchup completes. If append publishes events while catchup is querying the database, those events won't be in the catchup results (they occurred after the query started) and won't be captured by live (subscription wasn't established yet). This causes event loss for concurrent subscriptions.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/serverRuntimeStartup.ts around line 62:

Once `signalCommandReady` sets `commandReadinessState` to `"ready"`, `enqueueCommand` bypasses the queue and runs effects directly (line 86-87), while commands already in the queue are still being processed by `commandWorker`. This allows commands arriving after the state transition to execute concurrently with queued commands, breaking the sequential ordering guarantee that the queue was designed to provide.

Evidence trail:
apps/server/src/serverRuntimeStartup.ts lines 64-67 (commandWorker forked in separate fiber), lines 69-72 (signalCommandReady sets state to ready first, then succeeds deferred), lines 79-97 (enqueueCommand checks state and runs directly if ready at lines 82-83, or queues with Deferred.await(commandReady) at lines 90-95)

Also found in 1 other location(s):
- .reference/server/src/model-store.ts:218 -- Race condition in `subscribe`: Events published to `eventsPubSub` during the `catchup` query execution will be missed. `Stream.concat(catchup, live)` only subscribes to the PubSub after `catchup` completes. If `append` publishes events while `catchup` is querying the database, those events won't be in the catchup results (they occurred after the query started) and won't be captured by `live` (subscription wasn't established yet). This causes event loss for concurrent subscriptions.

juliusmarminge and others added 5 commits March 24, 2026 01:40
- Replace state-dir config with T3CODE_HOME/base-dir derivation
- Route attachments and provider logs through derived paths
- Lazily load Bun/Node HTTP, PTY, and platform services
Co-authored-by: codex <codex@users.noreply.github.com>
Comment on lines +148 to +158
[WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) =>
Stream.unwrap(
Effect.gen(function* () {
const snapshot = yield* orchestrationEngine.getReadModel();
const fromSequenceExclusive = snapshot.snapshotSequence;
const replayEvents: Array<OrchestrationEvent> = yield* Stream.runCollect(
orchestrationEngine.readEvents(fromSequenceExclusive),
).pipe(
Effect.map((events) => Array.from(events)),
Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)),
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/ws.ts:148

In subscribeOrchestrationDomainEvents, when orchestrationEngine.readEvents() fails, the error is silently caught and replaced with an empty array. The state machine then initializes nextSequence = fromSequenceExclusive + 1, but since the replay failed, events with that sequence number will never arrive. Live events accumulate in pendingBySequence waiting for the missing sequence, causing the subscription to hang indefinitely and leak memory. Consider propagating the replay error instead of silently succeeding so the client receives a failure indication.

            const replayEvents: Array<OrchestrationEvent> = yield* Stream.runCollect(
              orchestrationEngine.readEvents(fromSequenceExclusive),
            ).pipe(
              Effect.map((events) => Array.from(events)),
-             Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)),
+             Effect.mapError(
+               (cause) =>
+                 new OrchestrationReplayEventsError({
+                   message: "Failed to replay orchestration events for subscription",
+                   cause,
+                 }),
+             ),
            );
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/ws.ts around lines 148-158:

In `subscribeOrchestrationDomainEvents`, when `orchestrationEngine.readEvents()` fails, the error is silently caught and replaced with an empty array. The state machine then initializes `nextSequence = fromSequenceExclusive + 1`, but since the replay failed, events with that sequence number will never arrive. Live events accumulate in `pendingBySequence` waiting for the missing sequence, causing the subscription to hang indefinitely and leak memory. Consider propagating the replay error instead of silently succeeding so the client receives a failure indication.

Evidence trail:
apps/server/src/ws.ts lines 148-200 at REVIEWED_COMMIT:
- Line 157: `Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>))` silently catches errors
- Lines 164-167: State initialization with `nextSequence: fromSequenceExclusive + 1`
- Line 160: `Stream.merge(replayStream, orchestrationEngine.streamDomainEvents)` merges replay with live
- Lines 170-198: State machine logic that buffers out-of-order events in `pendingBySequence` and only emits when `nextSequence` matches

juliusmarminge and others added 4 commits March 28, 2026 20:39
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
juliusmarminge and others added 4 commits March 30, 2026 11:13
- Replace the temporary PubSub bridge with `Stream.callback`
- Wire terminal subscriptions through `Queue.offer` for simpler cleanup
- Preserve unsubscribe handling with acquire/release
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Comment on lines +73 to +81
connect(client: BrowserWsClient): void {
if (this.scope) {
void Effect.runPromise(Scope.close(this.scope, Exit.void)).catch(() => undefined);
}
if (this.streamPubSubs.size === 0) {
this.initializeStreamPubSubs();
}
this.client = client;
this.scope = Effect.runSync(Scope.make());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low test/wsRpcHarness.ts:73

When connect() is called while a previous connection exists, the old scope is closed asynchronously without awaiting completion (void Effect.runPromise(...) on line 75). Immediately after, this.client is reassigned to the new client on line 80 and a new scope is created on line 81. If the old server has pending finalizers that emit responses via this.client.send(), those responses will be sent to the new client instead of the old one, since this.client is already updated. This race condition causes unexpected messages to be received by the new client.

  connect(client: BrowserWsClient): void {
-    if (this.scope) {
-      void Effect.runPromise(Scope.close(this.scope, Exit.void)).catch(() => undefined);
-    }
+    if (this.scope) {
+      Effect.runSync(Scope.close(this.scope, Exit.void));
+    }
     if (this.streamPubSubs.size === 0) {
       this.initializeStreamPubSubs();
     }
     this.client = client;
     this.scope = Effect.runSync(Scope.make());
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/test/wsRpcHarness.ts around lines 73-81:

When `connect()` is called while a previous connection exists, the old scope is closed asynchronously without awaiting completion (`void Effect.runPromise(...)` on line 75). Immediately after, `this.client` is reassigned to the new client on line 80 and a new scope is created on line 81. If the old server has pending finalizers that emit responses via `this.client.send()`, those responses will be sent to the *new* client instead of the old one, since `this.client` is already updated. This race condition causes unexpected messages to be received by the new client.

Evidence trail:
apps/web/src/test/wsRpcHarness.ts lines 73-88 (connect method): Line 75 shows `void Effect.runPromise(Scope.close(this.scope, Exit.void))` - async close without await. Line 80: `this.client = client` - immediate reassignment.

apps/web/src/test/wsRpcHarness.ts lines 141-149 (makeServerOptions): The `onFromServer` callback captures `this` and accesses `this.client.send()` at execution time, not at callback creation time. This means old server finalizers would reference the newly-assigned client after line 80 executes.

- Import shared git error types directly from `@t3tools/contracts`
- Remove the local git error re-export module
@juliusmarminge juliusmarminge marked this pull request as ready for review March 31, 2026 00:21
@juliusmarminge
Copy link
Copy Markdown
Member Author

bugbot run

Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Boolean flag false values silently ignored by filter
    • Replaced Option.filter(flag, Boolean) with Option.getOrElse(flag, ...) so that Some(false) is correctly preserved instead of being converted to None.
  • ✅ Fixed: Settings update error drops schema validation details
    • Replaced the generic error message with Cause.pretty(decoded.cause) and passed the cause through, consistent with how schema errors are reported elsewhere in the codebase.

Create PR

Or push these changes by commenting:

@cursor push f7ab68c0c3
Preview (f7ab68c0c3)
diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts
--- a/apps/server/src/cli.ts
+++ b/apps/server/src/cli.ts
@@ -104,7 +104,7 @@
 }
 
 const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
-  Option.getOrElse(Option.filter(flag, Boolean), () => envValue);
+  Option.getOrElse(flag, () => envValue);
 
 export const resolveServerConfig = (
   flags: CliServerFlags,

diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts
--- a/apps/server/src/serverSettings.ts
+++ b/apps/server/src/serverSettings.ts
@@ -21,6 +21,7 @@
 } from "@t3tools/contracts";
 import {
   Cache,
+  Cause,
   Deferred,
   Duration,
   Effect,
@@ -317,7 +318,8 @@
           if (decoded._tag === "Failure") {
             return yield* new ServerSettingsError({
               settingsPath: "<memory>",
-              detail: "failed to normalize server settings",
+              detail: Cause.pretty(decoded.cause),
+              cause: decoded.cause,
             });
           }
           const next = decoded.value;

You can send follow-ups to this agent here.

}

const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
Option.getOrElse(Option.filter(flag, Boolean), () => envValue);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Boolean flag false values silently ignored by filter

Medium Severity

resolveBooleanFlag uses Option.filter(flag, Boolean) which filters out false values — Boolean(false) is false, so Some(false) becomes None, falling through to the env/default value. This means explicitly passing a boolean CLI flag as false (e.g., negating --auto-bootstrap-project-from-cwd) has no effect; the environment or default value wins instead. The intent is clearly to let CLI flags override env values for both true and false, but Option.filter with Boolean only preserves true.

Fix in Cursor Fix in Web

settingsPath: "<memory>",
detail: "failed to normalize server settings",
});
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Settings update error drops schema validation details

Low Severity

The updateSettings handler switched from Schema.decodeEffect (which provided detailed schema issue formatting via SchemaIssue.makeFormatterDefault() and passed the cause) to Schema.decodeUnknownExit with a generic error message "failed to normalize server settings" and no cause. When a user submits an invalid settings patch, the error no longer explains what was invalid, making debugging significantly harder.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant