Skip to content
Merged
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ ade actions list --text # discover every service action

## Architecture

Local-first, on purpose. The center of ADE is the **runtime daemon** — a single per-machine `ade` service that owns projects, lanes, chats, processes, sync, and proof artifacts. Desktop, the terminal client, the iOS app, and SSH-attached desktop windows all attach to it as clients. Runtime state lives under `.ade/` inside each project (SQLite db, worktree checkouts, proof artifacts, encrypted secrets) and the machine-wide socket lives under `~/.ade/sock/ade.sock`.
Local-first, on purpose. The center of ADE is the **runtime daemon** — a single per-machine `ade` service that owns projects, lanes, chats, processes, sync, and proof artifacts. Desktop, the terminal client, the iOS app, and SSH-attached desktop windows all attach to it as clients. Runtime state lives under `.ade/` inside each project (SQLite db, worktree checkouts, proof artifacts, encrypted secrets) and the machine-wide socket lives under `~/.ade/sock/ade.sock`. When desktop is running, its Electron main process also hosts a **bridge socket** at `~/.ade/sock/desktop-bridge.sock` (override: `ADE_DESKTOP_BRIDGE_SOCKET_PATH`) so the headless daemon can proxy `ade browser …` calls into the Electron-only `WebContentsView` APIs it can't reach under `ELECTRON_RUN_AS_NODE=1`.

```text
apps/ade-cli ADE runtime daemon (`ade serve`) + `ade` CLI + `ade code` terminal client
Expand Down Expand Up @@ -189,6 +189,7 @@ Override it when needed:
npm run dev:desktop -- --socket /tmp/my-ade-dev.sock
npm run dev:code -- --socket /tmp/my-ade-dev.sock
ADE_DEV_RUNTIME_SOCKET_PATH=/tmp/my-ade-dev.sock npm run dev:runtime
ADE_DESKTOP_BRIDGE_SOCKET_PATH=/tmp/my-bridge.sock npm run dev:desktop
```

To test auto-runtime creation, use the `:auto`/default commands after stopping the dev runtime:
Expand Down
1 change: 1 addition & 0 deletions apps/ade-cli/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
allowBuilds:
esbuild: set this to true or false
node-pty: set this to true or false
opencode-ai: set this to true or false
sqlite3: set this to true or false
22 changes: 22 additions & 0 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ import {
} from "../../desktop/src/main/services/appControl/appControlService";
import { createMacosVmService } from "../../desktop/src/main/services/macosVm/macosVmService";
import type { BuiltInBrowserService } from "../../desktop/src/main/services/builtInBrowser/builtInBrowserService";
import {
createBuiltInBrowserDesktopBridgeClient,
type BuiltInBrowserDesktopBridgeClient,
} from "./services/builtInBrowser/desktopBridgeClient";
import { resolveMachineAdeLayout } from "./services/projects/machineLayout";
import type { createFileService } from "../../desktop/src/main/services/files/fileService";
import type { AppNavigationRequest, AppNavigationResult, PortLease } from "../../desktop/src/shared/types";
import {
Expand Down Expand Up @@ -841,6 +846,21 @@ export async function createAdeRuntime(args: {
}),
});

// `built_in_browser` is hosted by the desktop's Electron main process (the
// browser pane owns a WebContentsView). The runtime daemon proxies calls
// through `<adeHome>/sock/desktop-bridge.sock`; if no desktop is running,
// individual calls fail clearly. Override the socket path with
// `ADE_DESKTOP_BRIDGE_SOCKET_PATH` for dev launches that use a non-default
// ADE home.
const builtInBrowserBridge: BuiltInBrowserDesktopBridgeClient | null = chatOnlyRuntime
? null
: createBuiltInBrowserDesktopBridgeClient({
socketPath:
process.env.ADE_DESKTOP_BRIDGE_SOCKET_PATH?.trim()
|| resolveMachineAdeLayout().desktopBridgeSocketPath,
logger,
});

