Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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