Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
5 changes: 3 additions & 2 deletions apps/ade-cli/src/multiProjectRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import { ProjectScopeRegistry } from "./services/projects/projectScope";

function createRegistry() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-multi-project-rpc-"));
const projectRoot = path.join(root, "project");
fs.mkdirSync(projectRoot, { recursive: true });
const rawProjectRoot = path.join(root, "project");
fs.mkdirSync(rawProjectRoot, { recursive: true });
const projectRoot = fs.realpathSync.native(rawProjectRoot);
const registry = new ProjectRegistry({
adeDir: path.join(root, "home"),
projectsPath: path.join(root, "home", "projects.json"),
Expand Down
2 changes: 1 addition & 1 deletion apps/ade-cli/src/stdioRpcDaemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ describe("ade rpc --stdio daemon bridge", () => {
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const cliPath = path.join(packageRoot, "src", "cli.ts");
const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-"));
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-project-"));
const projectRoot = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-project-")));
const socketPath = path.join(adeHome, "sock", "ade.sock");
const env = {
...process.env,
Expand Down
101 changes: 91 additions & 10 deletions apps/desktop/src/main/services/ipc/registerIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1765,14 +1765,86 @@ export function registerIpc({
if (getSyncService) return getSyncService() ?? null;
return getCtx().syncService ?? null;
};
const resolveOptionalSyncService = async (): Promise<ReturnType<typeof createSyncService> | null> =>
resolveSyncService
? (await resolveSyncService()) ?? null
: getOptionalSyncService();
const localRuntimeDaemonDisabled = process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON === "1";
const allowLocalRuntimeFallback =
process.env.ADE_LOCAL_RUNTIME_FALLBACK === "1" ||
process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON === "1";
process.env.ADE_LOCAL_RUNTIME_FALLBACK === "1";

const buildUnavailableSyncSnapshot = (): SyncRoleSnapshot => {
const now = new Date().toISOString();
const platform =
process.platform === "darwin"
? "macOS"
: process.platform === "win32"
? "windows"
: process.platform === "linux"
? "linux"
: "unknown";
const localDevice: SyncDeviceRecord = {
deviceId: "local-runtime-disabled",
siteId: "local-runtime-disabled",
name: "Local desktop",
platform,
deviceType: "desktop",
createdAt: now,
updatedAt: now,
lastSeenAt: now,
lastHost: null,
lastPort: null,
tailscaleIp: null,
ipAddresses: [],
metadata: { unavailableReason: "local_runtime_daemon_disabled" },
};
return {
mode: "standalone",
role: "brain",
localDevice,
currentBrain: localDevice,
clusterState: null,
bootstrapToken: null,
pairingPin: null,
pairingPinConfigured: false,
pairingConnectInfo: null,
connectedPeers: [],
tailnetDiscovery: {
state: "disabled",
serviceName: "ade-sync",
servicePort: 0,
target: null,
updatedAt: null,
error: null,
stderr: null,
},
client: {
state: "disconnected",
host: null,
port: null,
connectedAt: null,
lastSeenAt: null,
latencyMs: null,
syncLag: null,
lastRemoteDbVersion: 0,
brainDeviceId: localDevice.deviceId,
hostName: localDevice.name,
error: null,
message: "Sync service unavailable in local runtime disabled mode.",
savedDraft: null,
},
transferReadiness: {
ready: true,
blockers: [],
survivableState: [],
},
survivableStateText: "Sync service unavailable in local runtime disabled mode.",
blockingStateText: "",
};
};

const requireSyncService = async (): Promise<ReturnType<typeof createSyncService>> => {
const service = resolveSyncService
? await resolveSyncService()
: getOptionalSyncService();
const service = await resolveOptionalSyncService();
if (!service) {
throw new Error("Sync service is not available.");
}
Expand All @@ -1792,6 +1864,7 @@ export function registerIpc({
event: { sender: Electron.WebContents },
action: (pool: LocalRuntimeConnectionPool, rootPath: string) => Promise<T>,
): Promise<T | null> => {
if (localRuntimeDaemonDisabled) return null;
if (!localRuntimeConnectionPool) return null;
const rootPath = getLocalRuntimeRootForEvent(event);
if (!rootPath) return null;
Expand Down Expand Up @@ -4076,7 +4149,12 @@ export function registerIpc({
pool.syncStatusForRoot(rootPath, arg ?? {})
);
if (runtimeStatus) return runtimeStatus;
return await (await requireSyncService()).getStatus({
const service = await resolveOptionalSyncService();
if (!service) {
if (localRuntimeDaemonDisabled) return buildUnavailableSyncSnapshot();
throw new Error("Sync service is not available.");
}
return await service.getStatus({
includeTransferReadiness: arg?.includeTransferReadiness,
forceTransferReadiness: arg?.forceTransferReadiness,
});
Expand Down Expand Up @@ -4204,7 +4282,7 @@ export function registerIpc({
async (event, arg: { laneIds?: string[] | null }): Promise<void> => {
const laneIds = Array.isArray(arg?.laneIds) ? arg.laneIds : [];
const rootPath = getLocalRuntimeRootForEvent(event);
if (localRuntimeConnectionPool && rootPath) {
if (!localRuntimeDaemonDisabled && localRuntimeConnectionPool && rootPath) {
try {
await localRuntimeConnectionPool.callSyncForRoot(rootPath, "sync.setActiveLanePresence", { laneIds });
return;
Expand All @@ -4214,9 +4292,12 @@ export function registerIpc({
}
}
}
await (await requireSyncService()).setActiveLanePresence(
laneIds,
);
const service = await resolveOptionalSyncService();
if (!service) {
if (localRuntimeDaemonDisabled) return;
throw new Error("Sync service is not available.");
}
await service.setActiveLanePresence(laneIds);
},
);

Expand Down
104 changes: 104 additions & 0 deletions apps/desktop/src/main/services/ipc/runtimeBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,39 @@ const remoteCallMachineForTargetMock = vi.hoisted(() => vi.fn());
const remoteDisconnectMock = vi.hoisted(() => vi.fn());

vi.mock("electron", () => ({
app: {
getPath: vi.fn(() => "/tmp"),
getVersion: vi.fn(() => "1.0.0"),
isPackaged: false,
},
BrowserWindow: {
fromWebContents: browserWindowFromWebContents,
getAllWindows: browserWindowGetAllWindows,
},
clipboard: {
readImage: vi.fn(() => ({ isEmpty: () => true })),
readText: vi.fn(() => ""),
writeText: vi.fn(),
},
desktopCapturer: {
getSources: vi.fn(async () => []),
},
dialog: {
showOpenDialog: vi.fn(),
},
ipcMain: {
handle: vi.fn((channel: string, handler: (...args: any[]) => unknown) => {
ipcHandlers.set(channel, handler);
}),
on: vi.fn(),
},
nativeImage: {
createFromPath: vi.fn(() => ({ isEmpty: () => true })),
},
shell: {
openExternal: vi.fn(),
openPath: vi.fn(),
showItemInFolder: vi.fn(),
},
}));

Expand Down Expand Up @@ -62,6 +87,7 @@ vi.mock("../git/git", () => ({
}));

import { registerRuntimeBridge } from "./runtimeBridge";
import { registerIpc } from "./registerIpc";

const target: RemoteRuntimeTarget = {
id: "target-1",
Expand Down Expand Up @@ -99,6 +125,7 @@ function localBinding(rootPath = "/repo"): OpenProjectBinding {

describe("registerRuntimeBridge", () => {
beforeEach(() => {
delete process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON;
ipcHandlers.clear();
browserWindowFromWebContents.mockReset();
browserWindowGetAllWindows.mockReset().mockReturnValue([]);
Expand Down Expand Up @@ -373,3 +400,80 @@ describe("registerRuntimeBridge", () => {
);
});
});

describe("registerIpc sync bridge", () => {
beforeEach(() => {
delete process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON;
ipcHandlers.clear();
browserWindowFromWebContents.mockReset().mockReturnValue({ id: 7 });
});

it("returns an unavailable sync snapshot without probing local runtime when the daemon is disabled", async () => {
process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON = "1";
const localRuntimeConnectionPool = {
syncStatusForRoot: vi.fn(),
callSyncForRoot: vi.fn(),
};
registerIpc({
getCtx: () => ({
syncService: null,
}) as any,
getWindowSession: () => ({
windowId: 7,
project: { rootPath: "/repo", displayName: "Repo" } as any,
binding: localBinding("/repo"),
}),
localRuntimeConnectionPool: localRuntimeConnectionPool as any,
switchProjectFromDialog: vi.fn(),
closeCurrentProject: vi.fn(),
closeProjectByPath: vi.fn(),
globalStatePath: "/tmp/ade-state.json",
});

const snapshot = await ipcHandlers.get(IPC.syncGetStatus)?.(
eventForSender(),
{ includeTransferReadiness: true },
) as any;

expect(localRuntimeConnectionPool.syncStatusForRoot).not.toHaveBeenCalled();
expect(snapshot.mode).toBe("standalone");
expect(snapshot.localDevice.metadata).toEqual({
unavailableReason: "local_runtime_daemon_disabled",
});
expect(snapshot.client.message).toBe("Sync service unavailable in local runtime disabled mode.");
});

it("drops active lane presence updates instead of probing unavailable sync services when the daemon is disabled", async () => {
process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON = "1";
const localRuntimeConnectionPool = {
callSyncForRoot: vi.fn(),
};
const resolveSyncService = vi.fn(async () => null);
registerIpc({
getCtx: () => ({
syncService: null,
}) as any,
resolveSyncService,
getWindowSession: () => ({
windowId: 7,
project: { rootPath: "/repo", displayName: "Repo" } as any,
binding: localBinding("/repo"),
}),
localRuntimeConnectionPool: localRuntimeConnectionPool as any,
switchProjectFromDialog: vi.fn(),
closeCurrentProject: vi.fn(),
closeProjectByPath: vi.fn(),
globalStatePath: "/tmp/ade-state.json",
});

await expect(
ipcHandlers.get(IPC.syncSetActiveLanePresence)?.(
eventForSender(),
{ laneIds: ["lane-1"] },
),
).resolves.toBeUndefined();

expect(localRuntimeConnectionPool.callSyncForRoot).not.toHaveBeenCalled();
expect(resolveSyncService).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ describe("local runtime connection pool", () => {
expect(fs.existsSync(tsxLoaderPath)).toBe(true);

const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-"));
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-project-"));
const projectRoot = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-project-")));
const socketPath = path.join(adeHome, "sock", "ade.sock");
const originalEnv = {
ADE_CLI_JS: process.env.ADE_CLI_JS,
Expand Down Expand Up @@ -364,7 +364,7 @@ describe("local runtime connection pool", () => {
expect(fs.existsSync(tsxLoaderPath)).toBe(true);

const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-"));
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-project-"));
const projectRoot = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-project-")));
const socketPath = path.join(adeHome, "sock", "ade.sock");
const originalEnv = {
ADE_CLI_JS: process.env.ADE_CLI_JS,
Expand Down Expand Up @@ -450,7 +450,7 @@ describe("local runtime connection pool", () => {
expect(fs.existsSync(tsxLoaderPath)).toBe(true);

const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-build-"));
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-build-project-"));
const projectRoot = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-build-project-")));
const socketPath = path.join(adeHome, "sock", "ade.sock");
const originalEnv = {
ADE_CLI_JS: process.env.ADE_CLI_JS,
Expand Down
46 changes: 46 additions & 0 deletions apps/desktop/src/preload/preload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ describe("preload OAuth bridge", () => {
afterEach(() => {
vi.resetModules();
vi.doUnmock("electron");
delete process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON;
delete (globalThis as any).__adeBridge;
});

Expand Down Expand Up @@ -317,6 +318,51 @@ describe("preload OAuth bridge", () => {
expect(invoke).toHaveBeenCalledWith(IPC.lanesOpenFolder, { laneId: "lane-1" });
});

it("skips local runtime IPC when the local runtime daemon is disabled", async () => {
process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON = "1";
const binding = {
kind: "local",
key: "local:/repo",
rootPath: "/repo",
displayName: "Project",
};
const invoke = vi.fn(async (channel: string) => {
if (channel === IPC.appGetWindowSession) {
return { windowId: 1, project: { rootPath: "/repo", displayName: "Project" }, binding };
}
if (channel === IPC.lanesList) return [];
throw new Error(`unexpected IPC: ${channel}`);
});
const on = vi.fn();
const removeListener = vi.fn();
const exposeInMainWorld = vi.fn((name: string, value: unknown) => {
(globalThis as any).__bridgeName = name;
(globalThis as any).__adeBridge = value;
});

vi.doMock("electron", () => ({
contextBridge: { exposeInMainWorld },
ipcRenderer: { invoke, on, removeListener },
webFrame: {
getZoomLevel: vi.fn(() => 0),
setZoomLevel: vi.fn(),
getZoomFactor: vi.fn(() => 1),
},
}));

await import("./preload");

const bridge = (globalThis as any).__adeBridge;
await expect(bridge.lanes.list()).resolves.toEqual([]);

expect(invoke).toHaveBeenCalledWith(IPC.appGetWindowSession);
expect(invoke).toHaveBeenCalledWith(IPC.lanesList, {});
expect(invoke).not.toHaveBeenCalledWith(
IPC.localRuntimeCallAction,
expect.anything(),
);
});

it("routes project local-data cleanup through a remote project runtime when bound", async () => {
const binding = {
kind: "remote",
Expand Down
Loading