Skip to content

Commit 35ad55b

Browse files
authored
🤖 refactor: reintroduce ORPC migration for type-safe RPC (#784)
## Summary Reintroduces the ORPC refactoring originally merged in #763 and reverted in #777. This replaces the custom IPC layer with [oRPC](https://orpc.unnoq.com/) for type-safe RPC between browser/renderer and backend processes. ## Why it was reverted (#777) The original migration caused regressions: - Streaming content delay from ORPC schema validation - Field stripping issues in sendMessage output - Auto-compaction trigger deletion ## What's different this time - Rebased onto latest main which includes fixes developed post-revert - Conflict resolution preserves upstream features added after revert: - Workspace name collision retry with hash suffix (#779) - Mux Gateway coupon code handling with default models - AWS Bedrock credential nested structure ## Key Changes ### Architecture - **New ORPC router** (`src/node/orpc/router.ts`) - Central router with Zod schemas - **Schema definitions** (`src/common/orpc/schemas.ts`) - Shared validation - **ServiceContainer** (`src/node/services/serviceContainer.ts`) - DI container - **React integration** (`src/browser/orpc/react.tsx`) - `ORPCProvider` and `useORPC()` ### Transport - **Desktop (Electron)**: MessagePort-based RPC via `@orpc/server/message-port` - **Server mode**: HTTP + WebSocket via `@orpc/server/node` and `@orpc/server/ws` - Auth middleware with timing-safe token comparison ### Removed - `src/browser/api.ts` (old HTTP/WS client) - `src/node/services/ipcMain.ts` (old IPC handler registration) - Old IPC method definitions in preload.ts ## Test plan - [x] Run `make typecheck` - passes locally - [x] Run `make test` - verify existing tests pass - [x] Manual testing of desktop app (Electron) - [x] Manual testing of server mode (browser) - [x] Verify streaming chat works without delays - [x] Verify auto-compaction triggers correctly --- _Generated with [mux](https://github.com/coder/mux)_
1 parent 37b63e2 commit 35ad55b

File tree

261 files changed

+15783
-12530
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

261 files changed

+15783
-12530
lines changed

.claude/settings.json

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,5 @@
11
{
22
"hooks": {
3-
"PostToolUse": [
4-
{
5-
"matcher": "Edit|MultiEdit|Write|NotebookEdit",
6-
"hooks": [
7-
{
8-
"type": "command",
9-
"command": "bunx prettier --write \"$1\" 1>/dev/null 2>/dev/null || true"
10-
}
11-
]
12-
}
13-
]
3+
"PostToolUse": []
144
}
155
}

.github/actions/setup-mux/action.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,3 @@ runs:
3636
if: steps.cache-node-modules.outputs.cache-hit != 'true'
3737
shell: bash
3838
run: bun install --frozen-lockfile
39-

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ on:
66
workflow_dispatch:
77
inputs:
88
tag:
9-
description: 'Tag to release (e.g., v1.2.3). If provided, will checkout and release this tag regardless of current branch.'
9+
description: "Tag to release (e.g., v1.2.3). If provided, will checkout and release this tag regardless of current branch."
1010
required: false
1111
type: string
1212

.github/workflows/terminal-bench.yml

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,34 @@ on:
44
workflow_call:
55
inputs:
66
model_name:
7-
description: 'Model to use (e.g., anthropic:claude-sonnet-4-5)'
7+
description: "Model to use (e.g., anthropic:claude-sonnet-4-5)"
88
required: false
99
type: string
1010
thinking_level:
11-
description: 'Thinking level (off, low, medium, high)'
11+
description: "Thinking level (off, low, medium, high)"
1212
required: false
1313
type: string
1414
dataset:
15-
description: 'Terminal-Bench dataset to use'
15+
description: "Terminal-Bench dataset to use"
1616
required: false
1717
type: string
18-
default: 'terminal-bench-core==0.1.1'
18+
default: "terminal-bench-core==0.1.1"
1919
concurrency:
20-
description: 'Number of concurrent tasks (--n-concurrent)'
20+
description: "Number of concurrent tasks (--n-concurrent)"
2121
required: false
2222
type: string
23-
default: '4'
23+
default: "4"
2424
livestream:
25-
description: 'Enable livestream mode (verbose output to console)'
25+
description: "Enable livestream mode (verbose output to console)"
2626
required: false
2727
type: boolean
2828
default: false
2929
sample_size:
30-
description: 'Number of random tasks to run (empty = all tasks)'
30+
description: "Number of random tasks to run (empty = all tasks)"
3131
required: false
3232
type: string
3333
extra_args:
34-
description: 'Additional arguments to pass to terminal-bench'
34+
description: "Additional arguments to pass to terminal-bench"
3535
required: false
3636
type: string
3737
secrets:
@@ -42,34 +42,34 @@ on:
4242
workflow_dispatch:
4343
inputs:
4444
dataset:
45-
description: 'Terminal-Bench dataset to use'
45+
description: "Terminal-Bench dataset to use"
4646
required: false
47-
default: 'terminal-bench-core==0.1.1'
47+
default: "terminal-bench-core==0.1.1"
4848
type: string
4949
concurrency:
50-
description: 'Number of concurrent tasks (--n-concurrent)'
50+
description: "Number of concurrent tasks (--n-concurrent)"
5151
required: false
52-
default: '4'
52+
default: "4"
5353
type: string
5454
livestream:
55-
description: 'Enable livestream mode (verbose output to console)'
55+
description: "Enable livestream mode (verbose output to console)"
5656
required: false
5757
default: false
5858
type: boolean
5959
sample_size:
60-
description: 'Number of random tasks to run (empty = all tasks)'
60+
description: "Number of random tasks to run (empty = all tasks)"
6161
required: false
6262
type: string
6363
model_name:
64-
description: 'Model to use (e.g., anthropic:claude-sonnet-4-5, openai:gpt-5.1-codex)'
64+
description: "Model to use (e.g., anthropic:claude-sonnet-4-5, openai:gpt-5.1-codex)"
6565
required: false
6666
type: string
6767
thinking_level:
68-
description: 'Thinking level (off, low, medium, high)'
68+
description: "Thinking level (off, low, medium, high)"
6969
required: false
7070
type: string
7171
extra_args:
72-
description: 'Additional arguments to pass to terminal-bench'
72+
description: "Additional arguments to pass to terminal-bench"
7373
required: false
7474
type: string
7575

@@ -147,4 +147,3 @@ jobs:
147147
benchmark.log
148148
if-no-files-found: warn
149149
retention-days: 30
150-

.storybook/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import path from "path";
44

55
const config: StorybookConfig = {
66
stories: ["../src/browser/**/*.stories.@(ts|tsx)"],
7-
addons: ["@storybook/addon-links", "@storybook/addon-docs", "@storybook/addon-interactions"],
7+
addons: ["@storybook/addon-links", "@storybook/addon-docs"],
88
framework: {
99
name: "@storybook/react-vite",
1010
options: {},

.storybook/mocks/orpc.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* Mock ORPC client factory for Storybook stories.
3+
*
4+
* Creates a client that matches the AppRouter interface with configurable mock data.
5+
*/
6+
import type { APIClient } from "@/browser/contexts/API";
7+
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
8+
import type { ProjectConfig } from "@/node/config";
9+
import type { WorkspaceChatMessage } from "@/common/orpc/types";
10+
import type { ChatStats } from "@/common/types/chatStats";
11+
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
12+
import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue";
13+
14+
export interface MockORPCClientOptions {
15+
projects?: Map<string, ProjectConfig>;
16+
workspaces?: FrontendWorkspaceMetadata[];
17+
/** Per-workspace chat callback. Return messages to emit, or use the callback for streaming. */
18+
onChat?: (workspaceId: string, emit: (msg: WorkspaceChatMessage) => void) => (() => void) | void;
19+
/** Mock for executeBash per workspace */
20+
executeBash?: (
21+
workspaceId: string,
22+
script: string
23+
) => Promise<{ success: true; output: string; exitCode: number; wall_duration_ms: number }>;
24+
/** Provider configuration (API keys, base URLs, etc.) */
25+
providersConfig?: Record<string, { apiKeySet: boolean; baseUrl?: string; models?: string[] }>;
26+
/** List of available provider names */
27+
providersList?: string[];
28+
}
29+
30+
/**
31+
* Creates a mock ORPC client for Storybook.
32+
*
33+
* Usage:
34+
* ```tsx
35+
* const client = createMockORPCClient({
36+
* projects: new Map([...]),
37+
* workspaces: [...],
38+
* onChat: (wsId, emit) => {
39+
* emit({ type: "caught-up" });
40+
* // optionally return cleanup function
41+
* },
42+
* });
43+
*
44+
* return <AppLoader client={client} />;
45+
* ```
46+
*/
47+
export function createMockORPCClient(options: MockORPCClientOptions = {}): APIClient {
48+
const {
49+
projects = new Map(),
50+
workspaces = [],
51+
onChat,
52+
executeBash,
53+
providersConfig = {},
54+
providersList = [],
55+
} = options;
56+
57+
const workspaceMap = new Map(workspaces.map((w) => [w.id, w]));
58+
59+
const mockStats: ChatStats = {
60+
consumers: [],
61+
totalTokens: 0,
62+
model: "mock-model",
63+
tokenizerName: "mock-tokenizer",
64+
usageHistory: [],
65+
};
66+
67+
// Cast to ORPCClient - TypeScript can't fully validate the proxy structure
68+
return {
69+
tokenizer: {
70+
countTokens: async () => 0,
71+
countTokensBatch: async (_input: { model: string; texts: string[] }) =>
72+
_input.texts.map(() => 0),
73+
calculateStats: async () => mockStats,
74+
},
75+
server: {
76+
getLaunchProject: async () => null,
77+
},
78+
providers: {
79+
list: async () => providersList,
80+
getConfig: async () => providersConfig,
81+
setProviderConfig: async () => ({ success: true, data: undefined }),
82+
setModels: async () => ({ success: true, data: undefined }),
83+
},
84+
general: {
85+
listDirectory: async () => ({ entries: [], hasMore: false }),
86+
ping: async (input: string) => `Pong: ${input}`,
87+
tick: async function* () {
88+
// No-op generator
89+
},
90+
},
91+
projects: {
92+
list: async () => Array.from(projects.entries()),
93+
create: async () => ({
94+
success: true,
95+
data: { projectConfig: { workspaces: [] }, normalizedPath: "/mock/project" },
96+
}),
97+
pickDirectory: async () => null,
98+
listBranches: async () => ({
99+
branches: ["main", "develop", "feature/new-feature"],
100+
recommendedTrunk: "main",
101+
}),
102+
remove: async () => ({ success: true, data: undefined }),
103+
secrets: {
104+
get: async () => [],
105+
update: async () => ({ success: true, data: undefined }),
106+
},
107+
},
108+
workspace: {
109+
list: async () => workspaces,
110+
create: async (input: { projectPath: string; branchName: string }) => ({
111+
success: true,
112+
metadata: {
113+
id: Math.random().toString(36).substring(2, 12),
114+
name: input.branchName,
115+
projectPath: input.projectPath,
116+
projectName: input.projectPath.split("/").pop() ?? "project",
117+
namedWorkspacePath: `/mock/workspace/${input.branchName}`,
118+
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
119+
},
120+
}),
121+
remove: async () => ({ success: true }),
122+
rename: async (input: { workspaceId: string }) => ({
123+
success: true,
124+
data: { newWorkspaceId: input.workspaceId },
125+
}),
126+
fork: async () => ({ success: false, error: "Not implemented in mock" }),
127+
sendMessage: async () => ({ success: true, data: undefined }),
128+
resumeStream: async () => ({ success: true, data: undefined }),
129+
interruptStream: async () => ({ success: true, data: undefined }),
130+
clearQueue: async () => ({ success: true, data: undefined }),
131+
truncateHistory: async () => ({ success: true, data: undefined }),
132+
replaceChatHistory: async () => ({ success: true, data: undefined }),
133+
getInfo: async (input: { workspaceId: string }) =>
134+
workspaceMap.get(input.workspaceId) ?? null,
135+
executeBash: async (input: { workspaceId: string; script: string }) => {
136+
if (executeBash) {
137+
const result = await executeBash(input.workspaceId, input.script);
138+
return { success: true, data: result };
139+
}
140+
return {
141+
success: true,
142+
data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 },
143+
};
144+
},
145+
onChat: async function* (input: { workspaceId: string }) {
146+
if (!onChat) {
147+
yield { type: "caught-up" } as WorkspaceChatMessage;
148+
return;
149+
}
150+
151+
const { push, iterate, end } = createAsyncMessageQueue<WorkspaceChatMessage>();
152+
153+
// Call the user's onChat handler
154+
const cleanup = onChat(input.workspaceId, push);
155+
156+
try {
157+
yield* iterate();
158+
} finally {
159+
end();
160+
cleanup?.();
161+
}
162+
},
163+
onMetadata: async function* () {
164+
// Empty generator - no metadata updates in mock
165+
await new Promise(() => {}); // Never resolves, keeps stream open
166+
},
167+
activity: {
168+
list: async () => ({}),
169+
subscribe: async function* () {
170+
await new Promise(() => {}); // Never resolves
171+
},
172+
},
173+
},
174+
window: {
175+
setTitle: async () => undefined,
176+
},
177+
terminal: {
178+
create: async () => ({
179+
sessionId: "mock-session",
180+
workspaceId: "mock-workspace",
181+
cols: 80,
182+
rows: 24,
183+
}),
184+
close: async () => undefined,
185+
resize: async () => undefined,
186+
sendInput: () => undefined,
187+
onOutput: async function* () {
188+
await new Promise(() => {});
189+
},
190+
onExit: async function* () {
191+
await new Promise(() => {});
192+
},
193+
openWindow: async () => undefined,
194+
closeWindow: async () => undefined,
195+
openNative: async () => undefined,
196+
},
197+
update: {
198+
check: async () => undefined,
199+
download: async () => undefined,
200+
install: () => undefined,
201+
onStatus: async function* () {
202+
await new Promise(() => {});
203+
},
204+
},
205+
} as unknown as APIClient;
206+
}

.storybook/preview.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const preview: Preview = {
3535
theme: "dark",
3636
},
3737
decorators: [
38+
// Theme provider
3839
(Story, context) => {
3940
// Default to dark if mode not set (e.g., Chromatic headless browser defaults to light)
4041
const mode = (context.globals.theme as ThemeMode | undefined) ?? "dark";

babel.config.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
module.exports = {
2+
presets: [
3+
[
4+
"@babel/preset-env",
5+
{
6+
targets: {
7+
node: "current",
8+
},
9+
modules: "commonjs",
10+
},
11+
],
12+
[
13+
"@babel/preset-typescript",
14+
{
15+
allowDeclareFields: true,
16+
},
17+
],
18+
[
19+
"@babel/preset-react",
20+
{
21+
runtime: "automatic",
22+
},
23+
],
24+
],
25+
};

0 commit comments

Comments
 (0)