const aiOrchestratorService = createAiOrchestratorService({
db,
logger,
Expand Down Expand Up @@ -1187,6 +1207,7 @@ export async function createAdeRuntime(args: {
computerUseArtifactBrokerService,
iosSimulatorService,
appControlService,
builtInBrowserService: builtInBrowserBridge as unknown as BuiltInBrowserService | null,
macosVmService,
orchestratorService,
aiOrchestratorService,
Expand All @@ -1205,6 +1226,7 @@ export async function createAdeRuntime(args: {
swallow(() => portAllocationService.dispose());
swallow(() => iosSimulatorService?.dispose());
swallow(() => appControlService?.dispose());
swallow(() => builtInBrowserBridge?.dispose());
swallow(() => macosVmService?.dispose());
swallow(() => linearOAuthService.dispose());
swallow(() => headlessLinearServices.dispose());
Expand Down
1 change: 0 additions & 1 deletion apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3521,7 +3521,6 @@ function buildPrPlan(args: string[]): CliPlan {
status: "getStatus",
files: "getFiles",
"action-runs": "getActionRuns",
activity: "getActivity",
reviews: "getReviews",
threads: "getReviewThreads",
deployments: "getDeployments",
Expand Down
4 changes: 4 additions & 0 deletions apps/ade-cli/src/multiProjectRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function createRegistry() {
secretsDir: path.join(root, "home", "secrets"),
sockDir: path.join(root, "home", "sock"),
socketPath: path.join(root, "home", "sock", "ade.sock"),
desktopBridgeSocketPath: path.join(root, "home", "sock", "desktop-bridge.sock"),
binDir: path.join(root, "home", "bin"),
runtimeDir: path.join(root, "home", "runtime"),
});
Expand All @@ -34,6 +35,9 @@ function makeRuntime(label: string) {
laneService: {
list: vi.fn(async () => [{ id: `${label}-lane`, name: label }]),
},
sessionService: {
get: vi.fn(() => null),
},
syncService: {
getStatus: vi.fn(async () => ({ role: "brain", label })),
},
Expand Down
180 changes: 180 additions & 0 deletions apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import {
startJsonRpcServer,
type JsonRpcRequest,
type JsonRpcTransport,
} from "../../jsonrpc";
import { createBuiltInBrowserDesktopBridgeClient } from "./desktopBridgeClient";

function silentLogger() {
return {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
}

type ServerHandle = {
socketPath: string;
close: () => Promise<void>;
};

async function startBridgeServer(
handler: (request: JsonRpcRequest) => Promise<unknown>,
): Promise<ServerHandle> {
const socketPath = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), "ade-bridge-test-")),
"bridge.sock",
);
const stopHandles = new Set<() => void>();
const sockets = new Set<net.Socket>();
const server = net.createServer((conn) => {
sockets.add(conn);
const transport: JsonRpcTransport = {
onData: (callback) => conn.on("data", callback),
write: (data) => conn.write(data),
close: () => {
if (!conn.destroyed) conn.destroy();
},
};
const stop = startJsonRpcServer(handler, transport, { nonFatal: true });
stopHandles.add(stop);
conn.on("close", () => {
sockets.delete(conn);
stopHandles.delete(stop);
stop();
});
conn.on("error", () => {});
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(socketPath, () => resolve());
});
return {
socketPath,
close: () =>
new Promise<void>((resolve) => {
for (const s of sockets) {
try {
s.destroy();
} catch {
// ignore
}
}
for (const stop of stopHandles) {
try {
stop();
} catch {
// ignore
}
}
server.close(() => {
try {
fs.unlinkSync(socketPath);
} catch {
// ignore
}
resolve();
});
}),
};
}

describe("createBuiltInBrowserDesktopBridgeClient", () => {
let server: ServerHandle | null = null;

afterEach(async () => {
if (server) {
await server.close();
server = null;
}
});

it("forwards method + params and resolves the JSON-RPC response", async () => {
const seen: JsonRpcRequest[] = [];
server = await startBridgeServer(async (request) => {
seen.push(request);
if (request.method === "built_in_browser.navigate") {
return { ok: true, url: (request.params as { url: string }).url };
}
throw new Error(`unexpected method: ${request.method}`);
});
const client = createBuiltInBrowserDesktopBridgeClient({
socketPath: server.socketPath,
logger: silentLogger(),
});
const result = await client.navigate({ url: "https://example.com" });
expect(result).toEqual({ ok: true, url: "https://example.com" });
expect(seen).toHaveLength(1);
expect(seen[0]?.method).toBe("built_in_browser.navigate");
expect(seen[0]?.params).toEqual({ url: "https://example.com" });
client.dispose();
});

it("dispatches no-arg methods without params field", async () => {
const recorded: JsonRpcRequest[] = [];
server = await startBridgeServer(async (request) => {
recorded.push(request);
return { tabs: [] };
});
const client = createBuiltInBrowserDesktopBridgeClient({
socketPath: server.socketPath,
logger: silentLogger(),
});
await client.getStatus();
expect(recorded[0]?.method).toBe("built_in_browser.getStatus");
expect(recorded[0]?.params).toBeUndefined();
client.dispose();
});

it("surfaces a clear error when the bridge socket does not exist", async () => {
const missingPath = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), "ade-bridge-test-missing-")),
"absent.sock",
);
const client = createBuiltInBrowserDesktopBridgeClient({
socketPath: missingPath,
logger: silentLogger(),
});
await expect(client.getStatus()).rejects.toThrow(
/Desktop browser bridge not running/,
);
client.dispose();
});

it("propagates JSON-RPC server errors", async () => {
server = await startBridgeServer(async () => {
throw new Error("Browser pane is offline");
});
const client = createBuiltInBrowserDesktopBridgeClient({
socketPath: server.socketPath,
logger: silentLogger(),
});
await expect(client.getStatus()).rejects.toThrow(/Browser pane is offline/);
client.dispose();
});

it("reconnects after a transient failure on the next call", async () => {
let callCount = 0;
server = await startBridgeServer(async () => {
callCount += 1;
if (callCount === 1) throw new Error("temporary");
return { ok: true };
});
const client = createBuiltInBrowserDesktopBridgeClient({
socketPath: server.socketPath,
logger: silentLogger(),
});
await expect(client.getStatus()).rejects.toThrow(/temporary/);
const result = await client.getStatus();
expect(result).toEqual({ ok: true });
expect(callCount).toBe(2);
client.dispose();
});
});
Loading
Loading