diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 3dffa3269..f36f6476f 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -268,6 +268,7 @@ ade run start web --lane lane-id ade shell start --lane lane-id -- npm test ade shell start-cli codex --lane lane-id --permission-mode edit --message "fix failing tests" ade shell start-cli --provider claude --lane lane-id --permission-mode default +ade chat list --lane lane-id --include-automation --no-archived --text ade chat create --lane lane-id --model gpt-5.5 ade code ade code --embedded diff --git a/apps/ade-cli/scripts/build-static.mjs b/apps/ade-cli/scripts/build-static.mjs index 4f3c27b17..f11bd8d41 100644 --- a/apps/ade-cli/scripts/build-static.mjs +++ b/apps/ade-cli/scripts/build-static.mjs @@ -6,6 +6,9 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = path.resolve(packageRoot, "..", ".."); +const desktopPackageJsonPath = path.join(repoRoot, "apps", "desktop", "package.json"); +const cliPackageJsonPath = path.join(packageRoot, "package.json"); const defaultOutDir = path.join(packageRoot, "dist-static"); const fuse = "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2"; @@ -89,6 +92,27 @@ async function run(command, args, options = {}) { if (stderr) process.stderr.write(stderr); } +async function readPackageVersion(packageJsonPath) { + try { + const raw = await fs.readFile(packageJsonPath, "utf8"); + const parsed = JSON.parse(raw); + return typeof parsed.version === "string" ? parsed.version.trim() : ""; + } catch { + return ""; + } +} + +async function resolveRuntimeVersion() { + const explicit = process.env.ADE_CLI_VERSION?.trim(); + if (explicit) return explicit; + + const cliVersion = await readPackageVersion(cliPackageJsonPath); + if (cliVersion && cliVersion !== "0.0.0") return cliVersion; + + const desktopVersion = await readPackageVersion(desktopPackageJsonPath); + return desktopVersion || cliVersion || "0.0.0"; +} + async function assertSeaCapableNodeBinary(binaryPath) { const contents = await fs.readFile(binaryPath); if (contents.includes(Buffer.from(fuse))) return; @@ -112,6 +136,21 @@ async function adHocSignIfNeeded(binaryPath) { await run("codesign", ["--sign", "-", binaryPath]); } +async function assertStaticRuntimeVersion(binaryPath, expectedVersion, target) { + if (target !== currentTarget()) return; + const { stdout } = await execFileAsync(binaryPath, ["--version"], { + cwd: packageRoot, + env: { + ...process.env, + ADE_CLI_VERSION: "", + }, + }); + const actual = stdout.trim().replace(/^ade\s+/i, ""); + if (actual !== expectedVersion) { + throw new Error(`Static ADE runtime version mismatch: expected ${expectedVersion}, got ${actual || ""}.`); + } +} + async function writeSeaEntry(workDir) { const cliPath = path.join(packageRoot, "dist", "cli.cjs"); const seaEntryPath = path.join(workDir, "cli-sea.cjs"); @@ -221,6 +260,8 @@ async function main() { const args = parseArgs(process.argv.slice(2)); await assertHostOrExplicitBinary(args.target); await fs.mkdir(args.outDir, { recursive: true }); + const runtimeVersion = await resolveRuntimeVersion(); + process.env.ADE_CLI_VERSION = runtimeVersion; if (!args.skipBuild) { await run(process.platform === "win32" ? "npm.cmd" : "npm", ["run", "build"]); @@ -263,6 +304,7 @@ async function main() { } await run(path.join(packageRoot, "node_modules", ".bin", process.platform === "win32" ? "postject.cmd" : "postject"), postjectArgs); await adHocSignIfNeeded(binaryPath); + await assertStaticRuntimeVersion(binaryPath, runtimeVersion, args.target); let nativeArchivePath = null; if (!args.skipNativeDeps) { diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index f3af7f36c..097adc578 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -275,6 +275,12 @@ describe("ADE CLI", () => { }); }); + it("recognizes the hidden PTY host worker entrypoint", () => { + expect(buildCliPlan(["__ade-pty-host-worker"])).toEqual({ + kind: "pty-host-worker", + }); + }); + it("classifies only ADE temp runtime sockets as ephemeral", () => { const tempSocket = path.join(os.tmpdir(), "ade-stdio-rpc-test", "sock", "ade.sock"); @@ -869,6 +875,37 @@ describe("ADE CLI", () => { }); }); + it("maps chat list filters to the listSessions action", () => { + const plan = buildCliPlan([ + "chat", + "list", + "--lane", + "lane-1", + "--include-automation", + "--include-identity", + "--no-archived", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "chat", + action: "listSessions", + args: { + laneId: "lane-1", + includeAutomation: true, + includeIdentity: true, + includeArchived: false, + }, + }, + }); + + expect(() => + buildCliPlan(["chat", "list", "--include-archived", "--no-archived"]), + ).toThrow(/Use either --include-archived or --no-archived/); + }); + it("requires a chat session id for chat show", () => { expect(() => buildCliPlan(["chat", "show"])).toThrow( /sessionId is required/, diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index d632b7cbb..a56396232 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -163,6 +163,7 @@ type CliPlan = | { kind: "runtime"; rest: string[] } | { kind: "serve"; rest: string[] } | { kind: "rpc-stdio"; rest: string[] } + | { kind: "pty-host-worker" } | { kind: "init"; targetPath: string | null } | { kind: "cursor-cloud"; rest: string[] } | { kind: "deeplink"; rest: string[] }; @@ -1243,7 +1244,8 @@ const HELP_BY_COMMAND: Record = { Chat commands use ADE agent chat sessions. Live provider-backed chat normally requires an attached runtime because the daemon owns provider/session state. - $ ade chat list --text List chat sessions + $ ade chat list --lane --text List chat sessions + $ ade chat list --include-automation --no-archived --text $ ade chat create --lane --provider codex --model [--fast] $ ade chat create --from-linear-issue ENG-431 Start a chat with an attached issue + kickoff (alias: --linear-issue-json) $ ade chat send --text "next step" Send a message @@ -5759,7 +5761,30 @@ function buildChatPlan(args: string[]): CliPlan { ...base, ...(sessionId ? { sessionId } : {}), }); - if (sub === "list" || sub === "ls") + if (sub === "list" || sub === "ls") { + const includeArchived = readFlag(args, ["--archived", "--include-archived"]); + const excludeArchived = readFlag(args, [ + "--active", + "--no-archived", + "--exclude-archived", + ]); + if (includeArchived && excludeArchived) { + throw new CliUsageError( + "Use either --include-archived or --no-archived, not both.", + ); + } + const laneId = readLaneId(args); + const input = collectGenericObjectArgs(args, { + ...(laneId ? { laneId } : {}), + ...(includeArchived ? { includeArchived: true } : {}), + ...(excludeArchived ? { includeArchived: false } : {}), + ...(readFlag(args, ["--automation", "--include-automation"]) + ? { includeAutomation: true } + : {}), + ...(readFlag(args, ["--identity", "--include-identity"]) + ? { includeIdentity: true } + : {}), + }); return { kind: "execute", label: "chat list", @@ -5768,10 +5793,11 @@ function buildChatPlan(args: string[]): CliPlan { "result", "chat", "listSessions", - collectGenericObjectArgs(args), + input, ), ], }; + } if (sub === "show" || sub === "status") return { kind: "execute", @@ -10422,6 +10448,9 @@ function buildCliPlan(command: string[]): CliPlan { if (primary === "version" || primary === "--version" || primary === "-v") { return { kind: "help", text: `ade ${VERSION}\n` }; } + if (primary === "__ade-pty-host-worker") { + return { kind: "pty-host-worker" }; + } if (primary === "code") { const rest = args; return { kind: "ade-code", rest }; @@ -15374,6 +15403,17 @@ async function runCli( await runNativeRpcStdio(parsed.options); return { output: "", exitCode: 0 }; } + if (plan.kind === "pty-host-worker") { + await import("../../desktop/src/main/services/pty/ptyHostWorker"); + await new Promise((resolve) => { + if (typeof process.send !== "function") { + resolve(); + return; + } + process.once("disconnect", resolve); + }); + return { output: "", exitCode: 0 }; + } if (plan.kind === "desktop") { const result = await runDesktopCommand(plan.rest); return { diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index 08d8b67f9..fd035f035 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -58,10 +58,10 @@ describe("listLaneDiffStats", () => { describe("chat session archive helpers", () => { it("lists chats with archived sessions hidden by default", async () => { - const calls: Array<{ domain: string; action: string; argsList: unknown[] }> = []; + const calls: Array<{ domain: string; action: string; args: Record | undefined }> = []; const connection = { - actionList: async (domain: string, action: string, argsList: unknown[]) => { - calls.push({ domain, action, argsList }); + action: async (domain: string, action: string, args?: Record) => { + calls.push({ domain, action, args }); return []; }, } as unknown as AdeCodeConnection; @@ -70,8 +70,8 @@ describe("chat session archive helpers", () => { await listChatSessions(connection, "lane-1", { includeArchived: true }); expect(calls).toEqual([ - { domain: "chat", action: "listSessions", argsList: [null, { includeArchived: false }] }, - { domain: "chat", action: "listSessions", argsList: ["lane-1", { includeArchived: true }] }, + { domain: "chat", action: "listSessions", args: { includeArchived: false } }, + { domain: "chat", action: "listSessions", args: { laneId: "lane-1", includeArchived: true } }, ]); }); diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 0e10af007..104913344 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -74,9 +74,10 @@ export async function listChatSessions( laneId?: string | null, options: { includeArchived?: boolean } = {}, ): Promise { - const listOptions = { includeArchived: options.includeArchived ?? false }; - const argsList = laneId ? [laneId, listOptions] : [null, listOptions]; - return await connection.actionList("chat", "listSessions", argsList); + return await connection.action("chat", "listSessions", { + ...(laneId ? { laneId } : {}), + includeArchived: options.includeArchived ?? false, + }); } export async function archiveChatSession( diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index ceecd4ff4..b6c9ee4c2 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1446,6 +1446,8 @@ app.whenReady().then(async () => { options: { emit?: boolean; foreground?: boolean } = {}, ): void => { const normalizedRoot = projectRoot ? normalizeProjectRoot(projectRoot) : null; + const previousRemoteBinding = + windowId != null ? (windowProjectBindings.get(windowId) ?? null) : null; if (windowId != null) { windowProjectRoots.set(windowId, normalizedRoot); windowProjectBindings.delete(windowId); @@ -1477,6 +1479,15 @@ app.whenReady().then(async () => { }); } } + if (previousRemoteBinding) { + const remainingRemoteBinding = + Array.from(windowProjectBindings.values()).at(-1) ?? null; + if (remainingRemoteBinding) { + persistLastRemoteProjectBinding(remainingRemoteBinding); + } else { + clearLastRemoteProjectBinding(); + } + } if (options.emit !== false) { const project = projectForRoot(normalizedRoot); emitProjectChangedToWindow(windowId, project); diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 433421810..6be0fc197 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -72,6 +72,11 @@ describe("isAllowedAdeAction", () => { expect(isCtoOnlyAdeAction("ios_simulator", "ensurePreviewWorkspace")).toBe(false); }); + it("exposes subagent transcript reads through the chat runtime action surface", () => { + expect(isAllowedAdeAction("chat", "getSubagentTranscript")).toBe(true); + expect(isCtoOnlyAdeAction("chat", "getSubagentTranscript")).toBe(false); + }); + it("rejects an unknown action on a known domain", () => { expect(isAllowedAdeAction("git", "rmRf")).toBe(false); expect(isAllowedAdeAction("issue", "deleteAllIssues")).toBe(false); @@ -343,6 +348,26 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { expect(getModelCatalog).toHaveBeenCalledWith({ mode: "cached" }); }); + it("unwraps chat.listSessions action args before calling the positional chat service API", async () => { + const listSessions = vi.fn(async () => []); + const runtime = { + agentChatService: { + listSessions, + }, + } as unknown as Parameters[0]; + + const chat = getAdeActionDomainServices(runtime).chat as { + listSessions?: (args?: unknown) => Promise; + }; + + await expect(chat.listSessions?.({ + laneId: " lane-1 ", + includeAutomation: true, + })).resolves.toEqual([]); + expect(listAllowedAdeActionNames("chat", chat as Record)).toContain("listSessions"); + expect(listSessions).toHaveBeenCalledWith("lane-1", { includeAutomation: true }); + }); + it("exposes the browser panel and tab control surface", () => { const actions = ADE_ACTION_ALLOWLIST.built_in_browser ?? []; for (const name of ["claim", "startSession", "listSessions", "endSession", "showPanel", "navigate", "createTab", "switchTab", "closeTab", "observe", "getTrace", "click", "typeText", "dispatchKey", "scroll", "fill", "clear", "wait"]) { @@ -640,6 +665,32 @@ describe("runtime session actions", () => { }); }); + it("forwards remote subagent transcript reads through chat actions", async () => { + const transcript = [{ role: "assistant", content: "subagent output" }]; + const getSubagentTranscript = vi.fn(async () => transcript); + const runtime = { + agentChatService: { + getSubagentTranscript, + }, + } as unknown as Parameters[0]; + const chatService = getAdeActionDomainServices(runtime).chat as { + getSubagentTranscript: (args: { + sessionId: string; + subagentId: string; + }) => Promise>; + } & Record; + + expect(listAllowedAdeActionNames("chat", chatService)).toContain("getSubagentTranscript"); + await expect(chatService.getSubagentTranscript({ + sessionId: "chat-1", + subagentId: "subagent-1", + })).resolves.toEqual(transcript); + expect(getSubagentTranscript).toHaveBeenCalledWith({ + sessionId: "chat-1", + subagentId: "subagent-1", + }); + }); + it("adds getDelta from the runtime session delta service", () => { const delta = { sessionId: "session-1", filesChanged: 2 }; const runtime = { diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index d2114158f..e4fcc34d9 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -449,6 +449,7 @@ export const ADE_ACTION_ALLOWLIST: Partial { + const record = asActionRecord(args); + const laneId = typeof record.laneId === "string" && record.laneId.trim() + ? record.laneId.trim() + : undefined; + const options = { + ...(typeof record.includeArchived === "boolean" ? { includeArchived: record.includeArchived } : {}), + ...(typeof record.includeAutomation === "boolean" ? { includeAutomation: record.includeAutomation } : {}), + ...(typeof record.includeIdentity === "boolean" ? { includeIdentity: record.includeIdentity } : {}), + }; + return agentChatService.listSessions( + laneId, + Object.keys(options).length ? options : undefined, + ); + }, modelCatalog: (args?: unknown) => agentChatService.getModelCatalog(args && typeof args === "object" ? args as never : undefined), codexOpenInCli: async ( diff --git a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts index 03bdd0784..49a6f767e 100644 --- a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts +++ b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts @@ -475,19 +475,23 @@ describe("createBuiltInBrowserService — bounds and status dedupe", () => { it("keeps owned tabs alive while hidden and mutes them until visible", async () => { const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); - service.attachToWindow(fakeBrowserWindow() as unknown as Parameters[0]); + const win = fakeBrowserWindow(); + service.attachToWindow(win as unknown as Parameters[0]); await service.createTab({ url: "https://example.test", activate: true }); expect(service.getStatus().tabs).toHaveLength(1); + expect(win.contentView.children).toHaveLength(0); const wc = fakes.webContentsInstances[0]; expect(wc?.audioMutedCalls.at(-1)).toBe(true); await service.setBounds({ x: 12, y: 24, width: 640, height: 360, visible: true }); expect(service.getStatus().tabs).toHaveLength(1); + expect(win.contentView.children).toHaveLength(1); expect(wc?.audioMutedCalls.at(-1)).toBe(false); await service.setBounds({ x: 12, y: 24, width: 640, height: 360, visible: false }); expect(service.getStatus().tabs).toHaveLength(1); + expect(win.contentView.children).toHaveLength(0); expect(wc?.audioMutedCalls.at(-1)).toBe(true); }); diff --git a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts index b84a30b18..f1e4fa3a6 100644 --- a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts +++ b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts @@ -626,15 +626,19 @@ function createBuiltInBrowserWindowService(args: { } }; - const removeTabViewsFromWindow = (): void => { + const removeTabViewFromWindow = (tab: BrowserTabState): void => { if (!win || win.isDestroyed()) return; + if (!tab.view) return; + try { + win.contentView.removeChildView(tab.view); + } catch { + // ignore stale view/window links + } + }; + + const removeTabViewsFromWindow = (): void => { for (const tab of tabs) { - if (!tab.view) continue; - try { - win.contentView.removeChildView(tab.view); - } catch { - // ignore stale view/window links - } + removeTabViewFromWindow(tab); } }; @@ -1202,13 +1206,20 @@ function createBuiltInBrowserWindowService(args: { applyTabLifecycle(tab, visible && tab.id === activeTabId); continue; } + const isActive = tab.id === activeTabId; + const shouldAttach = visible && isActive; + if (!shouldAttach) { + tab.view.setVisible(false); + removeTabViewFromWindow(tab); + applyTabLifecycle(tab, false); + continue; + } if (!win.contentView.children.includes(tab.view)) { win.contentView.addChildView(tab.view); } - const isActive = tab.id === activeTabId; - if (isActive) tab.view.setBounds(electronRect); - tab.view.setVisible(visible && isActive); - applyTabLifecycle(tab, visible && isActive); + tab.view.setBounds(electronRect); + tab.view.setVisible(true); + applyTabLifecycle(tab, true); } }; diff --git a/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.test.ts b/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.test.ts index db734fd33..f7d2ecbcf 100644 --- a/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.test.ts +++ b/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.test.ts @@ -48,16 +48,6 @@ describe("startBuiltInBrowserDesktopBridgeServer", () => { it("creates private Unix socket directories and socket files", async () => { if (process.platform === "win32") return; - let currentUmask = 0o000; - const umaskCalls: number[] = []; - const umask = (mask?: number): number => { - const previous = currentUmask; - if (mask != null) { - umaskCalls.push(mask); - currentUmask = mask; - } - return previous; - }; const socketDir = path.join(tempDir, "sock"); fs.mkdirSync(socketDir, { mode: 0o755 }); fs.chmodSync(socketDir, 0o755); @@ -68,13 +58,10 @@ describe("startBuiltInBrowserDesktopBridgeServer", () => { socketPath, service: { getStatus: async () => ({ ok: true }) } as unknown as BuiltInBrowserService, logger: createLogger(), - umask, }); await waitForPath(socketPath); await waitForMode(socketPath, 0o600); - expect(umaskCalls).toEqual([0o177, 0o000]); - expect(currentUmask).toBe(0o000); expect(fs.statSync(socketDir).mode & 0o777).toBe(0o700); expect(fs.statSync(socketPath).mode & 0o777).toBe(0o600); } finally { diff --git a/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts b/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts index 6b5782e87..96dbfb8b6 100644 --- a/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts +++ b/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import net from "node:net"; +import os from "node:os"; import path from "node:path"; import { JsonRpcError, @@ -33,7 +34,6 @@ export function startBuiltInBrowserDesktopBridgeServer(args: { socketPath: string; service: BuiltInBrowserService; logger: Logger; - umask?: (mask?: number) => number; }): BuiltInBrowserDesktopBridgeServer { const { socketPath, service, logger } = args; const isNamedPipe = socketPath.startsWith("\\\\"); @@ -41,8 +41,11 @@ export function startBuiltInBrowserDesktopBridgeServer(args: { if (!isNamedPipe) { const socketDir = path.dirname(socketPath); try { + const existed = fs.existsSync(socketDir); fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 }); - fs.chmodSync(socketDir, 0o700); + if (!isSystemTempDir(socketDir) || !existed) { + fs.chmodSync(socketDir, 0o700); + } } catch (error) { logger.warn("built_in_browser_bridge.sockdir_create_failed", { socketPath, @@ -92,27 +95,7 @@ export function startBuiltInBrowserDesktopBridgeServer(args: { }); }); - let restoreSocketUmask: (() => void) | null = null; - if (!isNamedPipe && process.platform !== "win32") { - const setUmask = args.umask ?? process.umask.bind(process); - try { - const previousUmask = setUmask(0o177); - let restored = false; - restoreSocketUmask = () => { - if (restored) return; - restored = true; - setUmask(previousUmask); - }; - } catch (error) { - logger.warn("built_in_browser_bridge.sock_umask_failed", { - socketPath, - reason: error instanceof Error ? error.message : String(error), - }); - } - } - server.on("error", (error) => { - restoreSocketUmask?.(); logger.error("built_in_browser_bridge.server_error", { socketPath, reason: error instanceof Error ? error.message : String(error), @@ -121,7 +104,6 @@ export function startBuiltInBrowserDesktopBridgeServer(args: { try { server.listen(socketPath, () => { - restoreSocketUmask?.(); if (!isNamedPipe) { try { fs.chmodSync(socketPath, 0o600); @@ -141,7 +123,6 @@ export function startBuiltInBrowserDesktopBridgeServer(args: { logger.info("built_in_browser_bridge.listening", { socketPath }); }); } catch (error) { - restoreSocketUmask?.(); throw error; } @@ -217,3 +198,10 @@ export function startBuiltInBrowserDesktopBridgeServer(args: { }, }; } + +function isSystemTempDir(dirPath: string): boolean { + const normalized = path.resolve(dirPath); + return normalized === path.resolve(os.tmpdir()) + || normalized === "/tmp" + || normalized === "/private/tmp"; +} diff --git a/apps/desktop/src/main/services/ios/iosSimulatorService.ts b/apps/desktop/src/main/services/ios/iosSimulatorService.ts index de5355398..767f292ba 100644 --- a/apps/desktop/src/main/services/ios/iosSimulatorService.ts +++ b/apps/desktop/src/main/services/ios/iosSimulatorService.ts @@ -1119,8 +1119,12 @@ async function waitForTcpPort(host: string, port: number, timeoutMs: number): Pr while (Date.now() < deadline) { const connected = await new Promise((resolve) => { const socket = net.createConnection({ host, port }); + let settled = false; const finish = (ok: boolean) => { + if (settled) return; + settled = true; socket.removeAllListeners(); + socket.on("error", () => {}); socket.destroy(); resolve(ok); }; diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts index 8fce3aafe..3e803ce81 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts @@ -24,14 +24,46 @@ describe("ipcInvokeTimeoutMs", () => { expect(ipcInvokeTimeoutMs(IPC.localRuntimeStreamEvents)).toBe(150_000); }); - it("keeps ordinary remote runtime actions on the default timeout", () => { + it("gives retryable remote runtime actions enough time to reconnect", () => { expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeCallAction, [{ id: "target-1", projectId: "project-1", request: { domain: "lane", action: "list" }, + }])).toBe(75_000); + expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeCallAction, [{ + id: "target-1", + projectId: "project-1", + request: { domain: "file", action: "readFile", args: {} }, + }])).toBe(75_000); + expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeCallAction, [{ + id: "target-1", + projectId: "project-1", + request: { domain: "file", action: "listTreeChildren", args: {} }, + }])).toBe(75_000); + expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeCallAction, [{ + id: "target-1", + projectId: "project-1", + request: { domain: "file", action: "readFileRange", args: {} }, + }])).toBe(75_000); + expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeCallAction, [{ + id: "target-1", + projectId: "project-1", + request: { domain: "file", action: "refreshGitDecorations", args: {} }, + }])).toBe(75_000); + }); + + it("keeps ordinary remote runtime actions on the default timeout", () => { + expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeCallAction, [{ + id: "target-1", + projectId: "project-1", + request: { domain: "chat", action: "sendMessage" }, }])).toBe(30_000); }); + it("lets remote port forwarding include a cold SSH/runtime bind", () => { + expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeEnsurePortForward)).toBe(10 * 60_000); + }); + it("keeps iOS launch timeout separate from macOS VM provisioning", () => { expect(ipcInvokeTimeoutMs(IPC.iosSimulatorLaunch)).toBe(10 * 60_000); expect(ipcInvokeTimeoutMs(IPC.macosVmProvision)).toBe(120 * 60_000); diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts index a1d43154b..a4dc8794f 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts @@ -1,4 +1,5 @@ import { IPC } from "../../../shared/ipc"; +import { isRetryableRemoteAction } from "../remoteRuntime/retryableRemoteActions"; function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); @@ -33,6 +34,8 @@ const RUNTIME_ACTION_CHANNEL: Record> = { }; const LOCAL_RUNTIME_PROJECT_SETUP_TIMEOUT_MS = 150_000; +const REMOTE_RUNTIME_BOOTSTRAP_TIMEOUT_MS = 10 * 60_000; +const REMOTE_RUNTIME_RETRYABLE_ACTION_TIMEOUT_MS = 75_000; function runtimeActionTimeoutMs(args: readonly unknown[]): number | null { const payload = args[0]; @@ -42,6 +45,17 @@ function runtimeActionTimeoutMs(args: readonly unknown[]): number | null { return channel ? ipcInvokeTimeoutMs(channel) : null; } +function retryableRemoteActionTimeoutMs(args: readonly unknown[]): number | null { + const payload = args[0]; + const request = isRecord(payload) && isRecord(payload.request) ? payload.request : null; + const domain = request?.domain; + const action = request?.action; + if (typeof domain !== "string" || typeof action !== "string") return null; + return isRetryableRemoteAction(domain, action) + ? REMOTE_RUNTIME_RETRYABLE_ACTION_TIMEOUT_MS + : null; +} + export function ipcInvokeTimeoutMs(channel: string, args: readonly unknown[] = []): number { if (channel === IPC.localRuntimeCallAction) { const actionTimeoutMs = runtimeActionTimeoutMs(args); @@ -54,9 +68,27 @@ export function ipcInvokeTimeoutMs(channel: string, args: readonly unknown[] = [ if (channel === IPC.remoteRuntimeCallAction) { const actionTimeoutMs = runtimeActionTimeoutMs(args); if (actionTimeoutMs != null) return actionTimeoutMs; + const retryableActionTimeoutMs = retryableRemoteActionTimeoutMs(args); + if (retryableActionTimeoutMs != null) return retryableActionTimeoutMs; return 30_000; } switch (channel) { + case IPC.remoteRuntimeConnect: + case IPC.remoteRuntimeListProjects: + case IPC.remoteRuntimeAddProject: + case IPC.remoteRuntimeBrowseDirectories: + case IPC.remoteRuntimeGetProjectDetail: + case IPC.remoteRuntimeGetDefaultParentDir: + case IPC.remoteRuntimeCreateProject: + case IPC.remoteRuntimeCloneProject: + case IPC.remoteRuntimeListMyGitHubRepos: + case IPC.remoteRuntimeOpenProject: + case IPC.remoteRuntimeListActionRegistry: + case IPC.remoteRuntimeCallSync: + case IPC.remoteRuntimeEnsurePortForward: + case IPC.remoteRuntimeStreamEvents: + case IPC.remoteRuntimeCheckLocalWork: + return REMOTE_RUNTIME_BOOTSTRAP_TIMEOUT_MS; case IPC.lanesCreate: case IPC.lanesCreateChild: case IPC.lanesCreateFromUnstaged: diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index 2f67efc01..80090102e 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -12,15 +12,19 @@ const browserWindowFromWebContents = vi.hoisted(() => vi.fn()); const browserWindowFromId = vi.hoisted(() => vi.fn()); const browserWindowGetAllWindows = vi.hoisted(() => vi.fn(() => [])); const remoteRegistryGetMock = vi.hoisted(() => vi.fn()); -const remoteRegistryListMock = vi.hoisted(() => vi.fn(() => [])); +const remoteRegistryListMock = vi.hoisted(() => vi.fn<[], RemoteRuntimeTarget[]>(() => [])); const remoteRegistrySaveMock = vi.hoisted(() => vi.fn()); const remoteRegistryRemoveMock = vi.hoisted(() => vi.fn()); +const remoteRegistryUpdateMock = vi.hoisted(() => vi.fn()); const remoteConnectMock = vi.hoisted(() => vi.fn()); const remoteProjectsForTargetMock = vi.hoisted(() => vi.fn()); const remoteCallActionForTargetMock = vi.hoisted(() => vi.fn()); const remoteCallSyncForTargetMock = vi.hoisted(() => vi.fn()); +const remoteEnsureLocalPortForwardMock = vi.hoisted(() => vi.fn()); const remoteListActionRegistryForTargetMock = vi.hoisted(() => vi.fn()); const remoteCallMachineForTargetMock = vi.hoisted(() => vi.fn()); +const remoteStreamEventsForTargetMock = vi.hoisted(() => vi.fn()); +const remoteSubscribeEventsForTargetMock = vi.hoisted(() => vi.fn()); const remoteDisconnectMock = vi.hoisted(() => vi.fn()); const hasKnownSshHostKeyForTargetMock = vi.hoisted(() => vi.fn(() => false)); const getSshHostKeyTrustForTargetMock = vi.hoisted(() => vi.fn()); @@ -73,6 +77,7 @@ vi.mock("../remoteRuntime/remoteTargetRegistry", () => ({ list: remoteRegistryListMock, save: remoteRegistrySaveMock, remove: remoteRegistryRemoveMock, + update: remoteRegistryUpdateMock, })), })); @@ -82,8 +87,11 @@ vi.mock("../remoteRuntime/remoteConnectionPool", () => ({ projectsForTarget: remoteProjectsForTargetMock, callActionForTarget: remoteCallActionForTargetMock, callSyncForTarget: remoteCallSyncForTargetMock, + ensureLocalPortForward: remoteEnsureLocalPortForwardMock, listActionRegistryForTarget: remoteListActionRegistryForTargetMock, callMachineForTarget: remoteCallMachineForTargetMock, + streamEventsForTarget: remoteStreamEventsForTargetMock, + subscribeEventsForTarget: remoteSubscribeEventsForTargetMock, disconnect: remoteDisconnectMock, onEntryEvicted: vi.fn(() => () => {}), })), @@ -154,6 +162,7 @@ describe("registerRuntimeBridge", () => { remoteRegistryListMock.mockReset().mockReturnValue([]); remoteRegistrySaveMock.mockReset(); remoteRegistryRemoveMock.mockReset(); + remoteRegistryUpdateMock.mockReset(); remoteConnectMock.mockReset().mockResolvedValue({ target, arch: "darwin-arm64", @@ -163,8 +172,11 @@ describe("registerRuntimeBridge", () => { remoteProjectsForTargetMock.mockReset(); remoteCallActionForTargetMock.mockReset(); remoteCallSyncForTargetMock.mockReset(); + remoteEnsureLocalPortForwardMock.mockReset(); remoteListActionRegistryForTargetMock.mockReset(); remoteCallMachineForTargetMock.mockReset(); + remoteStreamEventsForTargetMock.mockReset(); + remoteSubscribeEventsForTargetMock.mockReset().mockResolvedValue(vi.fn()); remoteDisconnectMock.mockReset(); hasKnownSshHostKeyForTargetMock.mockReset().mockReturnValue(false); getSshHostKeyTrustForTargetMock.mockReset().mockResolvedValue({ @@ -190,6 +202,39 @@ describe("registerRuntimeBridge", () => { browserWindowFromWebContents.mockReturnValue({ id: 7 }); }); + it("does not start saved remote autoconnect when disabled for dev/test runs", async () => { + const previous = process.env.ADE_DISABLE_REMOTE_AUTOCONNECT; + process.env.ADE_DISABLE_REMOTE_AUTOCONNECT = "1"; + vi.useFakeTimers(); + remoteRegistryListMock.mockReturnValue([ + { ...target, lastConnectedAt: Date.now() }, + ]); + + try { + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + localRuntimeConnectionPool: {} as any, + getWindowSession: () => ({ + windowId: 7, + project: null, + binding: localBinding("/repo"), + }), + }); + + await vi.runOnlyPendingTimersAsync(); + + expect(remoteConnectMock).not.toHaveBeenCalled(); + } finally { + if (previous === undefined) { + delete process.env.ADE_DISABLE_REMOTE_AUTOCONNECT; + } else { + process.env.ADE_DISABLE_REMOTE_AUTOCONNECT = previous; + } + vi.useRealTimers(); + } + }); + it("forwards local project runtime actions with renderer client metadata for file watches", async () => { const localRuntimeConnectionPool = { callActionForRoot: vi.fn(async () => ({ @@ -479,7 +524,7 @@ describe("registerRuntimeBridge", () => { ); expect(localRuntimeConnectionPool.subscribeEventsForRoot).toHaveBeenCalledWith( "/other-repo", - { cursor: 2, limit: 10, category: "sync" }, + { cursor: 2, limit: 10, category: undefined, replay: undefined }, expect.any(Function), expect.any(Function), ); @@ -520,7 +565,7 @@ describe("registerRuntimeBridge", () => { ), ).resolves.toMatchObject({ result: { ptyId: "pty-1" } }); - expect(remoteConnectMock).toHaveBeenCalledWith(target); + expect(remoteConnectMock).not.toHaveBeenCalled(); expect(remoteCallActionForTargetMock).toHaveBeenCalledWith( target, "project-1", @@ -532,6 +577,51 @@ describe("registerRuntimeBridge", () => { ); }); + it("creates remote port forwards through the selected target", async () => { + remoteRegistryGetMock.mockReturnValue(target); + remoteEnsureLocalPortForwardMock.mockResolvedValue({ + targetId: "target-1", + remoteHost: "127.0.0.1", + remotePort: 3000, + localHost: "127.0.0.1", + localPort: 49152, + localUrl: "http://127.0.0.1:49152", + label: "preview", + createdAt: 1, + lastUsedAt: 1, + }); + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + }); + + await expect( + ipcHandlers.get(IPC.remoteRuntimeEnsurePortForward)?.( + eventForSender(sender(202)), + { + id: " target-1 ", + request: { + remoteHost: " 127.0.0.1 ", + remotePort: 3000, + label: " preview ", + }, + }, + ), + ).resolves.toMatchObject({ + localUrl: "http://127.0.0.1:49152", + }); + + expect(remoteConnectMock).toHaveBeenCalledWith(target); + expect(remoteEnsureLocalPortForwardMock).toHaveBeenCalledWith( + target.id, + { + remoteHost: "127.0.0.1", + remotePort: 3000, + label: "preview", + }, + ); + }); + it("forwards remote project action registry listing through the selected target and project", async () => { const registry = [ { domain: "chat", actions: [{ name: "codexOpenInCli" }] }, @@ -560,13 +650,121 @@ describe("registerRuntimeBridge", () => { ), ).resolves.toEqual(registry); - expect(remoteConnectMock).toHaveBeenCalledWith(target); + expect(remoteConnectMock).not.toHaveBeenCalled(); expect(remoteListActionRegistryForTargetMock).toHaveBeenCalledWith( target, "project-1", ); }); + it("opens remote event subscriptions without replaying buffered history", async () => { + remoteRegistryGetMock.mockReturnValue(target); + remoteStreamEventsForTargetMock.mockResolvedValue({ + events: [{ id: 1, timestamp: "now", category: "runtime", payload: { type: "stale" } }], + nextCursor: 1, + hasMore: false, + }); + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + }); + + await expect( + ipcHandlers.get(IPC.remoteRuntimeStreamEvents)?.( + eventForSender(sender(202)), + { + id: "target-1", + projectId: "project-1", + request: { cursor: 0, limit: 100, replay: false }, + }, + ), + ).resolves.toEqual({ events: [], nextCursor: 0, hasMore: false }); + + expect(remoteStreamEventsForTargetMock).not.toHaveBeenCalled(); + expect(remoteSubscribeEventsForTargetMock).toHaveBeenCalledWith( + target, + "project-1", + { cursor: 0, limit: 100, category: undefined, replay: false }, + expect.any(Function), + expect.any(Function), + ); + }); + + it("normalizes malformed remote event stream requests before forwarding", async () => { + remoteRegistryGetMock.mockReturnValue(target); + remoteStreamEventsForTargetMock.mockResolvedValue({ + events: [], + nextCursor: 0, + hasMore: false, + }); + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + }); + + await expect( + ipcHandlers.get(IPC.remoteRuntimeStreamEvents)?.( + eventForSender(sender(202)), + { + id: "target-1", + projectId: "project-1", + request: ["invalid"] as any, + }, + ), + ).resolves.toEqual({ events: [], nextCursor: 0, hasMore: false }); + + expect(remoteStreamEventsForTargetMock).toHaveBeenCalledWith( + target, + "project-1", + {}, + ); + expect(remoteSubscribeEventsForTargetMock).toHaveBeenCalledWith( + target, + "project-1", + { + cursor: undefined, + limit: undefined, + category: undefined, + replay: undefined, + }, + expect.any(Function), + expect.any(Function), + ); + }); + + it("cleans a remote event subscription before disconnecting that target", async () => { + const cleanup = vi.fn(); + remoteRegistryGetMock.mockReturnValue(target); + remoteSubscribeEventsForTargetMock.mockResolvedValue(cleanup); + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + }); + const webContents = sender(202); + + await expect( + ipcHandlers.get(IPC.remoteRuntimeStreamEvents)?.( + eventForSender(webContents), + { + id: "target-1", + projectId: "project-1", + request: { cursor: 0, limit: 100, replay: false }, + }, + ), + ).resolves.toEqual({ events: [], nextCursor: 0, hasMore: false }); + await Promise.resolve(); + + await expect( + ipcHandlers.get(IPC.remoteRuntimeDisconnect)?.( + eventForSender(webContents), + { id: "target-1" }, + ), + ).resolves.toEqual({ disconnected: true }); + + expect(cleanup).toHaveBeenCalledTimes(1); + expect(remoteDisconnectMock).toHaveBeenCalledWith("target-1"); + }); + it("rejects unexposed sync methods before calling local or remote runtimes", async () => { const localRuntimeConnectionPool = { callSyncForRoot: vi.fn(), @@ -671,7 +869,9 @@ describe("registerRuntimeBridge", () => { displayName: "ADE", }); - expect(remoteConnectMock).toHaveBeenCalledWith(target); + expect(remoteConnectMock).toHaveBeenCalledWith(target, { + bypassFailureBackoff: true, + }); expect(remoteProjectsForTargetMock).toHaveBeenCalledWith(target); expect(bindRemoteProject).toHaveBeenCalledWith(7, { kind: "remote", diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index 96395a61f..4b9c623ff 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -19,8 +19,11 @@ import type { RemoteRuntimeBufferedEvent, RemoteRuntimeConnectResult, RemoteRuntimeDiscoveryResult, + RemoteRuntimeEventCategory, RemoteRuntimeEventNotificationPayload, RemoteRuntimeLocalWorkCheckResult, + RemoteRuntimePortForward, + RemoteRuntimePortForwardRequest, RemoteRuntimeProjectRecord, RemoteRuntimeProjectWorkSummary, RemoteRuntimeSshHostKeyTrustStatus, @@ -97,6 +100,33 @@ function isObjectRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +function isRemoteRuntimeEventCategory(value: unknown): value is RemoteRuntimeEventCategory { + return ( + value === "orchestrator" || + value === "dag_mutation" || + value === "runtime" || + value === "pty" + ); +} + +function normalizeRuntimeStreamEventsRequest(value: unknown): RemoteRuntimeStreamEventsRequest { + if (!isObjectRecord(value)) return {}; + const request: RemoteRuntimeStreamEventsRequest = {}; + if (typeof value.cursor === "number" && Number.isFinite(value.cursor)) { + request.cursor = value.cursor; + } + if (typeof value.limit === "number" && Number.isFinite(value.limit)) { + request.limit = value.limit; + } + if (isRemoteRuntimeEventCategory(value.category)) { + request.category = value.category; + } + if (typeof value.replay === "boolean") { + request.replay = value.replay; + } + return request; +} + function isRemoteRuntimeSyncMethod(value: string): boolean { return REMOTE_RUNTIME_SYNC_METHODS.has(value); } @@ -301,10 +331,12 @@ export function registerRuntimeBridge({ ); } }); - const autoconnectTimer = setTimeout(() => { - remoteConnectionService.startAutoconnect(); - }, 0); - autoconnectTimer.unref?.(); + if (process.env.ADE_DISABLE_REMOTE_AUTOCONNECT !== "1") { + const autoconnectTimer = setTimeout(() => { + remoteConnectionService.startAutoconnect(); + }, 0); + autoconnectTimer.unref?.(); + } const probeRemoteConnectionsAfterWake = (): void => { remoteConnectionService.probeSavedConnections(); }; @@ -494,7 +526,7 @@ export function registerRuntimeBridge({ arg: { id: string }, ): Promise => { const id = typeof arg?.id === "string" ? arg.id.trim() : ""; - return await remoteConnectionService.connect(id); + return await remoteConnectionService.connect(id, { explicit: true }); }, ); @@ -641,7 +673,9 @@ export function registerRuntimeBridge({ if (!target) throw new Error("Remote target was not found."); if (!projectId) throw new Error("Remote project is required."); - const connection = await remoteConnectionService.connect(target.id); + const connection = await remoteConnectionService.connect(target.id, { + explicit: true, + }); let project = connection.projects.find( (candidate) => candidate.projectId === projectId, @@ -691,9 +725,8 @@ export function registerRuntimeBridge({ const target = id ? remoteConnectionService.getTarget(id) : null; if (!target) throw new Error("Remote target was not found."); if (!projectId) throw new Error("Remote project is required."); - await remoteConnectionService.connect(target.id); - return await remoteConnectionPool.listActionRegistryForTarget( - target, + return await remoteConnectionService.listActionRegistry( + target.id, projectId, ); }, @@ -727,19 +760,53 @@ export function registerRuntimeBridge({ if (!projectId) throw new Error("Remote project is required."); if (!domain || !action) throw new Error("Remote action domain and action are required."); - await remoteConnectionService.connect(target.id); const actionRequest = withRuntimeActionClientMetadata( { ...request!, domain, action }, event.sender.id, ); - return await remoteConnectionPool.callActionForTarget( - target, + return await remoteConnectionService.callAction( + target.id, projectId, actionRequest, ); }, ); + ipcMain.handle( + IPC.remoteRuntimeEnsurePortForward, + async ( + _event, + arg: { + id: string; + request: RemoteRuntimePortForwardRequest; + }, + ): Promise => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + const request = + arg?.request && + typeof arg.request === "object" && + !Array.isArray(arg.request) + ? arg.request + : null; + const remotePort = Number(request?.remotePort); + const remoteHost = + typeof request?.remoteHost === "string" + ? request.remoteHost.trim() + : null; + const label = + typeof request?.label === "string" ? request.label.trim() : null; + if (!id) throw new Error("Remote target id is required."); + if (!Number.isInteger(remotePort) || remotePort < 1 || remotePort > 65_535) { + throw new Error("Remote port must be an integer from 1 to 65535."); + } + return await remoteConnectionService.ensurePortForward(id, { + remoteHost, + remotePort, + label, + }); + }, + ); + ipcMain.handle( IPC.remoteRuntimeCallSync, async ( @@ -761,9 +828,8 @@ export function registerRuntimeBridge({ if (!projectId) throw new Error("Remote project is required."); if (!isRemoteRuntimeSyncMethod(method)) throw new Error("Remote sync method is not exposed."); - await remoteConnectionService.connect(target.id); - return await remoteConnectionPool.callSyncForTarget( - target, + return await remoteConnectionService.callSync( + target.id, projectId, method, params, @@ -894,6 +960,7 @@ export function registerRuntimeBridge({ "Local runtime project is not available for this window.", ); } + const request = normalizeRuntimeStreamEventsRequest(arg?.request); const requestedRootPath = normalizeLocalRuntimeRootPath(arg?.rootPath); if (binding?.kind === "local" || requestedRootPath) { const bindingKey = @@ -904,14 +971,15 @@ export function registerRuntimeBridge({ ensureRuntimeEventSubscription( event.sender, bindingKey, - `${bindingKey}:${arg?.request?.category ?? "*"}`, + `${bindingKey}:${request.category ?? "*"}:${request.replay === false ? "live" : "replay"}`, (onEvent, onEnded) => localRuntimeConnectionPool.subscribeEventsForRoot( rootPath, { - cursor: arg?.request?.cursor, - limit: arg?.request?.limit, - category: arg?.request?.category, + cursor: request.cursor, + limit: request.limit, + category: request.category, + replay: request.replay, }, onEvent, onEnded, @@ -919,13 +987,13 @@ export function registerRuntimeBridge({ ); return { events: [], - nextCursor: arg?.request?.cursor ?? 0, + nextCursor: request.cursor ?? 0, hasMore: false, }; } return await localRuntimeConnectionPool.streamEventsForRoot( rootPath, - arg?.request ?? {}, + request, ); }, ); @@ -947,29 +1015,37 @@ export function registerRuntimeBridge({ if (!projectId) throw new Error("Remote project id is required."); const target = remoteConnectionService.getTarget(id); if (!target) throw new Error("Remote target was not found."); - await remoteConnectionService.connect(target.id); + const request = normalizeRuntimeStreamEventsRequest(arg?.request); + const result = request.replay === false + ? { + events: [], + nextCursor: request.cursor ?? 0, + hasMore: false, + } + : await remoteConnectionService.streamEvents( + target.id, + projectId, + request, + ); ensureRuntimeEventSubscription( event.sender, `remote:${target.id}:${projectId}`, - `remote:${target.id}:${projectId}:${arg?.request?.category ?? "*"}`, + `remote:${target.id}:${projectId}:${request.category ?? "*"}:${request.replay === false ? "live" : "replay"}`, (onEvent, onEnded) => - remoteConnectionPool.subscribeEventsForTarget( - target, + remoteConnectionService.subscribeEvents( + target.id, projectId, { - cursor: arg?.request?.cursor, - limit: arg?.request?.limit, - category: arg?.request?.category, + cursor: request.cursor, + limit: request.limit, + category: request.category, + replay: request.replay, }, onEvent, onEnded, ), ); - return remoteConnectionPool.streamEventsForTarget( - target, - projectId, - arg?.request ?? {}, - ); + return result; }, ); @@ -1078,10 +1154,17 @@ export function registerRuntimeBridge({ ipcMain.handle( IPC.remoteRuntimeDisconnect, - async (_event, arg: { id: string }): Promise<{ disconnected: boolean }> => { + async ( + event, + arg: { id: string; manual?: boolean }, + ): Promise<{ disconnected: boolean }> => { const id = typeof arg?.id === "string" ? arg.id.trim() : ""; if (!id) return { disconnected: false }; - remoteConnectionService.disconnect(id); + const currentSubscription = runtimeEventSubscriptions.get(event.sender.id); + if (currentSubscription?.bindingKey.startsWith(`remote:${id}:`)) { + cleanupRuntimeEventSubscription(event.sender.id); + } + remoteConnectionService.disconnect(id, { manual: arg.manual !== false }); return { disconnected: true }; }, ); diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index aa176bb8c..d22f2fbff 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -3059,7 +3059,7 @@ describe("laneService delete teardown + cancellation + streaming", () => { expect(wtStep?.detail).toContain("manual cleanup failed"); }); - it("keeps recent delete progress queryable for remounted renderers", async () => { + it("keeps retained delete progress queryable for remounted renderers", async () => { const events: any[] = []; const fake = makeFakeServices(); const { service } = await setupWithLane({ teardown: fake, events }); @@ -3091,6 +3091,9 @@ describe("laneService delete teardown + cancellation + streaming", () => { expect(completedProgress).toHaveLength(1); expect(completedProgress[0]?.laneId).toBe("lane-child"); expect(completedProgress[0]?.overallStatus).toBe("completed"); + const last = events[events.length - 1]; + expect(last.progress.laneId).toBe("lane-child"); + expect(last.progress.overallStatus).toBe("completed"); }); it("reports whether a lane delete is currently running", async () => { diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 3d92aa795..577401103 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -4553,7 +4553,6 @@ export function createLaneService({ listDeleteProgress(): LaneDeleteProgress[] { pruneDeleteProgressHistory(); return Array.from(deleteProgressByLaneId.values()) - .filter((progress) => progress.overallStatus === "running" || progress.overallStatus === "completed" || progress.overallStatus === "completed_with_warnings") .map(cloneLaneDeleteProgress); }, diff --git a/apps/desktop/src/main/services/lanes/runtimeDiagnosticsService.ts b/apps/desktop/src/main/services/lanes/runtimeDiagnosticsService.ts index 15cd73b8d..5e3215001 100644 --- a/apps/desktop/src/main/services/lanes/runtimeDiagnosticsService.ts +++ b/apps/desktop/src/main/services/lanes/runtimeDiagnosticsService.ts @@ -48,10 +48,19 @@ export function createRuntimeDiagnosticsService({ function checkPort(port: number, timeoutMs = 500): Promise { return new Promise((resolve) => { const socket = new net.Socket(); + let settled = false; + const settle = (ok: boolean): void => { + if (settled) return; + settled = true; + socket.removeAllListeners?.("connect"); + socket.removeAllListeners?.("timeout"); + try { socket.destroy(); } catch { /* ignore */ } + resolve(ok); + }; socket.setTimeout(timeoutMs); - socket.once("connect", () => { socket.destroy(); resolve(true); }); - socket.once("timeout", () => { socket.destroy(); resolve(false); }); - socket.once("error", () => { socket.destroy(); resolve(false); }); + socket.once("connect", () => settle(true)); + socket.once("timeout", () => settle(false)); + socket.once("error", () => settle(false)); socket.connect(port, "127.0.0.1"); }); } diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index 9b83aee6d..8e31e346b 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -233,46 +233,69 @@ function openSocketTransport(socketPath: string, timeoutMs = 3_000): Promise { if (settled) return; settled = true; socket.destroy(); reject(new Error(`Timed out connecting to ADE service socket: ${socketPath}`)); }, timeoutMs); + const closeCallbacks = new Set<() => void>(); + const errorCallbacks = new Set<(error: Error) => void>(); const fail = (error: Error) => { - if (settled) return; - settled = true; - clearTimeout(timer); - socket.destroy(); - reject(error); + if (!connected) { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + reject(error); + return; + } + lastError = error; + for (const callback of [...errorCallbacks]) { + try { + callback(error); + } catch { + // Disconnect observers must not turn a socket reset into a process crash. + } + } }; - socket.once("error", fail); + socket.on("error", fail); socket.once("connect", () => { if (settled) return; settled = true; + connected = true; clearTimeout(timer); - socket.off("error", fail); - const closeCallbacks = new Set<() => void>(); - const errorCallbacks = new Set<(error: Error) => void>(); - socket.on("error", (error) => { - for (const callback of [...errorCallbacks]) { - callback(error); - } - }); socket.on("close", () => { + closed = true; for (const callback of [...closeCallbacks]) { - callback(); + try { + callback(); + } catch { + // Disconnect observers must not turn a socket close into a process crash. + } } }); resolve({ onData(callback) { - socket.on("data", (chunk) => callback(Buffer.from(chunk))); + socket.on("data", (chunk) => { + try { + callback(Buffer.from(chunk)); + } catch { + socket.destroy(); + } + }); }, onClose(callback) { closeCallbacks.add(callback); + if (closed) queueMicrotask(callback); }, onError(callback) { errorCallbacks.add(callback); + const error = lastError; + if (error) queueMicrotask(() => callback(error)); }, write(data) { socket.write(data); @@ -1412,6 +1435,7 @@ async function subscribeToRuntimeEvents( cursor: clampCursor(request.cursor), limit: clampLimit(request.limit), ...(isRemoteRuntimeEventCategory(request.category) ? { category: request.category } : {}), + ...(typeof request.replay === "boolean" ? { replay: request.replay } : {}), }); subscriptionId = readSubscriptionId(value); for (const notification of pendingNotifications) { diff --git a/apps/desktop/src/main/services/probeLocalhostPort.ts b/apps/desktop/src/main/services/probeLocalhostPort.ts index c8a5980e5..c8ee0513b 100644 --- a/apps/desktop/src/main/services/probeLocalhostPort.ts +++ b/apps/desktop/src/main/services/probeLocalhostPort.ts @@ -13,11 +13,9 @@ export async function probeLocalhostPort( const settle = (value: boolean) => { if (settled) return; settled = true; - try { - socket.destroy(); - } catch { - // ignore - } + socket.removeAllListeners("connect"); + socket.removeAllListeners("timeout"); + try { socket.destroy(); } catch { /* ignore */ } resolve(value); }; @@ -25,6 +23,6 @@ export async function probeLocalhostPort( socket.setTimeout(timeoutMs); socket.once("connect", () => settle(true)); socket.once("timeout", () => settle(false)); - socket.once("error", () => settle(false)); + socket.on("error", () => settle(false)); }); } diff --git a/apps/desktop/src/main/services/processes/processService.ts b/apps/desktop/src/main/services/processes/processService.ts index b1eaef438..662cdfe33 100644 --- a/apps/desktop/src/main/services/processes/processService.ts +++ b/apps/desktop/src/main/services/processes/processService.ts @@ -120,17 +120,15 @@ function checkPortReady(port: number): Promise { const settle = (ok: boolean) => { if (settled) return; settled = true; - try { - socket.destroy(); - } catch { - // ignore - } + socket.removeAllListeners("connect"); + socket.removeAllListeners("timeout"); + try { socket.destroy(); } catch { /* ignore */ } resolve(ok); }; socket.setTimeout(600); socket.once("connect", () => settle(true)); socket.once("timeout", () => settle(false)); - socket.once("error", () => settle(false)); + socket.on("error", () => settle(false)); }); } diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 3d1bee6e2..2a355da65 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -1442,6 +1442,22 @@ describe("ptyService", () => { ); }); + it("uses a default title when runtime payloads omit one", async () => { + const { service, sessionService } = createHarness(); + await service.create({ + laneId: "lane-1", + cols: 120, + rows: 40, + } as any); + expect(sessionService.create).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + title: "Terminal", + tracked: true, + }), + ); + }); + it("rejects terminal launches when the lane worktree does not exist", async () => { mocks.existsSyncResults.set("/tmp/test-worktree", false); const { service, loadPty } = createHarness(); @@ -4014,6 +4030,19 @@ describe("ptyService", () => { ); }); + it("ignores stale dispose after a PTY already exited", async () => { + const { service, mockPty, broadcastExit, sessionService } = createHarness(); + const { ptyId, sessionId } = await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24 }); + mockPty._emitter.emit("exit", { exitCode: 0 }); + const endCalls = sessionService.end.mock.calls.length; + const exitCalls = broadcastExit.mock.calls.length; + + service.dispose({ ptyId, sessionId }); + + expect(sessionService.end).toHaveBeenCalledTimes(endCalls); + expect(broadcastExit).toHaveBeenCalledTimes(exitCalls); + }); + it("marks session as failed when exit code is non-zero", async () => { const { service, mockPty, sessionService } = createHarness(); const { sessionId } = await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24 }); @@ -5068,6 +5097,8 @@ describe("ptyService", () => { /No running terminal/, ); expect(service.activeForChat({ chatSessionId: "no-such-chat" })).toBeNull(); + expect(service.activeForChat({})).toBeNull(); + expect(service.activeForChat(null)).toBeNull(); }); describe("reattachChatCli", () => { diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 6e969e7de..5a5bc0afd 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -536,6 +536,11 @@ function isCliPlaceholderTitle(title: string | null | undefined, toolType: Termi return false; } +function normalizePtySessionTitle(title: unknown): string { + const trimmed = typeof title === "string" ? title.trim() : ""; + return trimmed.length ? trimmed : "Terminal"; +} + function sanitizeGeneratedCliTitle(raw: string): string { let title = stripAnsi(raw) .replace(/\p{Extended_Pictographic}/gu, "") @@ -3398,7 +3403,8 @@ export function createPtyService({ }, async create(args: PtyCreateArgs): Promise { - const { laneId, title } = args; + const { laneId } = args; + const title = normalizePtySessionTitle(args.title); const chatSessionId = cleanOptionalId(args.chatSessionId); const launchContext = resolveLaneLaunchContext({ laneService, @@ -4278,8 +4284,8 @@ export function createPtyService({ }); }, - activeForChat(args: ChatTerminalActiveForChatArgs): ChatTerminalSession | null { - const chatSessionId = cleanOptionalId(args.chatSessionId); + activeForChat(args?: Partial | null): ChatTerminalSession | null { + const chatSessionId = cleanOptionalId(args?.chatSessionId); if (!chatSessionId) return null; const chatCli = activeChatCliEntryFor(chatSessionId); if (chatCli) { @@ -4708,6 +4714,8 @@ export function createPtyService({ if (!sessionId) return; const session = sessionService.get(sessionId); if (!session) return; + if (session.status && session.status !== "running") return; + if (session.ptyId && session.ptyId !== ptyId) return; if ( ownerPid != null && session.ownerPid != null diff --git a/apps/desktop/src/main/services/pty/supervisedPtyHost.test.ts b/apps/desktop/src/main/services/pty/supervisedPtyHost.test.ts index c75fdb8d4..45434aa21 100644 --- a/apps/desktop/src/main/services/pty/supervisedPtyHost.test.ts +++ b/apps/desktop/src/main/services/pty/supervisedPtyHost.test.ts @@ -5,10 +5,12 @@ import { createSupervisedPtyLoader, type HostedPty } from "./supervisedPtyHost"; const mocks = vi.hoisted(() => ({ fork: vi.fn(), + spawn: vi.fn(), })); vi.mock("node:child_process", () => ({ fork: mocks.fork, + spawn: mocks.spawn, })); type FakeChild = EventEmitter & { @@ -68,10 +70,12 @@ function spawnOptions(): IWindowsPtyForkOptions { describe("createSupervisedPtyLoader", () => { beforeEach(() => { vi.clearAllMocks(); + vi.unstubAllEnvs(); }); afterEach(() => { vi.useRealTimers(); + vi.unstubAllEnvs(); }); it("queues PTY operations until the host confirms spawn", async () => { @@ -101,6 +105,32 @@ describe("createSupervisedPtyLoader", () => { ]); }); + it("can launch the worker through an internal ADE command instead of node fork", () => { + const child = createFakeChild(); + mocks.spawn.mockReturnValueOnce(child); + vi.stubEnv("ADE_PTY_HOST_WORKER_COMMAND", "/Users/example/.ade/bin/ade"); + vi.stubEnv("ADE_PTY_HOST_WORKER_NODE", ""); + const loader = createSupervisedPtyLoader({ logger: createLogger() as any }); + + loader().spawn("/bin/zsh", [], spawnOptions()); + + expect(mocks.spawn).toHaveBeenCalledWith( + "/Users/example/.ade/bin/ade", + ["__ade-pty-host-worker"], + expect.objectContaining({ + stdio: ["ignore", "pipe", "pipe", "ipc"], + env: expect.objectContaining({ + ADE_PTY_HOST: "1", + }), + }), + ); + expect(mocks.fork).not.toHaveBeenCalled(); + expect(child.sent[0]).toEqual(expect.objectContaining({ + type: "spawn", + command: "/bin/zsh", + })); + }); + it("converts host process exit into PTY exits and restarts on the next spawn", async () => { const child1 = createFakeChild(); const child2 = createFakeChild(); diff --git a/apps/desktop/src/main/services/pty/supervisedPtyHost.ts b/apps/desktop/src/main/services/pty/supervisedPtyHost.ts index 19147fa69..52ed45f9c 100644 --- a/apps/desktop/src/main/services/pty/supervisedPtyHost.ts +++ b/apps/desktop/src/main/services/pty/supervisedPtyHost.ts @@ -1,4 +1,4 @@ -import { fork, type ChildProcess } from "node:child_process"; +import { fork, spawn, type ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; import fs from "node:fs"; import path from "node:path"; @@ -64,6 +64,7 @@ type HostChildState = { const HOST_KILL_GRACE_MS = 3_000; const HOST_SIGKILL_GRACE_MS = 500; const HOST_SPAWN_TIMEOUT_MS = 15_000; +const INTERNAL_PTY_HOST_WORKER_ARG = "__ade-pty-host-worker"; export type HostedPty = IPty & { __adePtyHostReady: Promise; @@ -81,6 +82,8 @@ function hostErrorToError(prefix: string, payload?: HostErrorPayload): Error { } function resolvePtyHostWorkerPath(): string { + const configured = process.env.ADE_PTY_HOST_WORKER_PATH?.trim(); + if (configured) return configured; const candidates = [ path.join(__dirname, "ptyHostWorker.cjs"), path.join(process.cwd(), "dist", "main", "ptyHostWorker.cjs"), @@ -94,6 +97,16 @@ function resolvePtyHostWorkerPath(): string { return candidates[0]!; } +function resolvePtyHostWorkerNodePath(): string | undefined { + const configured = process.env.ADE_PTY_HOST_WORKER_NODE?.trim(); + return configured || undefined; +} + +function resolvePtyHostWorkerCommand(): string | undefined { + const configured = process.env.ADE_PTY_HOST_WORKER_COMMAND?.trim(); + return configured || undefined; +} + function trimWorkerLogLine(text: string): string { const collapsed = text.replace(/\s+/g, " ").trim(); return collapsed.length > 2_000 ? `${collapsed.slice(0, 2_000)}...` : collapsed; @@ -387,15 +400,24 @@ class SupervisedPtyHost { } private startChildForPty(ptyId: string): HostChildState { - const child = fork(this.workerPath, [], { - stdio: ["ignore", "pipe", "pipe", "ipc"], - execArgv: [], - env: { - ...process.env, - ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {}), - ADE_PTY_HOST: "1", - }, - }); + const workerNodePath = resolvePtyHostWorkerNodePath(); + const workerCommand = resolvePtyHostWorkerCommand(); + const workerEnv = { + ...process.env, + ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {}), + ADE_PTY_HOST: "1", + }; + const child = workerCommand + ? spawn(workerCommand, [INTERNAL_PTY_HOST_WORKER_ARG], { + stdio: ["ignore", "pipe", "pipe", "ipc"], + env: workerEnv, + }) + : fork(this.workerPath, [], { + stdio: ["ignore", "pipe", "pipe", "ipc"], + execArgv: [], + ...(workerNodePath ? { execPath: workerNodePath } : {}), + env: workerEnv, + }); const childState: HostChildState = { child, ptyIds: new Set([ptyId]), @@ -406,6 +428,8 @@ class SupervisedPtyHost { this.restartCount += 1; this.logger.info("pty.host_started", { workerPath: this.workerPath, + workerNodePath: workerNodePath ?? null, + workerCommand: workerCommand ?? null, restartCount: this.restartCount, ptyId, }); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts index 8434eed2f..b651bba6f 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts @@ -1,7 +1,9 @@ import crypto from "node:crypto"; +import { EventEmitter } from "node:events"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { PassThrough } from "node:stream"; import type { Client } from "ssh2"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RemoteRuntimeTarget } from "../../../shared/types/remoteRuntime"; @@ -21,10 +23,20 @@ import { const connectSshWithRouteMock = vi.hoisted(() => vi.fn()); const execSshMock = vi.hoisted(() => vi.fn()); const openSshRuntimeTransportMock = vi.hoisted(() => vi.fn()); +const spawnMock = vi.hoisted(() => vi.fn()); const initializeMock = vi.hoisted(() => vi.fn()); const callMock = vi.hoisted(() => vi.fn()); const runtimeRpcClientMock = vi.hoisted(() => vi.fn()); +const guardedUploadCommandPattern = (remoteFilePattern: string): RegExp => + new RegExp( + String.raw`^umask 077; cat >> ${remoteFilePattern} & ade_upload_pid=\$!; .*sleep 75; .*exit "\$ade_upload_status"$`, + ); + +vi.mock("node:child_process", () => ({ + spawn: spawnMock, +})); + vi.mock("./sshTransport", () => ({ connectSshWithRoute: connectSshWithRouteMock, execSsh: execSshMock, @@ -85,6 +97,13 @@ describe("selectRemoteRuntimeVersion", () => { executableVersion: null, })).toBe("1.0.0"); }); + + it("uses the marker when the executable reports the placeholder version", () => { + expect(selectRemoteRuntimeVersion({ + markerVersion: "1.0.0", + executableVersion: "0.0.0", + })).toBe("1.0.0"); + }); }); describe("shouldUploadBundledRuntime", () => { @@ -106,6 +125,18 @@ describe("shouldUploadBundledRuntime", () => { })).toBe(false); }); + it("skips upload when a placeholder executable has a matching marker and binary identity", () => { + expect(shouldUploadBundledRuntime({ + localBinaryAvailable: true, + executableVersion: "0.0.0", + markerVersion: "1.0.0", + appVersion: "1.0.0", + localBinarySha256: "abc", + remoteBinarySha256: "abc", + remoteBinaryMatchesLocal: true, + })).toBe(false); + }); + it("uploads when the executable version matches but the binary hash changed", () => { expect(shouldUploadBundledRuntime({ localBinaryAvailable: true, @@ -116,6 +147,17 @@ describe("shouldUploadBundledRuntime", () => { })).toBe(true); }); + it("uploads when marker files match but the actual remote binary does not", () => { + expect(shouldUploadBundledRuntime({ + localBinaryAvailable: true, + executableVersion: "1.0.0", + appVersion: "1.0.0", + localBinarySha256: "abc", + remoteBinarySha256: "abc", + remoteBinaryMatchesLocal: false, + })).toBe(true); + }); + it("does not upload when no bundled runtime exists for the remote architecture", () => { expect(shouldUploadBundledRuntime({ localBinaryAvailable: false, @@ -142,6 +184,26 @@ describe("buildRemoteRuntimeEnvironmentPrefix", () => { })).toContain('NODE_PATH="$HOME/.ade/runtime/darwin-arm64/node_modules${NODE_PATH:+:$NODE_PATH}"'); }); + it("adds the uploaded PTY host worker path when the worker artifact is ready", () => { + expect(buildRemoteRuntimeEnvironmentPrefix({ + archLabel: "darwin-arm64", + nativeDepsReady: true, + ptyHostWorkerReady: true, + ptyHostWorkerNodePath: "/usr/local/bin/node", + layout: resolveRemoteRuntimeLayout({} as NodeJS.ProcessEnv), + })).toContain('ADE_PTY_HOST_WORKER_PATH="$HOME/.ade/runtime/ptyHostWorker.cjs" ADE_PTY_HOST_WORKER_NODE=\'/usr/local/bin/node\''); + }); + + it("can point the PTY host worker at the internal ADE runtime command", () => { + expect(buildRemoteRuntimeEnvironmentPrefix({ + archLabel: "darwin-arm64", + nativeDepsReady: true, + ptyHostWorkerReady: true, + ptyHostWorkerCommandExpr: "$HOME/.ade/bin/ade", + layout: resolveRemoteRuntimeLayout({} as NodeJS.ProcessEnv), + })).toContain('ADE_PTY_HOST_WORKER_COMMAND="$HOME/.ade/bin/ade"'); + }); + it("can suppress service installation for shared runtime fallback sessions", () => { expect(buildRemoteRuntimeEnvironmentPrefix({ archLabel: "linux-x64", @@ -279,10 +341,104 @@ function ok(stdout = "") { return { stdout, stderr: "", code: 0 }; } +const REMOTE_PREFLIGHT_MARKER_PREFIX = "__ade_remote_preflight_"; + +function remotePreflightOutput(fields: Record): string { + return Object.entries(fields) + .map(([field, value]) => `\n${REMOTE_PREFLIGHT_MARKER_PREFIX}${field}__\n${value ?? ""}`) + .join(""); +} + +function isRemoteRuntimeIdentityCommand(command: string, homeDirName = ".ade"): boolean { + const home = `$HOME/${homeDirName}`; + return ( + command.includes(`${REMOTE_PREFLIGHT_MARKER_PREFIX}marker_version__`) && + command.includes(`cat ${home}/bin/ade.version 2>/dev/null || true`) && + command.includes(`${REMOTE_PREFLIGHT_MARKER_PREFIX}marker_sha256__`) && + command.includes(`cat ${home}/bin/ade.sha256 2>/dev/null || true`) && + command.includes(`${REMOTE_PREFLIGHT_MARKER_PREFIX}executable_version__`) && + command.includes(`test -x ${home}/bin/ade && ${home}/bin/ade --version 2>/dev/null || true`) + ); +} + +function remoteRuntimeIdentityOk(args: { + markerVersion?: string | null; + sha256?: string | null; + executableVersion?: string | null; +}): ReturnType { + return ok(remotePreflightOutput({ + marker_version: args.markerVersion ?? "", + marker_sha256: args.sha256 ?? "", + executable_version: args.executableVersion ?? "", + })); +} + +function isRemoteRuntimeSupportCommand(command: string): boolean { + return command.includes(`${REMOTE_PREFLIGHT_MARKER_PREFIX}node_path__`) && + command.includes("command -v node || true"); +} + +function remoteRuntimeSupportOk(args: { + nodePath?: string | null; + nativeDepsReady?: boolean; + ptyHostWorkerReady?: boolean; +} = {}): ReturnType { + return ok(remotePreflightOutput({ + node_path: args.nodePath === undefined ? "/usr/local/bin/node" : args.nodePath, + native_deps_ready: args.nativeDepsReady ? "ok" : "", + pty_host_worker_ready: args.ptyHostWorkerReady ? "ok" : "", + })); +} + +function resolvedRemotePath(command: string): ReturnType | null { + if (!command.startsWith("printf '%s' ")) return null; + return ok(command.slice("printf '%s' ".length).replace("$HOME", "/home/ade")); +} + +function defaultRemoteBootstrapCommand(command: string): ReturnType { + if (isRemoteRuntimeSupportCommand(command)) return remoteRuntimeSupportOk(); + throw new Error(`Unexpected SSH command: ${command}`); +} + +function createFakeSpawnProcess(options: { closeCode?: number; error?: Error; stderr?: string } = {}) { + const child = new EventEmitter() as EventEmitter & { + stdin: EventEmitter & { + write: ReturnType; + end: ReturnType; + destroy: ReturnType; + }; + stderr: EventEmitter; + kill: ReturnType; + }; + child.stderr = new EventEmitter(); + child.stdin = Object.assign(new EventEmitter(), { + write: vi.fn(() => true), + end: vi.fn(), + destroy: vi.fn(), + }); + child.kill = vi.fn(); + setImmediate(() => { + if (options.error) { + child.emit("error", options.error); + return; + } + if (options.stderr) child.stderr.emit("data", Buffer.from(options.stderr)); + child.emit("close", options.closeCode ?? 0, null); + }); + return child; +} + function createTempResources( archLabel = "linux-x64", - options: { nativeDeps?: boolean } = {}, -): { resourcesPath: string; binaryPath: string; binarySha256: string; cleanup: () => void } { + options: { nativeDeps?: boolean; ptyHostWorker?: boolean } = {}, +): { + resourcesPath: string; + binaryPath: string; + binarySha256: string; + ptyHostWorkerPath: string | null; + ptyHostWorkerSha256: string | null; + cleanup: () => void; +} { const resourcesPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-remote-runtime-")); const runtimeDir = path.join(resourcesPath, "runtime"); fs.mkdirSync(runtimeDir, { recursive: true }); @@ -291,26 +447,76 @@ function createTempResources( if (options.nativeDeps) { fs.writeFileSync(path.join(runtimeDir, `ade-${archLabel}.native.tar.gz`), "native deps fixture\n"); } + let ptyHostWorkerPath: string | null = null; + let ptyHostWorkerSha256: string | null = null; + if (options.ptyHostWorker) { + const adeCliDir = path.join(resourcesPath, "ade-cli"); + fs.mkdirSync(adeCliDir, { recursive: true }); + ptyHostWorkerPath = path.join(adeCliDir, "ptyHostWorker.cjs"); + fs.writeFileSync(ptyHostWorkerPath, "process.on('message', () => {});\n"); + ptyHostWorkerSha256 = crypto.createHash("sha256").update(fs.readFileSync(ptyHostWorkerPath)).digest("hex"); + } const binarySha256 = crypto.createHash("sha256").update(fs.readFileSync(binaryPath)).digest("hex"); return { resourcesPath, binaryPath, binarySha256, + ptyHostWorkerPath, + ptyHostWorkerSha256, cleanup: () => fs.rmSync(resourcesPath, { recursive: true, force: true }), }; } -function createFakeSsh() { - const sftpEnd = vi.fn(); - const fastPut = vi.fn((_localPath: string, _remotePath: string, _options: object, callback: (error?: Error | null) => void) => { - callback(null); +function createFakeSsh(options: { + execError?: Error; + channelError?: Error; + closeCode?: number; + stderr?: string; + sftpError?: Error; + sftpTransferError?: Error; +} = {}) { + const exec = vi.fn((command: string, callback: (error: Error | null, channel?: PassThrough & { stderr: PassThrough }) => void) => { + if (options.execError) { + setImmediate(() => callback(options.execError!)); + return; + } + const channel = new PassThrough() as PassThrough & { stderr: PassThrough }; + channel.stderr = new PassThrough(); + channel.resume(); + channel.on("finish", () => { + setImmediate(() => { + if (options.channelError) { + channel.emit("error", options.channelError); + return; + } + if (options.stderr) channel.stderr.emit("data", Buffer.from(options.stderr)); + channel.emit("exit", options.closeCode ?? 0, null); + channel.emit("close", options.closeCode ?? 0, null); + }); + }); + callback(null, channel); + }); + const sftpWrapper = Object.assign(new EventEmitter(), { + fastPut: vi.fn((localPath: string, _remotePath: string, transferOptions: { step?: (total: number, nb: number, fsize: number) => void }, callback: (error?: Error) => void) => { + const size = fs.statSync(localPath).size; + transferOptions.step?.(size, size, size); + setImmediate(() => callback(options.sftpTransferError)); + }), + end: vi.fn(), + destroy: vi.fn(), }); - const sftp = vi.fn((callback: (error: Error | null, sftp: { fastPut: typeof fastPut; end: typeof sftpEnd }) => void) => { - callback(null, { fastPut, end: sftpEnd }); + const sftp = vi.fn((callback: (error: Error | undefined, wrapper?: typeof sftpWrapper) => void) => { + setImmediate(() => { + if (options.sftpError) { + callback(options.sftpError); + return; + } + callback(undefined, sftpWrapper); + }); }); const end = vi.fn(); - const ssh = { sftp, end } as unknown as Client; - return { ssh, sftp, fastPut, sftpEnd, end }; + const ssh = Object.assign(new EventEmitter(), { exec, sftp, end }) as unknown as Client; + return { ssh, exec, sftp, sftpWrapper, end }; } function createRegistry() { @@ -331,6 +537,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { connectSshWithRouteMock.mockReset(); execSshMock.mockReset(); openSshRuntimeTransportMock.mockReset(); + spawnMock.mockReset(); initializeMock.mockReset(); callMock.mockReset(); runtimeRpcClientMock.mockReset(); @@ -348,6 +555,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { write: vi.fn(), close: vi.fn(), }); + spawnMock.mockImplementation(() => createFakeSpawnProcess()); initializeMock.mockResolvedValue({ runtimeInfo: { version: APP_VERSION, multiProject: true }, capabilities: { @@ -389,43 +597,82 @@ describe("bootstrapRemoteRuntime upload flow", () => { cleanupResources = resources.cleanup; const fakeSsh = createFakeSsh(); const registry = createRegistry(); + const targetFromSshConfig: RemoteRuntimeTarget = { + ...uploadTarget, + sshUser: null, + port: null, + }; + const connectedRoute = { + ...uploadRoute, + port: null, + }; connectSshWithRouteMock.mockResolvedValue({ client: fakeSsh.ssh, - route: uploadRoute, + route: connectedRoute, + config: { + host: "resolved-build-host.local", + port: 2200, + username: "admin", + }, }); const commands: string[] = []; execSshMock.mockImplementation(async (_client: Client, command: string) => { commands.push(command); + const remotePath = resolvedRemotePath(command); + if (remotePath) return remotePath; if (command === "uname -sm") return ok("Linux x86_64\n"); - if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok(""); - if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(""); - if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok(""); - if (command === "mkdir -p $HOME/.ade/bin") return ok(""); - if (command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade/bin/ade.version")) return ok(""); + if (isRemoteRuntimeIdentityCommand(command)) return remoteRuntimeIdentityOk({}); + if (command === "mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin") return ok(""); + if (command.match(/^rm -f \$HOME\/\.ade\/bin\/ade\.upload-.* && umask 077 && : > \$HOME\/\.ade\/bin\/ade\.upload-.* && chmod 600 \$HOME\/\.ade\/bin\/ade\.upload-/)) return ok(""); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + !command.includes("shasum") && + !command.includes("mv -f") + ) return ok(`${fs.statSync(resources.binaryPath).size}\n`); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + command.includes("shasum -a 256 $HOME/.ade/bin/ade.upload-") && + command.includes("mv -f $HOME/.ade/bin/ade.upload-") && + command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade/bin/ade.version") + ) return ok(""); if (command.includes("$HOME/.ade/bin/ade --version")) return ok("ade 2.0.0\n"); if (command.includes("$HOME/.ade/bin/ade runtime stop --text")) return ok(""); - throw new Error(`Unexpected SSH command: ${command}`); + return defaultRemoteBootstrapCommand(command); }); const connected = await bootstrapRemoteRuntime({ - target: uploadTarget, + target: targetFromSshConfig, registry, resourcesPath: resources.resourcesPath, appVersion: APP_VERSION, }); - expect(connectSshWithRouteMock).toHaveBeenCalledWith(uploadTarget); - expect(fakeSsh.fastPut).toHaveBeenCalledWith(resources.binaryPath, ".ade/bin/ade", {}, expect.any(Function)); - expect(commands).toEqual([ + expect(connectSshWithRouteMock).toHaveBeenCalledWith(targetFromSshConfig); + expect(fakeSsh.sftp).toHaveBeenCalledTimes(1); + expect(fakeSsh.sftpWrapper.fastPut).toHaveBeenCalledWith( + resources.binaryPath, + expect.stringMatching(/^\/home\/ade\/\.ade\/bin\/ade\.upload-.*\.tmp$/), + expect.objectContaining({ fileSize: fs.statSync(resources.binaryPath).size, mode: 0o600 }), + expect.any(Function), + ); + expect(fakeSsh.exec).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); + expect(commands.slice(0, 2)).toEqual([ "uname -sm", - "cat $HOME/.ade/bin/ade.version 2>/dev/null || true", - "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true", - "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true", - "mkdir -p $HOME/.ade/bin", - `chmod 700 $HOME/.ade/bin && chmod +x $HOME/.ade/bin/ade && printf '%s\\n' '2.0.0' > $HOME/.ade/bin/ade.version && printf '%s\\n' '${resources.binarySha256}' > $HOME/.ade/bin/ade.sha256 && chmod 600 $HOME/.ade/bin/ade.version && chmod 600 $HOME/.ade/bin/ade.sha256`, + expect.stringContaining("__ade_remote_preflight_executable_version__"), + ]); + expect(commands).toContain("mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin"); + expect(commands.some((command) => + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + command.includes(`printf '%s\\n' '${resources.binarySha256}' > $HOME/.ade/bin/ade.sha256`) && + command.includes("mv -f $HOME/.ade/bin/ade.upload-"), + )).toBe(true); + expect(commands).toContain( 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade --version', + ); + expect(commands).toContain( 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade runtime stop --text >/dev/null 2>&1 || true', - ]); + ); expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( fakeSsh.ssh, 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade rpc --stdio', @@ -439,7 +686,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { routes: [ { hostname: "build-host.local", - port: 22, + port: null, source: "manual", lastSucceededAt: expect.any(Number), }, @@ -453,6 +700,352 @@ describe("bootstrapRemoteRuntime upload flow", () => { expect(fakeSsh.end).not.toHaveBeenCalled(); }); + it("uploads the PTY host worker and points the remote runtime at it", async () => { + const resources = createTempResources("linux-x64", { ptyHostWorker: true }); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + const registry = createRegistry(); + connectSshWithRouteMock.mockResolvedValue({ + client: fakeSsh.ssh, + route: uploadRoute, + openSshConfig: { + host: "resolved-build-host.local", + port: 2222, + username: "builder", + identityFile: "/Users/ade/.ssh/id_ed25519", + knownHostsPath: "/Users/ade/.ssh/known_hosts.ade", + hostAliases: ["build-host.local", "resolved-build-host.local"], + }, + }); + const commands: string[] = []; + execSshMock.mockImplementation(async (_client: Client, command: string) => { + commands.push(command); + const remotePath = resolvedRemotePath(command); + if (remotePath) return remotePath; + if (command === "uname -sm") return ok("Linux x86_64\n"); + if (isRemoteRuntimeIdentityCommand(command)) { + return remoteRuntimeIdentityOk({ + markerVersion: APP_VERSION, + sha256: resources.binarySha256, + executableVersion: `ade ${APP_VERSION}`, + }); + } + if (isRemoteRuntimeSupportCommand(command)) return remoteRuntimeSupportOk({ ptyHostWorkerReady: false }); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade") && + command.includes("shasum -a 256 $HOME/.ade/bin/ade") && + command.includes("echo ok") + ) return ok("ok\n"); + if (command.includes("test -f $HOME/.ade/runtime/ptyHostWorker.cjs")) return ok(""); + if (command === "mkdir -p $HOME/.ade/runtime") return ok(""); + if (command.match(/^rm -f \$HOME\/\.ade\/runtime\/ptyHostWorker\.cjs\.upload-.* && umask 077 && : > \$HOME\/\.ade\/runtime\/ptyHostWorker\.cjs\.upload-.* && chmod 600 \$HOME\/\.ade\/runtime\/ptyHostWorker\.cjs\.upload-/)) return ok(""); + if ( + command.includes("wc -c < $HOME/.ade/runtime/ptyHostWorker.cjs.upload-") && + !command.includes("shasum") && + !command.includes("mv -f") + ) return ok(`${fs.statSync(resources.ptyHostWorkerPath!).size}\n`); + if ( + command.includes("wc -c < $HOME/.ade/runtime/ptyHostWorker.cjs.upload-") && + command.includes("shasum -a 256 $HOME/.ade/runtime/ptyHostWorker.cjs.upload-") && + command.includes("mv -f $HOME/.ade/runtime/ptyHostWorker.cjs.upload-") && + command.includes(`printf '%s\\n' '${resources.ptyHostWorkerSha256}' > $HOME/.ade/runtime/ptyHostWorker.cjs.sha256`) + ) return ok(""); + return defaultRemoteBootstrapCommand(command); + }); + + const connected = await bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + }); + + expect(fakeSsh.sftpWrapper.fastPut).toHaveBeenCalledWith( + resources.ptyHostWorkerPath, + expect.stringMatching(/^\/home\/ade\/\.ade\/runtime\/ptyHostWorker\.cjs\.upload-.*\.tmp$/), + expect.objectContaining({ fileSize: fs.statSync(resources.ptyHostWorkerPath!).size, mode: 0o600 }), + expect.any(Function), + ); + expect(commands.some((command) => + command.includes("wc -c < $HOME/.ade/runtime/ptyHostWorker.cjs.upload-") && + command.includes(`printf '%s\\n' '${resources.ptyHostWorkerSha256}' > $HOME/.ade/runtime/ptyHostWorker.cjs.sha256`) && + command.includes("mv -f $HOME/.ade/runtime/ptyHostWorker.cjs.upload-"), + )).toBe(true); + expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( + fakeSsh.ssh, + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PTY_HOST_WORKER_PATH="$HOME/.ade/runtime/ptyHostWorker.cjs" ADE_PTY_HOST_WORKER_NODE=\'/usr/local/bin/node\' $HOME/.ade/bin/ade rpc --stdio', + ); + expect(connected.result).toMatchObject({ + arch: "linux-x64", + version: APP_VERSION, + projects: [{ projectId: "project-1", rootPath: "/srv/ade" }], + }); + }); + + it("uses the internal ADE PTY host worker command when remote node is unavailable", async () => { + const resources = createTempResources("linux-x64"); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + const registry = createRegistry(); + connectSshWithRouteMock.mockResolvedValue({ + client: fakeSsh.ssh, + route: uploadRoute, + openSshConfig: { + host: "resolved-build-host.local", + port: 2222, + username: "builder", + identityFile: "/Users/ade/.ssh/id_ed25519", + knownHostsPath: "/Users/ade/.ssh/known_hosts.ade", + hostAliases: ["build-host.local", "resolved-build-host.local"], + }, + }); + execSshMock.mockImplementation(async (_client: Client, command: string) => { + const remotePath = resolvedRemotePath(command); + if (remotePath) return remotePath; + if (command === "uname -sm") return ok("Linux x86_64\n"); + if (isRemoteRuntimeIdentityCommand(command)) { + return remoteRuntimeIdentityOk({ + markerVersion: APP_VERSION, + sha256: resources.binarySha256, + executableVersion: `ade ${APP_VERSION}`, + }); + } + if ( + command.includes("wc -c < $HOME/.ade/bin/ade") && + command.includes("shasum -a 256 $HOME/.ade/bin/ade") && + command.includes("echo ok") + ) return ok("ok\n"); + if (isRemoteRuntimeSupportCommand(command)) return remoteRuntimeSupportOk({ nodePath: null }); + return defaultRemoteBootstrapCommand(command); + }); + + const connected = await bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + }); + + expect(fakeSsh.sftpWrapper.fastPut).not.toHaveBeenCalled(); + expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( + fakeSsh.ssh, + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PTY_HOST_WORKER_COMMAND="$HOME/.ade/bin/ade" $HOME/.ade/bin/ade rpc --stdio', + ); + expect(connected.result).toMatchObject({ + arch: "linux-x64", + version: APP_VERSION, + projects: [{ projectId: "project-1", rootPath: "/srv/ade" }], + }); + }); + + it("reuses cached local runtime hashes across repeated bootstraps", async () => { + const resources = createTempResources("linux-x64"); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + const registry = createRegistry(); + connectSshWithRouteMock.mockResolvedValue({ + client: fakeSsh.ssh, + route: uploadRoute, + openSshConfig: { + host: "resolved-build-host.local", + port: 2222, + username: "builder", + identityFile: "/Users/ade/.ssh/id_ed25519", + knownHostsPath: "/Users/ade/.ssh/known_hosts.ade", + hostAliases: ["build-host.local", "resolved-build-host.local"], + }, + }); + execSshMock.mockImplementation(async (_client: Client, command: string) => { + const remotePath = resolvedRemotePath(command); + if (remotePath) return remotePath; + if (command === "uname -sm") return ok("Linux x86_64\n"); + if (isRemoteRuntimeIdentityCommand(command)) { + return remoteRuntimeIdentityOk({ + markerVersion: APP_VERSION, + sha256: resources.binarySha256, + executableVersion: `ade ${APP_VERSION}`, + }); + } + if ( + command.includes("wc -c < $HOME/.ade/bin/ade") && + command.includes("shasum -a 256 $HOME/.ade/bin/ade") && + command.includes("echo ok") + ) return ok("ok\n"); + if (isRemoteRuntimeSupportCommand(command)) return remoteRuntimeSupportOk({ nodePath: null }); + return defaultRemoteBootstrapCommand(command); + }); + const readFileSyncSpy = vi.spyOn(fs, "readFileSync"); + let binaryReadCount = 0; + try { + await bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + }); + await bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + }); + binaryReadCount = readFileSyncSpy.mock.calls.filter( + ([filePath]) => filePath === resources.binaryPath, + ).length; + } finally { + readFileSyncSpy.mockRestore(); + } + + expect(binaryReadCount).toBe(1); + }); + + it("uploads a same-version runtime when the bundled binary hash changed", async () => { + const resources = createTempResources(); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + const registry = createRegistry(); + connectSshWithRouteMock.mockResolvedValue({ + client: fakeSsh.ssh, + route: uploadRoute, + openSshConfig: { + host: "resolved-build-host.local", + port: 2222, + username: "builder", + identityFile: "/Users/ade/.ssh/id_ed25519", + knownHostsPath: "/Users/ade/.ssh/known_hosts.ade", + hostAliases: ["build-host.local", "resolved-build-host.local"], + }, + }); + const commands: string[] = []; + execSshMock.mockImplementation(async (_client: Client, command: string) => { + commands.push(command); + const remotePath = resolvedRemotePath(command); + if (remotePath) return remotePath; + if (command === "uname -sm") return ok("Linux x86_64\n"); + if (isRemoteRuntimeIdentityCommand(command)) { + return remoteRuntimeIdentityOk({ + markerVersion: APP_VERSION, + sha256: "previous-local-build-sha", + executableVersion: `ade ${APP_VERSION}`, + }); + } + if ( + command.includes("wc -c < $HOME/.ade/bin/ade") && + command.includes("shasum -a 256 $HOME/.ade/bin/ade") && + command.includes("echo ok") + ) return ok(""); + if (command === "mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin") return ok(""); + if (command.match(/^rm -f \$HOME\/\.ade\/bin\/ade\.upload-.* && umask 077 && : > \$HOME\/\.ade\/bin\/ade\.upload-.* && chmod 600 \$HOME\/\.ade\/bin\/ade\.upload-/)) return ok(""); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + !command.includes("shasum") && + !command.includes("mv -f") + ) return ok(`${fs.statSync(resources.binaryPath).size}\n`); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + command.includes("shasum -a 256 $HOME/.ade/bin/ade.upload-") && + command.includes("mv -f $HOME/.ade/bin/ade.upload-") && + command.includes(`printf '%s\\n' '${APP_VERSION}' > $HOME/.ade/bin/ade.version`) + ) return ok(""); + if (command.includes("$HOME/.ade/bin/ade --version")) return ok(`ade ${APP_VERSION}\n`); + if (command.includes("$HOME/.ade/bin/ade runtime stop --text")) return ok(""); + return defaultRemoteBootstrapCommand(command); + }); + + const connected = await bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + }); + + expect(spawnMock).not.toHaveBeenCalled(); + expect(commands).toContain("mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin"); + expect(commands.some((command) => + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + command.includes(`printf '%s\\n' '${resources.binarySha256}' > $HOME/.ade/bin/ade.sha256`) && + command.includes("mv -f $HOME/.ade/bin/ade.upload-"), + )).toBe(true); + expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( + fakeSsh.ssh, + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade rpc --stdio', + ); + expect(connected.result).toMatchObject({ + arch: "linux-x64", + version: APP_VERSION, + projects: [{ projectId: "project-1", rootPath: "/srv/ade" }], + }); + }); + + it("replaces a placeholder runtime even when its marker matches the desktop version", async () => { + const resources = createTempResources(); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + const registry = createRegistry(); + connectSshWithRouteMock.mockResolvedValue({ + client: fakeSsh.ssh, + route: uploadRoute, + }); + const commands: string[] = []; + execSshMock.mockImplementation(async (_client: Client, command: string) => { + commands.push(command); + const remotePath = resolvedRemotePath(command); + if (remotePath) return remotePath; + if (command === "uname -sm") return ok("Linux x86_64\n"); + if (isRemoteRuntimeIdentityCommand(command)) { + return remoteRuntimeIdentityOk({ + markerVersion: APP_VERSION, + sha256: "previous-local-build-sha", + executableVersion: "ade 0.0.0", + }); + } + if ( + command.includes("wc -c < $HOME/.ade/bin/ade") && + command.includes("shasum -a 256 $HOME/.ade/bin/ade") && + command.includes("echo ok") + ) return ok(""); + if (command === "mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin") return ok(""); + if (command.match(/^rm -f \$HOME\/\.ade\/bin\/ade\.upload-.* && umask 077 && : > \$HOME\/\.ade\/bin\/ade\.upload-.* && chmod 600 \$HOME\/\.ade\/bin\/ade\.upload-/)) return ok(""); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + !command.includes("shasum") && + !command.includes("mv -f") + ) return ok(`${fs.statSync(resources.binaryPath).size}\n`); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + command.includes("shasum -a 256 $HOME/.ade/bin/ade.upload-") && + command.includes("mv -f $HOME/.ade/bin/ade.upload-") && + command.includes(`printf '%s\\n' '${APP_VERSION}' > $HOME/.ade/bin/ade.version`) + ) return ok(""); + if (command.includes("$HOME/.ade/bin/ade --version")) return ok(`ade ${APP_VERSION}\n`); + if (command.includes("$HOME/.ade/bin/ade runtime stop --text")) return ok(""); + return defaultRemoteBootstrapCommand(command); + }); + + const connected = await bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + }); + + expect(commands).toContain("mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin"); + expect(commands.some((command) => + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + command.includes(`printf '%s\\n' '${resources.binarySha256}' > $HOME/.ade/bin/ade.sha256`) && + command.includes("mv -f $HOME/.ade/bin/ade.upload-"), + )).toBe(true); + expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( + fakeSsh.ssh, + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade rpc --stdio', + ); + expect(connected.result).toMatchObject({ + arch: "linux-x64", + version: APP_VERSION, + projects: [{ projectId: "project-1", rootPath: "/srv/ade" }], + }); + }); + it("fails closed when an uploaded runtime reports the wrong version", async () => { const resources = createTempResources(); cleanupResources = resources.cleanup; @@ -463,14 +1056,24 @@ describe("bootstrapRemoteRuntime upload flow", () => { route: uploadRoute, }); execSshMock.mockImplementation(async (_client: Client, command: string) => { + const remotePath = resolvedRemotePath(command); + if (remotePath) return remotePath; if (command === "uname -sm") return ok("Linux x86_64\n"); - if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok(""); - if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(""); - if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok(""); - if (command === "mkdir -p $HOME/.ade/bin") return ok(""); - if (command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade/bin/ade.version")) return ok(""); + if (isRemoteRuntimeIdentityCommand(command)) return remoteRuntimeIdentityOk({}); + if (command === "mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin") return ok(""); + if (command.match(/^rm -f \$HOME\/\.ade\/bin\/ade\.upload-.* && umask 077 && : > \$HOME\/\.ade\/bin\/ade\.upload-.* && chmod 600 \$HOME\/\.ade\/bin\/ade\.upload-/)) return ok(""); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + !command.includes("shasum") && + !command.includes("mv -f") + ) return ok(`${fs.statSync(resources.binaryPath).size}\n`); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + command.includes("mv -f $HOME/.ade/bin/ade.upload-") && + command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade/bin/ade.version") + ) return ok(""); if (command.includes("$HOME/.ade/bin/ade --version")) return ok("ade 1.9.0\n"); - throw new Error(`Unexpected SSH command: ${command}`); + return defaultRemoteBootstrapCommand(command); }); await expect(bootstrapRemoteRuntime({ @@ -480,7 +1083,88 @@ describe("bootstrapRemoteRuntime upload flow", () => { appVersion: APP_VERSION, })).rejects.toThrow(/uploaded ade service version mismatch/i); - expect(fakeSsh.fastPut).toHaveBeenCalledWith(resources.binaryPath, ".ade/bin/ade", {}, expect.any(Function)); + expect(fakeSsh.sftp).toHaveBeenCalledTimes(1); + expect(fakeSsh.sftpWrapper.fastPut).toHaveBeenCalledWith( + resources.binaryPath, + expect.stringMatching(/^\/home\/ade\/\.ade\/bin\/ade\.upload-.*\.tmp$/), + expect.objectContaining({ fileSize: fs.statSync(resources.binaryPath).size, mode: 0o600 }), + expect.any(Function), + ); + expect(fakeSsh.exec).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); + expect(openSshRuntimeTransportMock).not.toHaveBeenCalled(); + expect(initializeMock).not.toHaveBeenCalled(); + expect(registry.update).not.toHaveBeenCalled(); + expect(fakeSsh.end).toHaveBeenCalledTimes(1); + }); + + it("falls back to OpenSSH only when the connected SSH upload fails before writing", async () => { + const resources = createTempResources(); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh({ sftpError: new Error("sftp denied"), execError: new Error("channel denied") }); + spawnMock.mockImplementation(() => createFakeSpawnProcess({ error: new Error("pipe broke") })); + const registry = createRegistry(); + connectSshWithRouteMock.mockResolvedValue({ + client: fakeSsh.ssh, + route: uploadRoute, + openSshConfig: { + host: "resolved-build-host.local", + port: 2222, + username: "builder", + identityFile: "/Users/ade/.ssh/id_ed25519", + knownHostsPath: "/Users/ade/.ssh/known_hosts.ade", + hostAliases: ["build-host.local", "resolved-build-host.local"], + }, + }); + const commands: string[] = []; + execSshMock.mockImplementation(async (_client: Client, command: string) => { + commands.push(command); + const remotePath = resolvedRemotePath(command); + if (remotePath) return remotePath; + if (command === "uname -sm") return ok("Linux x86_64\n"); + if (isRemoteRuntimeIdentityCommand(command)) return remoteRuntimeIdentityOk({}); + if (command === "mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin") return ok(""); + if (command.match(/^rm -f \$HOME\/\.ade\/bin\/ade\.upload-.* && umask 077 && : > \$HOME\/\.ade\/bin\/ade\.upload-.* && chmod 600 \$HOME\/\.ade\/bin\/ade\.upload-/)) return ok(""); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + !command.includes("shasum") && + !command.includes("mv -f") + ) return ok("0\n"); + if (command.startsWith("rm -f $HOME/.ade/bin/ade.upload-")) return ok(""); + return defaultRemoteBootstrapCommand(command); + }); + + await expect(bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + })).rejects.toThrow(/sftp denied.*SSH stream fallback failed.*channel denied.*OpenSSH fallback failed.*pipe broke/i); + + expect(fakeSsh.sftp).toHaveBeenCalledTimes(1); + expect(fakeSsh.exec).toHaveBeenCalledWith( + expect.stringMatching(guardedUploadCommandPattern(String.raw`\$HOME/\.ade/bin/ade\.upload-.*\.tmp`)), + expect.any(Function), + ); + expect(spawnMock).toHaveBeenCalledWith( + "ssh", + expect.arrayContaining([ + "StrictHostKeyChecking=yes", + `UserKnownHostsFile=/Users/ade/.ssh/known_hosts.ade`, + "GlobalKnownHostsFile=/dev/null", + "-i", + "/Users/ade/.ssh/id_ed25519", + "-p", + "2222", + "builder@resolved-build-host.local", + expect.stringMatching(guardedUploadCommandPattern(String.raw`\$HOME/\.ade/bin/ade\.upload-.*\.tmp`)), + ]), + expect.objectContaining({ stdio: [expect.any(Number), "ignore", "pipe"] }), + ); + expect(spawnMock.mock.calls[0]?.[1]).not.toContain( + "StrictHostKeyChecking=accept-new", + ); + expect(commands.some((command) => command.startsWith("rm -f $HOME/.ade/bin/ade.upload-"))).toBe(true); expect(openSshRuntimeTransportMock).not.toHaveBeenCalled(); expect(initializeMock).not.toHaveBeenCalled(); expect(registry.update).not.toHaveBeenCalled(); @@ -498,19 +1182,40 @@ describe("bootstrapRemoteRuntime upload flow", () => { route: uploadRoute, }); execSshMock.mockImplementation(async (_client: Client, command: string) => { + const remotePath = resolvedRemotePath(command); + if (remotePath) return remotePath; if (command === "uname -sm") return ok("Darwin arm64\n"); - if (command === "cat $HOME/.ade-alpha/bin/ade.version 2>/dev/null || true") return ok(""); - if (command === "cat $HOME/.ade-alpha/bin/ade.sha256 2>/dev/null || true") return ok(""); - if (command === "test -x $HOME/.ade-alpha/bin/ade && $HOME/.ade-alpha/bin/ade --version || true") return ok(""); - if (command === "mkdir -p $HOME/.ade-alpha/bin") return ok(""); + if (isRemoteRuntimeIdentityCommand(command, ".ade-alpha")) return remoteRuntimeIdentityOk({}); + if (command === "mkdir -p $HOME/.ade-alpha/bin && chmod 700 $HOME/.ade-alpha/bin") return ok(""); if (command === "mkdir -p $HOME/.ade-alpha/runtime") return ok(""); - if (command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade-alpha/bin/ade.version")) return ok(""); + if (command.match(/^rm -f \$HOME\/\.ade-alpha\/bin\/ade\.upload-.* && umask 077 && : > \$HOME\/\.ade-alpha\/bin\/ade\.upload-.* && chmod 600 \$HOME\/\.ade-alpha\/bin\/ade\.upload-/)) return ok(""); + if (command.match(/^rm -f \$HOME\/\.ade-alpha\/runtime\/ade-darwin-arm64\.native\.tar\.gz\.upload-.* && umask 077 && : > \$HOME\/\.ade-alpha\/runtime\/ade-darwin-arm64\.native\.tar\.gz\.upload-.* && chmod 600 \$HOME\/\.ade-alpha\/runtime\/ade-darwin-arm64\.native\.tar\.gz\.upload-/)) return ok(""); + if ( + command.includes("wc -c < $HOME/.ade-alpha/bin/ade.upload-") && + !command.includes("shasum") && + !command.includes("mv -f") + ) return ok(`${fs.statSync(resources.binaryPath).size}\n`); + if ( + command.includes("wc -c < $HOME/.ade-alpha/runtime/ade-darwin-arm64.native.tar.gz.upload-") && + !command.includes("shasum") && + !command.includes("mv -f") + ) return ok(`${fs.statSync(path.join(resources.resourcesPath, "runtime", "ade-darwin-arm64.native.tar.gz")).size}\n`); + if ( + command.includes("wc -c < $HOME/.ade-alpha/bin/ade.upload-") && + command.includes("mv -f $HOME/.ade-alpha/bin/ade.upload-") && + command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade-alpha/bin/ade.version") + ) return ok(""); + if (isRemoteRuntimeSupportCommand(command)) return remoteRuntimeSupportOk({ nativeDepsReady: true }); if (command.includes("test -d $HOME/.ade-alpha/runtime/darwin-arm64/node_modules")) return ok("ok\n"); - if (command.includes("tar -xzf $HOME/.ade-alpha/runtime/ade-darwin-arm64.native.tar.gz")) return ok(""); + if ( + command.includes("wc -c < $HOME/.ade-alpha/runtime/ade-darwin-arm64.native.tar.gz.upload-") && + command.includes("mv -f $HOME/.ade-alpha/runtime/ade-darwin-arm64.native.tar.gz.upload-") && + command.includes("tar -xzf $HOME/.ade-alpha/runtime/ade-darwin-arm64.native.tar.gz") + ) return ok(""); if (command === "codesign --force --sign - $HOME/.ade-alpha/bin/ade") return ok(""); if (command.includes("$HOME/.ade-alpha/bin/ade --version")) return ok("ade 2.0.0\n"); if (command.includes("$HOME/.ade-alpha/bin/ade runtime stop --text")) return ok(""); - throw new Error(`Unexpected SSH command: ${command}`); + return defaultRemoteBootstrapCommand(command); }); await bootstrapRemoteRuntime({ @@ -520,8 +1225,28 @@ describe("bootstrapRemoteRuntime upload flow", () => { appVersion: APP_VERSION, }); - expect(fakeSsh.fastPut).toHaveBeenCalledWith(resources.binaryPath, ".ade-alpha/bin/ade", {}, expect.any(Function)); + const nativeDepsPath = path.join(resources.resourcesPath, "runtime", "ade-darwin-arm64.native.tar.gz"); + expect(fakeSsh.sftp).toHaveBeenCalledTimes(2); + expect(fakeSsh.sftpWrapper.fastPut).toHaveBeenCalledWith( + resources.binaryPath, + expect.stringMatching(/^\/home\/ade\/\.ade-alpha\/bin\/ade\.upload-.*\.tmp$/), + expect.objectContaining({ fileSize: fs.statSync(resources.binaryPath).size, mode: 0o600 }), + expect.any(Function), + ); + expect(fakeSsh.sftpWrapper.fastPut).toHaveBeenCalledWith( + nativeDepsPath, + expect.stringMatching(/^\/home\/ade\/\.ade-alpha\/runtime\/ade-darwin-arm64\.native\.tar\.gz\.upload-.*\.tmp$/), + expect.objectContaining({ fileSize: fs.statSync(nativeDepsPath).size, mode: 0o600 }), + expect.any(Function), + ); + expect(fakeSsh.exec).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); expect(execSshMock).toHaveBeenCalledWith(fakeSsh.ssh, "codesign --force --sign - $HOME/.ade-alpha/bin/ade"); + expect(execSshMock).toHaveBeenCalledWith( + fakeSsh.ssh, + expect.stringContaining("tar -xzf $HOME/.ade-alpha/runtime/ade-darwin-arm64.native.tar.gz"), + { timeoutMs: 10 * 60_000 }, + ); expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( fakeSsh.ssh, 'ADE_HOME="$HOME/.ade-alpha" PATH="$HOME/.ade-alpha/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PACKAGE_CHANNEL="alpha" ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 NODE_PATH="$HOME/.ade-alpha/runtime/darwin-arm64/node_modules${NODE_PATH:+:$NODE_PATH}" $HOME/.ade-alpha/bin/ade rpc --stdio', @@ -540,14 +1265,16 @@ describe("bootstrapRemoteRuntime upload flow", () => { }); execSshMock.mockImplementation(async (_client: Client, command: string) => { if (command === "uname -sm") return ok("Linux x86_64\n"); - if (command === "cat $HOME/.ade-beta/bin/ade.version 2>/dev/null || true") return ok(""); - if (command === "cat $HOME/.ade-beta/bin/ade.sha256 2>/dev/null || true") return ok(""); - if (command === "test -x $HOME/.ade-beta/bin/ade && $HOME/.ade-beta/bin/ade --version || true") return ok(""); - if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok(""); - if (command === "test -x $HOME/.ade-alpha/bin/ade && $HOME/.ade-alpha/bin/ade --version || true") return ok("ade 1.9.0-alpha.4\n"); - if (command === "cat $HOME/.ade-alpha/bin/ade.version 2>/dev/null || true") return ok("1.9.0-alpha.4\n"); - if (command === "cat $HOME/.ade-alpha/bin/ade.sha256 2>/dev/null || true") return ok("old-sha\n"); - throw new Error(`Unexpected SSH command: ${command}`); + if (isRemoteRuntimeIdentityCommand(command, ".ade-beta")) return remoteRuntimeIdentityOk({}); + if (isRemoteRuntimeIdentityCommand(command, ".ade")) return remoteRuntimeIdentityOk({}); + if (isRemoteRuntimeIdentityCommand(command, ".ade-alpha")) { + return remoteRuntimeIdentityOk({ + markerVersion: "1.9.0-alpha.4", + sha256: "old-sha", + executableVersion: "ade 1.9.0-alpha.4", + }); + } + return defaultRemoteBootstrapCommand(command); }); initializeMock.mockResolvedValueOnce({ runtimeInfo: { version: "1.9.0-alpha.4", packageChannel: "alpha", multiProject: true }, @@ -572,7 +1299,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { appVersion: APP_VERSION, }); - expect(fakeSsh.fastPut).not.toHaveBeenCalled(); + expect(fakeSsh.exec).not.toHaveBeenCalled(); expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( fakeSsh.ssh, 'ADE_HOME="$HOME/.ade-alpha" PATH="$HOME/.ade-alpha/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PACKAGE_CHANNEL="alpha" ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 ade rpc --stdio', @@ -595,13 +1322,17 @@ describe("bootstrapRemoteRuntime upload flow", () => { }); execSshMock.mockImplementation(async (_client: Client, command: string) => { if (command === "uname -sm") return ok("Linux x86_64\n"); - if (command === "cat $HOME/.ade-beta/bin/ade.version 2>/dev/null || true") return ok("1.8.0-beta.2\n"); - if (command === "cat $HOME/.ade-beta/bin/ade.sha256 2>/dev/null || true") return ok("old-beta-sha\n"); - if (command === "test -x $HOME/.ade-beta/bin/ade && $HOME/.ade-beta/bin/ade --version || true") return ok("ade 1.8.0-beta.2\n"); + if (isRemoteRuntimeIdentityCommand(command, ".ade-beta")) { + return remoteRuntimeIdentityOk({ + markerVersion: "1.8.0-beta.2", + sha256: "old-beta-sha", + executableVersion: "ade 1.8.0-beta.2", + }); + } if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok(""); if (command === "test -x $HOME/.ade-alpha/bin/ade && $HOME/.ade-alpha/bin/ade --version || true") return ok("ade 1.9.0-alpha.4\n"); if (command === "test -d $HOME/.ade-alpha/runtime/linux-x64/node_modules && echo ok || true") return ok("ok\n"); - throw new Error(`Unexpected SSH command: ${command}`); + return defaultRemoteBootstrapCommand(command); }); initializeMock .mockResolvedValueOnce({ @@ -661,12 +1392,16 @@ describe("bootstrapRemoteRuntime upload flow", () => { }); execSshMock.mockImplementation(async (_client: Client, command: string) => { if (command === "uname -sm") return ok("Linux x86_64\n"); - if (command === "cat $HOME/.ade-beta/bin/ade.version 2>/dev/null || true") return ok("1.8.0-beta.2\n"); - if (command === "cat $HOME/.ade-beta/bin/ade.sha256 2>/dev/null || true") return ok("old-beta-sha\n"); - if (command === "test -x $HOME/.ade-beta/bin/ade && $HOME/.ade-beta/bin/ade --version || true") return ok("ade 1.8.0-beta.2\n"); + if (isRemoteRuntimeIdentityCommand(command, ".ade-beta")) { + return remoteRuntimeIdentityOk({ + markerVersion: "1.8.0-beta.2", + sha256: "old-beta-sha", + executableVersion: "ade 1.8.0-beta.2", + }); + } if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok("ade 1.9.0\n"); if (command === "test -d $HOME/.ade/runtime/linux-x64/node_modules && echo ok || true") return ok("ok\n"); - throw new Error(`Unexpected SSH command: ${command}`); + return defaultRemoteBootstrapCommand(command); }); initializeMock .mockResolvedValueOnce({ @@ -716,11 +1451,20 @@ describe("bootstrapRemoteRuntime upload flow", () => { execSshMock.mockImplementation(async (_client: Client, command: string) => { commands.push(command); if (command === "uname -sm") return ok("Linux x86_64\n"); - if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok("2.0.0\n"); - if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(`${resources.binarySha256}\n`); - if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok("ade 2.0.0\n"); + if (isRemoteRuntimeIdentityCommand(command)) { + return remoteRuntimeIdentityOk({ + markerVersion: "2.0.0", + sha256: resources.binarySha256, + executableVersion: "ade 2.0.0", + }); + } + if ( + command.includes("wc -c < $HOME/.ade/bin/ade") && + command.includes("shasum -a 256 $HOME/.ade/bin/ade") && + command.includes("echo ok") + ) return ok("ok\n"); if (command.includes("$HOME/.ade/bin/ade runtime stop --text")) return ok(""); - throw new Error(`Unexpected SSH command: ${command}`); + return defaultRemoteBootstrapCommand(command); }); initializeMock.mockResolvedValueOnce({ runtimeInfo: { version: APP_VERSION, multiProject: true }, @@ -748,7 +1492,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { }, }); - expect(fakeSsh.fastPut).not.toHaveBeenCalled(); + expect(fakeSsh.exec).not.toHaveBeenCalled(); expect(openSshRuntimeTransportMock).toHaveBeenCalledTimes(1); expect(commands).not.toContain( 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade runtime stop --text >/dev/null 2>&1 || true', diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts index 385d04ad3..f020a8fe4 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts @@ -1,7 +1,9 @@ import crypto from "node:crypto"; +import { spawn } from "node:child_process"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; -import type { Client } from "ssh2"; +import type { Client, ConnectConfig, SFTPWrapper } from "ssh2"; import type { RemoteRuntimeCapabilities, RemoteRuntimeConnectResult, @@ -11,7 +13,7 @@ import type { RemoteRuntimeTargetRoute, } from "../../../shared/types/remoteRuntime"; import { RuntimeRpcClient } from "./runtimeRpcClient"; -import { connectSshWithRoute, execSsh, openSshRuntimeTransport } from "./sshTransport"; +import { connectSshWithRoute, execSsh, openSshRuntimeTransport, type ConnectedSshRoute, type OpenSshResolvedConfig } from "./sshTransport"; import { routeKey } from "./routeUtils"; import { normalizeRemoteTargetRoutes, @@ -41,22 +43,36 @@ export function normalizeRuntimeVersion(raw: string): string | null { return version || null; } +const PLACEHOLDER_RUNTIME_VERSION = "0.0.0"; + export function selectRemoteRuntimeVersion(args: { markerVersion: string | null; executableVersion: string | null; }): string | null { - return args.executableVersion ?? args.markerVersion; + if (args.executableVersion && args.executableVersion !== PLACEHOLDER_RUNTIME_VERSION) { + return args.executableVersion; + } + return args.markerVersion ?? args.executableVersion; } export function shouldUploadBundledRuntime(args: { localBinaryAvailable: boolean; executableVersion: string | null; + markerVersion?: string | null; appVersion: string; localBinarySha256?: string | null; remoteBinarySha256?: string | null; + remoteBinaryMatchesLocal?: boolean | null; }): boolean { if (!args.localBinaryAvailable) return false; - if (args.executableVersion !== args.appVersion) return true; + const installedVersion = selectRemoteRuntimeVersion({ + markerVersion: args.markerVersion ?? null, + executableVersion: args.executableVersion, + }); + if (installedVersion !== args.appVersion) return true; + if (args.remoteBinaryMatchesLocal != null) { + return !args.remoteBinaryMatchesLocal; + } if (args.localBinarySha256) { return args.remoteBinarySha256 !== args.localBinarySha256; } @@ -84,6 +100,9 @@ type RemoteRuntimeInitializeInfo = { compatibilityWarnings: string[]; }; +type OpenSshUploadConfig = Pick & + Partial>; + export function validateRemoteRuntimeInitializeResult(args: { result: unknown; expectedVersion: string | null; @@ -155,6 +174,8 @@ type RemoteRuntimeLayout = { binaryRelative: string; versionExpr: string; sha256Expr: string; + ptyHostWorkerExpr: string; + ptyHostWorkerSha256Expr: string; }; function normalizeRemoteRuntimeChannel(value: unknown): RemoteRuntimeChannel { @@ -185,6 +206,8 @@ export function resolveRemoteRuntimeLayout(env: NodeJS.ProcessEnv = process.env) binaryRelative: `${homeDirName}/bin/ade`, versionExpr: `${binDirExpr}/ade.version`, sha256Expr: `${binDirExpr}/ade.sha256`, + ptyHostWorkerExpr: `${runtimeDirExpr}/ptyHostWorker.cjs`, + ptyHostWorkerSha256Expr: `${runtimeDirExpr}/ptyHostWorker.cjs.sha256`, }; } @@ -209,6 +232,9 @@ export function buildRemoteRuntimeEnvironmentPrefix(args: { nativeDepsReady: boolean; layout?: RemoteRuntimeLayout; disableRuntimeServiceInstall?: boolean; + ptyHostWorkerReady?: boolean; + ptyHostWorkerNodePath?: string | null; + ptyHostWorkerCommandExpr?: string | null; }): string { const layout = args.layout ?? resolveRemoteRuntimeLayout(); const parts = [ @@ -225,6 +251,14 @@ export function buildRemoteRuntimeEnvironmentPrefix(args: { if (args.nativeDepsReady) { parts.push(`NODE_PATH="${layout.runtimeDirExpr}/${args.archLabel}/node_modules${"${NODE_PATH:+:$NODE_PATH}"}"`); } + if (args.ptyHostWorkerReady) { + if (args.ptyHostWorkerNodePath) { + parts.push(`ADE_PTY_HOST_WORKER_PATH="${layout.ptyHostWorkerExpr}"`); + parts.push(`ADE_PTY_HOST_WORKER_NODE=${shellQuote(args.ptyHostWorkerNodePath)}`); + } else if (args.ptyHostWorkerCommandExpr) { + parts.push(`ADE_PTY_HOST_WORKER_COMMAND="${args.ptyHostWorkerCommandExpr}"`); + } + } return `${parts.join(" ")} `; } @@ -263,71 +297,783 @@ function bundledNativeDepsPath(resourcesPath: string, archLabel: string): string }) ?? null; } +function bundledPtyHostWorkerPath(resourcesPath: string, localBinaryPath: string | null): string | null { + const candidates = [ + path.join(resourcesPath, "ade-cli", "ptyHostWorker.cjs"), + path.join(resourcesPath, "app.asar.unpacked", "ade-cli", "ptyHostWorker.cjs"), + ]; + if (localBinaryPath) { + const localBinaryDir = path.dirname(localBinaryPath); + const desktopRuntimeDir = path.resolve(process.cwd(), "resources", "runtime"); + const repoRuntimeDir = path.resolve(process.cwd(), "apps", "desktop", "resources", "runtime"); + if (localBinaryDir === desktopRuntimeDir) { + candidates.push(path.resolve(process.cwd(), "../ade-cli/dist/ptyHostWorker.cjs")); + } + if (localBinaryDir === repoRuntimeDir) { + candidates.push(path.resolve(process.cwd(), "apps/ade-cli/dist/ptyHostWorker.cjs")); + } + } + return candidates.find((candidate) => { + try { + return fs.statSync(candidate).isFile(); + } catch { + return false; + } + }) ?? null; +} + +type LocalArtifactHashCacheEntry = { + size: number; + mtimeMs: number; + ctimeMs: number; + sha256: string; +}; + +const localArtifactHashCache = new Map(); + function hashRuntimeBinary(localPath: string): string { - return crypto.createHash("sha256").update(fs.readFileSync(localPath)).digest("hex"); + const stat = fs.statSync(localPath); + const cached = localArtifactHashCache.get(localPath); + if ( + cached && + cached.size === stat.size && + cached.mtimeMs === stat.mtimeMs && + cached.ctimeMs === stat.ctimeMs + ) { + return cached.sha256; + } + const sha256 = crypto.createHash("sha256").update(fs.readFileSync(localPath)).digest("hex"); + localArtifactHashCache.set(localPath, { + size: stat.size, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs, + sha256, + }); + return sha256; } -async function uploadRuntimeBinary(client: Client, layout: RemoteRuntimeLayout, localPath: string, appVersion: string, localBinarySha256: string): Promise { - await execSsh(client, `mkdir -p ${layout.binDirExpr}`); - await new Promise((resolve, reject) => { +function fileSizeBytes(localPath: string): number { + return fs.statSync(localPath).size; +} + +function remoteUploadTempSuffix(): string { + return `upload-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}.tmp`; +} + +function remoteUploadAppendCommand(remoteFileExpr: string): string { + return [ + "umask 077", + `cat >> ${remoteFileExpr} & ade_upload_pid=$!`, + `( sleep ${REMOTE_ARTIFACT_UPLOAD_WATCHDOG_SECONDS}; kill "$ade_upload_pid" >/dev/null 2>&1 ) & ade_upload_watchdog=$!`, + "wait \"$ade_upload_pid\"", + "ade_upload_status=$?", + "kill \"$ade_upload_watchdog\" >/dev/null 2>&1 || true", + "wait \"$ade_upload_watchdog\" 2>/dev/null || true", + "exit \"$ade_upload_status\"", + ].join("; "); +} + +async function execSshOrThrow(client: Client, command: string, fallback: string): Promise { + const result = await execSsh(client, command); + if (result.code === 0) return; + throw new Error(result.stderr.trim() || result.stdout.trim() || fallback); +} + +async function resolveRemoteUploadPath(client: Client, remoteFileExpr: string): Promise { + const result = await execSsh(client, `printf '%s' ${remoteFileExpr}`); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || result.stdout.trim() || "Unable to resolve remote ADE service artifact path."); + } + const remotePath = result.stdout.trim(); + if (!remotePath) { + throw new Error("Remote ADE service artifact path resolved to an empty value."); + } + return remotePath; +} + +function openSftp(client: Client): Promise { + return new Promise((resolve, reject) => { client.sftp((error, sftp) => { if (error) { reject(error); return; } - sftp.fastPut(localPath, layout.binaryRelative, {}, (putError) => { - sftp.end(); - if (putError) reject(putError); - else resolve(); - }); + resolve(sftp); }); }); - await execSsh(client, [ - `chmod 700 ${layout.binDirExpr}`, - `chmod +x ${layout.binaryExpr}`, - `printf '%s\\n' ${shellQuote(appVersion)} > ${layout.versionExpr}`, - `printf '%s\\n' ${shellQuote(localBinarySha256)} > ${layout.sha256Expr}`, - `chmod 600 ${layout.versionExpr}`, - `chmod 600 ${layout.sha256Expr}`, - ].join(" && ")); } -async function signUploadedRuntimeBinaryIfNeeded(client: Client, layout: RemoteRuntimeLayout, platform: string): Promise { - if (platform !== "darwin") return; - const signed = await execSsh(client, `codesign --force --sign - ${layout.binaryExpr}`); - if (signed.code !== 0) { - throw new Error( - signed.stderr.trim() || - signed.stdout.trim() || - "Uploaded ADE service could not be signed on the remote Mac.", +async function uploadSftpFile( + client: Client, + localPath: string, + remoteFileExpr: string, + totalBytes: number, +): Promise { + const remotePath = await resolveRemoteUploadPath(client, remoteFileExpr); + const sftp = await openSftp(client); + try { + await new Promise((resolve, reject) => { + let settled = false; + let transferredBytes = 0; + let timeout: NodeJS.Timeout | null = null; + let idleTimeout: NodeJS.Timeout | null = null; + + const uploadProgressSuffix = (): string => + transferredBytes > 0 ? ` after ${transferredBytes} transferred bytes` : ""; + + const clearTimers = (): void => { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + if (idleTimeout) { + clearTimeout(idleTimeout); + idleTimeout = null; + } + }; + + const closeSftp = (destroy: boolean): void => { + try { + if (destroy) sftp.destroy(); + else sftp.end(); + } catch {} + }; + + const settle = (error?: Error | null): void => { + if (settled) return; + settled = true; + clearTimers(); + if (error) { + closeSftp(true); + reject(error); + return; + } + resolve(); + }; + + const resetIdleTimer = (): void => { + if (idleTimeout) clearTimeout(idleTimeout); + idleTimeout = setTimeout(() => { + settle(new Error(`Timed out uploading ADE service artifact with SFTP: no transfer progress for ${REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS}ms${uploadProgressSuffix()}.`)); + }, REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS); + idleTimeout.unref?.(); + }; + + timeout = setTimeout(() => { + settle(new Error(`Timed out uploading ADE service artifact with SFTP after ${REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS}ms${uploadProgressSuffix()}.`)); + }, REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS); + timeout.unref?.(); + resetIdleTimer(); + + sftp.fastPut(localPath, remotePath, { + concurrency: 4, + chunkSize: 256 * 1024, + fileSize: totalBytes, + mode: 0o600, + step: (total) => { + transferredBytes = total; + resetIdleTimer(); + }, + }, (error) => { + if (error) { + settle(new Error(`Unable to upload ADE service artifact with SFTP${uploadProgressSuffix()}: ${error.message}`)); + return; + } + settle(null); + }); + }); + } finally { + try { + sftp.end(); + } catch {} + } +} + +function remoteSha256Expr(fileExpr: string): string { + return `(command -v shasum >/dev/null 2>&1 && shasum -a 256 ${fileExpr} || sha256sum ${fileExpr}) | awk '{print $1}'`; +} + +function remoteFileMatchesCommand(fileExpr: string, expectedSize: number, expectedSha256: string): string { + return [ + `test "$(wc -c < ${fileExpr} | tr -d '[:space:]')" = ${shellQuote(String(expectedSize))}`, + `test "$(${remoteSha256Expr(fileExpr)})" = ${shellQuote(expectedSha256)}`, + ].join(" && "); +} + +const REMOTE_PREFLIGHT_MARKER_PREFIX = "__ade_remote_preflight_"; + +function remotePreflightSection(field: string, command: string): string { + return `printf '\\n${REMOTE_PREFLIGHT_MARKER_PREFIX}${field}__\\n'; ${command}`; +} + +function parseRemotePreflightSections(stdout: string): Record { + const sections: Record = {}; + const pattern = /^__ade_remote_preflight_([a-z0-9_]+)__$/gm; + const matches = Array.from(stdout.matchAll(pattern)); + for (let index = 0; index < matches.length; index += 1) { + const match = matches[index]!; + const field = match[1]!; + const start = match.index! + match[0].length; + const end = matches[index + 1]?.index ?? stdout.length; + sections[field] = stdout.slice(start, end).replace(/^\r?\n/u, "").trim(); + } + return sections; +} + +async function readRemoteRuntimeIdentity( + client: Client, + layout: RemoteRuntimeLayout, +): Promise<{ + markerVersion: string | null; + executableVersion: string | null; + remoteBinarySha256: string | null; +}> { + const result = await execSsh(client, [ + remotePreflightSection("marker_version", `cat ${layout.versionExpr} 2>/dev/null || true`), + remotePreflightSection("marker_sha256", `cat ${layout.sha256Expr} 2>/dev/null || true`), + remotePreflightSection("executable_version", `test -x ${layout.binaryExpr} && ${layout.binaryExpr} --version 2>/dev/null || true`), + ].join("; ")); + const sections = parseRemotePreflightSections(result.stdout); + return { + markerVersion: normalizeRuntimeVersion(sections.marker_version ?? ""), + executableVersion: normalizeRuntimeVersion(sections.executable_version ?? ""), + remoteBinarySha256: sections.marker_sha256 || null, + }; +} + +async function readRemoteRuntimeSupportStatus(args: { + client: Client; + layout: RemoteRuntimeLayout; + archLabel: string; + appVersion: string; + checkNativeDeps: boolean; + checkPtyHostWorker: boolean; + localPtyHostWorkerSha256: string | null; +}): Promise<{ + nativeDepsReady: boolean; + nodePath: string | null; + ptyHostWorkerReady: boolean; +}> { + const sections = [ + remotePreflightSection("node_path", "command -v node || true"), + ]; + if (args.checkNativeDeps) { + sections.push(remotePreflightSection("native_deps_ready", [ + `test -d ${args.layout.runtimeDirExpr}/${args.archLabel}/node_modules`, + `test "$(cat ${args.layout.runtimeDirExpr}/${args.archLabel}/.ade-version 2>/dev/null)" = ${shellQuote(args.appVersion)}`, + "echo ok", + ].join(" && ") + " || true")); + } + if (args.checkPtyHostWorker && args.localPtyHostWorkerSha256) { + sections.push(remotePreflightSection("pty_host_worker_ready", [ + `test -f ${args.layout.ptyHostWorkerExpr}`, + `test "$(cat ${args.layout.ptyHostWorkerSha256Expr} 2>/dev/null)" = ${shellQuote(args.localPtyHostWorkerSha256)}`, + "echo ok", + ].join(" && ") + " || true")); + } + const result = await execSsh(args.client, sections.join("; ")); + const parsed = parseRemotePreflightSections(result.stdout); + const nodePath = parsed.node_path?.split(/\r?\n/u)[0]?.trim() || null; + return { + nativeDepsReady: parsed.native_deps_ready === "ok", + nodePath, + ptyHostWorkerReady: parsed.pty_host_worker_ready === "ok", + }; +} + +const REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS = 10 * 60_000; +const REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS = 45_000; +const REMOTE_ARTIFACT_UPLOAD_WATCHDOG_SECONDS = Math.ceil( + REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS / 1000, +) + 30; +const REMOTE_ARTIFACT_UPLOAD_CHUNK_BYTES = 1024 * 1024; +const REMOTE_ARTIFACT_UPLOAD_NO_PROGRESS_RETRIES = 2; + +function openSshArgsForRoute( + target: RemoteRuntimeTarget, + route: ConnectedSshRoute, + connectedConfig: OpenSshUploadConfig | null | undefined, + remoteCommand: string, +): string[] { + const args = [ + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=yes", + ]; + const knownHostsPath = connectedConfig?.knownHostsPath?.trim(); + if (knownHostsPath) { + args.push( + "-o", + `UserKnownHostsFile=${knownHostsPath}`, + "-o", + "GlobalKnownHostsFile=/dev/null", ); } + const identityFile = + connectedConfig?.identityFile?.trim() || target.sshKeyPath?.trim(); + if (identityFile) args.push("-i", identityFile); + const port = connectedConfig?.port ?? route.port ?? target.port; + if (port) args.push("-p", String(port)); + const user = connectedConfig?.username?.trim() || target.sshUser?.trim(); + const host = connectedConfig?.host?.trim() || route.hostname; + args.push(user ? `${user}@${host}` : host); + args.push(remoteCommand); + return args; } -async function uploadNativeDepsBundle(client: Client, layout: RemoteRuntimeLayout, archLabel: string, localPath: string, appVersion: string): Promise { - await execSsh(client, `mkdir -p ${layout.runtimeDirExpr}`); - const remoteArchive = `${layout.runtimeDirRelative}/ade-${archLabel}.native.tar.gz`; +async function writeTemporaryUploadChunk(chunk: Buffer): Promise<{ path: string; remove: () => Promise }> { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "ade-remote-upload-")); + const filePath = path.join(dir, "chunk.bin"); + await fs.promises.writeFile(filePath, chunk, { mode: 0o600 }); + return { + path: filePath, + remove: () => fs.promises.rm(dir, { recursive: true, force: true }), + }; +} + +async function uploadSshChunk( + client: Client, + target: RemoteRuntimeTarget, + route: ConnectedSshRoute, + connectedConfig: OpenSshUploadConfig | null | undefined, + remoteFileExpr: string, + chunk: Buffer, + committedBytes: number, +): Promise { + try { + await uploadSshChunkViaConnectedClient(client, remoteFileExpr, chunk, committedBytes); + return; + } catch (error) { + if ((error as { wroteToChannel?: boolean }).wroteToChannel) { + throw error; + } + try { + await uploadSshChunkViaOpenSsh(target, route, connectedConfig, remoteFileExpr, chunk, committedBytes); + } catch (fallbackError) { + const primaryMessage = error instanceof Error ? error.message : String(error); + const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError); + throw new Error(`Unable to upload ADE service artifact over the existing SSH session: ${primaryMessage}; OpenSSH fallback failed: ${fallbackMessage}`); + } + } +} + +async function uploadSshChunkViaConnectedClient( + client: Client, + remoteFileExpr: string, + chunk: Buffer, + committedBytes: number, +): Promise { await new Promise((resolve, reject) => { - client.sftp((error, sftp) => { + let settled = false; + let wroteToChannel = false; + let stderr = ""; + let exitCode: number | null = null; + let exitSignal: string | null = null; + let timeout: NodeJS.Timeout | null = null; + let idleTimeout: NodeJS.Timeout | null = null; + let channelRef: { close?: () => void; destroy?: () => void } | null = null; + + const uploadProgressSuffix = (): string => + committedBytes > 0 ? ` after ${committedBytes} committed bytes` : ""; + + const markError = (error: Error): Error => { + (error as Error & { wroteToChannel?: boolean }).wroteToChannel = wroteToChannel; + return error; + }; + + const clearTimers = (): void => { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + if (idleTimeout) { + clearTimeout(idleTimeout); + idleTimeout = null; + } + }; + + const closeChannel = (): void => { + try { + channelRef?.close?.(); + } catch {} + try { + channelRef?.destroy?.(); + } catch {} + }; + + const settle = (error?: Error | null): void => { + if (settled) return; + settled = true; + clearTimers(); if (error) { - reject(error); + closeChannel(); + reject(markError(error)); return; } - sftp.fastPut(localPath, remoteArchive, {}, (putError) => { - sftp.end(); - if (putError) reject(putError); - else resolve(); + resolve(); + }; + + const resetIdleTimer = (): void => { + if (idleTimeout) clearTimeout(idleTimeout); + idleTimeout = setTimeout(() => { + settle(new Error(`Timed out uploading ADE service artifact: no remote chunk completion for ${REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS}ms${uploadProgressSuffix()}.`)); + }, REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS); + idleTimeout.unref?.(); + }; + + timeout = setTimeout(() => { + settle(new Error(`Timed out uploading ADE service artifact after ${REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS}ms${uploadProgressSuffix()}.`)); + }, REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS); + timeout.unref?.(); + resetIdleTimer(); + + client.exec(remoteUploadAppendCommand(remoteFileExpr), (error, channel) => { + if (error) { + settle(new Error(`SSH upload channel failed${uploadProgressSuffix()}: ${error.message}`)); + return; + } + channelRef = channel as unknown as { close?: () => void; destroy?: () => void }; + channel.resume(); + channel.stderr?.on("data", (data: Buffer | string) => { + stderr += data.toString(); + }); + channel.on("exit", (code: number | null, signal: string | null) => { + exitCode = typeof code === "number" ? code : null; + exitSignal = signal ?? null; + }); + channel.on("error", (channelError: Error) => { + settle(new Error(`SSH upload channel failed${uploadProgressSuffix()}: ${channelError.message}`)); + }); + channel.on("close", () => { + if (exitCode && exitCode !== 0) { + const detail = stderr.trim() || `remote command exited with code ${exitCode}`; + settle(new Error(`Unable to upload ADE service artifact: ${detail}`)); + return; + } + if (exitSignal) { + const detail = stderr.trim() || `remote command exited with signal ${exitSignal}`; + settle(new Error(`Unable to upload ADE service artifact: ${detail}`)); + return; + } + settle(null); }); + const finishWrite = (): void => { + try { + channel.end(); + } catch (writeError) { + settle(new Error(`SSH upload channel failed${uploadProgressSuffix()}: ${writeError instanceof Error ? writeError.message : String(writeError)}`)); + } + }; + wroteToChannel = true; + if (!channel.write(chunk)) { + channel.once("drain", finishWrite); + } else { + finishWrite(); + } }); }); - const extract = await execSsh(client, [ - `rm -rf ${layout.runtimeDirExpr}/${archLabel}`, - `mkdir -p ${layout.runtimeDirExpr}/${archLabel}`, - `tar -xzf ${layout.runtimeDirExpr}/ade-${archLabel}.native.tar.gz -C ${layout.runtimeDirExpr}/${archLabel}`, - `printf '%s\\n' ${shellQuote(appVersion)} > ${layout.runtimeDirExpr}/${archLabel}/.ade-version`, - ].join(" && ")); - if (extract.code !== 0) { - throw new Error(extract.stderr.trim() || "Unable to unpack ADE service native dependencies on the remote machine."); +} + +async function uploadSshChunkViaOpenSsh( + target: RemoteRuntimeTarget, + route: ConnectedSshRoute, + connectedConfig: OpenSshUploadConfig | null | undefined, + remoteFileExpr: string, + chunk: Buffer, + committedBytes: number, +): Promise { + const chunkFile = await writeTemporaryUploadChunk(chunk); + const chunkHandle = await fs.promises.open(chunkFile.path, "r"); + try { + await new Promise((resolve, reject) => { + let settled = false; + const child = spawn("ssh", openSshArgsForRoute(target, route, connectedConfig, remoteUploadAppendCommand(remoteFileExpr)), { + stdio: [chunkHandle.fd, "ignore", "pipe"], + }); + let stderr = ""; + let timeout: NodeJS.Timeout | null = null; + let idleTimeout: NodeJS.Timeout | null = null; + + const uploadProgressSuffix = (): string => + committedBytes > 0 ? ` after ${committedBytes} committed bytes` : ""; + + const clearTimers = (): void => { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + if (idleTimeout) { + clearTimeout(idleTimeout); + idleTimeout = null; + } + }; + + const resetIdleTimer = (): void => { + if (idleTimeout) clearTimeout(idleTimeout); + idleTimeout = setTimeout(() => { + settle(new Error(`Timed out uploading ADE service artifact: no remote chunk completion for ${REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS}ms${uploadProgressSuffix()}.`)); + }, REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS); + idleTimeout.unref?.(); + }; + + const settle = (error?: Error | null): void => { + if (settled) return; + settled = true; + clearTimers(); + if (error) { + try { child.kill("SIGKILL"); } catch {} + reject(error); + return; + } + resolve(); + }; + + timeout = setTimeout(() => { + settle(new Error(`Timed out uploading ADE service artifact after ${REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS}ms${uploadProgressSuffix()}.`)); + }, REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS); + timeout.unref?.(); + resetIdleTimer(); + + child.stderr?.on("data", (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + child.on("error", (error) => { + settle(new Error(`SSH upload process failed${uploadProgressSuffix()}: ${error.message}`)); + }); + child.on("close", (code, signal) => { + if (code && code !== 0) { + const detail = stderr.trim() || `ssh exited with code ${code}`; + settle(new Error(`Unable to upload ADE service artifact: ${detail}`)); + return; + } + if (signal) { + const detail = stderr.trim() || `ssh exited with signal ${signal}`; + settle(new Error(`Unable to upload ADE service artifact: ${detail}`)); + return; + } + settle(null); + }); + }); + } finally { + await chunkHandle.close().catch(() => undefined); + await chunkFile.remove(); + } +} + +async function uploadSshFileInChunks( + client: Client, + target: RemoteRuntimeTarget, + route: ConnectedSshRoute, + connectedConfig: OpenSshUploadConfig | null | undefined, + localPath: string, + remoteFileExpr: string, + totalBytes: number, +): Promise { + const readRemoteBytes = async (): Promise => { + const result = await execSsh(client, `wc -c < ${remoteFileExpr} | tr -d '[:space:]'`); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || result.stdout.trim() || "Unable to read remote ADE service artifact upload size."); + } + const value = Number.parseInt(result.stdout.trim(), 10); + if (!Number.isFinite(value) || value < 0 || value > totalBytes) { + throw new Error(`Remote ADE service artifact upload reported invalid size ${result.stdout.trim() || ""}.`); + } + return value; + }; + const file = await fs.promises.open(localPath, "r"); + try { + const buffer = Buffer.allocUnsafe(REMOTE_ARTIFACT_UPLOAD_CHUNK_BYTES); + let committedBytes = 0; + let noProgressRetries = 0; + while (committedBytes < totalBytes) { + const targetBytes = Math.min(REMOTE_ARTIFACT_UPLOAD_CHUNK_BYTES, totalBytes - committedBytes); + const { bytesRead } = await file.read(buffer, 0, targetBytes, committedBytes); + if (bytesRead <= 0) { + throw new Error(`Unable to read ADE service artifact after ${committedBytes} bytes.`); + } + try { + await uploadSshChunk(client, target, route, connectedConfig, remoteFileExpr, buffer.subarray(0, bytesRead), committedBytes); + } catch (error) { + const remoteBytes = await readRemoteBytes(); + if (remoteBytes > committedBytes) { + committedBytes = remoteBytes; + noProgressRetries = 0; + continue; + } + noProgressRetries += 1; + if (noProgressRetries <= REMOTE_ARTIFACT_UPLOAD_NO_PROGRESS_RETRIES) { + continue; + } + throw error; + } + const remoteBytes = committedBytes + bytesRead; + const actualRemoteBytes = await readRemoteBytes(); + if (actualRemoteBytes !== remoteBytes) { + if (actualRemoteBytes > committedBytes) { + committedBytes = actualRemoteBytes; + noProgressRetries = 0; + continue; + } + throw new Error(`Uploaded ADE service artifact stopped at ${actualRemoteBytes} bytes.`); + } + committedBytes = remoteBytes; + noProgressRetries = 0; + } + } finally { + await file.close(); + } +} + +async function uploadSshFile( + client: Client, + target: RemoteRuntimeTarget, + route: ConnectedSshRoute, + connectedConfig: OpenSshUploadConfig | null | undefined, + localPath: string, + remoteFileExpr: string, +): Promise { + const totalBytes = fileSizeBytes(localPath); + const prepareRemoteFile = async (): Promise => { + await execSshOrThrow( + client, + `rm -f ${remoteFileExpr} && umask 077 && : > ${remoteFileExpr} && chmod 600 ${remoteFileExpr}`, + "Unable to prepare remote ADE service artifact upload.", + ); + }; + + await prepareRemoteFile(); + try { + await uploadSftpFile(client, localPath, remoteFileExpr, totalBytes); + return; + } catch (sftpError) { + await prepareRemoteFile(); + try { + await uploadSshFileInChunks(client, target, route, connectedConfig, localPath, remoteFileExpr, totalBytes); + } catch (streamError) { + const sftpMessage = sftpError instanceof Error ? sftpError.message : String(sftpError); + const streamMessage = streamError instanceof Error ? streamError.message : String(streamError); + throw new Error(`Unable to upload ADE service artifact with SFTP: ${sftpMessage}; SSH stream fallback failed: ${streamMessage}`); + } + } +} + +async function uploadRuntimeBinary( + client: Client, + target: RemoteRuntimeTarget, + route: ConnectedSshRoute, + connectedConfig: OpenSshUploadConfig | null | undefined, + layout: RemoteRuntimeLayout, + localPath: string, + appVersion: string, + localBinarySha256: string, +): Promise { + const tempSuffix = remoteUploadTempSuffix(); + const tempExpr = `${layout.binaryExpr}.${tempSuffix}`; + await execSshOrThrow( + client, + `mkdir -p ${layout.binDirExpr} && chmod 700 ${layout.binDirExpr}`, + "Unable to create the remote ADE service directory.", + ); + try { + await uploadSshFile(client, target, route, connectedConfig, localPath, tempExpr); + await execSshOrThrow(client, [ + remoteFileMatchesCommand(tempExpr, fileSizeBytes(localPath), localBinarySha256), + `chmod +x ${tempExpr}`, + `mv -f ${tempExpr} ${layout.binaryExpr}`, + `printf '%s\\n' ${shellQuote(appVersion)} > ${layout.versionExpr}`, + `printf '%s\\n' ${shellQuote(localBinarySha256)} > ${layout.sha256Expr}`, + `chmod 600 ${layout.versionExpr}`, + `chmod 600 ${layout.sha256Expr}`, + ].join(" && "), "Uploaded ADE service did not pass size and checksum verification."); + } catch (error) { + await execSsh(client, `rm -f ${tempExpr}`).catch(() => undefined); + throw error; + } +} + +async function uploadNativeDepsBundle( + client: Client, + target: RemoteRuntimeTarget, + route: ConnectedSshRoute, + connectedConfig: OpenSshUploadConfig | null | undefined, + layout: RemoteRuntimeLayout, + archLabel: string, + localPath: string, + appVersion: string, +): Promise { + const localSha256 = hashRuntimeBinary(localPath); + const remoteArchiveExpr = `${layout.runtimeDirExpr}/ade-${archLabel}.native.tar.gz`; + const tempSuffix = remoteUploadTempSuffix(); + const tempExpr = `${remoteArchiveExpr}.${tempSuffix}`; + await execSshOrThrow( + client, + `mkdir -p ${layout.runtimeDirExpr}`, + "Unable to create the remote ADE native dependency directory.", + ); + try { + await uploadSshFile(client, target, route, connectedConfig, localPath, tempExpr); + const extract = await execSsh( + client, + [ + remoteFileMatchesCommand(tempExpr, fileSizeBytes(localPath), localSha256), + `mv -f ${tempExpr} ${remoteArchiveExpr}`, + `rm -rf ${layout.runtimeDirExpr}/${archLabel}`, + `mkdir -p ${layout.runtimeDirExpr}/${archLabel}`, + `tar -xzf ${remoteArchiveExpr} -C ${layout.runtimeDirExpr}/${archLabel}`, + `printf '%s\\n' ${shellQuote(appVersion)} > ${layout.runtimeDirExpr}/${archLabel}/.ade-version`, + ].join(" && "), + { timeoutMs: REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS }, + ); + if (extract.code !== 0) { + throw new Error(extract.stderr.trim() || "Unable to unpack ADE service native dependencies on the remote machine."); + } + } catch (error) { + await execSsh(client, `rm -f ${tempExpr}`).catch(() => undefined); + throw error; + } +} + +async function uploadPtyHostWorker( + client: Client, + target: RemoteRuntimeTarget, + route: ConnectedSshRoute, + connectedConfig: OpenSshUploadConfig | null | undefined, + layout: RemoteRuntimeLayout, + localPath: string, + localSha256: string, +): Promise { + const tempSuffix = remoteUploadTempSuffix(); + const tempExpr = `${layout.ptyHostWorkerExpr}.${tempSuffix}`; + await execSshOrThrow( + client, + `mkdir -p ${layout.runtimeDirExpr}`, + "Unable to create the remote ADE runtime artifact directory.", + ); + try { + await uploadSshFile(client, target, route, connectedConfig, localPath, tempExpr); + await execSshOrThrow(client, [ + remoteFileMatchesCommand(tempExpr, fileSizeBytes(localPath), localSha256), + `mv -f ${tempExpr} ${layout.ptyHostWorkerExpr}`, + `printf '%s\\n' ${shellQuote(localSha256)} > ${layout.ptyHostWorkerSha256Expr}`, + `chmod 600 ${layout.ptyHostWorkerExpr}`, + `chmod 600 ${layout.ptyHostWorkerSha256Expr}`, + ].join(" && "), "Uploaded ADE PTY host worker did not pass size and checksum verification."); + } catch (error) { + await execSsh(client, `rm -f ${tempExpr}`).catch(() => undefined); + throw error; + } +} + +async function signUploadedRuntimeBinaryIfNeeded(client: Client, layout: RemoteRuntimeLayout, platform: string): Promise { + if (platform !== "darwin") return; + const signed = await execSsh(client, `codesign --force --sign - ${layout.binaryExpr}`); + if (signed.code !== 0) { + throw new Error( + signed.stderr.trim() || + signed.stdout.trim() || + "Uploaded ADE service could not be signed on the remote Mac.", + ); } } @@ -424,7 +1170,13 @@ export async function bootstrapRemoteRuntime(args: { resourcesPath: string; appVersion: string; }): Promise<{ client: RuntimeRpcClient; result: RemoteRuntimeConnectResult; ssh: Client }> { - const { client: ssh, route: connectedRoute } = await connectSshWithRoute(args.target); + const { + client: ssh, + route: connectedRoute, + config: connectedConfig, + openSshConfig, + } = await connectSshWithRoute(args.target); + const uploadConnectionConfig = openSshConfig ?? connectedConfig; try { const uname = await execSsh(ssh, "uname -sm"); if (uname.code !== 0) { @@ -434,12 +1186,10 @@ export async function bootstrapRemoteRuntime(args: { const preferredLayout = resolveRemoteRuntimeLayout(); let layout = preferredLayout; let runtimeLayoutFallbackReason: string | null = null; - const binaryMarkerCheck = await execSsh(ssh, `cat ${layout.versionExpr} 2>/dev/null || true`); - let markedRuntimeVersion = normalizeRuntimeVersion(binaryMarkerCheck.stdout); - const binaryHashCheck = await execSsh(ssh, `cat ${layout.sha256Expr} 2>/dev/null || true`); - let remoteBinarySha256 = binaryHashCheck.stdout.trim() || null; - const versionCheck = await execSsh(ssh, `test -x ${layout.binaryExpr} && ${layout.binaryExpr} --version || true`); - let executableRuntimeVersion = normalizeRuntimeVersion(versionCheck.stdout); + const identity = await readRemoteRuntimeIdentity(ssh, layout); + let markedRuntimeVersion = identity.markerVersion; + let remoteBinarySha256 = identity.remoteBinarySha256; + let executableRuntimeVersion = identity.executableVersion; let runtimeVersion = selectRemoteRuntimeVersion({ markerVersion: markedRuntimeVersion, executableVersion: executableRuntimeVersion, @@ -447,21 +1197,19 @@ export async function bootstrapRemoteRuntime(args: { const localBinary = bundledRuntimePath(args.resourcesPath, arch.label); const localBinarySha256 = localBinary ? hashRuntimeBinary(localBinary) : null; const nativeDepsBundle = bundledNativeDepsPath(args.resourcesPath, arch.label); + const localPtyHostWorker = bundledPtyHostWorkerPath(args.resourcesPath, localBinary); + const localPtyHostWorkerSha256 = localPtyHostWorker ? hashRuntimeBinary(localPtyHostWorker) : null; + let remoteBinaryMatchesLocal: boolean | null = null; if (!localBinary && !executableRuntimeVersion) { for (const candidateLayout of resolveRemoteRuntimeLayoutCandidates().filter((candidate) => candidate.homeDirName !== layout.homeDirName)) { - const candidateVersionCheck = await execSsh( - ssh, - `test -x ${candidateLayout.binaryExpr} && ${candidateLayout.binaryExpr} --version || true`, - ); - const candidateExecutableVersion = normalizeRuntimeVersion(candidateVersionCheck.stdout); + const candidateIdentity = await readRemoteRuntimeIdentity(ssh, candidateLayout); + const candidateExecutableVersion = candidateIdentity.executableVersion; if (!candidateExecutableVersion) continue; - const candidateMarkerCheck = await execSsh(ssh, `cat ${candidateLayout.versionExpr} 2>/dev/null || true`); - const candidateHashCheck = await execSsh(ssh, `cat ${candidateLayout.sha256Expr} 2>/dev/null || true`); layout = candidateLayout; executableRuntimeVersion = candidateExecutableVersion; - markedRuntimeVersion = normalizeRuntimeVersion(candidateMarkerCheck.stdout); - remoteBinarySha256 = candidateHashCheck.stdout.trim() || null; + markedRuntimeVersion = candidateIdentity.markerVersion; + remoteBinarySha256 = candidateIdentity.remoteBinarySha256; runtimeVersion = selectRemoteRuntimeVersion({ markerVersion: markedRuntimeVersion, executableVersion: executableRuntimeVersion, @@ -472,41 +1220,106 @@ export async function bootstrapRemoteRuntime(args: { } let runtimeUploaded = false; - if (localBinary && localBinarySha256 && shouldUploadBundledRuntime({ + if (localBinary && localBinarySha256 && runtimeVersion === args.appVersion) { + if (arch.platform === "darwin") { + if (remoteBinarySha256 === localBinarySha256) { + const binarySignatureCheck = await execSsh( + ssh, + `codesign --verify ${layout.binaryExpr} >/dev/null 2>&1 && echo ok || true`, + ); + remoteBinaryMatchesLocal = binarySignatureCheck.stdout.trim() === "ok"; + } else { + remoteBinaryMatchesLocal = false; + } + } else { + const binaryContentCheck = await execSsh( + ssh, + `${remoteFileMatchesCommand(layout.binaryExpr, fileSizeBytes(localBinary), localBinarySha256)} && echo ok || true`, + ); + remoteBinaryMatchesLocal = binaryContentCheck.stdout.trim() === "ok"; + } + } + + const shouldUploadRuntime = Boolean(localBinary && localBinarySha256 && shouldUploadBundledRuntime({ localBinaryAvailable: true, executableVersion: executableRuntimeVersion, + markerVersion: markedRuntimeVersion, appVersion: args.appVersion, localBinarySha256, remoteBinarySha256, - })) { - await uploadRuntimeBinary(ssh, layout, localBinary, args.appVersion, localBinarySha256); + remoteBinaryMatchesLocal, + })); + + const uploadBundledRuntime = async (): Promise => { + if (!localBinary || !localBinarySha256) return; + await uploadRuntimeBinary(ssh, args.target, connectedRoute, uploadConnectionConfig, layout, localBinary, args.appVersion, localBinarySha256); await signUploadedRuntimeBinaryIfNeeded(ssh, layout, arch.platform); runtimeUploaded = true; runtimeVersion = args.appVersion; + }; + + if (shouldUploadRuntime) { + await uploadBundledRuntime(); } - let nativeDepsReady = false; - if (nativeDepsBundle) { - const nativeDepsCheck = await execSsh(ssh, [ - `test -d ${layout.runtimeDirExpr}/${arch.label}/node_modules`, - `test "$(cat ${layout.runtimeDirExpr}/${arch.label}/.ade-version 2>/dev/null)" = ${shellQuote(args.appVersion)}`, - "echo ok", - ].join(" && ") + " || true"); - const shouldUploadNativeDeps = runtimeUploaded || nativeDepsCheck.stdout.trim() !== "ok"; + const supportStatus = await readRemoteRuntimeSupportStatus({ + client: ssh, + layout, + archLabel: arch.label, + appVersion: args.appVersion, + checkNativeDeps: Boolean(nativeDepsBundle) && !runtimeUploaded, + checkPtyHostWorker: Boolean(localPtyHostWorkerSha256) && !runtimeUploaded, + localPtyHostWorkerSha256, + }); + + const ensureNativeDepsReady = async (forceUpload: boolean): Promise => { + if (!nativeDepsBundle) return false; + const shouldUploadNativeDeps = forceUpload || !supportStatus.nativeDepsReady; if (shouldUploadNativeDeps) { - await uploadNativeDepsBundle(ssh, layout, arch.label, nativeDepsBundle, args.appVersion); + await uploadNativeDepsBundle(ssh, args.target, connectedRoute, uploadConnectionConfig, layout, arch.label, nativeDepsBundle, args.appVersion); } - nativeDepsReady = true; - } + return true; + }; - const runtimeEnvPrefix = buildRemoteRuntimeEnvironmentPrefix({ + let nativeDepsReady = await ensureNativeDepsReady(runtimeUploaded); + + let ptyHostWorkerNodePath: string | null = null; + let ptyHostWorkerCommandExpr: string | null = null; + const ensurePtyHostWorkerReady = async (forceUpload: boolean): Promise => { + ptyHostWorkerNodePath = supportStatus.nodePath; + if (!ptyHostWorkerNodePath) { + ptyHostWorkerCommandExpr = layout.binaryExpr; + return true; + } + if (!localPtyHostWorker || !localPtyHostWorkerSha256) return false; + const shouldUploadPtyHostWorker = forceUpload || !supportStatus.ptyHostWorkerReady; + if (shouldUploadPtyHostWorker) { + await uploadPtyHostWorker( + ssh, + args.target, + connectedRoute, + uploadConnectionConfig, + layout, + localPtyHostWorker, + localPtyHostWorkerSha256, + ); + } + return true; + }; + + const ptyHostWorkerReady = await ensurePtyHostWorkerReady(runtimeUploaded); + + let runtimeEnvPrefix = buildRemoteRuntimeEnvironmentPrefix({ archLabel: arch.label, nativeDepsReady, layout, disableRuntimeServiceInstall: layout.homeDirName !== preferredLayout.homeDirName, + ptyHostWorkerReady, + ptyHostWorkerNodePath, + ptyHostWorkerCommandExpr, }); - if (runtimeUploaded) { + const verifyUploadedRuntime = async (): Promise => { const uploadedVersionCheck = await execSsh(ssh, `${runtimeEnvPrefix}${layout.binaryExpr} --version`); const uploadedVersion = normalizeRuntimeVersion(uploadedVersionCheck.stdout); if (uploadedVersionCheck.code !== 0 || !uploadedVersion) { @@ -519,9 +1332,10 @@ export async function bootstrapRemoteRuntime(args: { throw new Error(`Uploaded ADE service version mismatch: expected ${args.appVersion}, got ${uploadedVersion}.`); } runtimeVersion = uploadedVersion; - } + }; if (runtimeUploaded) { + await verifyUploadedRuntime(); await stopRemoteRuntimeDaemon(ssh, layout, runtimeEnvPrefix); } diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts index cd93f9d8f..bf5ed3bda 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts @@ -1,4 +1,5 @@ import type { Client } from "ssh2"; +import net from "node:net"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { RemoteRuntimeConnectResult, @@ -38,7 +39,9 @@ type SshListener = (...args: unknown[]) => void; type FakeSshClient = Client & { emitOnce(event: "close" | "error", ...args: unknown[]): void; + destroy: ReturnType; end: ReturnType; + forwardOut: ReturnType; once: ReturnType; }; @@ -127,10 +130,24 @@ function createSsh(): FakeSshClient { const listeners = new Map(); const fake = {} as { emitOnce?: FakeSshClient["emitOnce"]; + destroy?: ReturnType; end?: ReturnType; + forwardOut?: ReturnType; once?: ReturnType; }; + fake.destroy = vi.fn(); fake.end = vi.fn(); + fake.forwardOut = vi.fn(( + _sourceHost: string, + _sourcePort: number, + destinationHost: string, + destinationPort: number, + callback: (error: Error | undefined, stream?: net.Socket) => void, + ) => { + const stream = net.createConnection({ host: destinationHost, port: destinationPort }); + stream.once("connect", () => callback(undefined, stream)); + stream.once("error", (error) => callback(error)); + }); fake.once = vi.fn((event: string, callback: SshListener): FakeSshClient => { const existing = listeners.get(event) ?? []; existing.push(callback); @@ -147,6 +164,31 @@ function createSsh(): FakeSshClient { return fake as unknown as FakeSshClient; } +function listen(server: net.Server): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("server did not bind to a TCP port")); + return; + } + resolve(address.port); + }); + }); +} + +function closeServer(server: net.Server): Promise { + return new Promise((resolve) => { + if (!server.listening) { + resolve(); + return; + } + server.close(() => resolve()); + }); +} + describe("RemoteConnectionPool", () => { beforeEach(() => { bootstrapRemoteRuntimeMock.mockReset(); @@ -207,9 +249,42 @@ describe("RemoteConnectionPool", () => { expect(client.close).toHaveBeenCalledTimes(1); expect(ssh.end).toHaveBeenCalledTimes(1); + expect(ssh.destroy).toHaveBeenCalledTimes(1); expect(onEvicted).not.toHaveBeenCalled(); }); + it("reuses an in-flight bootstrap when reconnect follows disconnect", async () => { + const client = createClient(); + const ssh = createSsh(); + type BootstrapResolve = (value: { + client: RuntimeRpcClient; + ssh: Client; + result: RemoteRuntimeConnectResult; + }) => void; + let resolveBootstrap: BootstrapResolve | undefined; + bootstrapRemoteRuntimeMock.mockReturnValueOnce(new Promise((resolve) => { + resolveBootstrap = resolve; + })); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + const firstConnect = pool.connect(target); + pool.disconnect(target.id); + const secondConnect = pool.connect(target); + + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(1); + const resolveBootstrapNow = resolveBootstrap as BootstrapResolve; + resolveBootstrapNow({ + client, + ssh, + result: connectResult("1.0.0"), + }); + + await expect(firstConnect).resolves.toMatchObject({ version: "1.0.0" }); + await expect(secondConnect).resolves.toMatchObject({ version: "1.0.0" }); + expect(client.close).not.toHaveBeenCalled(); + expect(ssh.end).not.toHaveBeenCalled(); + }); + it("evicts cached entries and closes the RPC client after SSH closes", async () => { const firstClient = createClient(); const firstSsh = createSsh(); @@ -225,6 +300,7 @@ describe("RemoteConnectionPool", () => { expect(firstClient.close).toHaveBeenCalledTimes(1); expect(firstSsh.end).toHaveBeenCalledTimes(1); + expect(firstSsh.destroy).toHaveBeenCalledTimes(1); bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ client: createClient(), @@ -238,6 +314,145 @@ describe("RemoteConnectionPool", () => { expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); }); + it("opens a reusable local SSH forward and closes it on disconnect", async () => { + const upstream = net.createServer((socket) => { + socket.once("data", (chunk) => { + socket.end(`remote:${chunk.toString("utf8")}`); + }); + }); + const upstreamPort = await listen(upstream); + const client = createClient(); + const ssh = createSsh(); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client, + ssh, + result: connectResult("1.0.0"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + try { + await pool.connect(target); + const [firstForward, secondForward] = await Promise.all([ + pool.ensureLocalPortForward(target.id, { + remotePort: upstreamPort, + label: "preview", + }), + pool.ensureLocalPortForward(target.id, { + remotePort: upstreamPort, + label: "preview", + }), + ]); + + expect(secondForward.localPort).toBe(firstForward.localPort); + expect(ssh.forwardOut).not.toHaveBeenCalled(); + + const response = await new Promise((resolve, reject) => { + const socket = net.createConnection({ + host: firstForward.localHost, + port: firstForward.localPort, + }); + socket.once("connect", () => socket.write("ok")); + socket.once("data", (chunk) => { + resolve(chunk.toString("utf8")); + socket.end(); + }); + socket.once("error", reject); + }); + + expect(response).toBe("remote:ok"); + expect(ssh.forwardOut).toHaveBeenCalledWith( + "127.0.0.1", + 0, + "127.0.0.1", + upstreamPort, + expect.any(Function), + ); + + pool.disconnect(target.id); + await Promise.resolve(); + await expect(new Promise((resolve, reject) => { + const socket = net.createConnection({ + host: firstForward.localHost, + port: firstForward.localPort, + }); + socket.once("connect", () => { + socket.destroy(); + resolve(); + }); + socket.once("error", reject); + })).rejects.toBeTruthy(); + } finally { + pool.dispose(); + await closeServer(upstream); + } + }); + + it("uses the live SSH entry when a local SSH forward accepts connections", async () => { + const upstream = net.createServer((socket) => { + socket.once("data", (chunk) => { + socket.end(`remote:${chunk.toString("utf8")}`); + }); + }); + const upstreamPort = await listen(upstream); + const firstClient = createClient(); + const firstSsh = createSsh(); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: firstClient, + ssh: firstSsh, + result: connectResult("1.0.0"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + try { + await pool.connect(target); + const forward = await pool.ensureLocalPortForward(target.id, { + remotePort: upstreamPort, + label: "preview", + }); + const secondClient = createClient(); + const secondSsh = createSsh(); + ( + pool as unknown as { + entries: Map>; + } + ).entries.set(target.id, Promise.resolve({ + client: secondClient, + ssh: secondSsh, + result: connectResult("1.0.1"), + })); + + const response = await new Promise((resolve, reject) => { + const socket = net.createConnection({ + host: forward.localHost, + port: forward.localPort, + }); + socket.once("connect", () => socket.write("ok")); + socket.once("data", (chunk) => { + resolve(chunk.toString("utf8")); + socket.end(); + }); + socket.once("error", reject); + }); + + expect(response).toBe("remote:ok"); + expect(firstSsh.forwardOut).not.toHaveBeenCalled(); + expect(secondSsh.forwardOut).toHaveBeenCalledWith( + "127.0.0.1", + 0, + "127.0.0.1", + upstreamPort, + expect.any(Function), + ); + } finally { + pool.dispose(); + await closeServer(upstream); + } + }); + it("connects before streaming events and reconnects after disconnect", async () => { const firstClient = createClient(); firstClient.call.mockResolvedValueOnce({ @@ -390,6 +605,77 @@ describe("RemoteConnectionPool", () => { expect(secondClient.call).toHaveBeenCalledWith("projects.list", {}); }); + it("backs off new SSH bootstraps after a connect failure", async () => { + bootstrapRemoteRuntimeMock.mockRejectedValueOnce( + new Error("kex_exchange_identification: read: Connection reset by peer"), + ); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + await expect(pool.connect(target)).rejects.toThrow( + /kex_exchange_identification/i, + ); + await expect(pool.connect(target)).rejects.toThrow(/Retrying in \d+s/i); + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(1); + }); + + it("lets an explicit connect bypass a stale bootstrap backoff", async () => { + bootstrapRemoteRuntimeMock.mockRejectedValueOnce( + new Error("kex_exchange_identification: read: Connection reset by peer"), + ); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + await expect(pool.connect(target)).rejects.toThrow( + /kex_exchange_identification/i, + ); + await expect(pool.connect(target)).rejects.toThrow(/Retrying in \d+s/i); + + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: createClient(), + ssh: createSsh(), + result: connectResult("1.0.1"), + }); + + await expect( + pool.connect(target, { bypassFailureBackoff: true }), + ).resolves.toMatchObject({ version: "1.0.1" }); + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + }); + + it("does not reconnect a retryable request after an explicit disconnect during the request", async () => { + const firstClient = createClient(); + const firstSsh = createSsh(); + let rejectCall!: (error: Error) => void; + firstClient.call.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectCall = reject; + }), + ); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: firstClient, + ssh: firstSsh, + result: connectResult("1.0.0"), + }); + const pool = new RemoteConnectionPool( + { get: () => null } as unknown as RemoteTargetRegistry, + "1.0.0", + ); + + const pending = pool.callActionForTarget(target, "project-1", { + domain: "lane", + action: "list", + }); + while (firstClient.call.mock.calls.length === 0) { + await Promise.resolve(); + } + pool.disconnect(target.id); + rejectCall(new Error("Remote runtime connection closed.")); + + await expect(pending).rejects.toThrow(/connection closed/i); + expect(firstSsh.end).toHaveBeenCalledTimes(1); + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(1); + }); + it("does not replay non-idempotent machine calls after a connection interruption", async () => { const firstClient = createClient(); firstClient.call.mockRejectedValueOnce( @@ -490,11 +776,11 @@ describe("RemoteConnectionPool", () => { expect(secondClient.call).not.toHaveBeenCalled(); }); - it("retries read-only project actions once after reconnecting", async () => { + it("retries read-only project actions once after ECONNRESET", async () => { const firstClient = createClient(); const firstSsh = createSsh(); firstClient.call.mockRejectedValueOnce( - new Error("Remote runtime connection failed: stream closed"), + new Error("read ECONNRESET"), ); bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ client: firstClient, @@ -538,7 +824,7 @@ describe("RemoteConnectionPool", () => { domain: "lane", action: "list", }, - }); + }, { timeoutMs: 25_000 }); expect(secondClient.call).toHaveBeenCalledTimes(1); expect(secondClient.call).toHaveBeenCalledWith("ade/actions/call", { projectId: "project-1", @@ -547,7 +833,156 @@ describe("RemoteConnectionPool", () => { domain: "lane", action: "list", }, + }, { timeoutMs: 25_000 }); + }); + + it("retries file git decoration refresh once after ECONNRESET", async () => { + const firstClient = createClient(); + const firstSsh = createSsh(); + firstClient.call.mockRejectedValueOnce(new Error("read ECONNRESET")); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: firstClient, + ssh: firstSsh, + result: connectResult("1.0.0"), }); + const secondClient = createClient(); + secondClient.call.mockResolvedValueOnce({ + ok: true, + domain: "file", + action: "refreshGitDecorations", + result: { workspaceId: "primary", files: [], directories: [] }, + statusHints: { reconnected: true }, + }); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: secondClient, + ssh: createSsh(), + result: connectResult("1.0.1"), + }); + const pool = new RemoteConnectionPool({ get: () => null } as unknown as RemoteTargetRegistry, "1.0.0"); + + await expect( + pool.callActionForTarget(target, "project-1", { + domain: "file", + action: "refreshGitDecorations", + args: { workspaceId: "primary", forceFresh: true }, + }), + ).resolves.toEqual({ + domain: "file", + action: "refreshGitDecorations", + result: { workspaceId: "primary", files: [], directories: [] }, + statusHints: { reconnected: true }, + }); + + expect(firstSsh.end).toHaveBeenCalledTimes(1); + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + expect(secondClient.call).toHaveBeenCalledWith("ade/actions/call", { + projectId: "project-1", + name: "run_ade_action", + arguments: { + domain: "file", + action: "refreshGitDecorations", + args: { workspaceId: "primary", forceFresh: true }, + }, + }, { timeoutMs: 25_000 }); + }); + + it("falls back to empty git decorations when an older runtime lacks that optional action", async () => { + const client = createClient(); + client.call.mockRejectedValueOnce(new Error("Action 'file.refreshGitDecorations' is not callable.")); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client, + ssh: createSsh(), + result: connectResult("0.9.0"), + }); + const pool = new RemoteConnectionPool({ get: () => null } as unknown as RemoteTargetRegistry, "1.0.0"); + + await expect( + pool.callActionForTarget(target, "project-1", { + domain: "file", + action: "refreshGitDecorations", + args: { workspaceId: "primary", forceFresh: true }, + }), + ).resolves.toEqual({ + domain: "file", + action: "refreshGitDecorations", + result: { workspaceId: "primary", files: [], directories: [] }, + statusHints: { optionalActionMissing: true }, + }); + + await expect( + pool.callActionForTarget(target, "project-1", { + domain: "file", + action: "refreshGitDecorations", + args: { workspaceId: "primary", forceFresh: true }, + }), + ).resolves.toEqual({ + domain: "file", + action: "refreshGitDecorations", + result: { workspaceId: "primary", files: [], directories: [] }, + statusHints: { optionalActionMissing: true }, + }); + + expect(client.call).toHaveBeenCalledTimes(1); + expect(client.call).toHaveBeenCalledWith("ade/actions/call", { + projectId: "project-1", + name: "run_ade_action", + arguments: { + domain: "file", + action: "refreshGitDecorations", + args: { workspaceId: "primary", forceFresh: true }, + }, + }, { timeoutMs: 25_000 }); + }); + + it("falls back to empty PR queue states when an older runtime lacks that optional action", async () => { + const client = createClient(); + client.call.mockResolvedValueOnce({ + ok: false, + error: { message: "Action 'pr.listQueueStates' is not callable." }, + }); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client, + ssh: createSsh(), + result: connectResult("0.9.0"), + }); + const pool = new RemoteConnectionPool({ get: () => null } as unknown as RemoteTargetRegistry, "1.0.0"); + + await expect( + pool.callActionForTarget(target, "project-1", { + domain: "pr", + action: "listQueueStates", + args: { includeCompleted: true, limit: 50 }, + }), + ).resolves.toEqual({ + domain: "pr", + action: "listQueueStates", + result: [], + statusHints: { optionalActionMissing: true }, + }); + + await expect( + pool.callActionForTarget(target, "project-1", { + domain: "pr", + action: "listQueueStates", + args: { includeCompleted: true, limit: 50 }, + }), + ).resolves.toEqual({ + domain: "pr", + action: "listQueueStates", + result: [], + statusHints: { optionalActionMissing: true }, + }); + + expect(client.call).toHaveBeenCalledTimes(1); + expect(client.call).toHaveBeenCalledWith("ade/actions/call", { + projectId: "project-1", + name: "run_ade_action", + arguments: { + domain: "pr", + action: "listQueueStates", + args: { includeCompleted: true, limit: 50 }, + }, + }, { timeoutMs: 25_000 }); }); it("lists remote ADE actions as grouped registry entries", async () => { @@ -651,7 +1086,7 @@ describe("RemoteConnectionPool", () => { domain: "lane", action: "list", }, - }); + }, { timeoutMs: 25_000 }); }); it("calls project-scoped sync methods on the connected runtime", async () => { @@ -808,6 +1243,7 @@ describe("RemoteConnectionPool", () => { cursor: 5, limit: 10, category: "pty", + replay: false, }, onEvent, ); @@ -817,6 +1253,7 @@ describe("RemoteConnectionPool", () => { cursor: 5, limit: 10, category: "pty", + replay: false, }); expect(onEvent).toHaveBeenCalledTimes(1); expect(onEvent).toHaveBeenCalledWith({ diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts index f88815cd8..9dc84d779 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts @@ -1,4 +1,5 @@ import { app } from "electron"; +import net from "node:net"; import type { Client } from "ssh2"; import type { RemoteRuntimeActionRequest, @@ -6,6 +7,8 @@ import type { RemoteRuntimeBufferedEvent, RemoteRuntimeConnectResult, RemoteRuntimeEventCategory, + RemoteRuntimePortForward, + RemoteRuntimePortForwardRequest, RemoteRuntimeMachineProjectCapability, RemoteRuntimeStreamEventsRequest, RemoteRuntimeStreamEventsResult, @@ -16,6 +19,7 @@ import type { AdeActionRegistryEntry } from "../../../shared/types/automations"; import type { RuntimeRpcClient } from "./runtimeRpcClient"; import { bootstrapRemoteRuntime, ensureRemoteProject } from "./remoteBootstrap"; import type { RemoteTargetRegistry } from "./remoteTargetRegistry"; +import { isRetryableRemoteAction } from "./retryableRemoteActions"; type PoolEntry = { client: RuntimeRpcClient; @@ -24,40 +28,57 @@ type PoolEntry = { dispose?: (closeClient: boolean, notify?: boolean) => void; }; +type LocalPortForwardEntry = RemoteRuntimePortForward & { + server: net.Server; +}; + +function closePoolEntryResources( + entry: PoolEntry, + closeClient: boolean, + forceSshDestroy = false, +): void { + if (closeClient) { + try { + entry.client.close(); + } catch {} + } + try { + entry.ssh.end(); + } catch {} + if (forceSshDestroy) { + try { + (entry.ssh as unknown as { destroy?: () => void }).destroy?.(); + } catch {} + } +} + type RuntimeEventNotification = { subscriptionId: string; projectId: string; event: RemoteRuntimeBufferedEvent; }; +type ConnectFailureBackoff = { + error: Error; + failureCount: number; + retryAfterMs: number; +}; + +type RemoteConnectionPoolConnectOptions = { + bypassFailureBackoff?: boolean; +}; + function isRemoteRuntimeConnectionError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); - return /remote (?:runtime|ADE service) connection (?:closed|failed)|timed out waiting for method|stream closed|channel closed|connection lost|socket closed/i.test( + return /remote (?:runtime|ADE service) connection (?:closed|failed)|timed out waiting for method|stream closed|channel closed|connection lost|socket closed|ECONNRESET|ECONNABORTED|EPIPE|ENOTCONN/i.test( message, ); } -const RETRYABLE_REMOTE_ACTION_PREFIXES = [ - "diagnosticsGet", - "get", - "list", - "oauthGet", - "oauthList", - "portGet", - "portList", - "proxyGet", - "read", - "search", -] as const; - -const RETRYABLE_REMOTE_ACTIONS = new Set([ - "chat.codexFuzzyFileSearch", - "chat.fileSearch", - "chat.modelCatalog", - "file.quickOpen", - "terminal.activeForChat", - "terminal.preview", -]); +const RETRYABLE_REMOTE_ACTION_RPC_TIMEOUT_MS = 25_000; +const CONNECT_FAILURE_BASE_BACKOFF_MS = 3_000; +const CONNECT_FAILURE_MAX_BACKOFF_MS = 15_000; +const LOCAL_FORWARD_HOST = "127.0.0.1"; /** Project-scoped sync RPCs that are safe to replay after a reconnect. */ const RETRYABLE_REMOTE_SYNC_METHODS = new Set([ @@ -94,12 +115,64 @@ const MACHINE_PROJECT_CAPABILITY_LABEL: Record 65_535) { + throw new Error(`Remote port must be an integer from 1 to 65535 (received ${String(value)}).`); } - return RETRYABLE_REMOTE_ACTION_PREFIXES.some((prefix) => - request.action.startsWith(prefix), - ); + return port; +} + +function portForwardKey(targetId: string, remoteHost: string, remotePort: number): string { + return `${targetId}\0${remoteHost}\0${remotePort}`; +} + +function destroyAcceptedSocket(socket: net.Socket, error: Error): void { + if (socket.listenerCount("error") === 0) { + socket.once("error", () => {}); + } + socket.destroy(error); +} + +function snapshotPortForward(entry: LocalPortForwardEntry): RemoteRuntimePortForward { + const { + targetId, + remoteHost, + remotePort, + localHost, + localPort, + localUrl, + label, + createdAt, + lastUsedAt, + } = entry; + return { + targetId, + remoteHost, + remotePort, + localHost, + localPort, + localUrl, + label, + createdAt, + lastUsedAt, + }; } function assertMachineProjectCapability(entry: PoolEntry, method: string): void { @@ -113,8 +186,73 @@ function assertMachineProjectCapability(entry: PoolEntry, method: string): void ); } +function optionalRemoteActionFallbackResult( + request: RemoteRuntimeActionRequest, +): RemoteRuntimeActionResult | null { + if (request.domain === "file" && request.action === "refreshGitDecorations") { + const args = request.args && typeof request.args === "object" && !Array.isArray(request.args) + ? request.args as Record + : {}; + const workspaceId = typeof args.workspaceId === "string" && args.workspaceId.trim() + ? args.workspaceId.trim() + : "primary"; + return { + domain: "file", + action: "refreshGitDecorations", + result: { + workspaceId, + files: [], + directories: [], + }, + statusHints: { + optionalActionMissing: true, + }, + }; + } + if (request.domain === "pr" && request.action === "listQueueStates") { + return { + domain: "pr", + action: "listQueueStates", + result: [], + statusHints: { + optionalActionMissing: true, + }, + }; + } + return null; +} + +function isRemoteActionNotCallableMessage(message: string): boolean { + return /not callable|not exposed|not available|unknown action/i.test(message); +} + +function isRemoteActionNotCallableError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return isRemoteActionNotCallableMessage(message); +} + +function unsupportedOptionalActionKey( + targetId: string, + projectId: string, + request: RemoteRuntimeActionRequest, +): string { + return `${targetId}\0${projectId}\0${request.domain}.${request.action}`; +} + export class RemoteConnectionPool { private readonly entries = new Map>(); + private readonly localPortForwards = new Map< + string, + Promise + >(); + private readonly unsupportedOptionalActionKeys = new Set(); + private readonly pendingDisconnects = new Set(); + private readonly resolvedEntryPromises = new Set>(); + private readonly connectFailureBackoffByTargetId = new Map< + string, + ConnectFailureBackoff + >(); + private readonly disconnectGenerationByTargetId = new Map(); private readonly evictionListeners = new Set< (targetId: string, error: Error) => void >(); @@ -126,8 +264,9 @@ export class RemoteConnectionPool { async connect( target: RemoteRuntimeTarget, + options: RemoteConnectionPoolConnectOptions = {}, ): Promise { - return (await this.connectEntry(target)).result; + return (await this.connectEntry(target, options)).result; } onEntryEvicted(listener: (targetId: string, error: Error) => void): () => void { @@ -165,9 +304,20 @@ export class RemoteConnectionPool { }); } - private async connectEntry(target: RemoteRuntimeTarget): Promise { + private async connectEntry( + target: RemoteRuntimeTarget, + options: RemoteConnectionPoolConnectOptions = {}, + ): Promise { const existing = this.entries.get(target.id); - if (existing) return await existing; + if (existing) { + this.pendingDisconnects.delete(target.id); + return await existing; + } + if (options.bypassFailureBackoff) { + this.connectFailureBackoffByTargetId.delete(target.id); + } else { + this.assertConnectNotBackingOff(target.id); + } const pending = bootstrapRemoteRuntime({ target, registry: this.registry, @@ -177,6 +327,9 @@ export class RemoteConnectionPool { let entryPromise: Promise; entryPromise = pending.then(({ client, ssh, result }) => { const entry = { client, ssh, result }; + this.clearUnsupportedOptionalActionsForTarget(target.id); + this.connectFailureBackoffByTargetId.delete(target.id); + this.resolvedEntryPromises.add(entryPromise); this.attachEntryLifecycle(target.id, entryPromise, entry); return entry; }); @@ -185,6 +338,9 @@ export class RemoteConnectionPool { return await entryPromise; } catch (error) { this.entries.delete(target.id); + this.pendingDisconnects.delete(target.id); + this.resolvedEntryPromises.delete(entryPromise); + this.noteConnectFailure(target.id, error); throw error; } } @@ -257,6 +413,133 @@ export class RemoteConnectionPool { ); } + async ensureLocalPortForward( + targetId: string, + request: RemoteRuntimePortForwardRequest, + ): Promise { + const remoteHost = normalizeForwardRemoteHost(request.remoteHost); + const remotePort = normalizeForwardPort(request.remotePort); + const key = portForwardKey(targetId, remoteHost, remotePort); + const existing = this.localPortForwards.get(key); + if (existing) { + const entry = await existing; + entry.lastUsedAt = Date.now(); + return snapshotPortForward(entry); + } + + const pending = (async (): Promise => { + const entry = await this.requireEntry(targetId); + const createdAt = Date.now(); + const label = + typeof request.label === "string" && request.label.trim() + ? request.label.trim() + : null; + const server = net.createServer(async (socket) => { + let activeEntryPromise = this.entries.get(targetId); + if (!activeEntryPromise) { + destroyAcceptedSocket(socket, new Error(`Remote target is not connected: ${targetId}`)); + return; + } + let activeEntry: PoolEntry; + try { + activeEntry = await activeEntryPromise; + const latestEntryPromise = this.entries.get(targetId); + if (!latestEntryPromise) { + destroyAcceptedSocket(socket, new Error(`Remote target is not connected: ${targetId}`)); + return; + } + if (latestEntryPromise !== activeEntryPromise) { + activeEntryPromise = latestEntryPromise; + activeEntry = await activeEntryPromise; + } + } catch (error) { + destroyAcceptedSocket( + socket, + error instanceof Error ? error : new Error(String(error)), + ); + return; + } + activeEntry.ssh.forwardOut( + LOCAL_FORWARD_HOST, + 0, + remoteHost, + remotePort, + (error, stream) => { + if (error) { + destroyAcceptedSocket(socket, error); + return; + } + const closeBoth = () => { + try { + socket.destroy(); + } catch {} + try { + stream.destroy(); + } catch {} + }; + socket.once("error", closeBoth); + stream.once("error", closeBoth); + socket.once("close", closeBoth); + stream.once("close", closeBoth); + socket.pipe(stream).pipe(socket); + }, + ); + }); + + return await new Promise((resolve, reject) => { + let settled = false; + const rejectStart = (error: Error) => { + if (settled) return; + settled = true; + try { + server.close(); + } catch {} + reject(error); + }; + server.once("error", rejectStart); + server.listen(0, LOCAL_FORWARD_HOST, () => { + if (settled) return; + server.off("error", rejectStart); + const address = server.address(); + if (!address || typeof address === "string") { + rejectStart(new Error("Local remote-preview forward did not bind to a TCP port.")); + return; + } + settled = true; + const localPort = address.port; + const forward: LocalPortForwardEntry = { + targetId, + remoteHost, + remotePort, + localHost: LOCAL_FORWARD_HOST, + localPort, + localUrl: `http://${LOCAL_FORWARD_HOST}:${localPort}`, + label, + createdAt, + lastUsedAt: createdAt, + server, + }; + resolve(forward); + }); + }); + })(); + this.localPortForwards.set(key, pending); + try { + const forward = await pending; + forward.server.once("close", () => { + if (this.localPortForwards.get(key) === pending) { + this.localPortForwards.delete(key); + } + }); + return snapshotPortForward(forward); + } catch (error) { + if (this.localPortForwards.get(key) === pending) { + this.localPortForwards.delete(key); + } + throw error; + } + } + async callSyncForTarget( target: RemoteRuntimeTarget, projectId: string, @@ -310,7 +593,18 @@ export class RemoteConnectionPool { projectId: string, request: RemoteRuntimeActionRequest, ): Promise { - const value = await entry.client.call("ade/actions/call", { + const optionalFallback = optionalRemoteActionFallbackResult(request); + const optionalFallbackKey = optionalFallback + ? unsupportedOptionalActionKey(entry.result.target.id, projectId, request) + : null; + if ( + optionalFallback && + optionalFallbackKey && + this.unsupportedOptionalActionKeys.has(optionalFallbackKey) + ) { + return optionalFallback; + } + const params = { projectId, name: "run_ade_action", arguments: { @@ -322,7 +616,20 @@ export class RemoteConnectionPool { : {}), ...(request.argsList ? { argsList: request.argsList } : {}), }, - }); + }; + const callOptions = remoteRuntimeActionCallOptions(request); + let value: unknown; + try { + value = callOptions + ? await entry.client.call("ade/actions/call", params, callOptions) + : await entry.client.call("ade/actions/call", params); + } catch (error) { + if (optionalFallback && optionalFallbackKey && isRemoteActionNotCallableError(error)) { + this.unsupportedOptionalActionKeys.add(optionalFallbackKey); + return optionalFallback; + } + throw error; + } if (value && typeof value === "object" && !Array.isArray(value)) { const record = value as Record; @@ -333,10 +640,15 @@ export class RemoteConnectionPool { !Array.isArray(record.error) ? (record.error as Record) : {}; + const message = typeof error.message === "string" + ? error.message + : "Remote ADE service action failed."; + if (optionalFallback && optionalFallbackKey && isRemoteActionNotCallableMessage(message)) { + this.unsupportedOptionalActionKeys.add(optionalFallbackKey); + return optionalFallback; + } throw new Error( - typeof error.message === "string" - ? error.message - : "Remote ADE service action failed.", + message, ); } return { @@ -484,22 +796,45 @@ export class RemoteConnectionPool { } disconnect(targetId: string): void { + this.bumpDisconnectGeneration(targetId); + this.closeLocalPortForwardsForTarget(targetId); const existing = this.entries.get(targetId); - this.entries.delete(targetId); + if (!existing) return; + if (this.resolvedEntryPromises.has(existing)) { + this.entries.delete(targetId); + this.resolvedEntryPromises.delete(existing); + void existing + .then((entry) => { + if (entry.dispose) { + entry.dispose(true, false); + return; + } + closePoolEntryResources(entry, true, true); + }) + .catch(() => {}); + return; + } + this.pendingDisconnects.add(targetId); void existing - ?.then((entry) => { + .then((entry) => { + if (!this.pendingDisconnects.delete(targetId)) return; + if (this.entries.get(targetId) === existing) { + this.entries.delete(targetId); + } + this.resolvedEntryPromises.delete(existing); if (entry.dispose) { entry.dispose(true, false); return; } - try { - entry.client.close(); - } catch {} - try { - entry.ssh.end(); - } catch {} + closePoolEntryResources(entry, true, true); }) - .catch(() => {}); + .catch(() => { + this.pendingDisconnects.delete(targetId); + this.resolvedEntryPromises.delete(existing); + if (this.entries.get(targetId) === existing) { + this.entries.delete(targetId); + } + }); } dispose(): void { @@ -508,6 +843,28 @@ export class RemoteConnectionPool { } } + private closeLocalPortForwardsForTarget(targetId: string): void { + for (const [key, pending] of [...this.localPortForwards.entries()]) { + if (!key.startsWith(`${targetId}\0`)) continue; + this.localPortForwards.delete(key); + void pending + .then((entry) => { + try { + entry.server.close(); + } catch {} + }) + .catch(() => {}); + } + } + + private clearUnsupportedOptionalActionsForTarget(targetId: string): void { + for (const key of [...this.unsupportedOptionalActionKeys]) { + if (key.startsWith(`${targetId}\0`)) { + this.unsupportedOptionalActionKeys.delete(key); + } + } + } + private async requireEntry(targetId: string): Promise { const entry = this.entries.get(targetId); if (!entry) throw new Error(`Remote target is not connected: ${targetId}`); @@ -520,10 +877,18 @@ export class RemoteConnectionPool { options: { retryOnConnectionError: boolean }, ): Promise { const entry = await this.connectEntry(target); + const disconnectGeneration = + this.disconnectGenerationByTargetId.get(target.id) ?? 0; try { return await operation(entry); } catch (error) { if (!isRemoteRuntimeConnectionError(error)) throw error; + if ( + (this.disconnectGenerationByTargetId.get(target.id) ?? 0) !== + disconnectGeneration + ) { + throw error; + } this.disconnect(target.id); const reconnectTarget = this.registry.get(target.id) ?? target; const nextEntry = await this.connectEntry(reconnectTarget); @@ -560,16 +925,11 @@ export class RemoteConnectionPool { if (this.entries.get(targetId) === entryPromise) { this.entries.delete(targetId); } + this.resolvedEntryPromises.delete(entryPromise); if (cleanedUp) return; cleanedUp = true; - if (closeClient) { - try { - entry.client.close(); - } catch {} - } - try { - entry.ssh.end(); - } catch {} + this.closeLocalPortForwardsForTarget(targetId); + closePoolEntryResources(entry, closeClient, true); if (notify) notifyEvicted(error); }; @@ -578,6 +938,43 @@ export class RemoteConnectionPool { entry.ssh.once("error", (error) => evict(true, true, error instanceof Error ? error : new Error(String(error)))); entry.dispose = evict; } + + private assertConnectNotBackingOff(targetId: string): void { + const backoff = this.connectFailureBackoffByTargetId.get(targetId); + if (!backoff) return; + const remainingMs = backoff.retryAfterMs - Date.now(); + if (remainingMs <= 0) { + this.connectFailureBackoffByTargetId.delete(targetId); + return; + } + const seconds = Math.max(1, Math.ceil(remainingMs / 1000)); + throw new Error( + `Remote ADE service connection failed recently (${backoff.error.message}). Retrying in ${seconds}s.`, + ); + } + + private noteConnectFailure(targetId: string, error: unknown): void { + const normalizedError = + error instanceof Error ? error : new Error(String(error)); + const previous = this.connectFailureBackoffByTargetId.get(targetId); + const failureCount = (previous?.failureCount ?? 0) + 1; + const backoffMs = Math.min( + CONNECT_FAILURE_MAX_BACKOFF_MS, + CONNECT_FAILURE_BASE_BACKOFF_MS * 2 ** Math.max(0, failureCount - 1), + ); + this.connectFailureBackoffByTargetId.set(targetId, { + error: normalizedError, + failureCount, + retryAfterMs: Date.now() + backoffMs, + }); + } + + private bumpDisconnectGeneration(targetId: string): void { + this.disconnectGenerationByTargetId.set( + targetId, + (this.disconnectGenerationByTargetId.get(targetId) ?? 0) + 1, + ); + } } function clampCursor(value: unknown): number { @@ -720,6 +1117,7 @@ async function subscribeToRuntimeEvents( ...(isRemoteRuntimeEventCategory(request.category) ? { category: request.category } : {}), + ...(typeof request.replay === "boolean" ? { replay: request.replay } : {}), }); subscriptionId = readSubscriptionId(value); for (const notification of pendingNotifications) { diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.test.ts new file mode 100644 index 000000000..109aaa420 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.test.ts @@ -0,0 +1,518 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + RemoteRuntimeConnectResult, + RemoteRuntimeTarget, +} from "../../../shared/types/remoteRuntime"; +import type { RemoteConnectionPool } from "./remoteConnectionPool"; +import { RemoteConnectionService } from "./remoteConnectionService"; +import type { RemoteTargetRegistry } from "./remoteTargetRegistry"; + +function target( + id: string, + lastConnectedAt: number | null, +): RemoteRuntimeTarget { + return { + id, + name: id, + hostname: `${id}.example.test`, + sshUser: "ade", + port: 22, + sshKeyPath: null, + lastSeenArch: null, + runtimeBinaryVersion: null, + lastConnectedAt, + }; +} + +function connectResult( + target: RemoteRuntimeTarget, +): RemoteRuntimeConnectResult { + return { + target, + arch: "darwin-arm64", + version: "1.0.0", + projects: [], + capabilities: { + projects: true, + machineProjects: { + browseDirectories: true, + getDetail: true, + getWorkSummary: true, + getDefaultParentDir: true, + create: true, + clone: true, + listMyGitHubRepos: true, + }, + }, + }; +} + +describe("RemoteConnectionService", () => { + it("only autoconnects targets that have connected successfully before", async () => { + const neverConnected = target("never-connected", null); + const previouslyConnected = target("previously-connected", 1_700_000_000); + const registry = { + list: vi.fn(() => [neverConnected, previouslyConnected]), + get: vi.fn((id: string) => + id === neverConnected.id + ? neverConnected + : id === previouslyConnected.id + ? previouslyConnected + : null, + ), + } as unknown as RemoteTargetRegistry; + const pool = { + connect: vi.fn(async (target: RemoteRuntimeTarget) => + connectResult(target), + ), + disconnect: vi.fn(), + onEntryEvicted: vi.fn(() => () => {}), + } as unknown as RemoteConnectionPool; + + const service = new RemoteConnectionService(registry, pool); + service.startAutoconnect(); + await Promise.resolve(); + service.stopAutoconnect(); + + expect(pool.connect).toHaveBeenCalledTimes(1); + expect(pool.connect).toHaveBeenCalledWith(previouslyConnected); + }); + + it("does not autoconnect a manually disconnected saved target", async () => { + const previouslyConnected = target("previously-connected", 1_700_000_000); + const registry = { + list: vi.fn(() => [previouslyConnected]), + get: vi.fn((id: string) => + id === previouslyConnected.id ? previouslyConnected : null, + ), + update: vi.fn((_id: string, patch: Partial) => ({ + ...previouslyConnected, + ...patch, + })), + } as unknown as RemoteTargetRegistry; + const pool = { + connect: vi.fn(async (target: RemoteRuntimeTarget) => + connectResult(target), + ), + disconnect: vi.fn(), + onEntryEvicted: vi.fn(() => () => {}), + } as unknown as RemoteConnectionPool; + + const service = new RemoteConnectionService(registry, pool); + service.disconnect(previouslyConnected.id, { manual: true }); + service.startAutoconnect(); + await Promise.resolve(); + service.stopAutoconnect(); + + expect(pool.disconnect).toHaveBeenCalledWith(previouslyConnected.id); + expect(pool.connect).not.toHaveBeenCalled(); + }); + + it("disconnects the pool even when persisting the manual marker fails", () => { + const previouslyConnected = target("previously-connected", 1_700_000_000); + const registry = { + list: vi.fn(() => [previouslyConnected]), + get: vi.fn((id: string) => + id === previouslyConnected.id ? previouslyConnected : null, + ), + update: vi.fn(() => { + throw new Error("disk full"); + }), + } as unknown as RemoteTargetRegistry; + const pool = { + disconnect: vi.fn(), + onEntryEvicted: vi.fn(() => () => {}), + } as unknown as RemoteConnectionPool; + + const service = new RemoteConnectionService(registry, pool); + + expect(() => service.disconnect(previouslyConnected.id, { manual: true })) + .toThrow(/disk full/i); + expect(pool.disconnect).toHaveBeenCalledWith(previouslyConnected.id); + expect(service.snapshot().connections[0]).toMatchObject({ + target: { id: previouslyConnected.id }, + state: "idle", + lastError: null, + }); + }); + + it("blocks implicit RPC reconnect after manual disconnect until explicit connect", async () => { + const previouslyConnected = target("previously-connected", 1_700_000_000); + const registry = { + list: vi.fn(() => [previouslyConnected]), + get: vi.fn((id: string) => + id === previouslyConnected.id ? previouslyConnected : null, + ), + update: vi.fn((_id: string, patch: Partial) => ({ + ...previouslyConnected, + ...patch, + })), + } as unknown as RemoteTargetRegistry; + const actionResult = { + domain: "file", + action: "read", + result: { ok: true }, + statusHints: {}, + }; + const pool = { + connect: vi.fn(async (target: RemoteRuntimeTarget) => + connectResult(target), + ), + disconnect: vi.fn(), + callActionForTarget: vi.fn(async () => actionResult), + onEntryEvicted: vi.fn(() => () => {}), + } as unknown as RemoteConnectionPool; + + const service = new RemoteConnectionService(registry, pool); + service.disconnect(previouslyConnected.id, { manual: true }); + + await expect( + service.callAction(previouslyConnected.id, "project-1", { + domain: "file", + action: "read", + }), + ).rejects.toThrow(/manually disconnected/i); + expect(pool.callActionForTarget).not.toHaveBeenCalled(); + + await service.connect(previouslyConnected.id, { explicit: true }); + await expect( + service.callAction(previouslyConnected.id, "project-1", { + domain: "file", + action: "read", + }), + ).resolves.toEqual(actionResult); + + expect(pool.connect).toHaveBeenCalledWith(previouslyConnected, { + bypassFailureBackoff: true, + }); + expect(pool.callActionForTarget).toHaveBeenCalledTimes(1); + }); + + it("does not publish connected after a manual disconnect during pending connect", async () => { + const previouslyConnected = target("previously-connected", 1_700_000_000); + const registry = { + list: vi.fn(() => [previouslyConnected]), + get: vi.fn((id: string) => + id === previouslyConnected.id ? previouslyConnected : null, + ), + update: vi.fn((_id: string, patch: Partial) => ({ + ...previouslyConnected, + ...patch, + })), + } as unknown as RemoteTargetRegistry; + let resolveConnect!: (result: RemoteRuntimeConnectResult) => void; + const pool = { + connect: vi.fn( + () => + new Promise((resolve) => { + resolveConnect = resolve; + }), + ), + disconnect: vi.fn(), + onEntryEvicted: vi.fn(() => () => {}), + } as unknown as RemoteConnectionPool; + + const service = new RemoteConnectionService(registry, pool); + const pendingConnect = service.connect(previouslyConnected.id, { + explicit: true, + }); + await Promise.resolve(); + expect(service.snapshot().connections[0]?.state).toBe("connecting"); + + service.disconnect(previouslyConnected.id, { manual: true }); + resolveConnect(connectResult(previouslyConnected)); + + await expect(pendingConnect).rejects.toThrow( + /disconnected before ADE finished connecting/i, + ); + expect(service.snapshot().connections[0]).toMatchObject({ + state: "idle", + lastError: null, + }); + expect(service.snapshot().connectedCount).toBe(0); + expect(pool.disconnect).toHaveBeenCalledWith(previouslyConnected.id); + }); + + it("does not autoconnect a persisted manually disconnected target after restart", async () => { + let persistedTarget: RemoteRuntimeTarget = { + ...target("previously-connected", 1_700_000_000), + manuallyDisconnectedAt: 1_700_000_100, + }; + const registry = { + list: vi.fn(() => [persistedTarget]), + get: vi.fn((id: string) => + id === persistedTarget.id ? persistedTarget : null, + ), + update: vi.fn((_id: string, patch: Partial) => { + persistedTarget = { ...persistedTarget, ...patch }; + return persistedTarget; + }), + } as unknown as RemoteTargetRegistry; + const pool = { + connect: vi.fn(async (target: RemoteRuntimeTarget) => + connectResult(target), + ), + disconnect: vi.fn(), + callActionForTarget: vi.fn(), + onEntryEvicted: vi.fn(() => () => {}), + } as unknown as RemoteConnectionPool; + + const service = new RemoteConnectionService(registry, pool); + service.startAutoconnect(); + await Promise.resolve(); + service.stopAutoconnect(); + + expect(pool.connect).not.toHaveBeenCalled(); + await expect( + service.callAction(persistedTarget.id, "project-1", { + domain: "file", + action: "read", + }), + ).rejects.toThrow(/manually disconnected/i); + + await expect( + service.connect(persistedTarget.id, { explicit: true }), + ).resolves.toMatchObject({ target: { manuallyDisconnectedAt: null } }); + expect(registry.update).toHaveBeenCalledWith(persistedTarget.id, { + manuallyDisconnectedAt: null, + }); + expect(pool.connect).toHaveBeenCalledTimes(1); + }); + + it("keeps the persisted manual disconnect marker when explicit reconnect fails", async () => { + let persistedTarget: RemoteRuntimeTarget = { + ...target("previously-connected", 1_700_000_000), + manuallyDisconnectedAt: 1_700_000_100, + }; + const registry = { + list: vi.fn(() => [persistedTarget]), + get: vi.fn((id: string) => + id === persistedTarget.id ? persistedTarget : null, + ), + update: vi.fn((_id: string, patch: Partial) => { + persistedTarget = { ...persistedTarget, ...patch }; + return persistedTarget; + }), + } as unknown as RemoteTargetRegistry; + const pool = { + connect: vi.fn(async () => { + throw new Error("Remote ADE service connection failed."); + }), + disconnect: vi.fn(), + onEntryEvicted: vi.fn(() => () => {}), + } as unknown as RemoteConnectionPool; + + const service = new RemoteConnectionService(registry, pool); + + await expect( + service.connect(persistedTarget.id, { explicit: true }), + ).rejects.toThrow(/connection failed/i); + + expect(persistedTarget.manuallyDisconnectedAt).toBe(1_700_000_100); + expect(registry.update).not.toHaveBeenCalledWith(persistedTarget.id, { + manuallyDisconnectedAt: null, + }); + service.startAutoconnect(); + await Promise.resolve(); + service.stopAutoconnect(); + expect(pool.connect).toHaveBeenCalledTimes(1); + }); + + it("allows overlapping connect callers to share a successful pending connection", async () => { + const previouslyConnected = target("previously-connected", 1_700_000_000); + const registry = { + list: vi.fn(() => [previouslyConnected]), + get: vi.fn((id: string) => + id === previouslyConnected.id ? previouslyConnected : null, + ), + } as unknown as RemoteTargetRegistry; + let resolveConnect!: (result: RemoteRuntimeConnectResult) => void; + const connectPromise = new Promise((resolve) => { + resolveConnect = resolve; + }); + const pool = { + connect: vi.fn(() => connectPromise), + disconnect: vi.fn(), + onEntryEvicted: vi.fn(() => () => {}), + } as unknown as RemoteConnectionPool; + + const service = new RemoteConnectionService(registry, pool); + const implicitConnect = service.connect(previouslyConnected.id); + const explicitConnect = service.connect(previouslyConnected.id, { + explicit: true, + }); + await Promise.resolve(); + resolveConnect(connectResult(previouslyConnected)); + + await expect( + Promise.all([implicitConnect, explicitConnect]), + ).resolves.toHaveLength(2); + expect(service.snapshot().connections[0]).toMatchObject({ + state: "connected", + lastError: null, + }); + expect(service.snapshot().connectedCount).toBe(1); + }); + + it("pauses automatic reconnect after repeated implicit connection failures", async () => { + const previouslyConnected = target("previously-connected", 1_700_000_000); + const registry = { + list: vi.fn(() => [previouslyConnected]), + get: vi.fn((id: string) => + id === previouslyConnected.id ? previouslyConnected : null, + ), + } as unknown as RemoteTargetRegistry; + let failConnect = true; + const pool = { + connect: vi.fn(async (target: RemoteRuntimeTarget) => { + if (failConnect) { + throw new Error("Remote ADE service connection failed."); + } + return connectResult(target); + }), + disconnect: vi.fn(), + callActionForTarget: vi.fn(), + onEntryEvicted: vi.fn(() => () => {}), + } as unknown as RemoteConnectionPool; + + const service = new RemoteConnectionService(registry, pool); + for (let attempt = 0; attempt < 10; attempt += 1) { + await expect(service.connect(previouslyConnected.id)).rejects.toThrow( + /connection failed/i, + ); + } + + expect(pool.connect).toHaveBeenCalledTimes(10); + expect(service.snapshot().connections[0]?.lastError).toMatch( + /stopped automatic reconnecting after 10 failed attempts/i, + ); + + await expect(service.connect(previouslyConnected.id)).rejects.toThrow( + /stopped automatic reconnecting/i, + ); + await expect( + service.callAction(previouslyConnected.id, "project-1", { + domain: "file", + action: "read", + }), + ).rejects.toThrow(/stopped automatic reconnecting/i); + expect(pool.connect).toHaveBeenCalledTimes(10); + expect(pool.callActionForTarget).not.toHaveBeenCalled(); + + failConnect = false; + await expect( + service.connect(previouslyConnected.id, { explicit: true }), + ).resolves.toMatchObject({ target: previouslyConnected }); + expect(pool.connect).toHaveBeenCalledTimes(11); + expect(service.snapshot().connections[0]?.lastError).toBeNull(); + }); + + it("does not spend the reconnect budget on pool backoff throttle errors", async () => { + const previouslyConnected = target("previously-connected", 1_700_000_000); + const registry = { + list: vi.fn(() => [previouslyConnected]), + get: vi.fn((id: string) => + id === previouslyConnected.id ? previouslyConnected : null, + ), + } as unknown as RemoteTargetRegistry; + const pool = { + connect: vi.fn(async () => { + throw new Error( + "Remote ADE service connection failed recently (ssh reset). Retrying in 3s.", + ); + }), + disconnect: vi.fn(), + onEntryEvicted: vi.fn(() => () => {}), + } as unknown as RemoteConnectionPool; + + const service = new RemoteConnectionService(registry, pool); + for (let attempt = 0; attempt < 10; attempt += 1) { + await expect(service.connect(previouslyConnected.id)).rejects.toThrow( + /Retrying in 3s/i, + ); + } + + expect(service.snapshot().connections[0]?.lastError).toMatch( + /Retrying in 3s/i, + ); + await expect(service.connect(previouslyConnected.id)).rejects.toThrow( + /Retrying in 3s/i, + ); + expect(pool.connect).toHaveBeenCalledTimes(11); + }); + + it("spends the reconnect budget on normalized SSH handshake failures", async () => { + const previouslyConnected = target("previously-connected", 1_700_000_000); + const registry = { + list: vi.fn(() => [previouslyConnected]), + get: vi.fn((id: string) => + id === previouslyConnected.id ? previouslyConnected : null, + ), + } as unknown as RemoteTargetRegistry; + const pool = { + connect: vi.fn(async () => { + throw new Error( + "SSH server at studio.local:22 closed the connection before ADE could finish the SSH handshake.", + ); + }), + disconnect: vi.fn(), + onEntryEvicted: vi.fn(() => () => {}), + } as unknown as RemoteConnectionPool; + + const service = new RemoteConnectionService(registry, pool); + for (let attempt = 0; attempt < 10; attempt += 1) { + await expect(service.connect(previouslyConnected.id)).rejects.toThrow( + /SSH handshake/i, + ); + } + + expect(service.snapshot().connections[0]?.lastError).toMatch( + /stopped automatic reconnecting after 10 failed attempts/i, + ); + await expect(service.connect(previouslyConnected.id)).rejects.toThrow( + /stopped automatic reconnecting/i, + ); + expect(pool.connect).toHaveBeenCalledTimes(10); + }); + + it("does not spend the reconnect budget on ordinary remote action errors", async () => { + const previouslyConnected = target("previously-connected", 1_700_000_000); + const registry = { + list: vi.fn(() => [previouslyConnected]), + get: vi.fn((id: string) => + id === previouslyConnected.id ? previouslyConnected : null, + ), + } as unknown as RemoteTargetRegistry; + const pool = { + connect: vi.fn(async (target: RemoteRuntimeTarget) => + connectResult(target), + ), + disconnect: vi.fn(), + callActionForTarget: vi.fn(async () => { + throw new Error("Action 'pr.listQueueStates' is not callable."); + }), + onEntryEvicted: vi.fn(() => () => {}), + } as unknown as RemoteConnectionPool; + + const service = new RemoteConnectionService(registry, pool); + for (let attempt = 0; attempt < 10; attempt += 1) { + await expect( + service.callAction(previouslyConnected.id, "project-1", { + domain: "pr", + action: "listQueueStates", + }), + ).rejects.toThrow(/not callable/i); + } + + await expect( + service.callAction(previouslyConnected.id, "project-1", { + domain: "pr", + action: "listQueueStates", + }), + ).rejects.toThrow(/not callable/i); + expect(pool.callActionForTarget).toHaveBeenCalledTimes(11); + expect(service.snapshot().connections[0]?.lastError).toMatch( + /not callable/i, + ); + }); +}); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts index 130c17095..3b2beef1b 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts @@ -1,4 +1,5 @@ import type { + AdeActionRegistryEntry, CloneProjectInput, CreateProjectInput, ListMyGitHubReposInput, @@ -11,7 +12,14 @@ import type { RemoteRuntimeConnectionState, RemoteRuntimeConnectionStatus, RemoteRuntimeConnectResult, + RemoteRuntimeBufferedEvent, + RemoteRuntimePortForward, + RemoteRuntimePortForwardRequest, RemoteRuntimeProjectRecord, + RemoteRuntimeActionRequest, + RemoteRuntimeActionResult, + RemoteRuntimeStreamEventsRequest, + RemoteRuntimeStreamEventsResult, RemoteRuntimeSshHostKeyTrustStatus, RemoteRuntimeTarget, RemoteRuntimeTargetInput, @@ -32,10 +40,37 @@ type RemoteConnectionServiceOptions = { pingTimeoutMs?: number; }; +type RemoteConnectionDisconnectOptions = { + manual?: boolean; +}; + +type RemoteConnectionConnectOptions = { + explicit?: boolean; +}; + +const AUTOMATIC_RECONNECT_FAILURE_LIMIT = 10; + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function automaticReconnectStoppedMessage(): string { + return `ADE stopped automatic reconnecting after ${AUTOMATIC_RECONNECT_FAILURE_LIMIT} failed attempts. Press Connect to try again.`; +} + +function isImplicitConnectionFailure(error: unknown): boolean { + const message = errorMessage(error); + return /remote (?:runtime|ADE service) connection (?:closed|failed|was interrupted)|remote ADE service connection failed recently|timed out waiting for method|stream closed|channel closed|connection lost|socket closed|ECONNRESET|ECONNABORTED|EPIPE|ENOTCONN|remote target is not connected|SSH server at .* closed the connection before ADE could finish the SSH handshake|Timed out while waiting for the SSH handshake/i.test( + message, + ); +} + +function isConnectionBackoffThrottle(error: unknown): boolean { + return /^Remote ADE service connection failed recently\b/i.test( + errorMessage(error), + ); +} + function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -49,8 +84,19 @@ function coerceConnectionProject(value: unknown): RemoteRuntimeProjectRecord { return project; } +function shouldAutoconnectTarget(target: RemoteRuntimeTarget): boolean { + return target.lastConnectedAt != null && target.manuallyDisconnectedAt == null; +} + export class RemoteConnectionService { private readonly statusById = new Map(); + private readonly manuallyDisconnectedTargetIds = new Set(); + private readonly automaticReconnectFailuresByTargetId = new Map< + string, + number + >(); + private readonly automaticReconnectPausedTargetIds = new Set(); + private readonly disconnectGenerationByTargetId = new Map(); private readonly listeners = new Set< (snapshot: RemoteRuntimeConnectionSnapshot) => void >(); @@ -90,6 +136,8 @@ export class RemoteConnectionService { removeTarget(targetId: string): boolean { this.disconnect(targetId); + this.manuallyDisconnectedTargetIds.delete(targetId); + this.clearAutomaticReconnectBudget(targetId); this.statusById.delete(targetId); const removed = this.registry.remove(targetId); this.emit(); @@ -108,7 +156,10 @@ export class RemoteConnectionService { ): Promise { const fingerprint = fingerprintSha256.trim(); if (!fingerprint) throw new Error("SSH host key fingerprint is required."); - return await trustSshHostKeyForTarget(this.requireTarget(targetId), fingerprint); + return await trustSshHostKeyForTarget( + this.requireTarget(targetId), + fingerprint, + ); } snapshot(): RemoteRuntimeConnectionSnapshot { @@ -122,7 +173,9 @@ export class RemoteConnectionService { arch: status.arch ?? target.lastSeenArch, version: status.version ?? target.runtimeBinaryVersion, ...(status.capabilities ? { capabilities: status.capabilities } : {}), - ...(status.compatibilityWarnings ? { compatibilityWarnings: status.compatibilityWarnings } : {}), + ...(status.compatibilityWarnings + ? { compatibilityWarnings: status.compatibilityWarnings } + : {}), projects: status.projects ?? [], lastError: status.lastError ?? null, lastAttemptedAt: status.lastAttemptedAt ?? null, @@ -148,6 +201,9 @@ export class RemoteConnectionService { startAutoconnect(): void { for (const target of this.registry.list()) { + if (!shouldAutoconnectTarget(target)) continue; + if (this.manuallyDisconnectedTargetIds.has(target.id)) continue; + if (this.automaticReconnectPausedTargetIds.has(target.id)) continue; void this.connect(target.id).catch(() => {}); } if (this.autoconnectTimer) return; @@ -163,40 +219,95 @@ export class RemoteConnectionService { this.autoconnectTimer = null; } - async connect(targetId: string): Promise { + async connect( + targetId: string, + options: RemoteConnectionConnectOptions = {}, + ): Promise { const target = this.requireTarget(targetId); + const explicit = options.explicit === true; + if (explicit) { + this.manuallyDisconnectedTargetIds.delete(target.id); + this.clearAutomaticReconnectBudget(target.id); + } else { + this.assertImplicitReconnectAllowed(target.id); + } + const disconnectGeneration = this.getDisconnectGeneration(target.id); this.mergeStatus(target.id, { state: "connecting", lastAttemptedAt: Date.now(), lastError: null, }); try { - const result = await this.pool.connect(target); - this.mergeStatus(result.target.id, { + const result = explicit + ? await this.pool.connect(target, { bypassFailureBackoff: true }) + : await this.pool.connect(target); + if (!this.isDisconnectGenerationCurrent(target.id, disconnectGeneration)) { + if (this.manuallyDisconnectedTargetIds.has(target.id)) { + this.pool.disconnect(target.id); + } + throw new Error( + "Remote target was disconnected before ADE finished connecting.", + ); + } + const connectedResult = + explicit && result.target.manuallyDisconnectedAt != null + ? { + ...result, + target: this.registry.update(result.target.id, { + manuallyDisconnectedAt: null, + }), + } + : result; + this.mergeStatus(connectedResult.target.id, { state: "connected", - arch: result.arch, - version: result.version, - capabilities: result.capabilities, - compatibilityWarnings: result.compatibilityWarnings, - projects: result.projects, - connectedAt: result.target.lastConnectedAt ?? Date.now(), + arch: connectedResult.arch, + version: connectedResult.version, + capabilities: connectedResult.capabilities, + compatibilityWarnings: connectedResult.compatibilityWarnings, + projects: connectedResult.projects, + connectedAt: connectedResult.target.lastConnectedAt ?? Date.now(), lastAttemptedAt: Date.now(), lastError: null, }); - return result; + this.clearAutomaticReconnectBudget(connectedResult.target.id); + return connectedResult; } catch (error) { + if (!this.isDisconnectGenerationCurrent(target.id, disconnectGeneration)) { + throw error; + } + const lastError = explicit + ? errorMessage(error) + : this.recordImplicitFailure(target.id, error); this.mergeStatus(target.id, { state: "error", - lastError: errorMessage(error), + lastError, lastAttemptedAt: Date.now(), }); throw error; } } - disconnect(targetId: string): void { + disconnect( + targetId: string, + options: RemoteConnectionDisconnectOptions = {}, + ): void { + let persistenceError: unknown = null; + if (options.manual) { + this.manuallyDisconnectedTargetIds.add(targetId); + if (this.registry.get(targetId)) { + try { + this.registry.update(targetId, { manuallyDisconnectedAt: Date.now() }); + } catch (error) { + persistenceError = error; + } + } + } + this.bumpDisconnectGeneration(targetId); this.pool.disconnect(targetId); this.mergeStatus(targetId, { state: "idle", lastError: null }); + if (persistenceError) { + throw persistenceError; + } } probeSavedConnections(): void { @@ -206,7 +317,7 @@ export class RemoteConnectionService { } async projects(targetId: string): Promise { - const target = this.requireTarget(targetId); + const target = this.requireTargetForImplicitUse(targetId); try { const value = await this.pool.projectsForTarget(target); const projects = coerceProjects(value); @@ -215,11 +326,12 @@ export class RemoteConnectionService { projects, lastError: null, }); + this.clearAutomaticReconnectBudget(targetId); return projects; } catch (error) { this.mergeStatus(targetId, { state: "error", - lastError: errorMessage(error), + lastError: this.recordImplicitFailure(targetId, error), lastAttemptedAt: Date.now(), }); throw error; @@ -230,16 +342,35 @@ export class RemoteConnectionService { targetId: string, rootPath: string, ): Promise { - const target = this.requireTarget(targetId); + const target = this.requireTargetForImplicitUse(targetId); try { const value = await this.pool.addProjectForTarget(target, rootPath); const project = coerceConnectionProject(value); this.upsertProject(targetId, project); + this.clearAutomaticReconnectBudget(targetId); return project; } catch (error) { this.mergeStatus(targetId, { state: "error", - lastError: errorMessage(error), + lastError: this.recordImplicitFailure(targetId, error), + lastAttemptedAt: Date.now(), + }); + throw error; + } + } + + async ensurePortForward( + targetId: string, + request: RemoteRuntimePortForwardRequest, + ): Promise { + const target = this.requireTargetForImplicitUse(targetId); + await this.connect(target.id); + try { + return await this.pool.ensureLocalPortForward(target.id, request); + } catch (error) { + this.mergeStatus(targetId, { + state: "error", + lastError: this.recordImplicitFailure(targetId, error), lastAttemptedAt: Date.now(), }); throw error; @@ -331,6 +462,155 @@ export class RemoteConnectionService { )) as ListMyGitHubReposResult; } + async callAction( + targetId: string, + projectId: string, + request: RemoteRuntimeActionRequest, + ): Promise { + const target = this.requireTargetForImplicitUse(targetId); + try { + const result = await this.pool.callActionForTarget( + target, + projectId, + request, + ); + this.mergeStatus(targetId, { + state: "connected", + lastError: null, + lastAttemptedAt: Date.now(), + }); + this.clearAutomaticReconnectBudget(targetId); + return result; + } catch (error) { + this.mergeStatus(targetId, { + state: "error", + lastError: this.recordImplicitFailure(targetId, error), + lastAttemptedAt: Date.now(), + }); + throw error; + } + } + + async streamEvents( + targetId: string, + projectId: string, + request: RemoteRuntimeStreamEventsRequest = {}, + ): Promise { + const target = this.requireTargetForImplicitUse(targetId); + try { + const result = await this.pool.streamEventsForTarget( + target, + projectId, + request, + ); + this.mergeStatus(targetId, { + state: "connected", + lastError: null, + lastAttemptedAt: Date.now(), + }); + this.clearAutomaticReconnectBudget(targetId); + return result; + } catch (error) { + this.mergeStatus(targetId, { + state: "error", + lastError: this.recordImplicitFailure(targetId, error), + lastAttemptedAt: Date.now(), + }); + throw error; + } + } + + async subscribeEvents( + targetId: string, + projectId: string, + request: RemoteRuntimeStreamEventsRequest = {}, + onEvent: (event: RemoteRuntimeBufferedEvent) => void, + onEnded?: () => void, + ): Promise<() => void> { + const target = this.requireTargetForImplicitUse(targetId); + try { + const cleanup = await this.pool.subscribeEventsForTarget( + target, + projectId, + request, + onEvent, + onEnded, + ); + this.mergeStatus(targetId, { + state: "connected", + lastError: null, + lastAttemptedAt: Date.now(), + }); + this.clearAutomaticReconnectBudget(targetId); + return cleanup; + } catch (error) { + this.mergeStatus(targetId, { + state: "error", + lastError: this.recordImplicitFailure(targetId, error), + lastAttemptedAt: Date.now(), + }); + throw error; + } + } + + async listActionRegistry( + targetId: string, + projectId: string, + ): Promise { + const target = this.requireTargetForImplicitUse(targetId); + try { + const registry = await this.pool.listActionRegistryForTarget( + target, + projectId, + ); + this.mergeStatus(targetId, { + state: "connected", + lastError: null, + lastAttemptedAt: Date.now(), + }); + this.clearAutomaticReconnectBudget(targetId); + return registry; + } catch (error) { + this.mergeStatus(targetId, { + state: "error", + lastError: this.recordImplicitFailure(targetId, error), + lastAttemptedAt: Date.now(), + }); + throw error; + } + } + + async callSync( + targetId: string, + projectId: string, + method: string, + params: Record, + ): Promise { + const target = this.requireTargetForImplicitUse(targetId); + try { + const result = await this.pool.callSyncForTarget( + target, + projectId, + method, + params, + ); + this.mergeStatus(targetId, { + state: "connected", + lastError: null, + lastAttemptedAt: Date.now(), + }); + this.clearAutomaticReconnectBudget(targetId); + return result; + } catch (error) { + this.mergeStatus(targetId, { + state: "error", + lastError: this.recordImplicitFailure(targetId, error), + lastAttemptedAt: Date.now(), + }); + throw error; + } + } + dispose(): void { this.stopAutoconnect(); this.pool.dispose(); @@ -341,13 +621,21 @@ export class RemoteConnectionService { options: { pingTimeoutMs?: number } = {}, ): Promise { for (const target of this.registry.list()) { + if (!shouldAutoconnectTarget(target)) continue; + if (this.manuallyDisconnectedTargetIds.has(target.id)) continue; + if (this.automaticReconnectPausedTargetIds.has(target.id)) continue; const status = this.statusById.get(target.id); if (status?.state === "connecting") continue; if (status?.state === "connected") { try { - await this.pool.callMachineForTarget(target, "ping", {}, { - timeoutMs: options.pingTimeoutMs, - }); + await this.pool.callMachineForTarget( + target, + "ping", + {}, + { + timeoutMs: options.pingTimeoutMs, + }, + ); continue; } catch { this.pool.disconnect(target.id); @@ -367,6 +655,7 @@ export class RemoteConnectionService { params: Record, options: { retryOnConnectionError?: boolean } = {}, ): Promise { + this.assertImplicitReconnectAllowed(target.id); try { const result = await this.pool.callMachineForTarget( target, @@ -378,11 +667,12 @@ export class RemoteConnectionService { if (current?.state !== "connected") { this.mergeStatus(target.id, { state: "connected", lastError: null }); } + this.clearAutomaticReconnectBudget(target.id); return result; } catch (error) { this.mergeStatus(target.id, { state: "error", - lastError: errorMessage(error), + lastError: this.recordImplicitFailure(target.id, error), lastAttemptedAt: Date.now(), }); throw error; @@ -395,6 +685,55 @@ export class RemoteConnectionService { return target; } + private requireTargetForImplicitUse(targetId: string): RemoteRuntimeTarget { + const target = this.requireTarget(targetId); + this.assertImplicitReconnectAllowed(target.id); + return target; + } + + private assertImplicitReconnectAllowed(targetId: string): void { + const target = this.registry.get(targetId); + if ( + this.manuallyDisconnectedTargetIds.has(targetId) || + target?.manuallyDisconnectedAt != null + ) { + throw new Error( + "Remote machine was manually disconnected. Connect again to use this remote project.", + ); + } + if (this.automaticReconnectPausedTargetIds.has(targetId)) { + throw new Error(automaticReconnectStoppedMessage()); + } + } + + private clearAutomaticReconnectBudget(targetId: string): void { + this.automaticReconnectFailuresByTargetId.delete(targetId); + this.automaticReconnectPausedTargetIds.delete(targetId); + } + + private recordImplicitFailure(targetId: string, error: unknown): string { + if (!isImplicitConnectionFailure(error)) return errorMessage(error); + if (isConnectionBackoffThrottle(error)) return errorMessage(error); + return this.noteAutomaticReconnectFailure(targetId, error); + } + + private noteAutomaticReconnectFailure( + targetId: string, + error: unknown, + ): string { + if (this.automaticReconnectPausedTargetIds.has(targetId)) { + return automaticReconnectStoppedMessage(); + } + const failureCount = + (this.automaticReconnectFailuresByTargetId.get(targetId) ?? 0) + 1; + this.automaticReconnectFailuresByTargetId.set(targetId, failureCount); + if (failureCount >= AUTOMATIC_RECONNECT_FAILURE_LIMIT) { + this.automaticReconnectPausedTargetIds.add(targetId); + return automaticReconnectStoppedMessage(); + } + return errorMessage(error); + } + private upsertProject( targetId: string, project: RemoteRuntimeProjectRecord, @@ -425,6 +764,24 @@ export class RemoteConnectionService { this.emit(); } + private getDisconnectGeneration(targetId: string): number { + return this.disconnectGenerationByTargetId.get(targetId) ?? 0; + } + + private bumpDisconnectGeneration(targetId: string): void { + this.disconnectGenerationByTargetId.set( + targetId, + this.getDisconnectGeneration(targetId) + 1, + ); + } + + private isDisconnectGenerationCurrent( + targetId: string, + generation: number, + ): boolean { + return this.getDisconnectGeneration(targetId) === generation; + } + private emit(): void { if (this.listeners.size === 0) return; const snapshot = this.snapshot(); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.test.ts index b7f376c69..9812b7284 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.test.ts @@ -18,8 +18,8 @@ describe("RemoteTargetRegistry", () => { const registry = new RemoteTargetRegistry(); const target = registry.save({ - name: "Mac Studio", - hostname: "100.75.20.63", + name: "Build Server", + hostname: "203.0.113.10", sshUser: "admin", port: null, sshKeyPath: null, @@ -38,14 +38,14 @@ describe("RemoteTargetRegistry", () => { const registry = new RemoteTargetRegistry(); const target = registry.save({ - name: "Mac Studio", - hostname: "studio.tailnet.ts.net", + name: "Build Server", + hostname: "studio.tailnet.example", sshUser: "admin", port: null, sshKeyPath: null, routes: [ { - hostname: "studio.tailnet.ts.net", + hostname: "studio.tailnet.example", port: null, source: "tailscale", lastSucceededAt: null, @@ -61,7 +61,7 @@ describe("RemoteTargetRegistry", () => { expect(target.routes).toEqual([ { - hostname: "studio.tailnet.ts.net", + hostname: "studio.tailnet.example", port: null, source: "tailscale", lastSucceededAt: null, @@ -75,4 +75,29 @@ describe("RemoteTargetRegistry", () => { ]); expect(registry.list()[0]?.routes).toEqual(target.routes); }); + + it("round-trips the manual disconnect marker", () => { + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-remote-targets-")); + process.env.ADE_HOME = adeHome; + + const registry = new RemoteTargetRegistry(); + const target = registry.save({ + name: "Build Server", + hostname: "203.0.113.10", + sshUser: "admin", + port: 22, + sshKeyPath: null, + }); + + registry.update(target.id, { + lastConnectedAt: 1_700_000_000, + manuallyDisconnectedAt: 1_700_000_100, + }); + + const restored = new RemoteTargetRegistry().get(target.id); + expect(restored).toMatchObject({ + lastConnectedAt: 1_700_000_000, + manuallyDisconnectedAt: 1_700_000_100, + }); + }); }); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.ts b/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.ts index 31cb7dee6..26b15a099 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.ts @@ -140,6 +140,7 @@ function coerceTarget(value: unknown): RemoteRuntimeTarget | null { lastSeenArch: typeof record.lastSeenArch === "string" && record.lastSeenArch.trim() ? record.lastSeenArch.trim() : null, runtimeBinaryVersion: typeof record.runtimeBinaryVersion === "string" && record.runtimeBinaryVersion.trim() ? record.runtimeBinaryVersion.trim() : null, lastConnectedAt: typeof record.lastConnectedAt === "number" && Number.isFinite(record.lastConnectedAt) ? record.lastConnectedAt : null, + manuallyDisconnectedAt: typeof record.manuallyDisconnectedAt === "number" && Number.isFinite(record.manuallyDisconnectedAt) ? record.manuallyDisconnectedAt : null, }; } @@ -176,6 +177,7 @@ export class RemoteTargetRegistry { lastSeenArch: existing?.lastSeenArch ?? null, runtimeBinaryVersion: existing?.runtimeBinaryVersion ?? null, lastConnectedAt: existing?.lastConnectedAt ?? null, + manuallyDisconnectedAt: existing?.manuallyDisconnectedAt ?? null, }; file.targets = [next, ...file.targets.filter((target) => target.id !== id)]; this.write(file); diff --git a/apps/desktop/src/main/services/remoteRuntime/retryableRemoteActions.ts b/apps/desktop/src/main/services/remoteRuntime/retryableRemoteActions.ts new file mode 100644 index 000000000..9276e4b6b --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/retryableRemoteActions.ts @@ -0,0 +1,34 @@ +export const RETRYABLE_REMOTE_ACTION_PREFIXES = [ + "diagnosticsGet", + "get", + "list", + "oauthGet", + "oauthList", + "portGet", + "portList", + "proxyGet", + "read", + "search", +] as const; + +export const RETRYABLE_REMOTE_ACTIONS = new Set([ + "chat.codexFuzzyFileSearch", + "chat.fileSearch", + "chat.modelCatalog", + "file.listTreeChildren", + "file.quickOpen", + "file.readFileRange", + "file.refreshGitDecorations", + "terminal.activeForChat", + "terminal.preview", +]); + +export function isRetryableRemoteAction(domain: string, action: string): boolean { + if (RETRYABLE_REMOTE_ACTIONS.has(`${domain}.${action}`)) { + return true; + } + return RETRYABLE_REMOTE_ACTION_PREFIXES.some((prefix) => + action.startsWith(prefix), + ); +} + diff --git a/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts index 4618acf9e..02a61efb2 100644 --- a/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts @@ -21,9 +21,9 @@ describe("runtimeDiscovery", () => { projects: "project-a, project-b", projectCount: "2", host: "192.168.1.42", - addresses: "127.0.0.1,100.75.20.63", - tailscaleDnsName: "studio.tailnet.ts.net", - tailscaleIp: "100.75.20.63", + addresses: "127.0.0.1,100.64.0.10", + tailscaleDnsName: "studio.tail000000.ts.net", + tailscaleIp: "100.64.0.10", }, }, 1234, @@ -36,9 +36,9 @@ describe("runtimeDiscovery", () => { hostIdentity: "device-123", hostName: "studio.local", port: 8787, - addresses: ["192.168.1.42", "100.75.20.63", "127.0.0.1"], + addresses: ["192.168.1.42", "100.64.0.10", "127.0.0.1"], primaryRoute: "192.168.1.42", - tailscaleAddress: "studio.tailnet.ts.net", + tailscaleAddress: "100.64.0.10", runtimeKind: "daemon", runtimeVersion: "0.0.0", projectIds: ["project-a", "project-b"], @@ -84,10 +84,10 @@ describe("runtimeDiscovery", () => { Peer: { "nodekey:abc": { ID: "peer-1", - HostName: "aruls-mac-studio", - DNSName: "aruls-mac-studio.tail7497a6.ts.net.", + HostName: "build-studio", + DNSName: "build-studio.tail000000.ts.net.", OS: "macOS", - TailscaleIPs: ["100.75.20.63", "fd7a:115c:a1e0::1"], + TailscaleIPs: ["100.64.0.10", "fd7a:115c:a1e0::1"], Online: true, }, }, @@ -99,13 +99,13 @@ describe("runtimeDiscovery", () => { expect(discovered[0]).toMatchObject({ id: "tailscale:peer-1", serviceName: "Tailscale peer", - machineName: "aruls-mac-studio", + machineName: "build-studio", hostIdentity: "peer-1", - hostName: "aruls-mac-studio", + hostName: "build-studio", port: 22, - addresses: ["100.75.20.63", "aruls-mac-studio.tail7497a6.ts.net"], - primaryRoute: "aruls-mac-studio.tail7497a6.ts.net", - tailscaleAddress: "aruls-mac-studio.tail7497a6.ts.net", + addresses: ["100.64.0.10", "build-studio.tail000000.ts.net"], + primaryRoute: "100.64.0.10", + tailscaleAddress: "100.64.0.10", runtimeKind: "tailscale-peer", runtimeVersion: null, projectIds: [], @@ -121,17 +121,17 @@ describe("runtimeDiscovery", () => { "nodekey:iphone": { ID: "peer-phone", HostName: "iPhone", - DNSName: "iphone.tail7497a6.ts.net.", + DNSName: "iphone.tail000000.ts.net.", OS: "iOS", - TailscaleIPs: ["100.75.20.64"], + TailscaleIPs: ["100.64.0.11"], Online: true, }, "nodekey:mac": { ID: "peer-mac", HostName: "studio", - DNSName: "studio.tail7497a6.ts.net.", + DNSName: "studio.tail000000.ts.net.", OS: "macOS", - TailscaleIPs: ["100.75.20.63"], + TailscaleIPs: ["100.64.0.10"], Online: true, }, }, diff --git a/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts index cc593eb36..b93dae159 100644 --- a/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts +++ b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts @@ -152,7 +152,7 @@ export function discoveredRuntimeFromBonjourService( const port = servicePort ?? 8787; const announcedAddresses = splitCsv(txt.addresses); const tailscaleAddress = firstNonEmpty( - [txt.tailscaleDnsName, txt.tailscaleIp].filter((value): value is string => + [txt.tailscaleIp, txt.tailscaleDnsName].filter((value): value is string => Boolean(value && isTailscaleRoute(value)), ), ); @@ -218,7 +218,7 @@ export function discoveredRuntimesFromTailscaleStatus( ) : []; const dnsName = normalizeTailscaleDnsName(peer.DNSName); - const tailscaleAddress = firstNonEmpty([dnsName, tailscaleIps[0]]); + const tailscaleAddress = firstNonEmpty([tailscaleIps[0], dnsName]); if (!tailscaleAddress) continue; const hostName = trimmed(peer.HostName) ?? dnsName; diff --git a/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts b/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts index 3345213f4..56ef432b0 100644 --- a/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts +++ b/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts @@ -205,7 +205,11 @@ export class RuntimeRpcClient { this.closedError = error; this.rejectAll(error); for (const callback of this.disconnectCallbacks) { - callback(error); + try { + callback(error); + } catch { + // Disconnect observers are best-effort; the connection is already failed. + } } this.disconnectCallbacks.clear(); } diff --git a/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts b/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts index 694829948..fed97abba 100644 --- a/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts @@ -1,7 +1,10 @@ import fs from "node:fs"; import { createHmac } from "node:crypto"; +import net from "node:net"; import os from "node:os"; import path from "node:path"; +import { PassThrough } from "node:stream"; +import type { Client } from "ssh2"; import { afterEach, describe, expect, it } from "vitest"; import type { RemoteRuntimeTarget } from "../../../shared/types/remoteRuntime"; import { @@ -9,9 +12,11 @@ import { buildSshConfigCandidates, buildSshRouteCandidates, buildSshUsernameCandidates, + execSsh, getSshHostKeyTrustForTarget, hasKnownSshHostKeyForTarget, parseOpenSshHostConfig, + scanSshHostKeyForTarget, trustSshHostKeyForTarget, type ScannedSshHostKey, } from "./sshTransport"; @@ -85,8 +90,8 @@ describe("buildSshConfig", () => { host: "remote.example.test", port: 22, username: "ade", - readyTimeout: 20_000, - keepaliveInterval: 15_000, + readyTimeout: 10_000, + keepaliveInterval: 0, keepaliveCountMax: 3, agent: "/tmp/ade-agent.sock", }); @@ -140,7 +145,7 @@ describe("buildSshConfig", () => { it("builds an admin retry candidate when no SSH user is configured", () => { expect(buildSshUsernameCandidates({ ...target, - hostname: "100.75.20.63", + hostname: "203.0.113.10", sshUser: null, port: null, }, { @@ -169,7 +174,7 @@ describe("buildSshConfig", () => { it("builds retry configs with distinct SSH usernames", () => { const configs = buildSshConfigCandidates({ ...target, - hostname: "100.75.20.63", + hostname: "203.0.113.10", sshUser: null, port: null, }, { @@ -178,13 +183,13 @@ describe("buildSshConfig", () => { }); expect(configs.map((config) => config.username)).toEqual(Array.from(new Set([os.userInfo().username, "admin"]))); - expect(configs.every((config) => config.host === "100.75.20.63" && config.port === 22)).toBe(true); + expect(configs.every((config) => config.host === "203.0.113.10" && config.port === 22)).toBe(true); }); it("tries saved route fallbacks and prioritizes the last successful route", () => { const configs = buildSshConfigCandidates({ ...target, - hostname: "studio.tailnet.ts.net", + hostname: "studio.tailnet.example", sshUser: null, port: null, routes: [ @@ -195,7 +200,7 @@ describe("buildSshConfig", () => { lastSucceededAt: 200, }, { - hostname: "studio.tailnet.ts.net", + hostname: "studio.tailnet.example", port: null, source: "tailscale", lastSucceededAt: 100, @@ -209,11 +214,11 @@ describe("buildSshConfig", () => { const usernames = Array.from(new Set([os.userInfo().username, "admin"])); expect(configs.map((config) => config.host)).toEqual([ ...usernames.map(() => "192.168.1.42"), - ...usernames.map(() => "studio.tailnet.ts.net"), + ...usernames.map(() => "studio.tailnet.example"), ]); expect(buildSshRouteCandidates({ ...target, - hostname: "studio.tailnet.ts.net", + hostname: "studio.tailnet.example", routes: [ { hostname: "192.168.1.42", @@ -224,7 +229,7 @@ describe("buildSshConfig", () => { ], }).map((route) => route.hostname)).toEqual([ "192.168.1.42", - "studio.tailnet.ts.net", + "studio.tailnet.example", ]); }); @@ -478,6 +483,72 @@ describe("buildSshConfig", () => { }); }); + it("explains SSH servers that close before the handshake during host-key scan", async () => { + const server = net.createServer((socket) => { + socket.destroy(); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("Expected TCP test server to bind an address."); + } + + try { + await expect(scanSshHostKeyForTarget({ + ...target, + hostname: "127.0.0.1", + port: address.port, + }, { + env: {}, + sshConfigPath: null, + })).rejects.toThrow( + `SSH server at 127.0.0.1:${address.port} closed the connection before ADE could finish the SSH handshake.`, + ); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it("times out SSH sockets that never finish the handshake", async () => { + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.once("close", () => { + sockets.delete(socket); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("Expected TCP test server to bind an address."); + } + + try { + await expect(scanSshHostKeyForTarget({ + ...target, + hostname: "127.0.0.1", + port: address.port, + }, { + env: { ADE_REMOTE_SSH_CONNECT_TIMEOUT_MS: "75" } as NodeJS.ProcessEnv, + knownHostsPath: null, + sshConfigPath: null, + })).rejects.toThrow( + `Timed out while waiting for the SSH handshake from 127.0.0.1:${address.port}.`, + ); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it("rejects unknown SSH host keys by default", () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-ssh-home-")); const config = buildSshConfig(target, { @@ -494,6 +565,39 @@ describe("buildSshConfig", () => { }); }); +describe("execSsh", () => { + it("times out and closes a remote exec channel that never finishes", async () => { + let closed = false; + let destroyed = false; + const stream = new PassThrough() as PassThrough & { + stderr: PassThrough; + close: () => void; + destroy: () => PassThrough; + }; + stream.stderr = new PassThrough(); + stream.close = () => { + closed = true; + stream.emit("close"); + }; + const originalDestroy = stream.destroy.bind(stream); + stream.destroy = () => { + destroyed = true; + originalDestroy(); + return stream; + }; + const client = { + exec(_command: string, callback: (error: Error | null, channel: typeof stream) => void) { + callback(null, stream); + }, + } as unknown as Client; + + await expect(execSsh(client, "sleep 1000", { timeoutMs: 75 })).rejects.toThrow( + "Timed out waiting for SSH command to finish after 75ms.", + ); + expect(closed || destroyed).toBe(true); + }); +}); + describe("parseOpenSshHostConfig", () => { it("keeps the first matching value and supports wildcard blocks", () => { expect(parseOpenSshHostConfig([ diff --git a/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts b/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts index fb709217a..127af74e7 100644 --- a/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts +++ b/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts @@ -20,6 +20,13 @@ export type SshExecResult = { }; const MAX_SSH_EXEC_OUTPUT_BYTES = 8 * 1024 * 1024; +const DEFAULT_SSH_EXEC_TIMEOUT_MS = 30_000; +const MIN_SSH_EXEC_TIMEOUT_MS = 50; + +type ExecSshOptions = { + timeoutMs?: number; + env?: NodeJS.ProcessEnv; +}; type OpenSshHostConfig = { hostName?: string; @@ -39,6 +46,22 @@ export type BuildSshConfigOptions = { export type ConnectedSshRoute = RemoteRuntimeTargetRoute; +export type OpenSshResolvedConfig = { + host: string; + port: number; + username: string; + identityFile: string | null; + knownHostsPath: string | null; + hostAliases: string[]; +}; + +export type ConnectedSshSession = { + client: Client; + route: ConnectedSshRoute; + config: ConnectConfig; + openSshConfig: OpenSshResolvedConfig; +}; + type KnownHostEntry = { marker: string | null; hosts: string; @@ -79,6 +102,8 @@ const DEFAULT_IDENTITY_FILES = [ "id_ecdsa_sk", "id_rsa", ]; +const DEFAULT_SSH_CONNECT_ATTEMPT_TIMEOUT_MS = 10_000; +const MIN_SSH_CONNECT_ATTEMPT_TIMEOUT_MS = 50; function stripInlineComment(line: string): string { const hashIndex = line.indexOf("#"); @@ -533,17 +558,12 @@ export async function trustSshHostKeyForTarget( }; } -export function buildSshConfig(target: RemoteRuntimeTarget, options: BuildSshConfigOptions = {}): ConnectConfig { +function resolveOpenSshConfig( + target: RemoteRuntimeTarget, + options: BuildSshConfigOptions = {}, +): OpenSshResolvedConfig { const hostConfig = readOpenSshHostConfig(target, options); const endpoint = resolveSshEndpoint(target, options); - const config: ConnectConfig = { - host: endpoint.host, - port: endpoint.port, - username: endpoint.username, - readyTimeout: 20_000, - keepaliveInterval: 15_000, - keepaliveCountMax: 3, - }; const identityFile = target.sshKeyPath ?? (hostConfig.identityFile ? expandSshPath(hostConfig.identityFile, { host: endpoint.host, @@ -552,19 +572,58 @@ export function buildSshConfig(target: RemoteRuntimeTarget, options: BuildSshCon homeDir: endpoint.homeDir, }) : null) ?? firstReadableDefaultIdentity(endpoint.homeDir); - if (identityFile) { - config.privateKey = fs.readFileSync(identityFile); + return { + host: endpoint.host, + port: endpoint.port, + username: endpoint.username, + identityFile, + knownHostsPath: endpoint.knownHostsPath, + hostAliases: endpoint.hostAliases, + }; +} + +export function buildSshConfig(target: RemoteRuntimeTarget, options: BuildSshConfigOptions = {}): ConnectConfig { + const endpoint = resolveOpenSshConfig(target, options); + const env = options.env ?? process.env; + const config: ConnectConfig = { + host: endpoint.host, + port: endpoint.port, + username: endpoint.username, + readyTimeout: sshConnectAttemptTimeoutMs(env), + // Runtime RPC calls and artifact uploads have their own timeouts. SSH-level + // keepalives can starve behind large channel writes and close otherwise + // healthy uploads, so keep the transport-level probe disabled. + keepaliveInterval: 0, + keepaliveCountMax: 3, + }; + if (endpoint.identityFile) { + config.privateKey = fs.readFileSync(endpoint.identityFile); } const knownHostEntries = readKnownHostEntries(endpoint.knownHostsPath); const candidates = knownHostCandidates(endpoint.hostAliases, endpoint.port); config.hostVerifier = (key: Buffer) => knownHostsAllowKey(knownHostEntries, candidates, key); - const env = options.env ?? process.env; if (env.SSH_AUTH_SOCK) { config.agent = env.SSH_AUTH_SOCK; } return config; } +function sshConnectAttemptTimeoutMs(env: NodeJS.ProcessEnv): number { + const raw = env.ADE_REMOTE_SSH_CONNECT_TIMEOUT_MS; + const parsed = raw == null ? NaN : Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed >= MIN_SSH_CONNECT_ATTEMPT_TIMEOUT_MS + ? parsed + : DEFAULT_SSH_CONNECT_ATTEMPT_TIMEOUT_MS; +} + +function sshExecTimeoutMs(options: ExecSshOptions = {}): number { + const raw = options.timeoutMs ?? options.env?.ADE_REMOTE_SSH_EXEC_TIMEOUT_MS ?? process.env.ADE_REMOTE_SSH_EXEC_TIMEOUT_MS; + const parsed = raw == null ? NaN : typeof raw === "number" ? raw : Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed >= MIN_SSH_EXEC_TIMEOUT_MS + ? parsed + : DEFAULT_SSH_EXEC_TIMEOUT_MS; +} + function uniqueUsernames(values: Array): string[] { const seen = new Set(); const result: string[] = []; @@ -683,41 +742,223 @@ function isSshAuthenticationFailure(error: unknown): boolean { (typeof candidate.message === "string" && /authentication/i.test(candidate.message)); } +function sshEndpointLabel(config: ConnectConfig): string { + const host = typeof config.host === "string" && config.host.trim() + ? config.host.trim() + : "remote host"; + const port = typeof config.port === "number" && Number.isFinite(config.port) + ? config.port + : 22; + return `${host}:${port}`; +} + +function copySshErrorMetadata(source: unknown, target: Error): Error { + if (!source || typeof source !== "object") return target; + const candidate = source as { + code?: unknown; + errno?: unknown; + syscall?: unknown; + level?: unknown; + }; + for (const key of ["code", "errno", "syscall", "level"] as const) { + if (candidate[key] !== undefined) { + Object.defineProperty(target, key, { + configurable: true, + enumerable: false, + value: candidate[key], + }); + } + } + Object.defineProperty(target, "cause", { + configurable: true, + enumerable: false, + value: source, + }); + return target; +} + +function normalizeSshConnectError( + error: unknown, + fallback: string, + config: ConnectConfig, +): Error { + const source = error instanceof Error + ? error + : new Error(String(error ?? fallback)); + const message = source.message || fallback; + const endpoint = sshEndpointLabel(config); + const sourceMetadata = source as Error & { code?: unknown }; + const code = typeof sourceMetadata.code === "string" + ? sourceMetadata.code + : ""; + + if ( + code === "ECONNRESET" || + /(?:^|\s)ECONNRESET(?:\s|$)|connection reset|connection closed by remote host|socket closed|connection lost before handshake/i.test(message) || + /closed before (?:it became ready|ADE could complete the SSH handshake)/i.test(fallback) + ) { + return copySshErrorMetadata( + source, + new Error( + `SSH server at ${endpoint} closed the connection before ADE could finish the SSH handshake. ` + + "Check that Remote Login/sshd is enabled on the remote machine and that its firewall or Tailscale SSH policy allows this client.", + ), + ); + } + + if (/timed out while waiting for handshake|handshake.*timed out|ready timeout/i.test(message)) { + return copySshErrorMetadata( + source, + new Error( + `Timed out while waiting for the SSH handshake from ${endpoint}. ` + + "Check that the machine is online, reachable over this route, and accepting SSH connections.", + ), + ); + } + + if (isLocalPortUnavailableError(source)) { + return copySshErrorMetadata( + source, + new Error( + `macOS could not allocate a local TCP port for SSH to ${endpoint}. ` + + "Quit apps that are rapidly opening network connections and try again.", + ), + ); + } + + return source; +} + +function isLocalPortUnavailableError(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const candidate = error as { code?: unknown; message?: unknown }; + return candidate.code === "EADDRNOTAVAIL" || + candidate.code === "EADDRINUSE" || + (typeof candidate.message === "string" && /EADDRNOTAVAIL|EADDRINUSE|can't assign requested address|address already in use/i.test(candidate.message)); +} + function connectSshWithConfig(config: ConnectConfig): Promise { return new Promise((resolve, reject) => { const client = new Client(); - client.once("ready", () => resolve(client)); - client.once("error", reject); - client.connect(config); + let settled = false; + let connectTimer: NodeJS.Timeout | null = null; + const connectTimeoutMs = + typeof config.readyTimeout === "number" && Number.isFinite(config.readyTimeout) + ? Math.max(MIN_SSH_CONNECT_ATTEMPT_TIMEOUT_MS, config.readyTimeout) + : DEFAULT_SSH_CONNECT_ATTEMPT_TIMEOUT_MS; + const clearConnectTimer = (): void => { + if (!connectTimer) return; + clearTimeout(connectTimer); + connectTimer = null; + }; + const destroyClient = (): void => { + try { + client.end(); + } catch {} + try { + (client as unknown as { destroy?: () => void }).destroy?.(); + } catch {} + try { + (client as unknown as { _sock?: { destroy?: () => void } })._sock?.destroy?.(); + } catch {} + }; + const cleanupConnectListeners = (options: { keepErrorListener?: boolean } = {}): void => { + clearConnectTimer(); + client.off("ready", onReady); + if (!options.keepErrorListener) { + client.off("error", onError); + } + client.off("close", onClose); + client.off("end", onEnd); + }; + const fail = (error: unknown, fallback: string): void => { + if (settled) return; + settled = true; + // ssh2 may emit a late socket error after close/end during a failed + // connect attempt. Keep onError attached as a sink so the late error is + // contained after the promise has already rejected. + cleanupConnectListeners({ keepErrorListener: true }); + destroyClient(); + reject(normalizeSshConnectError(error, fallback, config)); + }; + const onTimeout = (): void => { + const timeoutError = new Error( + `Timed out while waiting for handshake after ${connectTimeoutMs}ms.`, + ); + fail(timeoutError, "Timed out while waiting for handshake."); + }; + const onReady = (): void => { + if (settled) return; + settled = true; + cleanupConnectListeners(); + // ssh2 can surface transport resets after ready and before the remote + // pool attaches its lifecycle listener. Keep a durable listener so those + // errors remain contained instead of becoming process-level exceptions. + client.on("error", () => {}); + resolve(client); + }; + const onError = (error: Error): void => { + fail(error, "SSH connection failed."); + }; + const onClose = (): void => { + fail(null, "SSH connection closed before it became ready."); + }; + const onEnd = (): void => { + fail(null, "SSH connection ended before it became ready."); + }; + client.once("ready", onReady); + client.on("error", onError); + client.once("close", onClose); + client.once("end", onEnd); + connectTimer = setTimeout(onTimeout, connectTimeoutMs); + connectTimer.unref?.(); + try { + client.connect(config); + } catch (error) { + fail(error, "SSH connection failed."); + } }); } function buildSshConnectionCandidates( target: RemoteRuntimeTarget, options: BuildSshConfigOptions = {}, -): Array<{ config: ConnectConfig; route: ConnectedSshRoute }> { +): Array<{ + config: ConnectConfig; + openSshConfig: OpenSshResolvedConfig; + route: ConnectedSshRoute; +}> { return buildSshRouteCandidates(target).flatMap((route) => { const routeTarget = targetForRoute(target, route); - return buildSshUsernameCandidates(routeTarget, options).map((username) => ({ - config: buildSshConfig(routeTarget, { + return buildSshUsernameCandidates(routeTarget, options).map((username) => { + const candidateOptions = { ...options, usernameOverride: username, - }), - route, - })); + }; + return { + config: buildSshConfig(routeTarget, candidateOptions), + openSshConfig: resolveOpenSshConfig(routeTarget, candidateOptions), + route, + }; + }); }); } export async function connectSshWithRoute( target: RemoteRuntimeTarget, -): Promise<{ client: Client; route: ConnectedSshRoute }> { +): Promise { const configs = buildSshConnectionCandidates(target); let lastError: unknown = null; for (let index = 0; index < configs.length; index += 1) { const candidate = configs[index]!; try { const client = await connectSshWithConfig(candidate.config); - return { client, route: candidate.route }; + return { + client, + route: candidate.route, + config: candidate.config, + openSshConfig: candidate.openSshConfig, + }; } catch (error) { lastError = error; if (index >= configs.length - 1) break; @@ -738,41 +979,76 @@ export async function connectSsh(target: RemoteRuntimeTarget): Promise { return (await connectSshWithRoute(target)).client; } -export function execSsh(client: Client, command: string): Promise { +export function execSsh(client: Client, command: string, options: ExecSshOptions = {}): Promise { return new Promise((resolve, reject) => { client.exec(command, (error, stream) => { if (error) { reject(error); return; } + let settled = false; let stdout = ""; let stderr = ""; let stdoutBytes = 0; let stderrBytes = 0; let code: number | null = null; + let timeout: NodeJS.Timeout | null = null; + const closeStream = (): void => { + try { + stream.close(); + } catch {} + try { + (stream as unknown as { destroy?: () => void }).destroy?.(); + } catch {} + }; + const clearTimer = (): void => { + if (!timeout) return; + clearTimeout(timeout); + timeout = null; + }; + const settle = (callback: () => void): void => { + if (settled) return; + settled = true; + clearTimer(); + callback(); + }; + const fail = (failure: Error): void => { + settle(() => { + closeStream(); + reject(failure); + }); + }; + const timeoutMs = sshExecTimeoutMs(options); + timeout = setTimeout(() => { + fail(new Error(`Timed out waiting for SSH command to finish after ${timeoutMs}ms.`)); + }, timeoutMs); + timeout.unref?.(); stream.on("data", (chunk: Buffer) => { + if (settled) return; stdoutBytes += chunk.byteLength; if (stdoutBytes > MAX_SSH_EXEC_OUTPUT_BYTES) { - reject(new Error(`SSH command stdout exceeded ${MAX_SSH_EXEC_OUTPUT_BYTES} bytes.`)); - stream.close(); + fail(new Error(`SSH command stdout exceeded ${MAX_SSH_EXEC_OUTPUT_BYTES} bytes.`)); return; } stdout += chunk.toString("utf8"); }); stream.stderr.on("data", (chunk: Buffer) => { + if (settled) return; stderrBytes += chunk.byteLength; if (stderrBytes > MAX_SSH_EXEC_OUTPUT_BYTES) { - reject(new Error(`SSH command stderr exceeded ${MAX_SSH_EXEC_OUTPUT_BYTES} bytes.`)); - stream.close(); + fail(new Error(`SSH command stderr exceeded ${MAX_SSH_EXEC_OUTPUT_BYTES} bytes.`)); return; } stderr += chunk.toString("utf8"); }); stream.on("exit", (exitCode: number | null) => { + if (settled) return; code = exitCode; }); - stream.on("close", () => resolve({ stdout, stderr, code })); - stream.on("error", reject); + stream.on("close", () => { + settle(() => resolve({ stdout, stderr, code })); + }); + stream.on("error", (streamError: Error) => fail(streamError)); }); }); } diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 9eb8c5f76..8e28bae4a 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -829,7 +829,10 @@ declare global { id: string, project: RemoteRuntimeProjectRecord, ) => Promise; - disconnect: (id: string) => Promise<{ disconnected: boolean }>; + disconnect: ( + id: string, + options?: { manual?: boolean }, + ) => Promise<{ disconnected: boolean }>; }; keybindings: { get: () => Promise; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index a3a64b907..1138fda93 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -1933,6 +1933,92 @@ describe("preload OAuth bridge", () => { } }); + it("ignores stale stream errors after switching event polling bindings", async () => { + vi.useFakeTimers(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const localBinding = { + kind: "local", + key: "local:/repo", + rootPath: "/repo", + displayName: "Project", + }; + const remoteBinding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/repo", + displayName: "Project", + }; + let binding: typeof localBinding | typeof remoteBinding = localBinding; + let rejectLocalStream = (_error: Error): void => { + throw new Error("local stream was not started"); + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: { rootPath: "/repo", displayName: "Project" }, binding }; + } + if (channel === IPC.localRuntimeStreamEvents) { + return await new Promise((_resolve, reject) => { + rejectLocalStream = reject; + }); + } + if (channel === IPC.remoteRuntimeStreamEvents) { + return { events: [], nextCursor: 0, hasMore: false }; + } + 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; + const unsubscribeState = bridge.project.onStateEvent(vi.fn()); + const unsubscribeBinding = bridge.app.onProjectBindingChanged(vi.fn()); + await vi.advanceTimersByTimeAsync(0); + + const localStreamCallCount = () => + invoke.mock.calls.filter(([channel]) => channel === IPC.localRuntimeStreamEvents).length; + const remoteStreamCallCount = () => + invoke.mock.calls.filter(([channel]) => channel === IPC.remoteRuntimeStreamEvents).length; + expect(localStreamCallCount()).toBe(1); + expect(remoteStreamCallCount()).toBe(0); + + const bindingListener = on.mock.calls.find(([channel]) => channel === IPC.appProjectBindingChanged)?.[1]; + expect(typeof bindingListener).toBe("function"); + binding = remoteBinding; + bindingListener({}, remoteBinding); + rejectLocalStream?.(new Error("Local runtime project is not available for this window.")); + await vi.advanceTimersByTimeAsync(0); + + expect(warn).not.toHaveBeenCalled(); + expect(remoteStreamCallCount()).toBe(1); + + unsubscribeBinding(); + unsubscribeState(); + } finally { + warn.mockRestore(); + vi.useRealTimers(); + } + }); + it("routes local project usage reads through the shared project runtime when bound", async () => { const binding = { kind: "local", @@ -3945,13 +4031,17 @@ describe("preload OAuth bridge", () => { const runtimeListener = on.mock.calls.find(([channel]) => channel === IPC.runtimeEvent)?.[1]; expect(typeof runtimeListener).toBe("function"); - const emit = (id: number, payload: Record) => { + const emit = ( + id: number, + payload: Record, + category = "runtime", + ) => { runtimeListener({}, { bindingKey: binding.key, event: { id, timestamp: `2026-05-10T12:00:${String(id).padStart(2, "0")}.000Z`, - category: "runtime", + category, payload, }, }); @@ -3994,9 +4084,9 @@ describe("preload OAuth bridge", () => { emit(1, { type: "usage", snapshot: usageSnapshot }); emit(3, { ...automationEvent, source: "automations" }); - emit(4, { type: "conflict_event", event: conflictEvent }); + emit(4, { type: "conflict_event", event: conflictEvent }, "dag_mutation"); emit(5, { type: "github_status_changed", event: githubStatus }); - emit(6, { type: "linear_workflow_event", event: linearWorkflowEvent }); + emit(6, { type: "linear_workflow_event", event: linearWorkflowEvent }, "orchestrator"); emit(7, { type: "feedback_submission_event", event: feedbackEvent }); emit(8, { type: "computer_use_event", event: computerUseEvent }); emit(9, { type: "ios_simulator_event", event: iosEvent }); @@ -4018,7 +4108,7 @@ describe("preload OAuth bridge", () => { expect(usageUpdate).toHaveBeenCalledTimes(1); }); - it("replays older remote runtime events during remote binding catch-up", async () => { + it("starts remote runtime event subscriptions without replaying buffered history", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-10T12:00:00.000Z")); try { @@ -4042,15 +4132,8 @@ describe("preload OAuth bridge", () => { } if (channel === IPC.remoteRuntimeStreamEvents) { return { - events: [ - { - id: 1, - timestamp: "2026-05-10T11:55:00.000Z", - category: "runtime", - payload: { type: "project_state_event", event: projectEvent }, - }, - ], - nextCursor: 1, + events: [], + nextCursor: 0, hasMore: false, }; } @@ -4084,9 +4167,151 @@ describe("preload OAuth bridge", () => { expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeStreamEvents, { id: "target-1", projectId: "project-1", - request: { cursor: 0, limit: 100 }, + request: { cursor: 0, limit: 100, replay: false }, }); - expect(callback).toHaveBeenCalledWith(projectEvent); + expect(callback).not.toHaveBeenCalledWith(projectEvent); + + unsubscribe(); + } finally { + vi.useRealTimers(); + } + }); + + it("backs off idle remote runtime event polling after empty batches", async () => { + vi.useFakeTimers(); + try { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const streamRequests: unknown[] = []; + const invoke = vi.fn(async (channel: string, arg?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeStreamEvents) { + streamRequests.push(arg); + return { + events: [], + nextCursor: 0, + hasMore: false, + eventEpoch: "epoch-a", + }; + } + return undefined; + }); + 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; + const unsubscribe = bridge.project.onStateEvent(vi.fn()); + + await vi.advanceTimersByTimeAsync(0); + expect(streamRequests).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(2_499); + expect(streamRequests).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + expect(streamRequests).toHaveLength(2); + + await vi.advanceTimersByTimeAsync(4_999); + expect(streamRequests).toHaveLength(2); + await vi.advanceTimersByTimeAsync(1); + expect(streamRequests).toHaveLength(3); + + unsubscribe(); + } finally { + vi.useRealTimers(); + } + }); + + it("stops remote event polling after disconnecting the active remote target", async () => { + vi.useFakeTimers(); + try { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const streamRequests: unknown[] = []; + const invoke = vi.fn(async (channel: string, arg?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeStreamEvents) { + streamRequests.push(arg); + return { + events: [], + nextCursor: 0, + hasMore: false, + eventEpoch: "epoch-a", + }; + } + if (channel === IPC.remoteRuntimeDisconnect) { + return { disconnected: true }; + } + return undefined; + }); + 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; + const unsubscribe = bridge.project.onStateEvent(vi.fn()); + + await vi.advanceTimersByTimeAsync(0); + expect(streamRequests).toHaveLength(1); + + await expect(bridge.remoteRuntime.disconnect("target-1")).resolves.toEqual({ + disconnected: true, + }); + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeDisconnect, { + id: "target-1", + manual: true, + }); + + await vi.advanceTimersByTimeAsync(20_000); + expect(streamRequests).toHaveLength(1); unsubscribe(); } finally { @@ -4199,7 +4424,7 @@ describe("preload OAuth bridge", () => { expect(streamRequests[1]).toEqual({ id: "target-2", projectId: "project-2", - request: { cursor: 0, limit: 100 }, + request: { cursor: 0, limit: 100, replay: false }, }); unsubscribeBinding(); @@ -4307,7 +4532,7 @@ describe("preload OAuth bridge", () => { { id: "target-1", projectId: "project-1", - request: { cursor: 0, limit: 100 }, + request: { cursor: 0, limit: 100, replay: false }, }, { id: "target-1", @@ -4317,7 +4542,7 @@ describe("preload OAuth bridge", () => { { id: "target-1", projectId: "project-1", - request: { cursor: 0, limit: 100 }, + request: { cursor: 0, limit: 100, replay: false }, }, ]); expect(callback).toHaveBeenCalledWith(oldEvent); @@ -4426,7 +4651,7 @@ describe("preload OAuth bridge", () => { { id: "target-1", projectId: "project-1", - request: { cursor: 0, limit: 100 }, + request: { cursor: 0, limit: 100, replay: false }, }, { id: "target-1", @@ -4436,7 +4661,7 @@ describe("preload OAuth bridge", () => { { id: "target-1", projectId: "project-1", - request: { cursor: 0, limit: 100 }, + request: { cursor: 0, limit: 100, replay: false }, }, ]); expect(callback).toHaveBeenCalledWith(oldEvent); @@ -5176,6 +5401,71 @@ describe("preload remote project binding", () => { }); }); + it("waits for remote project open before routing read-only project calls", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + let resolveOpen: (value: typeof binding) => void = () => {}; + const remoteRuntimeProjects: string[] = []; + const invoke = vi.fn(async (channel: string, payload?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding: null }; + } + if (channel === IPC.remoteRuntimeOpenProject) { + return await new Promise((resolve) => { + resolveOpen = resolve as (value: typeof binding) => void; + }); + } + if (channel === IPC.remoteRuntimeCallAction) { + remoteRuntimeProjects.push( + (payload as { projectId?: string } | undefined)?.projectId ?? "", + ); + return { result: [{ id: "lane-remote" }] }; + } + 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).__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; + + const pendingOpen = bridge.remoteRuntime.openProject("target-1", "project-1"); + const pendingLaneList = bridge.lanes.list(); + await Promise.resolve(); + + expect(invoke).not.toHaveBeenCalledWith( + IPC.remoteRuntimeCallAction, + expect.anything(), + ); + expect(invoke.mock.calls.some(([channel]) => channel === IPC.lanesList)) + .toBe(false); + + resolveOpen(binding); + await pendingOpen; + await expect(pendingLaneList).resolves.toEqual([{ id: "lane-remote" }]); + expect(remoteRuntimeProjects).toEqual(["project-1"]); + }); + it("blocks mutating sync calls while a remote project switch is in flight", async () => { let resolveOpen: (value: unknown) => void = () => {}; const binding = { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 9dab94555..a6b5bfdf0 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -657,8 +657,10 @@ import type { RemoteRuntimeConnectionSnapshot, RemoteRuntimeConnectResult, RemoteRuntimeDiscoveryResult, + RemoteRuntimeEventCategory, RemoteRuntimeEventNotificationPayload, RemoteRuntimeLocalWorkCheckResult, + RemoteRuntimePortForward, RemoteRuntimeProjectRecord, RemoteRuntimeSshHostKeyTrustStatus, RemoteRuntimeStreamEventsRequest, @@ -997,6 +999,7 @@ let projectBindingGeneration = 0; let projectBindingVersion = 0; let projectBindingRefreshPromise: Promise | null = null; let projectRuntimeTransitionDepth = 0; +let activeRemoteProjectOpenPromise: Promise | null = null; function rememberProjectBinding(binding: OpenProjectBinding | null): void { const previousKey = currentProjectBinding?.key ?? null; @@ -1136,6 +1139,33 @@ async function callRemoteProjectActionIfBound( return { handled: true, result: response.result as T }; } +function isValidPreviewTargetPort(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535; +} + +async function localizeRemoteLanePreviewInfo( + binding: Extract, + info: LanePreviewInfo | null, +): Promise { + if (!info) return null; + if (!isValidPreviewTargetPort(info.targetPort)) return info; + const forward = (await ipcRenderer.invoke(IPC.remoteRuntimeEnsurePortForward, { + id: binding.targetId, + request: { + remoteHost: "127.0.0.1", + remotePort: info.targetPort, + label: `${binding.displayName}:${info.laneId}`, + }, + })) as RemoteRuntimePortForward; + return { + ...info, + hostname: forward.localHost, + previewUrl: forward.localUrl, + proxyPort: forward.localPort, + active: true, + }; +} + async function callLocalProjectActionIfBound( domain: string, action: string, @@ -1250,6 +1280,7 @@ const PROJECT_SWITCHING_MESSAGE = let openRemoteProjectGeneration = 0; let activeRemoteProjectOpenGeneration: number | null = null; +const MAX_REMOTE_PROJECT_OPEN_REBIND_ATTEMPTS = 5; function isReadOnlyRuntimeAction(domain: string, action: string): boolean { const key = `${domain}.${action}`; @@ -1281,7 +1312,19 @@ function shouldBypassProjectRuntimeDuringTransition(domain: string, action: stri : "changing project state"; throw new Error(PROJECT_SWITCHING_MESSAGE.replace("changing project state", label)); } - return activeRemoteProjectOpenGeneration !== null; + return false; +} + +async function waitForRemoteProjectOpenIfActive(): Promise { + const generation = activeRemoteProjectOpenGeneration; + const promise = activeRemoteProjectOpenPromise; + if (generation == null || !promise) return false; + try { + await promise; + } catch { + return false; + } + return activeRemoteProjectOpenGeneration !== generation; } async function callProjectRuntimeActionIfBound( @@ -1304,6 +1347,19 @@ async function callProjectRuntimeActionIfBound( if (freshBinding && !isMutatingChatAction && projectRuntimeTransitionDepth > 0) { return { handled: false }; } + let rebindAttempts = 0; + while ( + activeRemoteProjectOpenGeneration !== null && + !isMutatingRuntimeAction(domain, action) && + await waitForRemoteProjectOpenIfActive() + ) { + rebindAttempts += 1; + if (rebindAttempts >= MAX_REMOTE_PROJECT_OPEN_REBIND_ATTEMPTS) { + throw new Error( + PROJECT_SWITCHING_MESSAGE.replace("changing project state", "reading project state"), + ); + } + } const remote = await callRemoteProjectActionIfBound( domain, action, @@ -1584,8 +1640,15 @@ let remoteRuntimeEventBindingKey: string | null = null; let remoteRuntimeEventGeneration = -1; let remoteRuntimeEventEpoch: string | null = null; let remoteRuntimeEventStartedAtMs = 0; +let remoteRuntimeEventReplaySuppressed = false; +let remoteRuntimeEmptyPollCount = 0; let remoteRuntimeSeenEventBindingKey: string | null = null; const remoteRuntimeSeenEventIds = new Set(); +const LOCAL_RUNTIME_EVENT_IDLE_POLL_MS = 750; +const REMOTE_RUNTIME_EVENT_ACTIVE_POLL_MS = 750; +const REMOTE_RUNTIME_EVENT_INITIAL_IDLE_POLL_MS = 2_500; +const REMOTE_RUNTIME_EVENT_IDLE_POLL_MS = 5_000; +const REMOTE_RUNTIME_EVENT_CATCH_UP_POLL_MS = 50; function clearPendingRemoteRuntimeEventPoll(): void { if (!remoteRuntimeEventTimer) return; @@ -1598,6 +1661,10 @@ function resetRemoteRuntimeEventDedup(bindingKey: string | null): void { remoteRuntimeSeenEventIds.clear(); } +function resetRemoteRuntimeEmptyPolls(): void { + remoteRuntimeEmptyPollCount = 0; +} + function shouldDispatchRemoteRuntimeEvent( bindingKey: string, event: RemoteRuntimeBufferedEvent, @@ -1699,6 +1766,8 @@ async function pollRemoteRuntimeEvents(): Promise { if (remoteRuntimeEventInFlight || !hasRemoteRuntimeEventSubscribers()) return; remoteRuntimeEventInFlight = true; let nextDelayMs: number | null = null; + let pollingBindingKey: string | null = null; + let pollingGeneration = projectBindingGeneration; try { const binding = await getProjectRuntimeBinding(); if (!binding) { @@ -1707,6 +1776,8 @@ async function pollRemoteRuntimeEvents(): Promise { remoteRuntimeEventGeneration = projectBindingGeneration; remoteRuntimeEventEpoch = null; remoteRuntimeEventStartedAtMs = 0; + remoteRuntimeEventReplaySuppressed = false; + resetRemoteRuntimeEmptyPolls(); resetRemoteRuntimeEventDedup(null); return; } @@ -1721,14 +1792,19 @@ async function pollRemoteRuntimeEvents(): Promise { remoteRuntimeEventEpoch = null; remoteRuntimeEventStartedAtMs = binding.kind === "local" ? Date.now() : 0; + remoteRuntimeEventReplaySuppressed = binding.kind === "remote"; + resetRemoteRuntimeEmptyPolls(); resetRemoteRuntimeEventDedup(binding.key); } - const pollingBindingKey = binding.key; - const pollingGeneration = projectBindingGeneration; + pollingBindingKey = binding.key; + pollingGeneration = projectBindingGeneration; const request = { cursor: remoteRuntimeEventCursor, limit: 100, + ...(binding.kind === "remote" && remoteRuntimeEventReplaySuppressed && remoteRuntimeEventCursor === 0 + ? { replay: false } + : {}), } satisfies RemoteRuntimeStreamEventsRequest; const batch = binding.kind === "remote" @@ -1761,6 +1837,8 @@ async function pollRemoteRuntimeEvents(): Promise { remoteRuntimeEventEpoch = batchEpoch; if (epochChanged) { remoteRuntimeEventCursor = 0; + remoteRuntimeEventReplaySuppressed = binding.kind === "remote"; + resetRemoteRuntimeEmptyPolls(); resetRemoteRuntimeEventDedup(binding.key); nextDelayMs = 0; return; @@ -1784,10 +1862,35 @@ async function pollRemoteRuntimeEvents(): Promise { if (!shouldDispatchRemoteRuntimeEvent(binding.key, event)) continue; dispatchRemoteRuntimeEventPayload(event.payload); } - nextDelayMs = batch.hasMore ? 50 : 750; + if (batch.hasMore) { + resetRemoteRuntimeEmptyPolls(); + nextDelayMs = REMOTE_RUNTIME_EVENT_CATCH_UP_POLL_MS; + } else if (batch.events.length > 0) { + resetRemoteRuntimeEmptyPolls(); + nextDelayMs = + binding.kind === "remote" + ? REMOTE_RUNTIME_EVENT_ACTIVE_POLL_MS + : LOCAL_RUNTIME_EVENT_IDLE_POLL_MS; + } else if (binding.kind === "remote") { + remoteRuntimeEmptyPollCount += 1; + nextDelayMs = + remoteRuntimeEmptyPollCount <= 1 + ? REMOTE_RUNTIME_EVENT_INITIAL_IDLE_POLL_MS + : REMOTE_RUNTIME_EVENT_IDLE_POLL_MS; + } else { + nextDelayMs = LOCAL_RUNTIME_EVENT_IDLE_POLL_MS; + } } catch (error) { - console.warn("ADE runtime event polling failed", error); - nextDelayMs = 2_000; + const stalePoll = + pollingBindingKey != null && + (currentProjectBinding?.key !== pollingBindingKey || + projectBindingGeneration !== pollingGeneration); + if (stalePoll) { + nextDelayMs = 0; + } else { + console.warn("ADE runtime event polling failed", error); + nextDelayMs = 2_000; + } } finally { remoteRuntimeEventInFlight = false; if ( @@ -1806,6 +1909,7 @@ function handleRemoteRuntimeEventNotification(value: unknown): void { const payload = toRemoteRuntimeEventNotificationPayload(value); const binding = currentProjectBinding; if (!payload || !binding || payload.bindingKey !== binding.key) return; + resetRemoteRuntimeEmptyPolls(); const eventTime = Date.parse(payload.event.timestamp); if ( binding.kind === "local" && @@ -1831,6 +1935,17 @@ function toRemoteRuntimeEventNotificationPayload( return { bindingKey, event }; } +function isRemoteRuntimeEventCategory( + value: unknown, +): value is RemoteRuntimeEventCategory { + return ( + value === "orchestrator" || + value === "dag_mutation" || + value === "runtime" || + value === "pty" + ); +} + function toRemoteRuntimeBufferedEvent( value: unknown, ): RemoteRuntimeBufferedEvent | null { @@ -1838,7 +1953,7 @@ function toRemoteRuntimeBufferedEvent( if (typeof value.id !== "number" || !Number.isFinite(value.id)) return null; if (typeof value.timestamp !== "string") return null; const category = value.category; - if (category !== "runtime" && category !== "pty") { + if (!isRemoteRuntimeEventCategory(category)) { return null; } const payload = isRecord(value.payload) ? value.payload : {}; @@ -3342,11 +3457,13 @@ contextBridge.exposeInMainWorld("ade", { const generation = ++openRemoteProjectGeneration; activeRemoteProjectOpenGeneration = generation; rememberProjectBinding(null); + const openPromise = ipcRenderer.invoke(IPC.remoteRuntimeOpenProject, { + id, + projectId, + }) as Promise; + activeRemoteProjectOpenPromise = openPromise; try { - const binding = (await ipcRenderer.invoke(IPC.remoteRuntimeOpenProject, { - id, - projectId, - })) as OpenProjectBinding; + const binding = await openPromise; if (generation === openRemoteProjectGeneration) { rememberProjectBinding(binding); activeRemoteProjectOpenGeneration = null; @@ -3358,6 +3475,10 @@ contextBridge.exposeInMainWorld("ade", { activeRemoteProjectOpenGeneration = null; } throw error; + } finally { + if (generation === openRemoteProjectGeneration) { + activeRemoteProjectOpenPromise = null; + } } }); }, @@ -3386,8 +3507,27 @@ contextBridge.exposeInMainWorld("ade", { project: RemoteRuntimeProjectRecord, ): Promise => ipcRenderer.invoke(IPC.remoteRuntimeCheckLocalWork, { id, project }), - disconnect: async (id: string): Promise<{ disconnected: boolean }> => - ipcRenderer.invoke(IPC.remoteRuntimeDisconnect, { id }), + disconnect: async ( + id: string, + options: { manual?: boolean } = {}, + ): Promise<{ disconnected: boolean }> => { + const trimmedId = typeof id === "string" ? id.trim() : ""; + const manual = options.manual !== false; + const result = (await ipcRenderer.invoke(IPC.remoteRuntimeDisconnect, { + id: trimmedId, + manual, + })) as { disconnected: boolean }; + if ( + manual && + result.disconnected && + currentProjectBinding?.kind === "remote" && + currentProjectBinding.targetId === trimmedId + ) { + rememberProjectBinding(null); + clearProjectScopedReadCaches(); + } + return result; + }, }, keybindings: { get: async (): Promise => @@ -4610,13 +4750,28 @@ contextBridge.exposeInMainWorld("ade", { }, proxyGetPreviewInfo: async ( args: GetPreviewInfoArgs, - ): Promise => - callProjectRuntimeActionOr("lane", "proxyGetPreviewInfo", { args }, () => - ipcRenderer.invoke(IPC.lanesProxyGetPreviewInfo, args), - ), + ): Promise => { + const binding = await getProjectRuntimeBinding(); + if (binding?.kind === "remote") { + const runtime = + await callProjectRuntimeActionIfBound( + "lane", + "proxyGetPreviewInfo", + { args }, + ); + if (runtime.handled) { + const activeBinding = await getProjectRuntimeBinding(); + if (activeBinding?.kind !== "remote") { + return ipcRenderer.invoke(IPC.lanesProxyGetPreviewInfo, args); + } + return localizeRemoteLanePreviewInfo(activeBinding, runtime.result); + } + } + return ipcRenderer.invoke(IPC.lanesProxyGetPreviewInfo, args); + }, proxyOpenPreview: async (args: OpenPreviewArgs): Promise => { const binding = await getProjectRuntimeBinding(); - if (binding) { + if (binding?.kind === "remote") { const runtime = await callProjectRuntimeActionIfBound( "lane", @@ -4627,7 +4782,12 @@ contextBridge.exposeInMainWorld("ade", { await ipcRenderer.invoke(IPC.lanesProxyOpenPreview, args); return; } - const info = runtime.result; + const activeBinding = await getProjectRuntimeBinding(); + if (activeBinding?.kind !== "remote") { + await ipcRenderer.invoke(IPC.lanesProxyOpenPreview, args); + return; + } + const info = await localizeRemoteLanePreviewInfo(activeBinding, runtime.result); if (!info) throw new Error(`No preview route for lane: ${args.laneId}`); await ipcRenderer.invoke(IPC.appOpenExternal, { url: info.previewUrl }); return; diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 2367bf036..1b6466421 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -95,13 +95,15 @@ import { AppStoreProvider, createProjectAppStore, hydrateProjectAppStore, + selectActiveProjectRoot, useAppStore, type AppStoreApi, } from "../../state/appStore"; import { getDirtyFileTextForWindow } from "../../lib/dirtyWorkspaceBuffers"; import { getAiStatusCached } from "../../lib/aiDiscoveryCache"; import { dispatchWorkSurfaceRevealed } from "../terminals/workSurfaceVisibility"; -import type { AppNavigationRequest, ProjectInfo } from "../../../shared/types"; +import { ADE_OPEN_BUILT_IN_BROWSER_EVENT } from "../../lib/openExternal"; +import type { AppNavigationRequest, OpenProjectBinding, ProjectInfo } from "../../../shared/types"; // Use path-based routes on http(s) (Vite in Chrome, Cursor Simple Browser, etc.). // Use hash routes for non-http(s) surfaces (e.g. packaged Electron `file://`) where @@ -251,6 +253,25 @@ const WARM_PROJECT_SURFACE_LIMIT = 8; const EMPTY_PROJECT_TAB_ROOTS: string[] = []; const EMPTY_PROJECT_INFO_BY_ROOT: Record = {}; +function localProjectBindingForProject(project: ProjectInfo): OpenProjectBinding { + return { + kind: "local", + key: `local:${project.rootPath}`, + rootPath: project.rootPath, + displayName: project.displayName, + }; +} + +function bindingForProject( + project: ProjectInfo, + activeProjectBinding: OpenProjectBinding | null, +): OpenProjectBinding { + if (activeProjectBinding?.rootPath === project.rootPath) { + return activeProjectBinding; + } + return localProjectBindingForProject(project); +} + function projectRouteStorageKey(projectRoot: string): string { return `${PROJECT_ROUTE_STORAGE_PREFIX}${projectRoot}`; } @@ -295,6 +316,44 @@ function writeStoredProjectRoute(projectRoot: string, route: string): void { } } +type ProjectSurfaceEntry = { + surfaceKey: string; + project: ProjectInfo; + binding: OpenProjectBinding; +}; + +function browserEventProjectRoot(value: unknown): string | null | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const record = value as Record; + if ("profileProjectRoot" in record) { + const root = record.profileProjectRoot; + return typeof root === "string" && root.trim().length > 0 ? root : null; + } + return browserEventProjectRoot(record.status); +} + +function browserEventMatchesProject(event: unknown, projectRoot: string | null): boolean { + const root = browserEventProjectRoot(event); + if (root === undefined) return projectRoot == null; + if (!projectRoot) return root === null; + return root === projectRoot; +} + +function hideBuiltInBrowserView(projectRoot: string | null): void { + const browser = window.ade?.builtInBrowser; + if (!browser) return; + const scope = projectRoot ? { projectRoot } : {}; + void browser.stopInspect(scope).catch(() => {}); + void browser.setBounds({ + ...scope, + x: 0, + y: 0, + width: 0, + height: 0, + visible: false, + }).catch(() => {}); +} + function projectNameFromRoot(rootPath: string | null | undefined): string | null { if (!rootPath) return null; const segments = rootPath.split(/[\\/]/).filter(Boolean); @@ -321,6 +380,9 @@ function ProjectTransitionVeil({ label }: { label: string }) { } function ProjectRouteContent({ active, route }: { active: boolean; route: string }) { + const navigate = useNavigate(); + const projectRoot = useAppStore(selectActiveProjectRoot); + const setWorkViewState = useAppStore((s) => s.setWorkViewState); const workSurfaceRef = React.useRef(null); const lanesSurfaceRef = React.useRef(null); const isWorkRoute = isWorkRoutePath(route.split(/[?#]/, 1)[0] || "/work"); @@ -345,6 +407,39 @@ function ProjectRouteContent({ active, route }: { active: boolean; route: string setLanesRoute(route); }, [isLanesRoute, route]); + React.useEffect(() => { + if (active && isWorkRoute) return; + hideBuiltInBrowserView(projectRoot); + }, [active, isWorkRoute, projectRoot]); + + React.useEffect(() => { + if (!active || !projectRoot) return; + const revealWorkBrowser = () => { + setWorkViewState(projectRoot, { + viewMode: "tabs", + workSidebarOpen: true, + workSidebarTab: "browser", + }); + if (!isWorkRoute) navigate("/work"); + }; + const handleBrowserEvent = (event: unknown) => { + if ( + event + && typeof event === "object" + && (event as { type?: unknown }).type === "open-request" + && browserEventMatchesProject(event, projectRoot) + ) { + revealWorkBrowser(); + } + }; + window.addEventListener(ADE_OPEN_BUILT_IN_BROWSER_EVENT, revealWorkBrowser); + const unsubscribeBrowserEvents = window.ade?.builtInBrowser?.onEvent?.(handleBrowserEvent) ?? null; + return () => { + window.removeEventListener(ADE_OPEN_BUILT_IN_BROWSER_EVENT, revealWorkBrowser); + unsubscribeBrowserEvents?.(); + }; + }, [active, isWorkRoute, navigate, projectRoot, setWorkViewState]); + React.useEffect(() => { const node = workSurfaceRef.current; if (!node) return; @@ -484,11 +579,13 @@ function ProjectRouteContent({ active, route }: { active: boolean; route: string function ProjectSurface({ active, project, + projectBinding, route, store, }: { active: boolean; project: ProjectInfo; + projectBinding: OpenProjectBinding; route: string; store: AppStoreApi; }) { @@ -497,16 +594,11 @@ function ProjectSurface({ React.useEffect(() => { hydrateProjectAppStore(store, { project, - projectBinding: { - kind: "local", - key: `local:${project.rootPath}`, - rootPath: project.rootPath, - displayName: project.displayName, - }, + projectBinding, projectHydrated: true, showWelcome: false, }); - }, [project, store]); + }, [project, projectBinding, store]); React.useEffect(() => { if (!active || !isWorkRoutePath(route.split(/[?#]/, 1)[0] || "/work")) return; @@ -568,6 +660,7 @@ function ProjectTabHost() { const location = useLocation(); const navigate = useNavigate(); const activeProject = useAppStore((s) => s.project); + const activeProjectBinding = useAppStore((s) => s.projectBinding); const projectHydrated = useAppStore((s) => s.projectHydrated); const showWelcome = useAppStore((s) => s.showWelcome); const projectTransition = useAppStore((s) => s.projectTransition); @@ -593,10 +686,13 @@ function ProjectTabHost() { }))); const storesRef = React.useRef(new Map()); const lruRef = React.useRef([]); - const [routesByRoot, setRoutesByRoot] = React.useState>({}); - const activeRoot = !showWelcome && activeProject?.rootPath ? activeProject.rootPath : null; - const previousActiveRootRef = React.useRef(null); - const pendingNavigationRef = React.useRef<{ root: string; route: string } | null>(null); + const [routesBySurfaceKey, setRoutesBySurfaceKey] = React.useState>({}); + const activeBinding = !showWelcome && activeProject?.rootPath + ? bindingForProject(activeProject, activeProjectBinding) + : null; + const activeSurfaceKey = activeBinding?.key ?? null; + const previousActiveSurfaceKeyRef = React.useRef(null); + const pendingNavigationRef = React.useRef<{ surfaceKey: string; route: string } | null>(null); React.useEffect(() => { for (const store of storesRef.current.values()) { @@ -605,12 +701,15 @@ function ProjectTabHost() { }, [rootPrefs]); React.useEffect(() => { - if (!activeRoot) return; - lruRef.current = [activeRoot, ...lruRef.current.filter((root) => root !== activeRoot)]; - }, [activeRoot]); + if (!activeSurfaceKey) return; + lruRef.current = [ + activeSurfaceKey, + ...lruRef.current.filter((key) => key !== activeSurfaceKey), + ]; + }, [activeSurfaceKey]); React.useEffect(() => { - if (!activeRoot) return; + if (!activeSurfaceKey) return; const preload = () => { void preloadTerminalsPage().catch(() => undefined); void preloadLanesPage().catch(() => undefined); @@ -627,85 +726,105 @@ function ProjectTabHost() { } const handle = window.setTimeout(preload, 150); return () => window.clearTimeout(handle); - }, [activeRoot]); + }, [activeSurfaceKey]); React.useEffect(() => { - const previousRoot = previousActiveRootRef.current; - if (previousRoot === activeRoot) return; + const previousSurfaceKey = previousActiveSurfaceKeyRef.current; + if (previousSurfaceKey === activeSurfaceKey) return; const currentRoute = serializeProjectRoute(location); - if (previousRoot && currentRoute) { - writeStoredProjectRoute(previousRoot, currentRoute); - setRoutesByRoot((prev) => ({ ...prev, [previousRoot]: currentRoute })); + if (previousSurfaceKey && currentRoute) { + writeStoredProjectRoute(previousSurfaceKey, currentRoute); + setRoutesBySurfaceKey((prev) => ({ ...prev, [previousSurfaceKey]: currentRoute })); } - previousActiveRootRef.current = activeRoot; - if (!activeRoot) return; + previousActiveSurfaceKeyRef.current = activeSurfaceKey; + if (!activeSurfaceKey) return; const shouldKeepInitialRoute = currentRoute && currentRoute !== "/project" && currentRoute !== "/onboarding"; - if (!previousRoot && shouldKeepInitialRoute) { - writeStoredProjectRoute(activeRoot, currentRoute); - setRoutesByRoot((prev) => (prev[activeRoot] === currentRoute ? prev : { ...prev, [activeRoot]: currentRoute })); + if (!previousSurfaceKey && shouldKeepInitialRoute) { + writeStoredProjectRoute(activeSurfaceKey, currentRoute); + setRoutesBySurfaceKey((prev) => (prev[activeSurfaceKey] === currentRoute ? prev : { ...prev, [activeSurfaceKey]: currentRoute })); return; } - const nextRoute = routesByRoot[activeRoot] ?? readStoredProjectRoute(activeRoot) ?? "/work"; - pendingNavigationRef.current = { root: activeRoot, route: nextRoute }; + const nextRoute = routesBySurfaceKey[activeSurfaceKey] ?? readStoredProjectRoute(activeSurfaceKey) ?? "/work"; + pendingNavigationRef.current = { surfaceKey: activeSurfaceKey, route: nextRoute }; if (currentRoute !== nextRoute) { navigate(nextRoute, { replace: true }); } - }, [activeRoot, location, navigate, routesByRoot]); + }, [activeSurfaceKey, location, navigate, routesBySurfaceKey]); React.useEffect(() => { - if (!activeRoot) return; + if (!activeSurfaceKey) return; const route = serializeProjectRoute(location); if (!route) return; const pending = pendingNavigationRef.current; - if (pending?.root === activeRoot && pending.route !== route) return; - if (pending?.root === activeRoot && pending.route === route) { + if (pending?.surfaceKey === activeSurfaceKey && pending.route !== route) return; + if (pending?.surfaceKey === activeSurfaceKey && pending.route === route) { pendingNavigationRef.current = null; } - writeStoredProjectRoute(activeRoot, route); - setRoutesByRoot((prev) => (prev[activeRoot] === route ? prev : { ...prev, [activeRoot]: route })); - }, [activeRoot, location]); - - const projects = React.useMemo(() => { - const roots = openProjectTabRoots.length > 0 + writeStoredProjectRoute(activeSurfaceKey, route); + setRoutesBySurfaceKey((prev) => (prev[activeSurfaceKey] === route ? prev : { ...prev, [activeSurfaceKey]: route })); + }, [activeSurfaceKey, location]); + + const projectEntries = React.useMemo(() => { + const entries: ProjectSurfaceEntry[] = []; + const activeRemoteRoot = + activeProjectBinding?.kind === "remote" ? activeProjectBinding.rootPath : null; + const rootsFromTabs = openProjectTabRoots.length > 0 ? openProjectTabRoots - : activeProject?.rootPath + : activeProject?.rootPath && !activeRemoteRoot ? [activeProject.rootPath] : []; - return roots - .map((root) => projectInfoByRoot[root] ?? (activeProject?.rootPath === root ? activeProject : null)) - .filter((project): project is ProjectInfo => project != null); - }, [activeProject, openProjectTabRoots, projectInfoByRoot]); + for (const root of rootsFromTabs) { + const project = projectInfoByRoot[root] ?? ( + activeProject?.rootPath === root && !activeRemoteRoot ? activeProject : null + ); + if (!project) continue; + const binding = localProjectBindingForProject(project); + entries.push({ surfaceKey: binding.key, project, binding }); + } + if (activeProject && activeBinding && !entries.some((entry) => entry.surfaceKey === activeBinding.key)) { + entries.unshift({ + surfaceKey: activeBinding.key, + project: activeProject, + binding: activeBinding, + }); + } + return entries; + }, [activeBinding, activeProject, activeProjectBinding, openProjectTabRoots, projectInfoByRoot]); const mountedProjects = React.useMemo(() => { const lru = lruRef.current; - const openSet = new Set(projects.map((project) => project.rootPath)); - const ordered = [...projects].sort((left, right) => { - const leftIndex = lru.indexOf(left.rootPath); - const rightIndex = lru.indexOf(right.rootPath); + const openSet = new Set(projectEntries.map((entry) => entry.surfaceKey)); + const ordered = [...projectEntries].sort((left, right) => { + const leftIndex = lru.indexOf(left.surfaceKey); + const rightIndex = lru.indexOf(right.surfaceKey); return (leftIndex < 0 ? Number.MAX_SAFE_INTEGER : leftIndex) - (rightIndex < 0 ? Number.MAX_SAFE_INTEGER : rightIndex); }); const warm = ordered.slice(0, WARM_PROJECT_SURFACE_LIMIT); - if (activeProject && openSet.has(activeProject.rootPath) && !warm.some((project) => project.rootPath === activeProject.rootPath)) { + if (activeSurfaceKey && openSet.has(activeSurfaceKey) && !warm.some((entry) => entry.surfaceKey === activeSurfaceKey)) { warm.pop(); - warm.unshift(activeProject); + const activeEntry = projectEntries.find((entry) => entry.surfaceKey === activeSurfaceKey); + if (activeEntry) warm.unshift(activeEntry); } return warm; - }, [activeProject, projects]); - - for (const project of mountedProjects) { - if (!storesRef.current.has(project.rootPath)) { - storesRef.current.set(project.rootPath, createProjectAppStore(project)); + }, [activeSurfaceKey, projectEntries]); + + for (const entry of mountedProjects) { + if (!storesRef.current.has(entry.surfaceKey)) { + storesRef.current.set(entry.surfaceKey, createProjectAppStore( + entry.project, + entry.binding, + )); } } React.useEffect(() => { - const mountedRoots = new Set(mountedProjects.map((project) => project.rootPath)); - for (const root of storesRef.current.keys()) { - if (!mountedRoots.has(root)) storesRef.current.delete(root); + const mountedKeys = new Set(mountedProjects.map((entry) => entry.surfaceKey)); + for (const key of storesRef.current.keys()) { + if (!mountedKeys.has(key)) storesRef.current.delete(key); } }, [mountedProjects]); @@ -740,16 +859,18 @@ function ProjectTabHost() { return (
- {mountedProjects.map((project) => { - const store = storesRef.current.get(project.rootPath); + {mountedProjects.map((entry) => { + const { binding: projectBinding, project, surfaceKey } = entry; + const store = storesRef.current.get(surfaceKey); if (!store) return null; - const liveRoute = project.rootPath === activeRoot ? serializeProjectRoute(location) : null; - const route = liveRoute ?? routesByRoot[project.rootPath] ?? readStoredProjectRoute(project.rootPath) ?? "/work"; + const liveRoute = surfaceKey === activeSurfaceKey ? serializeProjectRoute(location) : null; + const route = liveRoute ?? routesBySurfaceKey[surfaceKey] ?? readStoredProjectRoute(surfaceKey) ?? "/work"; return ( @@ -894,7 +1015,7 @@ function BrowserHashRouteBridge() { export function App() { const theme = useAppStore((s) => s.theme); - const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); React.useEffect(() => { const w = window as Window & { __ADE_GET_DIRTY_FILE_TEXT__?: (p: string) => string | undefined }; diff --git a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx index 3d854952a..f6438f127 100644 --- a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx +++ b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx @@ -5,6 +5,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type * as ReactNamespace from "react"; import type * as RouterNamespace from "react-router-dom"; +import { ADE_OPEN_BUILT_IN_BROWSER_EVENT } from "../../lib/openExternal"; const workLifecycle = vi.hoisted(() => ({ mounts: 0, @@ -20,10 +21,30 @@ const appStoreState = vi.hoisted(() => ({ projectHydrated: true, showWelcome: false, project: { rootPath: "/fake/project" }, + projectBinding: { + kind: "local", + key: "local:/fake/project", + rootPath: "/fake/project", + displayName: "project", + } as { + kind: "local" | "remote"; + key: string; + rootPath: string; + displayName: string; + targetId?: string; + runtimeName?: string; + projectId?: string; + }, projectTransition: null as { kind: "opening" | "switching" | "closing"; rootPath: string | null; startedAtMs: number } | null, theme: "dark", launchPromptClipboardEnabled: true, launchPromptClipboardNoticeEnabled: true, + workViewByProject: {} as Record>, + setWorkViewState: vi.fn((projectRoot: string | null | undefined, next: Record) => { + if (!projectRoot) return; + const current = appStoreState.workViewByProject[projectRoot] ?? {}; + appStoreState.workViewByProject[projectRoot] = { ...current, ...next }; + }), openProjectTabRoots: [] as string[], projectInfoByRoot: {} as Record, })); @@ -42,8 +63,11 @@ vi.mock("../../state/appStore", async () => { }); return { useAppStore: vi.fn((selector: (state: typeof appStoreState) => unknown) => selector(appStoreState)), - createProjectAppStore: vi.fn((project) => { - let state = createScopedState(project); + createProjectAppStore: vi.fn((project, projectBinding) => { + let state = { + ...createScopedState(project), + projectBinding: projectBinding ?? appStoreState.projectBinding, + }; return { getState: () => state, setState: (partial: unknown) => { @@ -59,6 +83,7 @@ vi.mock("../../state/appStore", async () => { store.setState(partial); }), AppStoreProvider: ({ children }: { children: React.ReactNode }) => ReactModule.createElement(ReactModule.Fragment, null, children), + selectActiveProjectRoot: (state: typeof appStoreState) => state.project?.rootPath ?? null, }; }); @@ -169,14 +194,33 @@ describe("App Work route keep-alive", () => { appStoreState.projectHydrated = true; appStoreState.showWelcome = false; appStoreState.project = { rootPath: "/fake/project" }; + appStoreState.projectBinding = { + kind: "local", + key: "local:/fake/project", + rootPath: "/fake/project", + displayName: "project", + }; appStoreState.projectTransition = null; appStoreState.theme = "dark"; appStoreState.launchPromptClipboardEnabled = true; appStoreState.launchPromptClipboardNoticeEnabled = true; + appStoreState.workViewByProject = {}; + appStoreState.setWorkViewState.mockClear(); appStoreState.openProjectTabRoots = []; appStoreState.projectInfoByRoot = {}; window.localStorage.clear(); (window as Window & { __adeBrowserMock?: boolean }).__adeBrowserMock = true; + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + builtInBrowser: { + stopInspect: vi.fn().mockResolvedValue({}), + setBounds: vi.fn().mockResolvedValue({}), + onEvent: vi.fn(() => () => {}), + }, + }, + }); window.history.replaceState({}, "", "/work"); }); @@ -215,6 +259,58 @@ describe("App Work route keep-alive", () => { expect(workLifecycle.unmounts).toBe(0); }); + it("parks the native Work browser view when the Work route is backgrounded", async () => { + const { App } = await import("./App"); + + render(); + + await screen.findByTestId("work-page"); + const browser = window.ade.builtInBrowser as unknown as { + stopInspect: ReturnType; + setBounds: ReturnType; + }; + expect(browser.setBounds).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Open files" })); + await screen.findByTestId("files-page"); + + await waitFor(() => { + expect(browser.stopInspect).toHaveBeenCalledWith({ projectRoot: "/fake/project" }); + expect(browser.setBounds).toHaveBeenCalledWith({ + projectRoot: "/fake/project", + x: 0, + y: 0, + width: 0, + height: 0, + visible: false, + }); + }); + }); + + it("reveals the Work browser pane when an ADE browser URL opens from another tab", async () => { + window.history.replaceState({}, "", "/files"); + const { App } = await import("./App"); + + render(); + + await screen.findByTestId("files-page"); + fireEvent(window, new CustomEvent(ADE_OPEN_BUILT_IN_BROWSER_EVENT, { + detail: { url: "http://127.0.0.1:64054" }, + })); + + await waitFor(() => { + expect(window.location.pathname).toBe("/work"); + }); + expect(appStoreState.setWorkViewState).toHaveBeenCalledWith( + "/fake/project", + expect.objectContaining({ + viewMode: "tabs", + workSidebarOpen: true, + workSidebarTab: "browser", + }), + ); + }); + it("hydrates project stores with launch clipboard reminder preferences", async () => { appStoreState.launchPromptClipboardNoticeEnabled = false; const { hydrateProjectAppStore } = await import("../../state/appStore"); @@ -357,7 +453,12 @@ describe("App Work route keep-alive", () => { render(); - await screen.findByTestId("work-page"); + await waitFor(() => { + const activeWorkPage = screen + .getAllByTestId("work-page") + .find((node) => node.getAttribute("data-active") === "true"); + expect(activeWorkPage).toBeTruthy(); + }); expect(screen.queryByTestId("lanes-page")).toBeNull(); expect(lanesLifecycle.mounts).toBe(0); expect(lanesLifecycle.unmounts).toBe(0); @@ -375,12 +476,72 @@ describe("App Work route keep-alive", () => { render(); - await screen.findByTestId("work-page"); + await waitFor(() => { + const activeWorkPage = screen + .getAllByTestId("work-page") + .find((node) => node.getAttribute("data-active") === "true"); + expect(activeWorkPage).toBeTruthy(); + }); expect(screen.queryByTestId("lanes-page")).toBeNull(); expect(lanesLifecycle.mounts).toBe(0); expect(lanesLifecycle.unmounts).toBe(0); }); + it("mounts the active remote project even when local project tabs are open", async () => { + appStoreState.project = { rootPath: "/remote/project", displayName: "Remote project" } as any; + appStoreState.projectBinding = { + kind: "remote", + key: "remote:studio:project-1", + targetId: "studio", + runtimeName: "Mac Studio", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Remote project", + }; + appStoreState.openProjectTabRoots = ["/fake/project"]; + appStoreState.projectInfoByRoot = { + "/fake/project": { rootPath: "/fake/project", displayName: "Fake" }, + }; + const { createProjectAppStore, hydrateProjectAppStore } = await import("../../state/appStore"); + const { App } = await import("./App"); + + render(); + + await waitFor(() => { + const remoteSurface = screen + .getAllByTestId("work-page") + .find((node) => node.closest("[data-project-root='/remote/project']")); + expect(remoteSurface).toBeTruthy(); + expect(remoteSurface?.closest("[aria-hidden='true']")).toBeNull(); + expect(remoteSurface?.getAttribute("data-active")).toBe("true"); + }); + expect(createProjectAppStore).toHaveBeenCalledWith( + expect.objectContaining({ rootPath: "/remote/project" }), + expect.objectContaining({ + kind: "remote", + runtimeName: "Mac Studio", + rootPath: "/remote/project", + }), + ); + await waitFor(() => { + expect(hydrateProjectAppStore).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + projectBinding: expect.objectContaining({ + kind: "remote", + runtimeName: "Mac Studio", + rootPath: "/remote/project", + }), + }), + ); + }); + + const localSurface = screen + .getAllByTestId("work-page") + .find((node) => node.closest("[data-project-root='/fake/project']")); + expect(localSurface?.closest("[aria-hidden='true']")).not.toBeNull(); + }); + it("converts legacy hash app routes into BrowserRouter paths", async () => { window.history.replaceState({}, "", "/work#/lanes"); const { App } = await import("./App"); diff --git a/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx b/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx index 99f28192a..9d7afa444 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx @@ -6,6 +6,7 @@ import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentChatEventEnvelope, AiSettingsStatus } from "../../../shared/types"; import { getAiStatusCached, invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; +import { listSessionsCached } from "../../lib/sessionListCache"; import { useAppStore } from "../../state/appStore"; import { AppShell } from "./AppShell"; @@ -82,6 +83,7 @@ function resetStore() { describe("AppShell AI provider status", () => { const getStatusMock = vi.fn(); + const githubGetStatusMock = vi.fn(); let chatEventListener: ((envelope: AgentChatEventEnvelope) => void) | null = null; beforeEach(() => { @@ -90,6 +92,9 @@ describe("AppShell AI provider status", () => { resetStore(); invalidateAiDiscoveryCache(); getStatusMock.mockReset(); + githubGetStatusMock.mockReset(); + vi.mocked(listSessionsCached).mockClear(); + githubGetStatusMock.mockResolvedValue(null); chatEventListener = null; Object.defineProperty(window, "ade", { configurable: true, @@ -117,13 +122,14 @@ describe("AppShell AI provider status", () => { onUpdate: vi.fn(() => () => {}), }, github: { - getStatus: vi.fn(async () => null), + getStatus: githubGetStatusMock, onStatusChanged: vi.fn(() => () => {}), }, keybindings: { get: vi.fn(async () => null), }, lanes: { + list: vi.fn(async () => []), listSnapshots: vi.fn(async () => []), }, onboarding: { @@ -224,4 +230,166 @@ describe("AppShell AI provider status", () => { refreshOpenCodeInventory: false, }); }); + + it("skips shell GitHub auth discovery on remote Work startup", async () => { + getStatusMock.mockResolvedValue(makeAiStatus(true)); + useAppStore.setState({ + project, + projectBinding: { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + projectId: "project-1", + runtimeName: "Mac Studio", + displayName: "perf pass", + rootPath: "/Users/admin/Projects/perf pass", + }, + projectHydrated: true, + showWelcome: false, + } as any); + + render( + + +
Work content
+
+
, + ); + + await act(async () => { + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + }); + + expect(githubGetStatusMock).not.toHaveBeenCalled(); + }); + + it("loads shell GitHub auth discovery on remote PR routes", async () => { + getStatusMock.mockResolvedValue(makeAiStatus(true)); + useAppStore.setState({ + project, + projectBinding: { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + projectId: "project-1", + runtimeName: "Mac Studio", + displayName: "perf pass", + rootPath: "/Users/admin/Projects/perf pass", + }, + projectHydrated: true, + showWelcome: false, + } as any); + + render( + + +
PR content
+
+
, + ); + + await act(async () => { + vi.advanceTimersByTime(12_000); + await Promise.resolve(); + }); + + expect(githubGetStatusMock).toHaveBeenCalledTimes(1); + }); + + it("skips remote shell AI and stale-session polling on Files routes", async () => { + const remoteRoot = "/Users/admin/Projects/perf pass"; + const remoteProject = { rootPath: remoteRoot, displayName: "perf pass", baseRef: "main" } as any; + const remoteBinding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + projectId: "project-1", + runtimeName: "Mac Studio", + displayName: "perf pass", + rootPath: remoteRoot, + } as any; + const refreshProviderMode = vi.fn(async () => undefined); + (window.ade.app.getWindowSession as any).mockResolvedValue({ + project: remoteProject, + binding: remoteBinding, + }); + useAppStore.setState({ + project: remoteProject, + projectBinding: remoteBinding, + projectHydrated: true, + showWelcome: false, + refreshProviderMode, + } as any); + + render( + + +
Files content
+
+
, + ); + + await act(async () => { + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(getStatusMock).not.toHaveBeenCalled(); + expect(listSessionsCached).not.toHaveBeenCalled(); + }); + + it("ignores stale auth-related chat history while remote AI status is cached", async () => { + vi.setSystemTime(new Date("2026-06-04T12:00:00.000Z")); + getStatusMock.mockResolvedValue(makeAiStatus(true)); + const remoteRoot = "/Users/admin/Projects/perf pass"; + const remoteProject = { rootPath: remoteRoot, displayName: "perf pass", baseRef: "main" } as any; + const remoteBinding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + projectId: "project-1", + runtimeName: "Mac Studio", + displayName: "perf pass", + rootPath: remoteRoot, + } as any; + await getAiStatusCached({ projectRoot: remoteRoot }); + getStatusMock.mockClear(); + (window.ade.app.getWindowSession as any).mockResolvedValue({ + project: remoteProject, + binding: remoteBinding, + }); + useAppStore.setState({ + project: remoteProject, + projectBinding: remoteBinding, + projectHydrated: true, + showWelcome: false, + } as any); + + render( + + +
Work content
+
+
, + ); + + await act(async () => { + await Promise.resolve(); + }); + await act(async () => { + chatEventListener?.({ + sessionId: "session-1", + timestamp: "2026-06-04T11:59:00.000Z", + event: { + type: "error", + message: "not authenticated", + }, + }); + await Promise.resolve(); + }); + + expect(getStatusMock).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index bd4aefc91..69d7330be 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -2,9 +2,11 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { ArrowSquareOut, CheckCircle, + CircleNotch, GitBranch, GithubLogo, GitPullRequest, + PlugsConnected, WarningCircle, XCircle, } from "@phosphor-icons/react"; @@ -21,7 +23,7 @@ import { } from "./prToastPresentation"; import { TabBackground } from "../ui/TabBackground"; import { LaneAccentDot } from "../lanes/LaneAccentDot"; -import { useAppStore } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; import { Button } from "../ui/Button"; import type { AiSettingsStatus, @@ -31,6 +33,7 @@ import type { PrEventPayload, ProjectInfo, OpenProjectBinding, + RemoteRuntimeConnectionSnapshot, TerminalSessionSummary, } from "../../../shared/types"; import { @@ -67,6 +70,24 @@ function primaryTabPath(pathname: string): string { return roots.find((root) => pathname === root || pathname.startsWith(`${root}/`)) ?? pathname; } +function shouldLoadShellGithubStatus(pathname: string, isRemoteProject: boolean): boolean { + if (!isRemoteProject) return true; + return pathname === "/prs" + || pathname.startsWith("/prs/") + || pathname === "/settings" + || pathname.startsWith("/settings/"); +} + +function shouldLoadShellAiStatus(pathname: string, isRemoteProject: boolean): boolean { + if (!isRemoteProject) return true; + return pathname === "/work" + || pathname.startsWith("/work/") + || pathname === "/lanes" + || pathname.startsWith("/lanes/") + || pathname === "/settings" + || pathname.startsWith("/settings/"); +} + const PROJECT_ROUTE_STORAGE_PREFIX = "ade:project-route:"; const AI_STATUS_STARTUP_DELAY_MS = 1_000; const AI_STATUS_CHAT_EVENT_REFRESH_MIN_GAP_MS = 30_000; @@ -124,6 +145,14 @@ type LinearWorkflowToast = { >; }; +type RemoteConnectionNotice = { + key: string; + state: "connecting" | "error" | "idle"; + badge: string; + title: string; + body: string; +}; + type StaleCliNotice = { count: number; oldestStartedAt: string; @@ -238,6 +267,13 @@ function getPrToastIcon(kind: PrToast["event"]["kind"]) { return GitPullRequest; } +function cleanRemoteConnectionError(message: string | null): string { + return (message ?? "") + .replace(/^Error invoking remote method '[^']+':\s*/i, "") + .replace(/^Error:\s*/i, "") + .trim(); +} + export function AppShell({ children }: { children: React.ReactNode }) { const location = useLocation(); const navigate = useNavigate(); @@ -252,6 +288,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { const keybindings = useAppStore((s) => s.keybindings); const lanes = useAppStore((s) => s.lanes); const project = useAppStore((s) => s.project); + const projectBinding = useAppStore((s) => s.projectBinding); const projectRevision = useAppStore((s) => s.projectRevision); const setShowWelcome = useAppStore((s) => s.setShowWelcome); const showWelcome = useAppStore((s) => s.showWelcome); @@ -278,6 +315,10 @@ export function AppShell({ children }: { children: React.ReactNode }) { const [staleCliNotice, setStaleCliNotice] = useState(null); const dismissedStaleCliNoticeKeyRef = useRef(null); + const [remoteSnapshot, setRemoteSnapshot] = + useState(null); + const [dismissedRemoteNoticeKey, setDismissedRemoteNoticeKey] = + useState(null); const [aiFailure, setAiFailure] = useState(null); const [aiMockProvider, setAiMockProvider] = useState<{ createdAt: string; @@ -294,7 +335,10 @@ export function AppShell({ children }: { children: React.ReactNode }) { const dismissedGithubBannerRoots = useAppStore((s) => s.dismissedGithubBannerRoots); const dismissMissingAiBanner = useAppStore((s) => s.dismissMissingAiBanner); const dismissGithubBanner = useAppStore((s) => s.dismissGithubBanner); - const currentProjectRoot = project?.rootPath ?? null; + const currentProjectRoot = useAppStore(selectActiveProjectRoot); + const activeRemoteBinding = + projectBinding?.kind === "remote" ? projectBinding : null; + const isRemoteProject = projectBinding?.kind === "remote"; const missingAiBannerDismissed = Boolean( currentProjectRoot && dismissedMissingAiBannerRoots[currentProjectRoot], ); @@ -309,11 +353,13 @@ export function AppShell({ children }: { children: React.ReactNode }) { githubBannerDismissedRef.current = githubBannerDismissed; const isOnboardingRoute = location.pathname === "/onboarding"; const isLanesRoute = location.pathname.startsWith("/lanes"); + const isWorkRoute = location.pathname === "/work" || location.pathname.startsWith("/work/"); + const isWorkAdjacentRoute = isWorkRoute || isLanesRoute; const isLanesRouteRef = useRef(isLanesRoute); const shouldTrackTerminalAttention = - Boolean(project?.rootPath) && + Boolean(currentProjectRoot) && !showWelcome && - (location.pathname === "/work" || location.pathname === "/lanes"); + isWorkAdjacentRoute; useEffect(() => { isLanesRouteRef.current = isLanesRoute; @@ -322,17 +368,41 @@ export function AppShell({ children }: { children: React.ReactNode }) { useEffect(() => { logRendererDebugEvent("renderer.route_change", { pathname: location.pathname, - projectRoot: project?.rootPath ?? null, + projectRoot: currentProjectRoot, showWelcome, }); console.info( `renderer.route_change ${JSON.stringify({ pathname: location.pathname, - projectRoot: project?.rootPath ?? null, + projectRoot: currentProjectRoot, showWelcome, })}`, ); - }, [location.pathname, project?.rootPath, showWelcome]); + }, [currentProjectRoot, location.pathname, showWelcome]); + + useEffect(() => { + const remoteRuntime = window.ade.remoteRuntime; + if (!remoteRuntime?.getConnectionSnapshot) return; + let cancelled = false; + let seenLiveUpdate = false; + void remoteRuntime + .getConnectionSnapshot() + .then((snapshot) => { + if (!cancelled && !seenLiveUpdate) setRemoteSnapshot(snapshot); + }) + .catch(() => { + if (!cancelled && !seenLiveUpdate) setRemoteSnapshot(null); + }); + const unsubscribe = + remoteRuntime.onConnectionSnapshotChanged?.((snapshot) => { + seenLiveUpdate = true; + if (!cancelled) setRemoteSnapshot(snapshot); + }) ?? (() => {}); + return () => { + cancelled = true; + unsubscribe(); + }; + }, []); useEffect(() => { disposeTerminalRuntimesForProjectChange(project?.rootPath ?? null, projectRevision); @@ -397,12 +467,12 @@ export function AppShell({ children }: { children: React.ReactNode }) { const welcomeChanged = currentShowWelcome === hasStoredProject; if (remoteBinding) { + setProjectBinding(remoteBinding); setProject({ rootPath: remoteBinding.rootPath, displayName: remoteBinding.displayName, baseRef: "main", }); - setProjectBinding(remoteBinding); setShowWelcome(false); clearScheduledRefreshes(); void refreshLanes({ includeStatus: false }); @@ -420,6 +490,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { if (nextProject) { setProject(nextProject); + setProjectBinding(nextBinding ?? null); setShowWelcome(false); } else { setProject(null); @@ -604,13 +675,18 @@ export function AppShell({ children }: { children: React.ReactNode }) { }, [setTerminalAttention, shouldTrackTerminalAttention]); useEffect(() => { - if (!project?.rootPath || showWelcome) { + const projectRoot = project?.rootPath ?? null; + const shouldCheckStaleCliNotice = + Boolean(projectRoot) && + !showWelcome && + (!isRemoteProject || isWorkAdjacentRoute); + if (!shouldCheckStaleCliNotice) { setStaleCliNotice(null); dismissedStaleCliNoticeKeyRef.current = null; return; } - const projectRoot = project.rootPath; + if (!projectRoot) return; let cancelled = false; let refreshTimer: number | null = null; @@ -672,11 +748,11 @@ export function AppShell({ children }: { children: React.ReactNode }) { window.removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onVisibilityChange); }; - }, [project?.rootPath, showWelcome]); + }, [isRemoteProject, isWorkAdjacentRoute, project?.rootPath, showWelcome]); useEffect(() => { let cancelled = false; - if (!project?.rootPath || showWelcome) { + if (!project?.rootPath || showWelcome || isRemoteProject) { setOnboardingStatus(null); setOnboardingStatusLoading(false); return () => { @@ -701,10 +777,11 @@ export function AppShell({ children }: { children: React.ReactNode }) { return () => { cancelled = true; }; - }, [project?.rootPath, showWelcome]); + }, [isRemoteProject, project?.rootPath, showWelcome]); useEffect(() => { const handler = (event: Event) => { + if (isRemoteProject) return; const detail = (event as CustomEvent).detail; if (!detail) return; setOnboardingStatus(detail); @@ -713,7 +790,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { window.addEventListener(ONBOARDING_STATUS_UPDATED_EVENT, handler); return () => window.removeEventListener(ONBOARDING_STATUS_UPDATED_EVENT, handler); - }, []); + }, [isRemoteProject]); // Track visited tabs — mark after a short delay so stagger animation can play on first visit useEffect(() => { @@ -741,7 +818,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { }, [project?.rootPath]); useEffect(() => { - const projectRoot = project?.rootPath ?? null; + const projectRoot = currentProjectRoot; if (!projectRoot || showWelcome) return; if (lastRouteSaveProjectRootRef.current !== projectRoot) { @@ -751,11 +828,11 @@ export function AppShell({ children }: { children: React.ReactNode }) { const route = serializeLocationRoute(location); if (route) writeStoredProjectRoute(projectRoot, route); - }, [location, project?.rootPath, showWelcome]); + }, [location, currentProjectRoot, showWelcome]); useEffect(() => { let cancelled = false; - if (!currentProjectRoot) { + if (!currentProjectRoot || !shouldLoadShellAiStatus(location.pathname, isRemoteProject)) { setAiStatus(null); setAiStatusLoaded(false); return; @@ -767,6 +844,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { let refreshSerial = 0; let lastChatEventRefreshAt = 0; let lastKnownHasProvider = hasConfiguredAiProvider(cachedStatus); + const chatEventSubscriptionStartedAt = Date.now(); const refreshAiStatus = (options: { force?: boolean } = {}) => { if (document.visibilityState !== "visible") return; const serial = ++refreshSerial; @@ -798,6 +876,12 @@ export function AppShell({ children }: { children: React.ReactNode }) { window.addEventListener("focus", onFocus); document.addEventListener("visibilitychange", onVisibilityChange); const unsubscribeChatEvents = window.ade.agentChat.onEvent((envelope) => { + if (isRemoteProject) { + const eventTimestamp = Date.parse(envelope.timestamp); + if (Number.isFinite(eventTimestamp) && eventTimestamp < chatEventSubscriptionStartedAt - 10_000) { + return; + } + } if (lastKnownHasProvider && !shouldRefreshAiStatusForChatEvent(envelope)) return; const now = Date.now(); if (now - lastChatEventRefreshAt < AI_STATUS_CHAT_EVENT_REFRESH_MIN_GAP_MS) return; @@ -818,11 +902,11 @@ export function AppShell({ children }: { children: React.ReactNode }) { window.removeEventListener(AI_STATUS_CACHE_INVALIDATED_EVENT, onAiStatusCacheInvalidated); unsubscribeChatEvents(); }; - }, [currentProjectRoot]); + }, [currentProjectRoot, isRemoteProject, location.pathname]); useEffect(() => { let cancelled = false; - if (!currentProjectRoot) { + if (!currentProjectRoot || !shouldLoadShellGithubStatus(location.pathname, isRemoteProject)) { githubStatusProjectRootRef.current = null; setGithubStatus(null); return; @@ -847,7 +931,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { cancelled = true; window.clearTimeout(githubTimer); }; - }, [currentProjectRoot]); + }, [currentProjectRoot, isRemoteProject, location.pathname]); // Refresh the GitHub banner the moment Settings saves/clears a token, so the // shell does not lag behind the Settings UI (the original "banner stays up @@ -855,10 +939,13 @@ export function AppShell({ children }: { children: React.ReactNode }) { useEffect(() => { return ( window.ade.github?.onStatusChanged?.((status) => { + if (!currentProjectRoot || !shouldLoadShellGithubStatus(location.pathname, isRemoteProject)) { + return; + } setGithubStatus(status); }) ?? (() => {}) ); - }, []); + }, [currentProjectRoot, isRemoteProject, location.pathname]); useEffect(() => { if (!window.ade.feedback?.onUpdate) return; @@ -871,6 +958,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { useEffect(() => { if (!project?.rootPath || showWelcome) return; + if (isRemoteProject) return; if (isOnboardingRoute) return; if (onboardingStatusLoading) return; if ( @@ -887,6 +975,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { onboardingStatus?.dismissedAt, onboardingStatus?.freshProject, onboardingStatusLoading, + isRemoteProject, project?.rootPath, showWelcome, ]); @@ -1022,6 +1111,58 @@ export function AppShell({ children }: { children: React.ReactNode }) { staleCliNotice && currentProjectRoot ? `${currentProjectRoot}:${staleCliNotice.count}:${staleCliNotice.oldestStartedAt}` : null; + const activeRemoteConnection = + activeRemoteBinding && remoteSnapshot + ? remoteSnapshot.connections.find( + (entry) => entry.target.id === activeRemoteBinding.targetId, + ) ?? null + : null; + const remoteConnectionNotice = useMemo(() => { + if (!activeRemoteBinding || !remoteSnapshot) return null; + const state = activeRemoteConnection?.state ?? "idle"; + if (state === "connected") return null; + const lastError = cleanRemoteConnectionError( + activeRemoteConnection?.lastError ?? null, + ); + const key = [ + activeRemoteBinding.key, + state, + activeRemoteConnection?.lastAttemptedAt ?? 0, + lastError, + ].join(":"); + if (state === "connecting") { + return { + key, + state, + badge: "Reconnecting", + title: `Reconnecting to ${activeRemoteBinding.runtimeName}`, + body: "ADE is restoring the remote session. The project stays open while it retries.", + }; + } + if (state === "error") { + return { + key, + state, + badge: "Disconnected", + title: `${activeRemoteBinding.runtimeName} is unreachable`, + body: lastError + ? `${lastError} ADE will keep trying to reconnect while this project is open.` + : "ADE will keep trying to reconnect while this project is open.", + }; + } + return { + key, + state, + badge: "Disconnected", + title: `${activeRemoteBinding.runtimeName} is not connected`, + body: "ADE will try to reconnect when the remote project needs runtime data.", + }; + }, [activeRemoteBinding, activeRemoteConnection, remoteSnapshot]); + const visibleRemoteConnectionNotice = + remoteConnectionNotice && + remoteConnectionNotice.key !== dismissedRemoteNoticeKey + ? remoteConnectionNotice + : null; return (
@@ -1203,8 +1344,99 @@ export function AppShell({ children }: { children: React.ReactNode }) { children )}
- {staleCliNotice || prToasts.length > 0 ? ( + {visibleRemoteConnectionNotice || staleCliNotice || prToasts.length > 0 ? (
+ {visibleRemoteConnectionNotice ? ( +
+
+
+ {visibleRemoteConnectionNotice.state === "connecting" ? ( + + ) : ( + + )} +
+
+
+
+ + {visibleRemoteConnectionNotice.badge} + +
+ {visibleRemoteConnectionNotice.title} +
+
+ +
+
+ {visibleRemoteConnectionNotice.body} +
+
+ +
+
+
+
+ ) : null} {staleCliNotice ? (
diff --git a/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx b/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx index da5894b2f..2ea1dcdfc 100644 --- a/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx +++ b/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx @@ -45,6 +45,7 @@ import { copyLaunchPromptToClipboard } from "../../lib/launchPromptClipboard"; const INITIAL_VISIBILITY_CHECK_DELAY_MS = 2_000; const VISIBILITY_RETRY_INTERVAL_MS = 3_000; +const REMOTE_VISIBILITY_RETRY_INTERVAL_MS = 15_000; const VISIBILITY_CONNECTED_CACHE_TTL_MS = 60_000; const VISIBILITY_DISCONNECTED_CACHE_TTL_MS = 1_500; @@ -109,6 +110,7 @@ export function LinearQuickViewButton({ onMenuActivate?: () => void; } = {}) { const project = useAppStore((s) => s.project); + const projectBinding = useAppStore((s) => s.projectBinding); const lanes = useAppStore((s) => s.lanes); const refreshLanes = useAppStore((s) => s.refreshLanes); const selectLane = useAppStore((s) => s.selectLane); @@ -130,14 +132,21 @@ export function LinearQuickViewButton({ const occludesNativeBrowser = open || batchModalOpen; // Remembers each issue's chosen config so "Retry failed" reuses the same model. const batchConfigByIssueRef = useRef>(new Map()); + const activeProjectRoot = + projectBinding?.kind === "remote" ? projectBinding.rootPath : project?.rootPath; + const shouldAutoCheckVisibility = projectBinding?.kind !== "remote"; + const visibilityRetryIntervalMs = + projectBinding?.kind === "remote" + ? REMOTE_VISIBILITY_RETRY_INTERVAL_MS + : VISIBILITY_RETRY_INTERVAL_MS; const loadVisibility = useCallback(async (options?: { force?: boolean }): Promise => { return readLinearVisibilityCached({ - projectRoot: project?.rootPath, + projectRoot: activeProjectRoot, reader: window.ade.cto?.getLinearConnectionStatus, force: options?.force === true, }); - }, [project?.rootPath]); + }, [activeProjectRoot]); const openLinearSettings = useCallback(() => { setConnectionPrompt(null); @@ -181,11 +190,14 @@ export function LinearQuickViewButton({ }, [occludesNativeBrowser]); useEffect(() => { - if (variant !== "icon") return; - let cancelled = false; setVisible(false); setOpen(false); setQuickView(null); + }, [activeProjectRoot]); + + useEffect(() => { + if (!shouldAutoCheckVisibility) return; + let cancelled = false; const timer = window.setTimeout(() => { void loadVisibility() .then((nextVisible) => { @@ -199,10 +211,10 @@ export function LinearQuickViewButton({ cancelled = true; window.clearTimeout(timer); }; - }, [loadVisibility, project?.rootPath, variant]); + }, [loadVisibility, shouldAutoCheckVisibility]); useEffect(() => { - if (variant !== "icon") return; + if (!shouldAutoCheckVisibility) return; let timer: number | null = null; let cancelled = false; const onBridge = () => { @@ -228,11 +240,11 @@ export function LinearQuickViewButton({ if (timer != null) window.clearTimeout(timer); window.removeEventListener("ade:runtime-bridge-ready", onBridge); }; - }, [loadVisibility, variant]); + }, [loadVisibility, shouldAutoCheckVisibility]); useEffect(() => { - if (variant !== "icon") return; - if (!project?.rootPath) return; + if (!shouldAutoCheckVisibility) return; + if (!activeProjectRoot) return; let cancelled = false; const refresh = () => { void loadVisibility({ force: true }) @@ -248,12 +260,12 @@ export function LinearQuickViewButton({ cancelled = true; window.removeEventListener("focus", refresh); }; - }, [loadVisibility, project?.rootPath, variant]); + }, [loadVisibility, activeProjectRoot, shouldAutoCheckVisibility]); useEffect(() => { - if (variant !== "icon") return; + if (!shouldAutoCheckVisibility) return; if (visible) return; - if (!project?.rootPath) return; + if (!activeProjectRoot) return; let cancelled = false; const interval = window.setInterval(() => { void loadVisibility().then((v) => { @@ -262,12 +274,12 @@ export function LinearQuickViewButton({ window.clearInterval(interval); } }).catch(() => {}); - }, VISIBILITY_RETRY_INTERVAL_MS); + }, visibilityRetryIntervalMs); return () => { cancelled = true; window.clearInterval(interval); }; - }, [loadVisibility, visible, project?.rootPath, variant]); + }, [loadVisibility, visible, activeProjectRoot, visibilityRetryIntervalMs, shouldAutoCheckVisibility]); const openQuickView = useCallback(() => { if (cachedQuickViewRef.current) { @@ -277,9 +289,9 @@ export function LinearQuickViewButton({ }, []); const close = useCallback(() => { - clearLinearQuickViewSelection(project?.rootPath); + clearLinearQuickViewSelection(activeProjectRoot); setOpen(false); - }, [project?.rootPath]); + }, [activeProjectRoot]); useEffect(() => { if (!open) return; diff --git a/apps/desktop/src/renderer/components/app/TabNav.tsx b/apps/desktop/src/renderer/components/app/TabNav.tsx index be121ca83..8f8ea7c8b 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.tsx @@ -52,11 +52,14 @@ function githubProfileUrl(login: string): string { export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) { const project = useAppStore((s) => s.project); + const projectBinding = useAppStore((s) => s.projectBinding); const showWelcome = useAppStore((s) => s.showWelcome); const terminalAttention = useAppStore((s) => s.terminalAttention); const macosVmTabIndicator = useAppStore((s) => s.macosVmTabIndicator); const location = useLocation(); - const hasActiveProject = Boolean(project?.rootPath); + const activeProjectRoot = + projectBinding?.kind === "remote" ? projectBinding.rootPath : (project?.rootPath ?? null); + const hasActiveProject = Boolean(activeProjectRoot); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [avatarBroken, setAvatarBroken] = useState(false); const [isPackaged, setIsPackaged] = useState(false); @@ -99,7 +102,7 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) const onWelcomeLanding = showWelcome || !hasActiveProject; const isActive = !onWelcomeLanding && primaryTabPath(location.pathname) === it.to; const isActiveAllowed = !showWelcome && hasActiveProject; - const navTarget = it.to === "/prs" ? readStoredPrsRoute(project?.rootPath) ?? it.to : it.to; + const navTarget = it.to === "/prs" ? readStoredPrsRoute(activeProjectRoot) ?? it.to : it.to; if (!isActiveAllowed) { return ( @@ -130,6 +133,7 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) data-active={isActive ? "true" : undefined} onClick={() => { logRendererDebugEvent("renderer.tab_nav.click", { + projectRoot: activeProjectRoot, from: location.pathname, to: navTarget, showWelcome, @@ -263,7 +267,7 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) {/* Context menu */} - {contextMenu && project?.rootPath ? ( + {contextMenu && activeProjectRoot ? (
{ setContextMenu(null); - window.ade.app.revealPath(project.rootPath).catch(() => {}); + window.ade.app.revealPath(activeProjectRoot).catch(() => {}); }} > {revealLabel} diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index e1e6f86eb..767c37b1b 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -93,6 +93,41 @@ function makeSyncSnapshot(overrides: Record = {}) { }; } +function makeRemoteConnectionSnapshot( + targetId: string, + name = "Mac Studio", + overrides: Record = {}, +) { + const state = overrides.state ?? "connected"; + return { + connections: [ + { + target: { + id: targetId, + name, + hostname: "studio.local", + sshUser: "admin", + port: 22, + sshKeyPath: null, + routes: [], + lastSeenArch: "darwin-arm64", + runtimeBinaryVersion: "1.0.0-beta.1", + lastConnectedAt: 1_700_000_000, + }, + state, + arch: "darwin-arm64", + version: "1.0.0-beta.1", + projects: [], + lastError: overrides.lastError ?? null, + lastAttemptedAt: overrides.lastAttemptedAt ?? null, + connectedAt: state === "connected" ? 1_700_000_000 : null, + }, + ], + connectedCount: state === "connected" ? 1 : 0, + updatedAt: 1_700_000_000, + }; +} + function resetStore() { useAppStore.setState({ project: { rootPath: "/Users/arul/ADE", name: "ADE" } as any, @@ -116,6 +151,8 @@ function resetStore() { cancelNewTab: vi.fn(), projectTransition: null, projectTransitionError: null, + openProjectTabRoots: [], + projectInfoByRoot: {}, clearProjectTransitionError: vi.fn(), switchProjectToPath: vi.fn(async () => undefined), switchRemoteProject: vi.fn(async (targetId: string, projectId: string) => ({ @@ -313,6 +350,8 @@ describe("TopBar", () => { }); it("renders a remote project tab without local sync polling", async () => { + (globalThis.window.ade.remoteRuntime.getConnectionSnapshot as any) + .mockResolvedValue(makeRemoteConnectionSnapshot("studio")); useAppStore.setState({ project: { rootPath: "/srv/ade/remote-app", displayName: "Remote App", baseRef: "main" }, projectBinding: { @@ -330,14 +369,48 @@ describe("TopBar", () => { render(); - expect(await screen.findByTitle("Mac Studio: /srv/ade/remote-app")).toBeTruthy(); + expect(await screen.findByTitle("Mac Studio: /srv/ade/remote-app (Connected)")).toBeTruthy(); expect(screen.getByText("Remote App")).toBeTruthy(); expect(screen.getByLabelText("Remote: Mac Studio")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Remote, connected" })).toBeTruthy(); expect(globalThis.window.ade.sync.getStatus).not.toHaveBeenCalled(); expect(screen.queryByTitle("Connect a phone to this machine")).toBeNull(); }); + it("marks a remote project tab disconnected when the target snapshot is errored", async () => { + (globalThis.window.ade.remoteRuntime.getConnectionSnapshot as any) + .mockResolvedValue( + makeRemoteConnectionSnapshot("studio", "Mac Studio", { + state: "error", + lastError: "Remote ADE service connection was interrupted.", + lastAttemptedAt: 1_700_000_001, + }), + ); + useAppStore.setState({ + project: { rootPath: "/srv/ade/remote-app", displayName: "Remote App", baseRef: "main" }, + projectBinding: { + kind: "remote", + key: "remote:studio:project-1", + targetId: "studio", + runtimeName: "Mac Studio", + projectId: "project-1", + rootPath: "/srv/ade/remote-app", + displayName: "Remote App", + }, + projectHydrated: true, + showWelcome: false, + } as any); + + render(); + + expect(await screen.findByTitle("Mac Studio: /srv/ade/remote-app (Disconnected)")).toBeTruthy(); + expect(screen.getByLabelText("Disconnected: Mac Studio")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Remote, not connected" })).toBeTruthy(); + }); + it("keeps local tabs visible when a remote project is active", async () => { + (globalThis.window.ade.remoteRuntime.getConnectionSnapshot as any) + .mockResolvedValue(makeRemoteConnectionSnapshot("studio")); render(); const localTab = await screen.findByTitle("/Users/arul/ADE"); @@ -359,7 +432,7 @@ describe("TopBar", () => { } as any); }); - expect(await screen.findByTitle("Mac Studio: /srv/ade/remote-app")).toBeTruthy(); + expect(await screen.findByTitle("Mac Studio: /srv/ade/remote-app (Connected)")).toBeTruthy(); expect(screen.getByTitle("/Users/arul/ADE")).toBeTruthy(); fireEvent.click(localTab); @@ -433,6 +506,83 @@ describe("TopBar", () => { expect(useAppStore.getState().switchProjectToPath).not.toHaveBeenCalled(); }); + it("does not render the active remote project as a local project tab", async () => { + const remoteBinding = { + kind: "remote" as const, + key: "remote:target-1:project-a", + targetId: "target-1", + runtimeName: "Mac Studio", + projectId: "project-a", + rootPath: "/Users/admin/Projects/perf pass", + displayName: "perf pass", + }; + (globalThis.window.ade.remoteRuntime.getConnectionSnapshot as any) + .mockResolvedValue(makeRemoteConnectionSnapshot("target-1")); + useAppStore.setState({ + project: { + rootPath: remoteBinding.rootPath, + displayName: remoteBinding.displayName, + baseRef: "main", + } as any, + projectBinding: remoteBinding, + openProjectTabRoots: [remoteBinding.rootPath], + } as any); + (globalThis.window.ade.app.getWindowSession as any).mockResolvedValueOnce({ + windowId: 1, + project: null, + binding: remoteBinding, + openProjectTabs: [], + }); + + render(); + + await waitFor(() => { + expect(screen.getByTitle("Mac Studio: /Users/admin/Projects/perf pass (Connected)")).toBeTruthy(); + expect(screen.queryByTitle("/Users/admin/Projects/perf pass")).toBeNull(); + expect(useAppStore.getState().openProjectTabRoots).toEqual([]); + }); + }); + + it("keeps a known local tab when a remote project has the same root path", async () => { + const remoteBinding = { + kind: "remote" as const, + key: "remote:target-1:project-a", + targetId: "target-1", + runtimeName: "Mac Studio", + projectId: "project-a", + rootPath: "/Users/arul/Projects/perf pass", + displayName: "perf pass", + }; + (globalThis.window.ade.remoteRuntime.getConnectionSnapshot as any) + .mockResolvedValue(makeRemoteConnectionSnapshot("target-1")); + useAppStore.setState({ + project: { + rootPath: remoteBinding.rootPath, + displayName: remoteBinding.displayName, + baseRef: "main", + } as any, + projectBinding: remoteBinding, + projectInfoByRoot: { + [remoteBinding.rootPath]: { + rootPath: remoteBinding.rootPath, + displayName: "Local perf pass", + baseRef: "main", + }, + }, + openProjectTabRoots: [remoteBinding.rootPath], + } as any); + + render(); + + expect( + await screen.findByTitle("Mac Studio: /Users/arul/Projects/perf pass (Connected)"), + ).toBeTruthy(); + expect(screen.getByTitle("/Users/arul/Projects/perf pass")).toBeTruthy(); + expect(useAppStore.getState().openProjectTabRoots).toEqual([ + remoteBinding.rootPath, + ]); + }); + it("does not detach again after a project tab is dropped onto an ADE target", async () => { render(); @@ -549,12 +699,18 @@ describe("TopBar", () => { fireEvent.click(await screen.findByTitle("Manage remote machines")); - expect(await screen.findByText("Remote machines")).toBeTruthy(); + expect( + await screen.findByRole("dialog", { name: "Remote machines" }), + ).toBeTruthy(); await waitFor(() => expect(events).toEqual(["start"])); fireEvent.click(screen.getByTitle("Close remote machines")); - await waitFor(() => expect(screen.queryByText("Remote machines")).toBeNull()); + await waitFor(() => + expect( + screen.queryByRole("dialog", { name: "Remote machines" }), + ).toBeNull(), + ); expect(events).toEqual(["start", "end"]); } finally { window.removeEventListener(ADE_BROWSER_VIEW_OCCLUSION_START_EVENT, onStart); @@ -1041,6 +1197,51 @@ describe("TopBar", () => { } }); + it("does not run automatic hidden Linear checks on remote projects", async () => { + vi.useFakeTimers(); + useAppStore.setState({ + project: null, + projectBinding: { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Mac Studio", + projectId: "project-1", + rootPath: "/Users/admin/Projects/perf pass", + displayName: "perf pass", + }, + } as any); + const getLinearConnectionStatus = vi.fn(async () => ({ + tokenStored: true, + connected: false, + viewerId: null, + viewerName: null, + checkedAt: "2026-04-22T01:00:00.000Z", + authMode: "manual", + oauthAvailable: true, + tokenExpiresAt: null, + message: "Linear connection check is still starting.", + })); + globalThis.window.ade.cto = { + getLinearConnectionStatus, + } as any; + + try { + render(); + + await act(async () => { + window.dispatchEvent(new Event("ade:runtime-bridge-ready")); + window.dispatchEvent(new Event("focus")); + vi.advanceTimersByTime(30_000); + await flushMicrotasks(2); + }); + expect(getLinearConnectionStatus).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: /linear quick view/i })).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + it("shows project icon replacement errors", async () => { globalThis.window.ade.project.chooseIcon = vi.fn(async () => { throw new Error("Failed to set project icon: Project icon must be 10 MB or smaller."); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 9b207a699..b63de4ef3 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -42,6 +42,7 @@ import type { OpenProjectBinding, RecentProjectSummary, RemoteRuntimeConnectionSnapshot, + RemoteRuntimeTarget, SyncRoleSnapshot, AppResourceUsageSnapshot, } from "../../../shared/types"; @@ -51,6 +52,7 @@ import { HelpMenu } from "../onboarding/HelpMenu"; import { LinearQuickViewButton } from "./LinearQuickViewButton"; import { PublishToGitHubDialog } from "../projects/PublishToGitHubDialog"; import { RemoteTargetList } from "../remoteTargets/RemoteTargetList"; +import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; import { SyncDevicesSection } from "../settings/SyncDevicesSection"; import { HeaderUsageControl } from "../usage/HeaderUsageControl"; import { appResourcePressureLevel, getAppResourceUsageCoalesced, resourcePressureDescription } from "../../lib/resourcePressure"; @@ -952,6 +954,11 @@ export function TopBar() { ); const [phoneSyncOpen, setPhoneSyncOpen] = useState(false); const [remotePanelOpen, setRemotePanelOpen] = useState(false); + const { + state: remoteDisconnectConfirmState, + confirmAsync: confirmRemoteDisconnect, + close: closeRemoteDisconnectConfirm, + } = useConfirmDialog(); const [remoteSnapshot, setRemoteSnapshot] = useState(null); const [feedbackOpen, setFeedbackOpen] = useState(false); @@ -1004,6 +1011,7 @@ export function TopBar() { hasGitHubRemote === false && hasOrigin === false; const connectedRemoteCount = remoteSnapshot?.connectedCount ?? 0; + const remoteStatusCount = Math.max(connectedRemoteCount, openRemoteProjectTabs.length); const remoteConnected = connectedRemoteCount > 0; const syncConnected = isSyncConnected(syncSnapshot); const showSyncControl = workspaceProjectOpen; @@ -1073,6 +1081,11 @@ export function TopBar() { useEffect(() => { if (!remoteBinding) return; + setOpenProjectTabRoots((prev) => + useAppStore.getState().projectInfoByRoot[remoteBinding.rootPath] + ? prev + : prev.filter((rootPath) => rootPath !== remoteBinding.rootPath), + ); setOpenRemoteProjectTabs((prev) => { const existingIndex = prev.findIndex( (entry) => entry.key === remoteBinding.key, @@ -1124,6 +1137,8 @@ export function TopBar() { useAppStore.getState().rememberProjectInfo(tabProject); } setOpenProjectTabRoots(session.openProjectTabs.map((entry) => entry.rootPath)); + } else if (session.binding?.kind === "remote" || !session.project) { + setOpenProjectTabRoots([]); } }) .catch(() => { @@ -1459,6 +1474,99 @@ export function TopBar() { switchRemoteProject, ]); + const confirmAndCloseRemoteTargetTabs = useCallback( + async ( + target: RemoteRuntimeTarget, + action: "disconnect" | "remove", + ): Promise => { + const latestRemoteTabs = openRemoteProjectTabsRef.current; + const affectedTabs = latestRemoteTabs.filter( + (entry) => entry.targetId === target.id, + ); + const targetName = target.name || target.hostname; + const affectedCount = affectedTabs.length; + const affectedProjectLines = + affectedTabs.length > 0 + ? affectedTabs.map((entry) => `- ${entry.displayName}`).join("\n") + : ""; + const verb = action === "remove" ? "Removing" : "Disconnecting"; + const reconnectCopy = action === "remove" + ? "Add the machine again to reconnect." + : "ADE will not reconnect to this machine until you connect again."; + const message = + affectedCount > 0 + ? [ + `${affectedCount} open project tab${affectedCount === 1 ? "" : "s"} use this remote connection:`, + affectedProjectLines, + "", + `${verb} will close those project tabs. ${reconnectCopy}`, + ].join("\n") + : action === "remove" + ? "Removing this machine will delete its saved SSH details." + : "Disconnecting will stop this remote connection. ADE will not reconnect to this machine until you connect again."; + + const confirmed = await confirmRemoteDisconnect({ + title: action === "remove" + ? `Remove ${targetName}?` + : `Disconnect ${targetName}?`, + message, + confirmLabel: action === "remove" ? "REMOVE" : "DISCONNECT", + danger: true, + }); + if (!confirmed) return false; + if (affectedTabs.length === 0) return true; + + const affectedKeys = new Set(affectedTabs.map((entry) => entry.key)); + const nextRemoteTabs = latestRemoteTabs.filter( + (entry) => !affectedKeys.has(entry.key), + ); + openRemoteProjectTabsRef.current = nextRemoteTabs; + setOpenRemoteProjectTabs(nextRemoteTabs); + + const latestState = useAppStore.getState(); + const latestRemoteBinding = + latestState.projectBinding?.kind === "remote" + ? latestState.projectBinding + : null; + if (!latestRemoteBinding || !affectedKeys.has(latestRemoteBinding.key)) { + return true; + } + + const nextRemoteTab = nextRemoteTabs[0] ?? null; + if (nextRemoteTab) { + latestState.switchRemoteProject( + nextRemoteTab.targetId, + nextRemoteTab.projectId, + ).catch(() => {}); + return true; + } + + const nextLocalRoot = + openProjectTabRootsRef.current[ + openProjectTabRootsRef.current.length - 1 + ] ?? null; + if (nextLocalRoot) { + latestState.switchProjectToPath(nextLocalRoot).catch(() => {}); + } else { + latestState.closeProject().catch(() => {}); + } + return true; + }, + [confirmRemoteDisconnect], + ); + + const handleRemoteTargetDisconnectRequested = useCallback( + (target: RemoteRuntimeTarget): Promise => + confirmAndCloseRemoteTargetTabs(target, "disconnect"), + [confirmAndCloseRemoteTargetTabs], + ); + + const handleRemoteTargetRemoveRequested = useCallback( + (target: RemoteRuntimeTarget): Promise => + confirmAndCloseRemoteTargetTabs(target, "remove"), + [confirmAndCloseRemoteTargetTabs], + ); + const handleRelocate = useCallback( (oldPath: string) => { setRelocatingPath(oldPath); @@ -1779,7 +1887,11 @@ export function TopBar() { return (
- + {remoteChip} {mobileChip}
@@ -1791,12 +1903,13 @@ export function TopBar() { {remoteChip} {mobileChip} - + ); }, [ phoneSyncOpen, + remoteBinding, remoteConnected, remotePanelOpen, showSyncControl, @@ -1858,22 +1971,42 @@ export function TopBar() { <> {openRemoteProjectTabs.map((remoteTab) => { const isCurrentRemote = remoteBinding?.key === remoteTab.key; + const remoteTabConnection = + remoteSnapshot?.connections.find( + (entry) => entry.target.id === remoteTab.targetId, + ) ?? null; + const remoteTabState = remoteTabConnection?.state ?? "idle"; + const remoteTabConnected = remoteTabState === "connected"; + const remoteTabConnecting = remoteTabState === "connecting"; + const remoteTabDisconnected = + remoteTabState === "error" || remoteTabState === "idle"; + const remoteTabStatusLabel = remoteTabConnected + ? "Connected" + : remoteTabConnecting + ? "Reconnecting" + : "Disconnected"; return (
handleSwitchRemoteProject(remoteTab)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { @@ -1892,12 +2025,28 @@ export function TopBar() { {remoteTab.displayName} - + {remoteTabConnecting ? ( + + ) : remoteTabDisconnected ? ( + + ) : ( + + )} -
-
- + +
+
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 05c42e977..d9ecf2148 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -72,6 +72,12 @@ function LocationProbe() { ); } +async function expectLocationText(expected: string): Promise { + await waitFor(() => { + expect(screen.getByTestId("location").textContent).toBe(expected); + }); +} + function renderMessageList( events: AgentChatEventEnvelope[], options?: { @@ -818,7 +824,7 @@ describe("AgentChatMessageList transcript rendering", () => { // "absorbs tool summaries" test removed: tested old ChatWorkLogBlock // summary absorption rendering which changes with UI iterations. - it("makes workspace markdown links open the Files tab", () => { + it("makes workspace markdown links open the Files tab", async () => { renderMessageList( [ { @@ -839,7 +845,7 @@ describe("AgentChatMessageList transcript rendering", () => { fireEvent.click(screen.getByRole("button", { name: "AgentChatMessageList.tsx" })); - expect(screen.getByTestId("location").textContent).toBe( + await expectLocationText( "/files::{\"openFilePath\":\"apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx\",\"laneId\":\"lane-123\"}", ); }); @@ -863,9 +869,7 @@ describe("AgentChatMessageList transcript rendering", () => { }, ); - await waitFor(() => { - expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); - }); + expect(globalThis.window.ade.files.listWorkspaces).not.toHaveBeenCalled(); fireEvent.click( screen.getByRole("button", { @@ -873,7 +877,10 @@ describe("AgentChatMessageList transcript rendering", () => { }), ); - expect(screen.getByTestId("location").textContent).toBe( + await waitFor(() => { + expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); + }); + await expectLocationText( "/files::{\"openFilePath\":\"apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx\",\"laneId\":\"lane-123\"}", ); }); @@ -908,13 +915,14 @@ describe("AgentChatMessageList transcript rendering", () => { }, ); - await waitFor(() => { - expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); - }); + expect(globalThis.window.ade.files.listWorkspaces).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "C:\\Users\\me\\repo\\src\\main.ts" })); - expect(screen.getByTestId("location").textContent).toBe( + await waitFor(() => { + expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); + }); + await expectLocationText( "/files::{\"openFilePath\":\"src/main.ts\",\"laneId\":\"lane-win\"}", ); }); @@ -949,13 +957,14 @@ describe("AgentChatMessageList transcript rendering", () => { }, ); - await waitFor(() => { - expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); - }); + expect(globalThis.window.ade.files.listWorkspaces).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "c:\\users\\me\\repo\\src\\main.ts" })); - expect(screen.getByTestId("location").textContent).toBe( + await waitFor(() => { + expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); + }); + await expectLocationText( "/files::{\"openFilePath\":\"src/main.ts\",\"laneId\":\"lane-win\"}", ); }); @@ -990,13 +999,14 @@ describe("AgentChatMessageList transcript rendering", () => { }, ); - await waitFor(() => { - expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); - }); + expect(globalThis.window.ade.files.listWorkspaces).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "main.ts" })); - expect(screen.getByTestId("location").textContent).toBe( + await waitFor(() => { + expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); + }); + await expectLocationText( "/files::{\"openFilePath\":\"src/main.ts\",\"laneId\":\"lane-win\"}", ); }); @@ -1031,13 +1041,14 @@ describe("AgentChatMessageList transcript rendering", () => { }, ); - await waitFor(() => { - expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); - }); + expect(globalThis.window.ade.files.listWorkspaces).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "C:\\Users\\me\\repo\\src\\main.ts:42:5" })); - expect(screen.getByTestId("location").textContent).toBe( + await waitFor(() => { + expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); + }); + await expectLocationText( "/files::{\"openFilePath\":\"src/main.ts\",\"laneId\":\"lane-win\",\"startLine\":42,\"startColumn\":5}", ); }); @@ -1072,13 +1083,14 @@ describe("AgentChatMessageList transcript rendering", () => { }, ); - await waitFor(() => { - expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); - }); + expect(globalThis.window.ade.files.listWorkspaces).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "C:\\Users\\me\\repo\\src\\..\\main.ts:42" })); - expect(screen.getByTestId("location").textContent).toBe( + await waitFor(() => { + expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); + }); + await expectLocationText( "/files::{\"openFilePath\":\"main.ts\",\"laneId\":\"lane-win\",\"startLine\":42}", ); }); @@ -1113,13 +1125,14 @@ describe("AgentChatMessageList transcript rendering", () => { }, ); - await waitFor(() => { - expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); - }); + expect(globalThis.window.ade.files.listWorkspaces).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "\\\\server\\share\\repo\\src\\main.ts" })); - expect(screen.getByTestId("location").textContent).toBe( + await waitFor(() => { + expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); + }); + await expectLocationText( "/files::{\"openFilePath\":\"src/main.ts\",\"laneId\":\"lane-unc\"}", ); }); @@ -1154,13 +1167,14 @@ describe("AgentChatMessageList transcript rendering", () => { }, ); - await waitFor(() => { - expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); - }); + expect(globalThis.window.ade.files.listWorkspaces).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "file://server/share/repo/src/main.ts#line=12" })); - expect(screen.getByTestId("location").textContent).toBe( + await waitFor(() => { + expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); + }); + await expectLocationText( "/files::{\"openFilePath\":\"src/main.ts\",\"laneId\":\"lane-unc\",\"startLine\":12}", ); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 5eefb7d85..762bc29a1 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -4168,26 +4168,6 @@ function AgentChatMessageListMain({ ? (location.state as { laneId: string }).laneId : null; - useEffect(() => { - let cancelled = false; - const listWorkspaces = window.ade?.files?.listWorkspaces; - if (typeof listWorkspaces !== "function") return; - listWorkspaces() - .then((workspaces) => { - if (!cancelled) { - setFilesWorkspaces(workspaces); - } - }) - .catch(() => { - if (!cancelled) { - setFilesWorkspaces([]); - } - }); - return () => { - cancelled = true; - }; - }, []); - const openWorkspacePath = useCallback(async (path: string | WorkspacePathLocation) => { let resolvedWorkspaces = filesWorkspaces; let target = resolveFilesNavigationTarget({ diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 030519dda..22e668cec 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -16,8 +16,11 @@ import type { TerminalSessionDetail, } from "../../../shared/types"; import { createDynamicCursorCliModelDescriptor, getModelById } from "../../../shared/modelRegistry"; -import { invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; +import { invalidateAgentChatSessionListCache } from "../../lib/agentChatSessionListCache"; +import { invalidateAgentChatSlashCommandsCache } from "../../lib/agentChatSlashCommandsCache"; +import { getAiStatusCached, invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; import { DRAFT_LAUNCH_JOB_STALE_AFTER_MS } from "../../lib/draftLaunchJobs"; +import { invalidateProjectConfigCache } from "../../lib/projectConfigCache"; import { useAppStore } from "../../state/appStore"; import { rememberRuntimeCatalog, @@ -639,6 +642,7 @@ function installAdeMocks(options?: { function resetChatTestStore() { useAppStore.setState({ project: null, + projectBinding: null, laneSnapshots: [], lanes: [], selectedLaneId: null, @@ -683,7 +687,10 @@ function installMatchMediaMock(): void { beforeEach(() => { installMatchMediaMock(); + invalidateAgentChatSessionListCache(); + invalidateAgentChatSlashCommandsCache(); invalidateAiDiscoveryCache(); + invalidateProjectConfigCache(); resetModelPickerRuntimeCatalogForTests(); window.localStorage.clear(); window.sessionStorage.clear(); @@ -711,7 +718,10 @@ beforeEach(() => { afterEach(() => { cleanup(); + invalidateAgentChatSessionListCache(); + invalidateAgentChatSlashCommandsCache(); invalidateAiDiscoveryCache(); + invalidateProjectConfigCache(); resetModelPickerRuntimeCatalogForTests(); Object.defineProperty(window.navigator, "platform", { configurable: true, @@ -787,6 +797,31 @@ function seedDrawerStore() { }); } +function seedRemoteChatStore() { + const rootPath = "/Users/admin/Projects/perf pass"; + useAppStore.setState({ + project: { rootPath, displayName: "perf pass" } as any, + projectBinding: { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + projectId: "project-1", + runtimeName: "Mac Studio", + displayName: "perf pass", + rootPath, + } as any, + lanes: [{ + id: "lane-1", + name: "remote lane", + branchRef: "refs/heads/remote-lane", + laneType: "worktree", + worktreePath: `${rootPath}/.ade/worktrees/remote-lane`, + } as any], + selectedLaneId: "lane-1", + }); + return rootPath; +} + function renderDrawerPane() { const session = buildSession("session-1", { title: "Drawer audit chat" }); installAdeMocks({ sessions: [session] }); @@ -959,6 +994,124 @@ function sessionTabTitles(expectedTitles: string[]) { return tabs.map((button) => button.textContent?.trim()); } +describe("AgentChatPane remote startup", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("uses the remote binding root for AI status cache lookups", async () => { + const session = buildSession("session-1", { status: "idle" }); + installAdeMocks({ sessions: [session] }); + window.ade.ai.getStatus = vi.fn().mockResolvedValue({ + mode: "subscription", + availableProviders: { + claude: { + binary: { present: false, source: "missing", path: null }, + auth: { ready: false, mode: "none", detail: null }, + }, + codex: true, + cursor: false, + droid: false, + }, + models: { claude: [], codex: [], cursor: [], droid: [] }, + features: [], + availableModelIds: ["openai/gpt-5.4"], + }) as any; + const remoteRoot = seedRemoteChatStore(); + await getAiStatusCached({ projectRoot: remoteRoot }); + vi.mocked(window.ade.ai.getStatus).mockClear(); + + renderPane(session); + + await screen.findByRole("button", { name: /^Select model/ }); + await Promise.resolve(); + + expect(window.ade.ai.getStatus).not.toHaveBeenCalled(); + }); + + it("skips mount-time session delta fetches for remote chats", async () => { + const session = buildSession("session-1", { status: "idle" }); + installAdeMocks({ sessions: [session] }); + seedRemoteChatStore(); + + renderPane(session); + + await screen.findByRole("button", { name: /^Select model/ }); + await Promise.resolve(); + + expect(window.ade.sessions.getDelta).not.toHaveBeenCalled(); + }); + + it("defers unfinished parallel launch recovery on remote draft mount", async () => { + vi.useFakeTimers(); + const { parallelLaunchStateGet } = installAdeMocks({ sessions: [] }); + seedRemoteChatStore(); + + render( + + + , + ); + + await act(async () => { + await Promise.resolve(); + }); + expect(parallelLaunchStateGet).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(14_999); + await Promise.resolve(); + }); + expect(parallelLaunchStateGet).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(1); + await Promise.resolve(); + }); + expect(parallelLaunchStateGet).toHaveBeenCalledWith({ + projectRoot: "/Users/admin/Projects/perf pass", + parentLaneId: "lane-1", + }); + }); + + it("fetches remote session delta after a turn completes", async () => { + const session = buildSession("session-1", { status: "idle" }); + const mocks = installAdeMocks({ sessions: [session] }); + vi.mocked(window.ade.sessions.getDelta).mockResolvedValue({ insertions: 4, deletions: 2 } as any); + seedRemoteChatStore(); + + renderPane(session); + + await screen.findByRole("button", { name: /^Select model/ }); + expect(window.ade.sessions.getDelta).not.toHaveBeenCalled(); + + await act(async () => { + mocks.emitChatEvent({ + sessionId: session.sessionId, + timestamp: "2026-06-04T12:00:00.000Z", + event: { type: "status", turnStatus: "started", turnId: "turn-1" }, + } as any); + }); + expect(window.ade.sessions.getDelta).not.toHaveBeenCalled(); + + await act(async () => { + mocks.emitChatEvent({ + sessionId: session.sessionId, + timestamp: "2026-06-04T12:00:01.000Z", + event: { type: "status", turnStatus: "completed", turnId: "turn-1" }, + } as any); + }); + + await waitFor(() => { + expect(window.ade.sessions.getDelta).toHaveBeenCalledWith(session.sessionId); + }); + }); +}); + describe("AgentChatPane companion drawers", () => { it("opens and closes the iOS simulator and App Control drawers from chat chrome", async () => { renderDrawerPane(); @@ -1447,10 +1600,11 @@ describe("AgentChatPane submit recovery", () => { await clickEnabledModelOption(/Claude Sonnet 4\.6/i); await waitFor(() => { - expect(window.ade.agentChat.slashCommands).toHaveBeenCalledWith({ + expect(window.ade.agentChat.slashCommands).toHaveBeenCalledWith(expect.objectContaining({ laneId: "lane-1", provider: "claude", - }); + projectRoot: "/tmp/project-under-test", + })); }); const textbox = await screen.findByRole("textbox"); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 953e5b316..e2698f837 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -121,7 +121,7 @@ import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; import { ChatActionsDrawerPanel, type ChatActionsTab } from "./ChatActionsDrawerPanel"; -import { useAppStore } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; import { buildChatAppearanceRootStyle } from "./chatAppearance"; import { copyLaunchPromptToClipboard } from "../../lib/launchPromptClipboard"; import { LaneAccentDot } from "../lanes/LaneAccentDot"; @@ -136,7 +136,13 @@ import { import { ClaudeCacheTtlBadge } from "../shared/ClaudeCacheTtlBadge"; import { WorkSurfaceHeader } from "../work/WorkSurfaceHeader"; import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl"; +import { + invalidateAgentChatSessionListCache, + listAgentChatSessionsCached, +} from "../../lib/agentChatSessionListCache"; +import { getAgentChatSlashCommandsCached } from "../../lib/agentChatSlashCommandsCache"; import { getAgentChatModelsCached, getAiStatusCached, invalidateAiDiscoveryCache, peekAiStatusCached } from "../../lib/aiDiscoveryCache"; +import { getProjectConfigCached } from "../../lib/projectConfigCache"; import { invalidateSessionListCache } from "../../lib/sessionListCache"; import { isDraftLaunchJobStale, @@ -182,6 +188,7 @@ const COMPOSER_DRAFT_WRITE_DEBOUNCE_MS = 350; const SUBAGENT_AUTOOPEN_FIRED_KEY_PREFIX = "ade.chat.subagentAutoOpenFired"; const SUBAGENT_AUTOOPEN_FIRED_TTL_MS = 7 * 24 * 60 * 60 * 1000; const workCliStartupDelayMs = 180; +const REMOTE_PARALLEL_LAUNCH_RECOVERY_DELAY_MS = 15_000; export const DEFAULT_PARALLEL_ATTACHMENT_REQUEST = "Please review the attached files."; const chatToolbarActionBase = @@ -2336,8 +2343,9 @@ export function AgentChatPane({ /** Callback when lane selection changes in empty state */ onLaneChange?: (laneId: string) => void; }) { - const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const projectTransition = useAppStore((s) => s.projectTransition); + const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote"); const agentTurnCompletionSound = useAppStore((s) => s.agentTurnCompletionSound); const agentTurnCompletionSoundVolume = useAppStore((s) => s.agentTurnCompletionSoundVolume); const agentTurnCompletionSoundQuietWhenFocused = useAppStore((s) => s.agentTurnCompletionSoundQuietWhenFocused); @@ -2738,8 +2746,18 @@ export function AgentChatPane({ useEffect(() => { const api = window.ade?.appControl; - if (!api?.getStatus) return; - if (!laneToolsVisible) return; + if (!api?.getStatus) { + setAppControlAvailable(false); + return; + } + if (!laneToolsVisible) { + setAppControlAvailable(false); + return; + } + if (isRemoteProject && !effectiveAppControlOpen) { + setAppControlAvailable(false); + return; + } let cancelled = false; void api.getStatus() .then((status) => { @@ -2753,7 +2771,7 @@ export function AgentChatPane({ return () => { cancelled = true; }; - }, [laneToolsVisible]); + }, [effectiveAppControlOpen, isRemoteProject, laneToolsVisible]); useEffect(() => { companionHydrationKeyRef.current = companionStateKey; @@ -3130,67 +3148,82 @@ export function AgentChatPane({ if (recoveredParallelLaunchKeyRef.current === recoveryKey) return; recoveredParallelLaunchKeyRef.current = recoveryKey; let cancelled = false; + let recoveryTimer: number | null = null; - void (async () => { - let pendingState: AgentChatParallelLaunchState | null = null; - try { - pendingState = await window.ade.agentChat.parallelLaunchState.get({ - projectRoot, - parentLaneId: laneId, - }); - } catch { - return; - } - if (!pendingState) return; - if (isCompletedParallelLaunchState(pendingState)) { - await persistParallelLaunchState(null); - return; - } + const recoverParallelLaunchState = () => { + void (async () => { + let pendingState: AgentChatParallelLaunchState | null = null; + try { + pendingState = await window.ade.agentChat.parallelLaunchState.get({ + projectRoot, + parentLaneId: laneId, + }); + } catch { + return; + } + if (!pendingState) return; + if (isCompletedParallelLaunchState(pendingState)) { + await persistParallelLaunchState(null); + return; + } - if (!pendingState.createdLaneIds.length) { - await persistParallelLaunchState(null); - return; - } + if (!pendingState.createdLaneIds.length) { + await persistParallelLaunchState(null); + return; + } - if (cancelled) return; - setParallelLaunchBusy(true); - setParallelLaunchStatus("Cleaning up unfinished parallel launch…"); - const cleanupIssues = await cleanupTransientParallelLaunchLanes({ - laneIds: pendingState.createdLaneIds, - deleteLane: (args) => window.ade.lanes.delete(args), - refreshLanes: refreshLanesStore, - onCleanupError: logParallelLaunchCleanupError, - }); + if (cancelled) return; + setParallelLaunchBusy(true); + setParallelLaunchStatus("Cleaning up unfinished parallel launch…"); + try { + const cleanupIssues = await cleanupTransientParallelLaunchLanes({ + laneIds: pendingState.createdLaneIds, + deleteLane: (args) => window.ade.lanes.delete(args), + refreshLanes: refreshLanesStore, + onCleanupError: logParallelLaunchCleanupError, + }); - if (cleanupIssues.length === 0) { - await persistParallelLaunchState(null); - } else { - await persistParallelLaunchState(buildParallelLaunchState({ - parentLaneId: pendingState.parentLaneId, - createdLaneIds: pendingState.createdLaneIds, - sentLaneIds: pendingState.sentLaneIds, - status: "cleanup_pending", - lastError: pendingState.lastError, - })); - if (!cancelled) { - setError(formatParallelLaunchFailureMessage({ - launchError: "Recovered an unfinished parallel launch from before ADE closed.", - cleanupIssues, - })); + if (cleanupIssues.length === 0) { + await persistParallelLaunchState(null); + } else { + await persistParallelLaunchState(buildParallelLaunchState({ + parentLaneId: pendingState.parentLaneId, + createdLaneIds: pendingState.createdLaneIds, + sentLaneIds: pendingState.sentLaneIds, + status: "cleanup_pending", + lastError: pendingState.lastError, + })); + if (!cancelled) { + setError(formatParallelLaunchFailureMessage({ + launchError: "Recovered an unfinished parallel launch from before ADE closed.", + cleanupIssues, + })); + } + } + } finally { + setParallelLaunchBusy(false); + setParallelLaunchStatus(null); } - } + })(); + }; - if (!cancelled) { - setParallelLaunchBusy(false); - setParallelLaunchStatus(null); - } - })(); + if (isRemoteProject) { + recoveryTimer = window.setTimeout(recoverParallelLaunchState, REMOTE_PARALLEL_LAUNCH_RECOVERY_DELAY_MS); + } else { + recoverParallelLaunchState(); + } return () => { cancelled = true; + setParallelLaunchBusy(false); + setParallelLaunchStatus(null); + if (recoveryTimer != null) { + window.clearTimeout(recoveryTimer); + } }; }, [ initialSessionId, + isRemoteProject, laneId, lockSessionId, persistParallelLaunchState, @@ -3917,6 +3950,10 @@ export function AgentChatPane({ }); }, []); + const invalidateCurrentChatSessionList = useCallback(() => { + invalidateAgentChatSessionListCache(laneId ? { laneId } : undefined); + }, [laneId]); + const refreshLockedSessionSummary = useCallback(async () => { if (!lockSessionId) { setSessions([]); @@ -3944,7 +3981,7 @@ export function AgentChatPane({ return summary; }, [initialSessionSummary, lockSessionId]); - const refreshSessions = useCallback(async () => { + const refreshSessions = useCallback(async (options?: { force?: boolean }) => { if (lockedSingleSessionMode && lockSessionId) { await refreshLockedSessionSummary(); return; @@ -3960,7 +3997,10 @@ export function AgentChatPane({ return; } - const allRows = await window.ade.agentChat.list({ laneId }); + const allRows = await listAgentChatSessionsCached( + { laneId }, + options?.force ? { force: true } : undefined, + ); const rows = allRows.filter((session) => !session.archivedAt); setArchivedSessions(sortSessionSummariesByRecency( allRows.filter((session) => Boolean(session.archivedAt)), @@ -4356,7 +4396,7 @@ export function AgentChatPane({ setLoading(!hasRenderableSession); setPreferencesReady(false); try { - const snapshot = await window.ade.projectConfig.get(); + const snapshot = await getProjectConfigCached({ projectRoot }); const chat = snapshot.effective.ai?.chat; if (!cancelled) { // Don't auto-restore model — user must pick one explicitly each session @@ -4637,6 +4677,10 @@ export function AgentChatPane({ setComputerUseSnapshot(null); return; } + if (isRemoteProject && !(chatActionsOpen && chatActionsTab === "proof")) { + setComputerUseSnapshot(null); + return; + } if (!lockedSingleSessionMode) { void refreshComputerUseSnapshot(selectedSessionId); return; @@ -4645,7 +4689,15 @@ export function AgentChatPane({ void refreshComputerUseSnapshot(selectedSessionId); }, 180); return () => window.clearTimeout(handle); - }, [isTileActive, lockedSingleSessionMode, refreshComputerUseSnapshot, selectedSessionId]); + }, [ + chatActionsOpen, + chatActionsTab, + isRemoteProject, + isTileActive, + lockedSingleSessionMode, + refreshComputerUseSnapshot, + selectedSessionId, + ]); useEffect(() => { setPromptSuggestion(null); @@ -4686,17 +4738,38 @@ export function AgentChatPane({ if (!selectedSessionId && !laneId) { setSdkSlashCommands([]); return; } let cancelled = false; const args = selectedSessionId - ? { sessionId: selectedSessionId } - : { laneId, provider: sessionProvider }; - window.ade.agentChat.slashCommands(args) + ? { sessionId: selectedSessionId, projectRoot } + : { laneId, provider: sessionProvider, projectRoot }; + getAgentChatSlashCommandsCached(args) .then((cmds) => { if (!cancelled) setSdkSlashCommands(cmds); }) .catch(() => { if (!cancelled) setSdkSlashCommands([]); }); return () => { cancelled = true; }; - }, [isTileActive, laneId, selectedSessionId, sessionProvider]); + }, [isTileActive, laneId, projectRoot, selectedSessionId, sessionProvider]); - // Fetch git diff stats when the session changes or a turn completes + const sessionDeltaTurnActiveRef = useRef(false); + const sessionDeltaSessionIdRef = useRef(null); + const remoteDeltaArmedSessionsRef = useRef>(new Set()); + + // Fetch git diff stats when the session changes or a turn completes. Remote + // chats skip the mount-time decoration fetch; the bridge should stay focused + // on loading the transcript until the user actually runs a turn. useEffect(() => { if (!selectedSessionId || !isTileActive) { setSessionDelta(null); return; } + const sameSession = sessionDeltaSessionIdRef.current === selectedSessionId; + const previousTurnActive = sameSession ? sessionDeltaTurnActiveRef.current : false; + sessionDeltaSessionIdRef.current = selectedSessionId; + sessionDeltaTurnActiveRef.current = turnActive; + if (isRemoteProject) { + const completedTurn = + sameSession + && previousTurnActive + && !turnActive; + if (!completedTurn) { + if (!turnActive) setSessionDelta(null); + return; + } + remoteDeltaArmedSessionsRef.current.delete(selectedSessionId); + } let cancelled = false; const fetchDelta = () => { window.ade.sessions.getDelta(selectedSessionId) @@ -4712,7 +4785,7 @@ export function AgentChatPane({ }; fetchDelta(); return () => { cancelled = true; }; - }, [isTileActive, selectedSessionId, turnActive]); + }, [isRemoteProject, isTileActive, selectedSessionId, turnActive]); const flushQueuedEvents = useCallback(() => { const queued = pendingEventQueueRef.current; @@ -4836,6 +4909,9 @@ export function AgentChatPane({ envelope.event.type === "user_message" || (envelope.event.type === "status" && envelope.event.turnStatus === "started") ) { + if (isRemoteProject && envelope.event.type === "status") { + remoteDeltaArmedSessionsRef.current.add(envelope.sessionId); + } patchSessionSummary(envelope.sessionId, { status: "active", idleSinceAt: null, @@ -4931,17 +5007,25 @@ export function AgentChatPane({ if (shouldRefreshSlashCommands) { if (envelope.sessionId === selectedSessionIdRef.current) { - window.ade.agentChat.slashCommands({ sessionId: envelope.sessionId }) + getAgentChatSlashCommandsCached( + { sessionId: envelope.sessionId }, + { + force: envelope.event.type === "system_notice", + }, + ) .then(setSdkSlashCommands) .catch(() => {}); } } }); return unsubscribe; - }, [isTileVisible, layoutVariant, lockSessionId, flushQueuedEvents, patchSessionSummary, scheduleQueuedEventFlush, scheduleSessionsRefresh, touchSession]); + }, [isRemoteProject, isTileVisible, layoutVariant, lockSessionId, flushQueuedEvents, patchSessionSummary, scheduleQueuedEventFlush, scheduleSessionsRefresh, touchSession]); useEffect(() => { if (!isTileActive) return undefined; + if (isRemoteProject && !(chatActionsOpen && chatActionsTab === "proof")) { + return undefined; + } const unsubscribe = window.ade.computerUse.onEvent((event) => { if (!selectedSessionId) return; if (event.owner?.kind === "chat_session" && event.owner.id === selectedSessionId) { @@ -4949,7 +5033,14 @@ export function AgentChatPane({ } }); return unsubscribe; - }, [isTileActive, refreshComputerUseSnapshot, selectedSessionId]); + }, [ + chatActionsOpen, + chatActionsTab, + isRemoteProject, + isTileActive, + refreshComputerUseSnapshot, + selectedSessionId, + ]); useEffect(() => { if (!selectedSessionId) { @@ -5592,6 +5683,7 @@ export function AgentChatPane({ ...nativeControlPayload, ...orchestratorOverrides, }); + invalidateAgentChatSessionListCache({ laneId: targetLaneId }); // Follow-up: allocate the orchestration bundle. We do this immediately // so the bundle path is persisted alongside the new chat (workers will // pick it up from the manifest). If it fails, stop before sending the @@ -5656,11 +5748,11 @@ export function AgentChatPane({ sessionId: created.id, modelId: launchModelId, }).then(() => { - if (targetLaneId === laneId) void refreshSessions(); + if (targetLaneId === laneId) void refreshSessions({ force: true }); }).catch(() => { /* warmup is best-effort */ }); } if (options.notify) notifySessionCreated(created, options.notifyOptions); - if (targetLaneId === laneId) void refreshSessions().catch(() => {}); + if (targetLaneId === laneId) void refreshSessions({ force: true }).catch(() => {}); return created; }, [codexFastMode, constrainedModelSelectionError, currentNativeControls, executionMode, initialNativeControls, laneId, lastLaunchConfigStorageKey, modelId, notifySessionCreated, patchSessionSummary, reasoningEffort, refreshSessions, touchSession, workDraftKind]); @@ -5939,8 +6031,9 @@ export function AgentChatPane({ optimisticSessionIdsRef.current.delete(session.id); knownSessionIdsRef.current.delete(session.id); invalidateSessionListCache(); + invalidateAgentChatSessionListCache({ laneId: targetLane.laneId }); if (targetLane.laneId === laneId) { - await refreshSessions().catch(() => undefined); + await refreshSessions({ force: true }).catch(() => undefined); } }, [laneId, refreshSessions]); @@ -6134,8 +6227,9 @@ export function AgentChatPane({ ? await startDraftChatLaunch(prepared, targetLane) : await startDraftCliLaunch(prepared, targetLane, mode); invalidateSessionListCache(); + invalidateAgentChatSessionListCache({ laneId: targetLane.laneId }); if (launched.draftKind === "chat" && targetLane.laneId === laneId) { - void refreshSessions().catch(() => {}); + void refreshSessions({ force: true }).catch(() => {}); } const launch = { laneId: targetLane.laneId, @@ -6235,7 +6329,8 @@ export function AgentChatPane({ }); setChatActionsOpen(false); notifySessionCreated(result.session); - void refreshSessions().catch(() => {}); + invalidateCurrentChatSessionList(); + void refreshSessions({ force: true }).catch(() => {}); } catch (handoffError) { setError(handoffError instanceof Error ? handoffError.message : String(handoffError)); } finally { @@ -6257,6 +6352,7 @@ export function AgentChatPane({ handoffOpenCodePermissionMode, handoffReasoningEffort, handoffTargetProvider, + invalidateCurrentChatSessionList, notifySessionCreated, refreshSessions, selectedSession?.permissionMode, @@ -6276,10 +6372,11 @@ export function AgentChatPane({ void window.ade.agentChat.delete({ sessionId: selectedSessionId }) .then(async () => { invalidateSessionListCache(); + invalidateCurrentChatSessionList(); draftsPerSessionRef.current.delete(selectedSessionId); localTouchBySessionRef.current.delete(selectedSessionId); loadedHistoryRef.current.delete(selectedSessionId); - await refreshSessions().catch(() => {}); + await refreshSessions({ force: true }).catch(() => {}); }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err); @@ -6288,23 +6385,24 @@ export function AgentChatPane({ .finally(() => { setDeletingChatSessionId((current) => (current === selectedSessionId ? null : current)); }); - }, [refreshSessions, selectedSession, selectedSessionId]); + }, [invalidateCurrentChatSessionList, refreshSessions, selectedSession, selectedSessionId]); const handleArchiveChat = useCallback((sessionId: string) => { setError(null); void window.ade.agentChat.archive({ sessionId }) .then(async () => { invalidateSessionListCache(); + invalidateCurrentChatSessionList(); if (selectedSessionIdRef.current === sessionId) { setSelectedSessionId(null); } - await refreshSessions().catch(() => {}); + await refreshSessions({ force: true }).catch(() => {}); }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err); setError(`Archive failed: ${message}`); }); - }, [refreshSessions]); + }, [invalidateCurrentChatSessionList, refreshSessions]); const archiveConfirm = useConfirmDialog(); const requestArchiveChat = useCallback( @@ -6324,13 +6422,14 @@ export function AgentChatPane({ void window.ade.agentChat.unarchive({ sessionId }) .then(async () => { invalidateSessionListCache(); - await refreshSessions().catch(() => {}); + invalidateCurrentChatSessionList(); + await refreshSessions({ force: true }).catch(() => {}); }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err); setError(`Restore failed: ${message}`); }); - }, [refreshSessions]); + }, [invalidateCurrentChatSessionList, refreshSessions]); // ── Eager session creation ── // Create a session as soon as we have a model + lane, so slash commands @@ -8288,7 +8387,7 @@ export function AgentChatPane({ cursorModeId: updatedSession.cursorModeId, cursorModeSnapshot: updatedSession.cursorModeSnapshot, }); - window.ade.agentChat.slashCommands({ sessionId: selectedSessionId }) + getAgentChatSlashCommandsCached({ sessionId: selectedSessionId }, { force: true }) .then(setSdkSlashCommands) .catch(() => {}); if ( diff --git a/apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx index 2e6be5728..ed0fb4fb5 100644 --- a/apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx @@ -19,7 +19,7 @@ import type { AgentChatFileRef } from "../../../shared/types"; import { inferAttachmentType } from "../../../shared/types"; import type { BuiltInBrowserTab } from "../../../shared/types/builtInBrowser"; import { consumePendingBuiltInBrowserNavigation } from "../../lib/openExternal"; -import { useAppStore } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; import { ADE_BROWSER_VIEW_OCCLUSION_END_EVENT, ADE_BROWSER_VIEW_OCCLUSION_START_EVENT, @@ -623,7 +623,7 @@ export function ChatBuiltInBrowserPanel({ onAddAttachment, onInsertDraft, }: ChatBuiltInBrowserPanelProps) { - const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const browserSurfaceRef = useRef(null); const browserWebviewsRef = useRef>(new Map()); const browserWebviewAttachCleanupRef = useRef void>>(new Map()); diff --git a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx index 4f8ebf6ac..50c123c2b 100644 --- a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { useAppStore } from "../../state/appStore"; @@ -50,8 +50,26 @@ function installAdeMocks() { } as any; } -function resetStore() { +function resetStore(options?: { remote?: boolean }) { + const rootPath = options?.remote ? "/Users/admin/Projects/perf pass" : "/tmp/project"; useAppStore.setState({ + project: { rootPath, displayName: options?.remote ? "perf pass" : "Project" } as any, + projectBinding: options?.remote + ? { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + projectId: "project-1", + runtimeName: "Mac Studio", + displayName: "perf pass", + rootPath, + } as any + : { + kind: "local", + key: `local:${rootPath}`, + rootPath, + displayName: "Project", + } as any, lanes: [{ id: "lane-1", name: "UI audit lane", @@ -111,4 +129,40 @@ describe("ChatGitToolbar", () => { "/prs?tab=normal&create=1&sourceLaneId=lane-1&target=primary", ); }); + + it("does not load diff or PR state when mounted for a remote project", async () => { + resetStore({ remote: true }); + + renderToolbar(); + + await Promise.resolve(); + + expect(window.ade.diff.getChanges).not.toHaveBeenCalled(); + expect(window.ade.prs.getForLane).not.toHaveBeenCalled(); + }); + + it("resolves the linked PR on first remote PR click before routing", async () => { + resetStore({ remote: true }); + vi.mocked(window.ade.prs.getForLane).mockResolvedValue({ + id: "pr-1", + laneId: "lane-1", + title: "Remote linked PR", + state: "open", + checksStatus: "unknown", + githubUrl: "https://github.com/acme/perf-pass/pull/1", + additions: 0, + deletions: 0, + updatedAt: null, + } as any); + + renderToolbar(); + + fireEvent.click(await screen.findByRole("button", { name: "PR" })); + + await waitFor(() => { + expect(screen.getByTestId("location").textContent).toBe("/prs?tab=normal&prId=pr-1"); + }); + expect(window.ade.prs.getForLane).toHaveBeenCalledTimes(1); + expect(window.ade.diff.getChanges).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx index 506488d2c..e9c98f2e0 100644 --- a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx @@ -16,6 +16,7 @@ import { cn } from "../ui/cn"; import type { DiffChanges, PrSummary, PrCheck } from "../../../shared/types"; import { useLaneGitActionRuntimeState } from "../lanes/LaneGitActionsPane"; import { formatPrBadgeLabel } from "../prs/shared/prFormatters"; +import { useAppStore } from "../../state/appStore"; // --------------------------------------------------------------------------- // Types @@ -103,9 +104,12 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ }: ChatGitToolbarProps) { const navigate = useNavigate(); const runtime = useLaneGitActionRuntimeState(laneId); + const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote"); const [dirtyCount, setDirtyCount] = useState(0); const [linkedPr, setLinkedPr] = useState(null); + const [prLoaded, setPrLoaded] = useState(false); + const [prActionBusy, setPrActionBusy] = useState(false); const [prMenuOpen, setPrMenuOpen] = useState(false); const [prChecks, setPrChecks] = useState(null); const [prChecksLoading, setPrChecksLoading] = useState(false); @@ -117,10 +121,7 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ const refreshStatus = useCallback(async () => { try { - const [, changes] = await Promise.all([ - window.ade.git.listBranches({ laneId }), - window.ade.diff.getChanges({ laneId }), - ]); + const changes = await window.ade.diff.getChanges({ laneId }); setDirtyCount(dirtyFileCount(changes)); } catch { // best-effort @@ -131,25 +132,39 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ try { const pr = await window.ade.prs.getForLane(laneId); setLinkedPr(pr); + setPrLoaded(true); + return pr; } catch { setLinkedPr(null); + setPrLoaded(true); + return null; } }, [laneId]); useEffect(() => { + setDirtyCount(0); + setLinkedPr(null); + setPrLoaded(false); + setPrMenuOpen(false); + setPrChecks(null); + if (isRemoteProject) return; void refreshStatus(); void refreshPr(); - }, [refreshStatus, refreshPr]); + }, [isRemoteProject, refreshStatus, refreshPr]); // Re-poll after the runtime finishes an action (from either pane or toolbar) const prevBusy = React.useRef(runtime.busyAction); useEffect(() => { if (prevBusy.current && !runtime.busyAction) { + if (isRemoteProject && !prLoaded) { + prevBusy.current = runtime.busyAction; + return; + } void refreshStatus(); void refreshPr(); } prevBusy.current = runtime.busyAction; - }, [runtime.busyAction, refreshStatus, refreshPr]); + }, [isRemoteProject, prLoaded, runtime.busyAction, refreshStatus, refreshPr]); // Subscribe to backend PR events so the linked-PR pill reflects external // changes (PR closed, merged, checks finished, etc.) without a manual refresh. @@ -158,6 +173,7 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ if (event.type !== "prs-updated") return; // Only re-fetch when an update could plausibly touch this lane's PR. if (event.prs.some((pr) => pr.laneId === laneId)) { + if (isRemoteProject && !prLoaded && !linkedPr) return; void refreshPr(); } else if (linkedPr && !event.prs.some((pr) => pr.id === linkedPr.id)) { // The linked PR vanished from the latest snapshot — clear the pill. @@ -165,21 +181,36 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ } }); return unsubscribe; - }, [laneId, linkedPr, refreshPr]); + }, [isRemoteProject, laneId, linkedPr, prLoaded, refreshPr]); - const handlePr = useCallback(() => { + const handlePr = useCallback(async () => { if (linkedPr) { navigate(`/prs?tab=normal&prId=${encodeURIComponent(linkedPr.id)}`); - } else { - const params = new URLSearchParams({ - tab: "normal", - create: "1", - sourceLaneId: laneId, - target: "primary", - }); - navigate(`/prs?${params.toString()}`); + return; } - }, [laneId, linkedPr, navigate]); + + if (!prLoaded) { + setPrActionBusy(true); + const latestPr = await refreshPr().finally(() => setPrActionBusy(false)); + if (latestPr) { + navigate(`/prs?tab=normal&prId=${encodeURIComponent(latestPr.id)}`); + return; + } + } + + const params = new URLSearchParams({ + tab: "normal", + create: "1", + sourceLaneId: laneId, + target: "primary", + }); + navigate(`/prs?${params.toString()}`); + }, [laneId, linkedPr, navigate, prLoaded, refreshPr]); + + const handlePrClick = useCallback(() => { + if (prActionBusy) return; + void handlePr(); + }, [handlePr, prActionBusy]); // Reset menu state when the linked PR identity changes (lane switch, PR // unlinked) so stale data from another PR doesn't show. @@ -243,7 +274,7 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ } }, [linkedPr]); - const isBusy = Boolean(runtime.busyAction); + const isBusy = Boolean(runtime.busyAction) || prActionBusy; // ----------------------------------------------------------------------- // PR badge @@ -396,7 +427,7 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({
) : ( - diff --git a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.test.tsx b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.test.tsx index b9bd46097..4ce26c9b2 100644 --- a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.test.tsx @@ -98,6 +98,22 @@ describe("ChatTerminalDrawer", () => { expect(onToggle).toHaveBeenCalledTimes(2); }); + it("does not restore terminal tabs while the drawer is closed", async () => { + render( + , + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(window.ade.terminal.list).not.toHaveBeenCalled(); + expect(window.ade.appControl.getStatus).not.toHaveBeenCalled(); + }); + it("switches restored terminal tabs", async () => { vi.mocked(window.ade.terminal.list).mockResolvedValueOnce([ { diff --git a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx index c3c2e8151..35dc8b138 100644 --- a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx @@ -250,6 +250,7 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ useEffect(() => { if (!chatSessionId) return; + if (!open && !revealRequest) return; let cancelled = false; setRestoringTabs(true); window.ade.terminal.list({ chatSessionId, limit: 20 }) @@ -285,7 +286,7 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ return () => { cancelled = true; }; - }, [chatSessionId, uiStateKey]); + }, [chatSessionId, open, revealRequest, uiStateKey]); useEffect(() => { if (!revealRequest) return; @@ -417,6 +418,7 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ }, []); useEffect(() => { + if (!open) return undefined; const appControlBridge = window.ade?.appControl; if (!appControlBridge) return undefined; let cancelled = false; @@ -437,7 +439,7 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ cancelled = true; unsubscribe(); }; - }, []); + }, [open]); const closeTab = useCallback((tabId: string) => { const entry = tabsRef.current.find((tab) => tab.id === tabId); diff --git a/apps/desktop/src/renderer/components/files/FilesExplorer.test.tsx b/apps/desktop/src/renderer/components/files/FilesExplorer.test.tsx new file mode 100644 index 000000000..15d847fe8 --- /dev/null +++ b/apps/desktop/src/renderer/components/files/FilesExplorer.test.tsx @@ -0,0 +1,64 @@ +/* @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FilesExplorer, type FilesExplorerProps } from "./FilesExplorer"; + +afterEach(cleanup); + +function renderExplorer(overrides: Partial = {}) { + const props: FilesExplorerProps = { + tree: [], + expanded: new Set(), + loadingDirectories: new Set(), + selectedNodePath: null, + activeTabPath: null, + activeContextDir: "", + workspaceComparisonRoot: null, + searchQuery: "", + inlineRenameRequest: null, + onSearchQueryChange: vi.fn(), + singleRowHeader: true, + onCreateFile: vi.fn(), + onCreateDirectory: vi.fn(), + onToggleDirectory: vi.fn(), + onOpenFile: vi.fn(), + onSelectNode: vi.fn(), + onContextMenu: vi.fn(), + onRenamePath: vi.fn(async () => undefined), + onInlineRenameSettled: vi.fn(), + ...overrides, + }; + render(); + return props; +} + +describe("FilesExplorer mutating controls", () => { + it("disables create controls for read-only workspaces", () => { + const props = renderExplorer({ canMutate: false }); + + const newFile = screen.getByLabelText("New file") as HTMLButtonElement; + const newFolder = screen.getByLabelText("New folder") as HTMLButtonElement; + expect(newFile.disabled).toBe(true); + expect(newFolder.disabled).toBe(true); + + fireEvent.click(newFile); + fireEvent.click(newFolder); + expect(props.onCreateFile).not.toHaveBeenCalled(); + expect(props.onCreateDirectory).not.toHaveBeenCalled(); + }); + + it("keeps create controls active for mutable workspaces", () => { + const props = renderExplorer({ canMutate: true }); + + const newFile = screen.getByLabelText("New file") as HTMLButtonElement; + const newFolder = screen.getByLabelText("New folder") as HTMLButtonElement; + expect(newFile.disabled).toBe(false); + expect(newFolder.disabled).toBe(false); + + fireEvent.click(newFile); + fireEvent.click(newFolder); + expect(props.onCreateFile).toHaveBeenCalledWith(""); + expect(props.onCreateDirectory).toHaveBeenCalledWith(""); + }); +}); diff --git a/apps/desktop/src/renderer/components/files/FilesExplorer.tsx b/apps/desktop/src/renderer/components/files/FilesExplorer.tsx index f041323bb..76f8b616b 100644 --- a/apps/desktop/src/renderer/components/files/FilesExplorer.tsx +++ b/apps/desktop/src/renderer/components/files/FilesExplorer.tsx @@ -77,6 +77,7 @@ export type FilesExplorerProps = { onContextMenu: (event: FilesExplorerContextMenuEvent) => void; onRenamePath: (sourcePath: string, destinationPath: string) => Promise; onInlineRenameSettled: () => void; + canMutate?: boolean; compact?: boolean; }; @@ -172,6 +173,7 @@ export function FilesExplorer({ onContextMenu, onRenamePath, onInlineRenameSettled, + canMutate = true, compact = false, }: FilesExplorerProps) { const scrollRef = useRef(null); @@ -282,6 +284,7 @@ export function FilesExplorer({ type="button" title="New file" aria-label="New file" + disabled={!canMutate} style={{ ...outlineButton({ height: 24, padding: "0 6px", fontSize: 10 }) }} onClick={() => onCreateFile(activeContextDir)} onMouseEnter={(event) => { event.currentTarget.style.borderColor = COLORS.accent; event.currentTarget.style.color = COLORS.accent; }} @@ -295,6 +298,7 @@ export function FilesExplorer({ type="button" title="New folder" aria-label="New folder" + disabled={!canMutate} style={{ ...outlineButton({ height: 24, padding: "0 6px", fontSize: 10 }) }} onClick={() => onCreateDirectory(activeContextDir)} onMouseEnter={(event) => { event.currentTarget.style.borderColor = COLORS.accent; event.currentTarget.style.color = COLORS.accent; }} @@ -357,6 +361,7 @@ export function FilesExplorer({ type="button" title="New file" aria-label="New file" + disabled={!canMutate} style={{ ...outlineButton({ height: 28, padding: "0 7px", fontSize: 10 }) }} onClick={() => onCreateFile(activeContextDir)} onMouseEnter={(event) => { event.currentTarget.style.borderColor = COLORS.accent; event.currentTarget.style.color = COLORS.accent; }} @@ -370,6 +375,7 @@ export function FilesExplorer({ type="button" title="New folder" aria-label="New folder" + disabled={!canMutate} style={{ ...outlineButton({ height: 28, padding: "0 7px", fontSize: 10 }) }} onClick={() => onCreateDirectory(activeContextDir)} onMouseEnter={(event) => { event.currentTarget.style.borderColor = COLORS.accent; event.currentTarget.style.color = COLORS.accent; }} @@ -458,6 +464,7 @@ export function FilesExplorer({ type="button" title="New file" aria-label="New file" + disabled={!canMutate} style={{ ...outlineButton({ height: 24, padding: "0 6px", fontSize: 10 }) }} onClick={() => onCreateFile(activeContextDir)} onMouseEnter={(event) => { event.currentTarget.style.borderColor = COLORS.accent; event.currentTarget.style.color = COLORS.accent; }} @@ -471,6 +478,7 @@ export function FilesExplorer({ type="button" title="New folder" aria-label="New folder" + disabled={!canMutate} style={{ ...outlineButton({ height: 24, padding: "0 6px", fontSize: 10 }) }} onClick={() => onCreateDirectory(activeContextDir)} onMouseEnter={(event) => { event.currentTarget.style.borderColor = COLORS.accent; event.currentTarget.style.color = COLORS.accent; }} diff --git a/apps/desktop/src/renderer/components/files/treeHelpers.test.ts b/apps/desktop/src/renderer/components/files/treeHelpers.test.ts new file mode 100644 index 000000000..7b28bdb72 --- /dev/null +++ b/apps/desktop/src/renderer/components/files/treeHelpers.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { isUnavailableGitDecorationsError } from "./treeHelpers"; + +describe("isUnavailableGitDecorationsError", () => { + it("matches optional remote git decoration action availability failures", () => { + expect( + isUnavailableGitDecorationsError( + new Error( + "Error invoking remote method 'ade.remoteRuntime.callAction': Error: Action 'file.refreshGitDecorations' is not callable.", + ), + ), + ).toBe(true); + expect( + isUnavailableGitDecorationsError( + new Error("Action 'file.refreshGitDecorations' is not exposed through ADE actions."), + ), + ).toBe(true); + }); + + it("does not hide unrelated Files errors", () => { + expect(isUnavailableGitDecorationsError(new Error("ENOENT: no such file or directory"))).toBe(false); + expect(isUnavailableGitDecorationsError(new Error("Action 'file.readFile' is not callable."))).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/components/files/treeHelpers.ts b/apps/desktop/src/renderer/components/files/treeHelpers.ts index 20057bd6d..4882b3b7b 100644 --- a/apps/desktop/src/renderer/components/files/treeHelpers.ts +++ b/apps/desktop/src/renderer/components/files/treeHelpers.ts @@ -39,6 +39,12 @@ export function formatFilesError(err: unknown, fallback = "File operation failed ); } +export function isUnavailableGitDecorationsError(err: unknown): boolean { + const message = formatFilesError(err); + return /file\.refreshGitDecorations/i.test(message) + && /not callable|not exposed|unavailable|not a function|not implemented|not found|no .*handler|missing .*handler|handler missing|does not exist/i.test(message); +} + export function isMissingWorkspaceRootError(message: string): boolean { return /(?:ENOENT|no such file or directory|worktree is missing|workspace is missing)/i.test(message); } diff --git a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx index 39a076d6a..012d5b3e4 100644 --- a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx +++ b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx @@ -12,6 +12,7 @@ import { defaultFilesWorkspaceId, filesSessionKey, formatFilesError, + isUnavailableGitDecorationsError, mergeTreePreservingLoadedChildren, replaceTreeNodeChildren, } from "../treeHelpers"; @@ -31,7 +32,7 @@ import { } from "./editorGroupsStore"; import { resolveViewerKind } from "./viewerRegistry"; import { invalidateFileContent, primeFileContent } from "./useFileContent"; -import { getRecentFiles, recordRecentFile } from "./recentFiles"; +import { forgetRecentFile, getRecentFiles, isNestedFilePath, pruneMissingRootRecentFiles, recordRecentFile } from "./recentFiles"; import { EditorGroups } from "./EditorGroups"; import { StatusBar } from "./StatusBar"; import { WarmEmptyState } from "./WarmEmptyState"; @@ -124,6 +125,21 @@ export function FilesWorkbench({ () => new Set(Object.values(groupsState.groups).flatMap((g) => g.tabs.map((t) => t.path))).size, [groupsState.groups], ); + const knownRootPaths = useMemo(() => new Set(tree.map((node) => node.path)), [tree]); + const recentFiles = getRecentFiles(sessionKey); + const visibleRecentFiles = useMemo( + () => ( + tree.length > 0 + ? recentFiles.filter((path) => isNestedFilePath(path) || knownRootPaths.has(path)) + : recentFiles + ), + [knownRootPaths, recentFiles, tree.length], + ); + + useEffect(() => { + if (tree.length === 0) return; + pruneMissingRootRecentFiles(sessionKey, knownRootPaths); + }, [knownRootPaths, sessionKey, tree.length]); /* ---- Workspace resolution ---- */ useEffect(() => { @@ -168,7 +184,13 @@ export function FilesWorkbench({ return merged; }); setError(null); - const decorations = await window.ade.files.refreshGitDecorations({ workspaceId: reqId, forceFresh: true }); + let decorations = null; + try { + decorations = await window.ade.files.refreshGitDecorations({ workspaceId: reqId, forceFresh: true }); + } catch (decorationError) { + if (!isUnavailableGitDecorationsError(decorationError)) throw decorationError; + } + if (!decorations) return; if (workspaceIdRef.current !== reqId) return; setTree((prev) => { const decorated = applyGitStatusToTree(prev, decorations); @@ -435,29 +457,42 @@ export function FilesWorkbench({ const renamePath = useCallback( async (sourcePath: string, destinationPath: string) => { if (!workspaceId) return; - await window.ade.files.rename({ workspaceId, oldPath: sourcePath, newPath: destinationPath }).catch((err) => { + if (!canEdit) { + setError("This workspace is read-only."); + return; + } + try { + await window.ade.files.rename({ workspaceId, oldPath: sourcePath, newPath: destinationPath }); + } catch (err) { setError(err instanceof Error ? err.message : String(err)); - }); + return; + } + forgetRecentFile(sessionKey, sourcePath); closeOpenTabsUnder(sourcePath); // old path/tabs are stale after rename await refreshRoot(); }, - [workspaceId, refreshRoot, closeOpenTabsUnder], + [workspaceId, canEdit, sessionKey, refreshRoot, closeOpenTabsUnder], ); const deletePath = useCallback( async (path: string) => { if (!workspaceId) return; + if (!canEdit) { + setError("This workspace is read-only."); + return; + } const ok = window.confirm(`Delete "${path}"? This cannot be undone.`); if (!ok) return; try { await window.ade.files.delete({ workspaceId, path }); + forgetRecentFile(sessionKey, path); closeOpenTabsUnder(path); await refreshRoot(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } }, - [workspaceId, refreshRoot, closeOpenTabsUnder], + [workspaceId, canEdit, sessionKey, refreshRoot, closeOpenTabsUnder], ); const dirForNode = (menu: FilesExplorerContextMenuEvent): string => @@ -472,20 +507,24 @@ export function FilesWorkbench({ items.push({ type: "item", label: "Open", onClick: () => void openFile(path, { preview: false }) }); items.push({ type: "separator" }); } - items.push({ type: "item", label: "New File…", icon: , onClick: () => setOverlay({ kind: "create", create: "file", baseDir }) }); - items.push({ type: "item", label: "New Folder…", icon: , onClick: () => setOverlay({ kind: "create", create: "directory", baseDir }) }); + items.push({ type: "item", label: "New File…", icon: , onClick: () => setOverlay({ kind: "create", create: "file", baseDir }), disabled: !canEdit }); + items.push({ type: "item", label: "New Folder…", icon: , onClick: () => setOverlay({ kind: "create", create: "directory", baseDir }), disabled: !canEdit }); items.push({ type: "separator" }); - items.push({ type: "item", label: "Rename…", icon: , onClick: () => setInlineRename({ path, nonce: ++renameNonceRef.current }) }); - items.push({ type: "item", label: "Delete", icon: , danger: true, onClick: () => void deletePath(path) }); + items.push({ type: "item", label: "Rename…", icon: , onClick: () => setInlineRename({ path, nonce: ++renameNonceRef.current }), disabled: !canEdit }); + items.push({ type: "item", label: "Delete", icon: , danger: true, onClick: () => void deletePath(path), disabled: !canEdit }); items.push({ type: "separator" }); items.push({ type: "item", label: "Copy Path", icon: , onClick: () => void window.ade.app.writeClipboardText?.(path) }); items.push({ type: "item", label: "Reveal in Finder", icon: , onClick: () => void window.ade.app.openPathInEditor?.({ rootPath, relativePath: path, target: "finder" }).catch(() => {}) }); return items; - }, [treeMenu, openFile, deletePath, rootPath]); + }, [treeMenu, openFile, canEdit, deletePath, rootPath]); const createInWorkspace = useCallback( async (kind: "file" | "directory", baseDir: string, name: string) => { if (!workspaceId) return; + if (!canEdit) { + setError("This workspace is read-only."); + return; + } const rel = baseDir ? `${baseDir}/${name}` : name; try { if (kind === "file") await window.ade.files.createFile({ workspaceId, path: rel }); @@ -496,7 +535,7 @@ export function FilesWorkbench({ setError(err instanceof Error ? err.message : String(err)); } }, - [workspaceId, refreshRoot, openFile], + [workspaceId, canEdit, refreshRoot, openFile], ); // Files-scoped keybindings: ⌘P / ⌘⇧F both open the unified in-depth search. @@ -572,6 +611,7 @@ export function FilesWorkbench({ onContextMenu={setTreeMenu} onRenamePath={renamePath} onInlineRenameSettled={() => setInlineRename(null)} + canMutate={canEdit} compact={embedded} />
@@ -582,7 +622,7 @@ export function FilesWorkbench({ workspaceName={workspace?.name ?? null} branch={branch} dirtyCount={dirtyPaths.size} - recents={getRecentFiles(sessionKey)} + recents={visibleRecentFiles} onOpen={(path) => void openFile(path, { preview: false })} onSearch={() => setOverlay({ kind: "search", query: "" })} modifierKey={modifierKeyLabel} diff --git a/apps/desktop/src/renderer/components/files/v2/recentFiles.test.ts b/apps/desktop/src/renderer/components/files/v2/recentFiles.test.ts new file mode 100644 index 000000000..001f6c2db --- /dev/null +++ b/apps/desktop/src/renderer/components/files/v2/recentFiles.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { forgetRecentFile, getRecentFiles, pruneMissingRootRecentFiles, recordRecentFile } from "./recentFiles"; + +describe("recentFiles", () => { + it("removes stale paths without disturbing other recents", () => { + const sessionKey = "recent-files-test-remove"; + + recordRecentFile(sessionKey, "README.md"); + recordRecentFile(sessionKey, "deleted.txt"); + recordRecentFile(sessionKey, "src/index.ts"); + + forgetRecentFile(sessionKey, "deleted.txt"); + + expect(getRecentFiles(sessionKey)).toEqual(["src/index.ts", "README.md"]); + }); + + it("keeps recents unique and most-recent first", () => { + const sessionKey = "recent-files-test-order"; + + recordRecentFile(sessionKey, "README.md"); + recordRecentFile(sessionKey, "src/index.ts"); + recordRecentFile(sessionKey, "README.md"); + + expect(getRecentFiles(sessionKey)).toEqual(["README.md", "src/index.ts"]); + }); + + it("prunes root-level recents missing from the loaded root tree", () => { + const sessionKey = "recent-files-test-prune-root"; + + recordRecentFile(sessionKey, "README.md"); + recordRecentFile(sessionKey, "deleted.txt"); + recordRecentFile(sessionKey, "src/index.ts"); + + const visible = pruneMissingRootRecentFiles(sessionKey, new Set(["README.md"])); + + expect(visible).toEqual(["src/index.ts", "README.md"]); + expect(getRecentFiles(sessionKey)).toEqual(["src/index.ts", "README.md"]); + }); + + it("keeps nested Windows-style paths while pruning missing root files", () => { + const sessionKey = "recent-files-test-prune-windows"; + + recordRecentFile(sessionKey, "README.md"); + recordRecentFile(sessionKey, "deleted.txt"); + recordRecentFile(sessionKey, "src\\index.ts"); + + const visible = pruneMissingRootRecentFiles(sessionKey, new Set(["README.md"])); + + expect(visible).toEqual(["src\\index.ts", "README.md"]); + expect(getRecentFiles(sessionKey)).toEqual(["src\\index.ts", "README.md"]); + }); +}); diff --git a/apps/desktop/src/renderer/components/files/v2/recentFiles.ts b/apps/desktop/src/renderer/components/files/v2/recentFiles.ts index d4f9f7ab3..65db4bcc7 100644 --- a/apps/desktop/src/renderer/components/files/v2/recentFiles.ts +++ b/apps/desktop/src/renderer/components/files/v2/recentFiles.ts @@ -3,6 +3,10 @@ const recentsBySession = new Map(); const MAX_RECENTS = 8; +export function isNestedFilePath(path: string): boolean { + return /[\\/]/.test(path); +} + export function recordRecentFile(sessionKey: string, path: string): void { const current = recentsBySession.get(sessionKey) ?? []; const next = [path, ...current.filter((p) => p !== path)].slice(0, MAX_RECENTS); @@ -18,3 +22,10 @@ export function forgetRecentFile(sessionKey: string, path: string): void { if (!current) return; recentsBySession.set(sessionKey, current.filter((p) => p !== path)); } + +export function pruneMissingRootRecentFiles(sessionKey: string, knownRootPaths: ReadonlySet): string[] { + const current = recentsBySession.get(sessionKey) ?? []; + const next = current.filter((path) => isNestedFilePath(path) || knownRootPaths.has(path)); + if (next.length !== current.length) recentsBySession.set(sessionKey, next); + return next; +} diff --git a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx index 98da76118..573b44144 100644 --- a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx +++ b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx @@ -48,7 +48,7 @@ import type { PrWithConflicts, IntegrationProposal } from "../../../shared/types"; -import { useAppStore } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; import { buildIntegrationSourcesByLaneId, isIntegrationLaneFromMetadata, @@ -163,7 +163,7 @@ function GraphInner({ active = true }: { active?: boolean }) { const [searchParams, setSearchParams] = useSearchParams(); const reactFlow = useReactFlow, Edge>(); const project = useAppStore((s) => s.project); - const projectRoot = project?.rootPath ?? null; + const projectRoot = useAppStore(selectActiveProjectRoot); const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote"); const lanes = useAppStore((s) => s.lanes); const lanesKey = React.useMemo(() => lanes.map((l) => l.id).join(","), [lanes]); @@ -435,7 +435,7 @@ function GraphInner({ active = true }: { active?: boolean }) { React.useEffect(() => { if (!active) return; void refreshEnvironmentMappings(); - }, [active, project?.rootPath, refreshEnvironmentMappings]); + }, [active, projectRoot, refreshEnvironmentMappings]); const riskRefreshTimerRef = React.useRef(null); const dragOriginRef = React.useRef>(new Map()); @@ -809,7 +809,7 @@ function GraphInner({ active = true }: { active?: boolean }) { } catch { // ignore } - }, [project?.rootPath]); + }, [projectRoot]); const scheduleRefreshActivity = React.useCallback((delayMs = 700, options?: { includeOperations?: boolean }) => { if (options?.includeOperations !== false) { @@ -818,7 +818,6 @@ function GraphInner({ active = true }: { active?: boolean }) { if (activityRefreshTimerRef.current != null) return; activityRefreshTimerRef.current = window.setTimeout(() => { activityRefreshTimerRef.current = null; - if (document.visibilityState !== "visible") return; if (activityRefreshInFlightRef.current) { activityRefreshQueuedRef.current = true; return; @@ -840,8 +839,8 @@ function GraphInner({ active = true }: { active?: boolean }) { React.useEffect(() => { if (!active) return; - if (!project?.rootPath) return; - const rootPath = project.rootPath; + if (!projectRoot) return; + const rootPath = projectRoot; let cancelled = false; let riskTimer: number | null = null; let activityTimer: number | null = null; @@ -873,19 +872,19 @@ function GraphInner({ active = true }: { active?: boolean }) { } riskTimer = window.setTimeout(() => { - if (cancelled || document.visibilityState !== "visible") return; + if (cancelled) return; void refreshRiskBatch(); }, 1_500); activityTimer = window.setTimeout(() => { - if (cancelled || document.visibilityState !== "visible") return; + if (cancelled) return; scheduleRefreshActivity(250, { includeOperations: true }); }, 800); syncTimer = window.setTimeout(() => { - if (cancelled || document.visibilityState !== "visible") return; + if (cancelled) return; void refreshLaneSyncStatuses(); }, 2_500); autoRebaseTimer = window.setTimeout(() => { - if (cancelled || document.visibilityState !== "visible") return; + if (cancelled) return; void refreshAutoRebaseStatuses(); }, 3_500); @@ -896,7 +895,7 @@ function GraphInner({ active = true }: { active?: boolean }) { if (syncTimer != null) window.clearTimeout(syncTimer); if (autoRebaseTimer != null) window.clearTimeout(autoRebaseTimer); }; - }, [active, project?.rootPath, refreshAutoRebaseStatuses, refreshGraphLanes, refreshLaneSyncStatuses, refreshRiskBatch, reportGraphIssue, scheduleRefreshActivity]); + }, [active, projectRoot, refreshAutoRebaseStatuses, refreshGraphLanes, refreshLaneSyncStatuses, refreshRiskBatch, reportGraphIssue, scheduleRefreshActivity]); React.useEffect(() => { if (!active) return; @@ -959,12 +958,12 @@ function GraphInner({ active = true }: { active?: boolean }) { React.useEffect(() => { if (!active) return; - if (!project?.rootPath) { + if (!projectRoot) { setLoadedGraphPreferences(false); skipNextGraphPreferencePersistRootRef.current = null; return; } - const rootPath = project.rootPath; + const rootPath = projectRoot; let cancelled = false; setLoadedGraphPreferences(false); void window.ade.graphState @@ -990,17 +989,17 @@ function GraphInner({ active = true }: { active?: boolean }) { return () => { cancelled = true; }; - }, [active, project?.rootPath]); + }, [active, projectRoot]); React.useEffect(() => { if (!active) return; - if (!project?.rootPath || !loadedGraphPreferences) return; - if (skipNextGraphPreferencePersistRootRef.current === project.rootPath) { + if (!projectRoot || !loadedGraphPreferences) return; + if (skipNextGraphPreferencePersistRootRef.current === projectRoot) { skipNextGraphPreferencePersistRootRef.current = null; return; } - void window.ade.graphState.set(project.rootPath, createGraphPreferences(viewMode)).catch(() => {}); - }, [active, loadedGraphPreferences, project?.rootPath, viewMode]); + void window.ade.graphState.set(projectRoot, createGraphPreferences(viewMode)).catch(() => {}); + }, [active, loadedGraphPreferences, projectRoot, viewMode]); React.useEffect(() => { if (!undoToast) return; @@ -1202,7 +1201,7 @@ function GraphInner({ active = true }: { active?: boolean }) { reportGraphIssue("Conflict prediction live updates are unavailable in the graph.", error); } try { - const currentProjectRoot = project?.rootPath ?? null; + const currentProjectRoot = projectRoot; const isCurrentProjectEvent = (event: { projectRoot?: string | null }) => !event.projectRoot || event.projectRoot === currentProjectRoot; unsubPtyData = window.ade.pty.onData((event) => { @@ -1276,7 +1275,7 @@ function GraphInner({ active = true }: { active?: boolean }) { prRefreshTimerRef.current = null; } }; - }, [active, project?.rootPath, refreshLaneSyncStatuses, refreshGraphLanes, refreshRiskBatch, refreshAutoRebaseStatuses, reportGraphIssue, scheduleRefreshActivity, scheduleRefreshPrs]); + }, [active, projectRoot, refreshLaneSyncStatuses, refreshGraphLanes, refreshRiskBatch, refreshAutoRebaseStatuses, reportGraphIssue, scheduleRefreshActivity, scheduleRefreshPrs]); const baseGraph = React.useMemo(() => { if (!loadedGraphPreferences) { diff --git a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx index b43178c06..edf6d8771 100644 --- a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx @@ -75,6 +75,28 @@ function makeProps(overrides: Partial = {}): DialogProps { } describe("CreateLaneDialog VM-lane gate", () => { + it("describes the default runtime as the local Mac", () => { + render(); + + expect(screen.getByText("Local Mac")).toBeTruthy(); + expect(screen.getByText("Use this ADE runtime and local worktree.")).toBeTruthy(); + }); + + it("uses the provided runtime copy for a remote project", () => { + render( + , + ); + + expect(screen.getByText("Mac Studio")).toBeTruthy(); + expect(screen.getByText("Use this connected remote ADE runtime and remote worktree.")).toBeTruthy(); + expect(screen.queryByText("Use this ADE runtime and local worktree.")).toBeNull(); + }); + it("disables the Mac VM radio and shows the setup CTA when no VM exists", () => { const onOpenVmTab = vi.fn(); const onOpenChange = vi.fn(); diff --git a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx index 5336eec94..84ed02577 100644 --- a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx @@ -107,6 +107,8 @@ export function CreateLaneDialog({ existingVmLane = null, onOpenVmTab, onOpenVmLaneInWork, + localRuntimeLabel = "Local Mac", + localRuntimeDescription = "Use this ADE runtime and local worktree.", projectRoot, createBranches, lanes, @@ -158,6 +160,13 @@ export function CreateLaneDialog({ onOpenVmTab?: () => void; /** Open the Work tab on the existing VM lane. Used when a VM lane already exists. */ onOpenVmLaneInWork?: (laneId: string) => void; + /** Label for the non-VM runtime. + * Remote projects still submit `runtimePlacement: "local"`, but this copy + * should name the connected runtime. + */ + localRuntimeLabel?: string; + /** Description for the non-VM runtime card. */ + localRuntimeDescription?: string; /** Project scope for shared Linear issue browser cache/filter persistence. */ projectRoot?: string | null; createBranches: LaneBranchOption[]; @@ -568,11 +577,11 @@ export function CreateLaneDialog({
-
Local Mac
+
{localRuntimeLabel}
- Use this ADE runtime and local worktree. + {localRuntimeDescription}
diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 545a9fb67..c47b7da31 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -4,7 +4,7 @@ import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { Group, Panel } from "react-resizable-panels"; import { Check, CaretDown, FileCode, GitPullRequest, Stack, Link, ArrowsOutSimple, ArrowsInSimple, PushPin, Plus, MagnifyingGlass, Terminal, X, ArrowSquareOut, Info, ArrowCounterClockwise, UsersThree, CircleNotch } from "@phosphor-icons/react"; import { BranchIcon, LaneIcon } from "../ui/vcsIcons"; -import { useAppStore, useAppStoreApi, type LaneInspectorTab } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore, useAppStoreApi, type LaneInspectorTab } from "../../state/appStore"; import { buildIntegrationSourcesByLaneId } from "../../lib/integrationLanes"; import { EmptyState } from "../ui/EmptyState"; import { Button } from "../ui/Button"; @@ -249,6 +249,10 @@ function isTrustedGitHubUrl(rawUrl: string): boolean { } export function isLaneDeleteProgressActive(progress: LaneDeleteProgress | null | undefined): boolean { + return progress?.overallStatus === "running"; +} + +function isLaneDeleteProgressHydratable(progress: LaneDeleteProgress | null | undefined): boolean { return progress?.overallStatus === "running" || progress?.overallStatus === "completed" || progress?.overallStatus === "completed_with_warnings"; @@ -459,7 +463,24 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const clearLaneInspectorTab = useAppStore((s) => s.clearLaneInspectorTab); const setLaneWorkViewState = useAppStore((s) => s.setLaneWorkViewState); const keybindings = useAppStore((s) => s.keybindings); - const project = useAppStore((s) => s.project); + const projectBinding = useAppStore((s) => s.projectBinding); + const activeProjectRoot = useAppStore(selectActiveProjectRoot); + const createLaneRuntimeCopy = useMemo(() => { + if (projectBinding?.kind !== "remote") { + return { + label: "Local Mac", + description: "Use this ADE runtime and local worktree.", + }; + } + const runtimeName = projectBinding.runtimeName.trim(); + return { + label: runtimeName || "Remote runtime", + description: "Use this connected remote ADE runtime and remote worktree.", + }; + }, [projectBinding]); + const getActiveProjectRoot = useCallback(() => { + return selectActiveProjectRoot(appStore.getState()); + }, [appStore]); const activeTourId = useOnboardingStore((s) => s.activeTourId); const suppressTourDistractions = activeTourId === "first-journey"; @@ -520,7 +541,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const [attachDescription, setAttachDescription] = useState(""); const [attachBusy, setAttachBusy] = useState(false); const [attachError, setAttachError] = useState(null); - const canCreateLane = Boolean(project?.rootPath); + const canCreateLane = Boolean(activeProjectRoot); const [adoptBusy, setAdoptBusy] = useState(false); const [adoptError, setAdoptError] = useState(null); const [adoptConfirmOpen, setAdoptConfirmOpen] = useState(false); @@ -578,7 +599,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const pendingLaneDeleteRefreshIdsRef = useRef>(new Set()); const laneDeleteRefreshTimerRef = useRef(null); const hydratedLaneDeleteProgressProjectRef = useRef(null); - const deleteProgressProjectRootRef = useRef(project?.rootPath ?? null); + const deleteProgressProjectRootRef = useRef(activeProjectRoot); const activeLanePresenceSignatureRef = useRef(null); // Refs for the onDeleteEvent IPC handler. Capturing high-churn values // (selectedLaneId, lanesById, managedLaneIds, manageOpen) in refs lets the @@ -613,15 +634,19 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const consumedCommitDeepLinkSignatureRef = useRef(null); useEffect(() => { - logRendererDebugEvent("renderer.lanes.page_mount"); + logRendererDebugEvent("renderer.lanes.page_mount", { + projectRoot: activeProjectRoot, + }); return () => { - logRendererDebugEvent("renderer.lanes.page_unmount"); + logRendererDebugEvent("renderer.lanes.page_unmount", { + projectRoot: activeProjectRoot, + }); }; - }, []); + }, [activeProjectRoot]); useEffect(() => { if (!active) return; - const projectRoot = project?.rootPath ?? null; + const projectRoot = activeProjectRoot; const previousProjectRoot = deleteProgressProjectRootRef.current; deleteProgressProjectRootRef.current = projectRoot; hydratedLaneDeleteProgressProjectRef.current = null; @@ -634,7 +659,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { if (previousProjectRoot !== projectRoot) { setDeleteProgressByLaneId({}); } - }, [project?.rootPath, setDeleteProgressByLaneId]); + }, [activeProjectRoot, setDeleteProgressByLaneId]); const laneSnapshotByLaneId = useMemo( () => new Map(laneSnapshots.map((snapshot) => [snapshot.lane.id, snapshot] as const)), @@ -804,14 +829,14 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { if (!syncApi?.setActiveLanePresence) { return; } - const laneIds = active && project?.rootPath ? [...visibleLaneIds] : []; + const laneIds = active && activeProjectRoot ? [...visibleLaneIds] : []; const signature = laneIds.join("\0"); if (activeLanePresenceSignatureRef.current === signature) { return; } activeLanePresenceSignatureRef.current = signature; void syncApi.setActiveLanePresence({ laneIds }).catch(() => {}); - }, [active, project?.rootPath, visibleLaneIds]); + }, [active, activeProjectRoot, visibleLaneIds]); useEffect(() => { const syncApi = window.ade.sync; @@ -906,7 +931,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const refreshAutoRebaseEnabled = useCallback(async () => { try { - const snapshot = await getProjectConfigCached({ projectRoot: project?.rootPath ?? null }); + const snapshot = await getProjectConfigCached({ projectRoot: activeProjectRoot }); const enabled = typeof snapshot.effective.git?.autoRebaseOnHeadChange === "boolean" ? snapshot.effective.git.autoRebaseOnHeadChange @@ -915,7 +940,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { } catch { setAutoRebaseEnabled(false); } - }, [project?.rootPath]); + }, [activeProjectRoot]); const refreshIntegrationProposals = useCallback(async () => { try { @@ -928,10 +953,10 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const refreshLanePrTags = useCallback(async (options?: { refreshMapped?: boolean }) => { const requestId = ++lanePrTagsRequestRef.current; - const startedRoot = appStore.getState().project?.rootPath ?? null; + const startedRoot = getActiveProjectRoot(); const stillCurrent = () => requestId === lanePrTagsRequestRef.current - && (appStore.getState().project?.rootPath ?? null) === startedRoot; + && getActiveProjectRoot() === startedRoot; try { const prs = await listPrsCoalesced({ projectRoot: startedRoot }); if (!stillCurrent()) return; @@ -956,25 +981,25 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { if (!stillCurrent()) return; setLanePrTags([]); } - }, [appStore]); + }, [getActiveProjectRoot]); const refreshLaneGithubPrTags = useCallback(async (options?: { force?: boolean }) => { const requestId = ++laneGithubPrTagsRequestRef.current; - const startedRoot = appStore.getState().project?.rootPath ?? null; + const startedRoot = getActiveProjectRoot(); try { const snapshot = await getGitHubSnapshotCoalesced( { force: options?.force === true }, { projectRoot: startedRoot }, ); if (requestId !== laneGithubPrTagsRequestRef.current) return; - if ((appStore.getState().project?.rootPath ?? null) !== startedRoot) return; + if (getActiveProjectRoot() !== startedRoot) return; setLaneGithubPrTags(snapshot.repoPullRequests); } catch { if (requestId !== laneGithubPrTagsRequestRef.current) return; - if ((appStore.getState().project?.rootPath ?? null) !== startedRoot) return; + if (getActiveProjectRoot() !== startedRoot) return; // Keep the last usable GitHub snapshot visible on transient refresh failures. } - }, [appStore]); + }, [getActiveProjectRoot]); const scheduleLaneDeleteRefresh = useCallback(() => { if (laneDeleteRefreshTimerRef.current != null) return; @@ -1117,27 +1142,27 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { void refreshIntegrationProposals(); }, 140); return () => window.clearTimeout(timer); - }, [active, refreshIntegrationProposals, project?.rootPath]); + }, [active, refreshIntegrationProposals, activeProjectRoot]); useEffect(() => { lanePrTagsRequestRef.current += 1; laneGithubPrTagsRequestRef.current += 1; setLanePrTags([]); setLaneGithubPrTags([]); - if (!active || !project?.rootPath) { + if (!active || !activeProjectRoot) { return; } void refreshLanePrTags({ refreshMapped: true }); void refreshLaneGithubPrTags({ force: true }); void warmPrSurfaceCoalesced({ - projectRoot: project.rootPath, + projectRoot: activeProjectRoot, includeGithubSnapshot: false, }); return () => { lanePrTagsRequestRef.current += 1; laneGithubPrTagsRequestRef.current += 1; }; - }, [active, refreshLanePrTags, refreshLaneGithubPrTags, project?.rootPath, lanePrBranchSignature]); + }, [active, refreshLanePrTags, refreshLaneGithubPrTags, activeProjectRoot, lanePrBranchSignature]); useEffect(() => { if (!active) return; @@ -1165,7 +1190,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { }, []); useEffect(() => { - const projectRoot = project?.rootPath ?? null; + const projectRoot = activeProjectRoot; if (laneVisiblePrRefreshProjectRootRef.current !== projectRoot) { laneVisiblePrRefreshProjectRootRef.current = projectRoot; laneVisiblePrRefreshRequestedAtRef.current.clear(); @@ -1190,7 +1215,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const timer = window.setTimeout(() => { void window.ade.prs.refresh({ prIds }) .then((refreshed) => { - if ((appStore.getState().project?.rootPath ?? null) !== startedRoot) return; + if (getActiveProjectRoot() !== startedRoot) return; if (refreshed.length === 0) return; setLanePrTags((current) => mergePrSummariesById(current, refreshed)); }) @@ -1202,8 +1227,8 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { return () => window.clearTimeout(timer); }, [ active, - appStore, - project?.rootPath, + getActiveProjectRoot, + activeProjectRoot, visibleLaneIds, lanePrByLaneId, lanePrTags, @@ -1239,7 +1264,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { void refreshRuntimeOnly().catch(() => {}); }, delayMs)); }; - const currentProjectRoot = project?.rootPath ?? null; + const currentProjectRoot = activeProjectRoot; const isCurrentProjectEvent = (event: { projectRoot?: string | null }) => !event.projectRoot || event.projectRoot === currentProjectRoot; const unsubPtyData = window.ade.pty.onData((event) => { @@ -1276,7 +1301,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { } window.clearInterval(intervalId); }; - }, [active, project?.rootPath, refreshLanes]); + }, [active, activeProjectRoot, refreshLanes]); useEffect(() => { hasActiveLaneRuntimeRef.current = laneSnapshots.some((snapshot) => @@ -1690,7 +1715,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { ]); useEffect(() => { - const projectRoot = project?.rootPath ?? null; + const projectRoot = activeProjectRoot; if (!projectRoot) return; if (hydratedLaneDeleteProgressProjectRef.current === projectRoot) return; hydratedLaneDeleteProgressProjectRef.current = projectRoot; @@ -1711,7 +1736,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { void window.ade.lanes.listDeleteProgress() .then((progresses) => { if (cancelled) return; - const activeProgresses = (Array.isArray(progresses) ? progresses : []).filter(isLaneDeleteProgressActive); + const activeProgresses = (Array.isArray(progresses) ? progresses : []).filter(isLaneDeleteProgressHydratable); const activeProgressLaneIds = new Set(activeProgresses.map((progress) => progress.laneId)); const storedActiveLaneIds = getStoredActiveLaneIds(); const laneIdsWithoutBackendProgress = storedActiveLaneIds.filter((laneId) => !activeProgressLaneIds.has(laneId)); @@ -1756,13 +1781,12 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { return () => { cancelled = true; }; - }, [active, appStore, project?.rootPath, moveAwayFromDeletingLanes, queueLaneDeleteRefresh, setDeleteProgressByLaneId]); + }, [active, activeProjectRoot, appStore, moveAwayFromDeletingLanes, queueLaneDeleteRefresh, setDeleteProgressByLaneId]); const deleteManagedLanes = async () => { const targets = isBatchManage ? managedLanes : managedLane ? [managedLane] : []; const actionable = targets.filter((l) => l.laneType !== "primary"); if (actionable.length === 0) return; - if (deleteConfirmText.trim().toLowerCase() !== deletePhrase.toLowerCase()) return; const deleteArgsByLaneId = new Map(); for (const lane of actionable) { @@ -1895,7 +1919,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { setActiveLaneIds(mergeUnique([laneId], pinned)); selectLane(laneId); setStackGraphHeaderOpen(false); - setLaneWorkViewState(project?.rootPath ?? null, laneId, (prev) => ({ + setLaneWorkViewState(activeProjectRoot, laneId, (prev) => ({ ...prev, draftKind: "chat", viewMode: "tabs", @@ -1908,7 +1932,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { requestedAt: Date.now(), }); navigate(`/lanes?laneId=${encodeURIComponent(laneId)}`); - }, [deletingLaneIds, lanesById, navigate, pinnedLaneIds, project?.rootPath, selectLane, setLaneWorkViewState]); + }, [activeProjectRoot, deletingLaneIds, lanesById, navigate, pinnedLaneIds, selectLane, setLaneWorkViewState]); // Open a specific agent (chat or CLI) in the Work tab of its lane, from any // of the inline lane dashboards (stack drawer, graph card, lane list row). @@ -1919,7 +1943,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { setActiveLaneIds(mergeUnique([laneId], pinned)); selectLane(laneId); setStackGraphHeaderOpen(false); - setLaneWorkViewState(project?.rootPath ?? null, laneId, (prev) => ({ + setLaneWorkViewState(activeProjectRoot, laneId, (prev) => ({ ...prev, viewMode: "tabs", openItemIds: prev.openItemIds.includes(agent.sessionId) @@ -1929,7 +1953,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { selectedItemId: agent.sessionId, })); navigate(openAgentInWorkTabPath(laneId, agent.sessionId)); - }, [deletingLaneIds, lanesById, navigate, pinnedLaneIds, project?.rootPath, selectLane, setLaneWorkViewState]); + }, [activeProjectRoot, deletingLaneIds, lanesById, navigate, pinnedLaneIds, selectLane, setLaneWorkViewState]); const removeSplitLane = useCallback((laneId: string) => { if (pinnedLaneIds.has(laneId)) return; @@ -4420,7 +4444,9 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { // longer the canonical destination for opening a lane. navigate("/project"); }} - projectRoot={project?.rootPath ?? null} + localRuntimeLabel={createLaneRuntimeCopy.label} + localRuntimeDescription={createLaneRuntimeCopy.description} + projectRoot={activeProjectRoot} createBranches={createBranches} lanes={lanes} onSubmit={handleCreateSubmit} diff --git a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx index 4dfa61fd5..e89822b2b 100644 --- a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx @@ -159,4 +159,68 @@ describe("ManageLaneDialog tabs", () => { expect(screen.getByText("1 chat session")).toBeTruthy(); }); }); + + it("allows a clean worktree-only delete without hidden confirmation text", async () => { + const onDelete = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("tab", { name: "Delete" })); + + const deleteButton = await screen.findByRole("button", { name: /delete lane/i }); + await waitFor(() => { + expect((deleteButton as HTMLButtonElement).disabled).toBe(false); + }); + + fireEvent.click(deleteButton); + + expect(onDelete).toHaveBeenCalledTimes(1); + }); + + it("requires typed confirmation for a dirty lane delete", async () => { + (window as any).ade.lanes.getDeleteRisk.mockResolvedValueOnce({ + ...deleteRisk, + dirty: true, + }); + const onDelete = vi.fn(); + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("tab", { name: "Delete" })); + + const deleteButton = await screen.findByRole("button", { name: /delete lane/i }); + await waitFor(() => { + expect((deleteButton as HTMLButtonElement).disabled).toBe(true); + }); + + rerender( + , + ); + + fireEvent.click(screen.getByRole("tab", { name: "Delete" })); + await waitFor(() => { + expect((screen.getByRole("button", { name: /delete lane/i }) as HTMLButtonElement).disabled).toBe(false); + }); + + fireEvent.click(screen.getByRole("button", { name: /delete lane/i })); + + expect(onDelete).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts b/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts index f77163b37..de62defa4 100644 --- a/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts +++ b/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts @@ -62,6 +62,37 @@ describe("buildLaneAgents", () => { expect(agents).toHaveLength(0); }); + it("prefers chat summaries when the same chat session is mirrored through sessions.list", () => { + const agents = buildLaneAgents( + [chat({ sessionId: "same-session", title: "Chat row", provider: "codex" })], + [cli({ id: "same-session", toolType: "codex-chat", title: "Terminal mirror" })], + ); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + sessionId: "same-session", + kind: "chat", + name: "Chat row", + }); + }); + + it("collapses duplicate terminal summaries for the same session", () => { + const agents = buildLaneAgents( + [], + [ + cli({ id: "duplicated", title: "First terminal row" }), + cli({ id: "duplicated", title: "Second terminal row" }), + ], + ); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + sessionId: "duplicated", + kind: "cli", + name: "First terminal row", + }); + }); + it("merges chat + CLI agents and sorts live before ended", () => { const agents = buildLaneAgents( [ diff --git a/apps/desktop/src/renderer/components/lanes/laneAgents.ts b/apps/desktop/src/renderer/components/lanes/laneAgents.ts index 45e84677a..03ffce8f2 100644 --- a/apps/desktop/src/renderer/components/lanes/laneAgents.ts +++ b/apps/desktop/src/renderer/components/lanes/laneAgents.ts @@ -5,7 +5,7 @@ import type { TerminalToolType, } from "../../../shared/types"; import { listSessionsCached } from "../../lib/sessionListCache"; -import { useAppStore } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; /** Unified live state for an agent row, glanceable at a list level. */ export type LaneAgentActivity = "working" | "awaiting-input" | "idle" | "ended"; @@ -103,14 +103,21 @@ export function buildLaneAgents( cliSessions: TerminalSessionSummary[], ): LaneAgent[] { const agents: LaneAgent[] = []; + const chatSessionIds = new Set(); for (const summary of chatSessions) { if (summary.archivedAt) continue; + if (chatSessionIds.has(summary.sessionId)) continue; + chatSessionIds.add(summary.sessionId); agents.push(chatAgentFrom(summary)); } + const cliSessionIds = new Set(); for (const summary of cliSessions) { if (summary.archivedAt) continue; if (SHELL_TOOL_TYPES.has(summary.toolType ?? "shell")) continue; if (summary.chatSessionId) continue; // child terminal of a chat — not a standalone agent + if (chatSessionIds.has(summary.id)) continue; // persisted chat session mirrored through sessions.list + if (cliSessionIds.has(summary.id)) continue; + cliSessionIds.add(summary.id); agents.push(cliAgentFrom(summary)); } // Live agents first (working → awaiting → idle), ended last; within a bucket, @@ -134,7 +141,7 @@ export function buildLaneAgents( */ export function useLaneAgents(laneIds: string[]): Map { const [byLane, setByLane] = useState>(new Map()); - const projectRoot = useAppStore((state) => state.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const laneKey = useMemo(() => [...laneIds].sort().join(","), [laneIds]); const refreshTimerRef = useRef(null); const refreshInFlightRef = useRef(false); @@ -149,7 +156,6 @@ export function useLaneAgents(laneIds: string[]): Map { try { do { refreshQueuedRef.current = false; - if (document.visibilityState !== "visible") return; const ids = laneKey ? laneKey.split(",") : []; if (!ids.length) { diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts index 10d889cfa..dd40f41ae 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts @@ -58,6 +58,12 @@ vi.mock("../../lib/sessions", () => ({ })); vi.mock("../../state/appStore", () => ({ + selectActiveProjectRoot: (state: Record) => { + const binding = state.projectBinding as { kind?: string; rootPath?: string | null } | null | undefined; + if (binding?.kind === "remote") return binding.rootPath?.trim() || null; + const project = state.project as { rootPath?: string | null } | null | undefined; + return project?.rootPath?.trim() || null; + }, useAppStore: vi.fn((selector: (state: Record) => unknown) => { const fakeState: Record = { project: { rootPath: fakeProjectRoot }, diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts index efe832b17..d98273c4a 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AgentChatSession, TerminalSessionSummary } from "../../../shared/types"; -import { useAppStore, type WorkDraftKind, type WorkProjectViewState, type WorkViewMode } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore, type WorkDraftKind, type WorkProjectViewState, type WorkViewMode } from "../../state/appStore"; import { listSessionsCached, invalidateSessionListCache } from "../../lib/sessionListCache"; import { sessionStatusBucket } from "../../lib/terminalAttention"; import { shouldRefreshSessionListForChatEvent } from "../../lib/chatSessionEvents"; @@ -113,7 +113,7 @@ function isActiveSession(session: TerminalSessionSummary): boolean { } export function useLaneWorkSessions(laneId: string | null) { - const projectRoot = useAppStore((state) => state.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const lanes = useAppStore((state) => state.lanes); const focusSession = useAppStore((state) => state.focusSession); const focusedSessionId = useAppStore((state) => state.focusedSessionId); diff --git a/apps/desktop/src/renderer/components/onboarding/OnboardingBootstrap.tsx b/apps/desktop/src/renderer/components/onboarding/OnboardingBootstrap.tsx index e895ca28a..886156b2f 100644 --- a/apps/desktop/src/renderer/components/onboarding/OnboardingBootstrap.tsx +++ b/apps/desktop/src/renderer/components/onboarding/OnboardingBootstrap.tsx @@ -18,6 +18,7 @@ export function OnboardingBootstrap() { const showWelcome = useAppStore((s) => s.showWelcome); const isNewTabOpen = useAppStore((s) => s.isNewTabOpen); const project = useAppStore((s) => s.project); + const projectBinding = useAppStore((s) => s.projectBinding); const hydrated = useOnboardingStore((s) => s.hydrated); const hydrate = useOnboardingStore((s) => s.hydrate); @@ -35,13 +36,14 @@ export function OnboardingBootstrap() { const workTourAutoFiredRef = useRef(false); const filesTourAutoFiredRef = useRef(false); const runTourAutoFiredRef = useRef(false); + const isRemoteProject = projectBinding?.kind === "remote"; // Hydrate once. useEffect(() => { - if (!hydrated) { + if (!hydrated && !isRemoteProject) { void hydrate(); } - }, [hydrated, hydrate]); + }, [hydrated, hydrate, isRemoteProject]); const hasActiveProject = projectHydrated === true && diff --git a/apps/desktop/src/renderer/components/prs/PRsPage.tsx b/apps/desktop/src/renderer/components/prs/PRsPage.tsx index 686f408e9..717b332a1 100644 --- a/apps/desktop/src/renderer/components/prs/PRsPage.tsx +++ b/apps/desktop/src/renderer/components/prs/PRsPage.tsx @@ -5,7 +5,7 @@ import { EmptyState } from "../ui/EmptyState"; import { cn } from "../ui/cn"; import { PrsProvider, usePrs } from "./state/PrsContext"; import { CreatePrModal, type CreatePrModalInitialValues } from "./CreatePrModal"; -import { useAppStore } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; import { useDialogBus } from "../../lib/useDialogBus"; import { GitHubTab, type GitHubHeaderChromeState } from "./tabs/GitHubTab"; import { WorkflowsTab, type WorkflowCategory } from "./tabs/WorkflowsTab"; @@ -106,7 +106,7 @@ function createInitialValuesFromDialogProps(props?: Record): Cr function PRsPageInner() { const navigate = useNavigate(); const location = useLocation(); - const projectRoot = useAppStore((state) => state.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const refreshLanes = useAppStore((state) => state.refreshLanes); const { activeTab, diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx index ec00a7373..29590481a 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx @@ -22,7 +22,7 @@ import { PrsProvider, usePrs } from "./PrsContext"; const originalAde = globalThis.window.ade; function Harness() { - const { refresh, rebaseNeeds, autoRebaseStatuses, loading } = usePrs(); + const { refresh, rebaseNeeds, autoRebaseStatuses, loading, queueStates } = usePrs(); return (
); } @@ -353,6 +354,33 @@ describe("PrsContext refresh", () => { }); }); + it("keeps workflow PRs usable when queue states are unavailable on the runtime", async () => { + window.location.hash = "#/prs?tab=workflows&workflow=integration"; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.mocked(window.ade.prs.listWithConflicts).mockResolvedValue([makeFakePr("pr-1")]); + vi.mocked(window.ade.prs.listQueueStates).mockRejectedValue( + new Error("action 'pr.listQueueStates' is not callable"), + ); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("loading").textContent).toBe("idle"); + }); + expect(window.ade.prs.listWithConflicts).toHaveBeenCalledWith({ includeConflictAnalysis: true }); + expect(window.ade.lanes.list).toHaveBeenCalledWith({ includeStatus: false }); + expect(screen.getByTestId("queue-count").textContent).toBe("0"); + expect(warnSpy).toHaveBeenCalledWith( + "[PrsContext] Failed to load workflow queue states:", + expect.any(Error), + ); + warnSpy.mockRestore(); + }); + it("runs a GitHub PR refresh for explicit refresh actions", async () => { const user = userEvent.setup(); diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx index c75b01ed1..b74f054c7 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx @@ -36,7 +36,7 @@ import { buildPrAiResolutionContextKey } from "../../../../shared/types"; import { getModelById, resolveProviderGroupForModel, type ModelProviderGroup } from "../../../../shared/modelRegistry"; import { parsePrsRouteState, resolvePrsActiveTab } from "../prsRouteState"; import { resolveRouteRebaseSelection } from "../shared/rebaseNeedUtils"; -import { useAppStore } from "../../../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../../../state/appStore"; import { refreshPrsCoalesced } from "../../../lib/prReadCache"; type PrTab = "normal" | "queue" | "integration" | "rebase"; @@ -218,6 +218,15 @@ function readPrsContextWarmCache(projectRoot?: string | null): PrsContextWarmCac return prsContextWarmCacheByProject.get(prsContextCacheKey(projectRoot)) ?? null; } +async function listWorkflowQueueStates(): Promise { + try { + return await window.ade.prs.listQueueStates({ includeCompleted: true, limit: 50 }); + } catch (err) { + console.warn("[PrsContext] Failed to load workflow queue states:", err); + return []; + } +} + function readBoolLs(key: string, fallback: boolean): boolean { try { const raw = localStorage.getItem(key); @@ -420,7 +429,7 @@ function diffPrIds(prev: PrWithConflicts[], next: PrWithConflicts[]): string[] { } export function PrsProvider({ active = true, children }: { active?: boolean; children: React.ReactNode }) { - const projectRoot = useAppStore((state) => state.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const cacheKey = prsContextCacheKey(projectRoot); const warmCache = useMemo(() => readPrsContextWarmCache(projectRoot), [projectRoot]); const warmCacheHydratedAtRef = React.useRef(warmCache?.dataLoadedAt ?? warmCache?.cachedAt ?? 0); @@ -814,7 +823,7 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi window.ade.prs.listWithConflicts({ includeConflictAnalysis: shouldLoadWorkflowState }), window.ade.lanes.list({ includeStatus: false }), shouldLoadWorkflowState - ? window.ade.prs.listQueueStates({ includeCompleted: true, limit: 50 }) + ? listWorkflowQueueStates() : Promise.resolve([] as QueueLandingState[]), ]); const changedPrIds = diffPrIds(prsRef.current, prList); @@ -1493,16 +1502,13 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi useEffect(() => { if (!active || activeTab === "normal") return; let cancelled = false; - window.ade.prs.listQueueStates({ includeCompleted: true, limit: 50 }) + listWorkflowQueueStates() .then((states) => { if (cancelled) return; setQueueStates((prev) => { const next = Object.fromEntries(states.map((state) => [state.groupId, state] as const)); return jsonEqual(prev, next) ? prev : next; }); - }) - .catch((err) => { - console.warn("[PrsContext] Failed to load workflow queue states:", err); }); return () => { cancelled = true; diff --git a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx index 22f90fe0e..42840f757 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx @@ -19,7 +19,7 @@ import { EmptyState } from "../../ui/EmptyState"; import { ResizeGutter } from "../../ui/ResizeGutter"; import { COLORS, LABEL_STYLE, MONO_FONT, SANS_FONT, cardStyle, inlineBadge, outlineButton, primaryButton } from "../../lanes/laneDesignTokens"; import { LaneAccentDot } from "../../lanes/LaneAccentDot"; -import { useAppStore, useAppStoreApi } from "../../../state/appStore"; +import { selectActiveProjectRoot, useAppStore, useAppStoreApi } from "../../../state/appStore"; import { PrDetailPane } from "../detail/PrDetailPane"; import { formatTimestampShort, formatTimeAgoCompact } from "../shared/prFormatters"; import { PrCiRunningIndicator } from "../shared/prVisuals"; @@ -789,7 +789,7 @@ export function GitHubTab({ loading: prsContextLoading, setViewerLogin: setContextViewerLogin, } = usePrs(); - const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const refreshLanes = useAppStore((s) => s.refreshLanes); const selectLane = useAppStore((s) => s.selectLane); @@ -1342,7 +1342,7 @@ export function GitHubTab({ ? refreshLanes({ includeStatus: false, includeSnapshots: false }) .catch(() => {}) .then(() => { - const currentProjectRoot = appStore.getState().project?.rootPath ?? null; + const currentProjectRoot = selectActiveProjectRoot(appStore.getState()); if (createProjectRoot && currentProjectRoot !== createProjectRoot) return; appStore.setState((prev) => ({ lanes: upsertLaneSummary(prev.lanes, result.lane), diff --git a/apps/desktop/src/renderer/components/prs/tabs/WorkflowsTab.tsx b/apps/desktop/src/renderer/components/prs/tabs/WorkflowsTab.tsx index 472d30554..0a2af64b1 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/WorkflowsTab.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/WorkflowsTab.tsx @@ -27,7 +27,7 @@ import { rebaseNeedItemKey } from "../shared/rebaseNeedUtils"; import { filterRebaseAttentionStatuses } from "../shared/rebaseAttentionUtils"; import { usePrs } from "../state/PrsContext"; import { getQueueWorkflowBucket } from "./queueWorkflowModel"; -import { useAppStore } from "../../../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../../../state/appStore"; const CATEGORY_THEMES = { integration: { color: "#8B5CF6", bg: "rgba(139, 92, 246, 0.08)", border: "rgba(139, 92, 246, 0.20)", bgSubtle: "rgba(139, 92, 246, 0.04)" }, @@ -673,7 +673,7 @@ export function WorkflowsTab({ } = usePrs(); const navigate = useNavigate(); - const projectRoot = useAppStore((state) => state.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const cacheKey = workflowsCacheKey(projectRoot); const warmCache = !WORKFLOWS_CACHE_DISABLED ? workflowsWarmCacheByProject.get(cacheKey) ?? null : null; const [view, setViewRaw] = React.useState(() => warmCache?.view ?? readWorkflowView(projectRoot)); diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetForm.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetForm.tsx index ecb438b7e..4d824f49a 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetForm.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetForm.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState, type CSSProperties, type FormEvent } from "react"; -import { COLORS, LABEL_STYLE, MONO_FONT, SANS_FONT, outlineButton, primaryButton } from "../lanes/laneDesignTokens"; +import { COLORS, LABEL_STYLE, MONO_FONT, outlineButton, primaryButton } from "../lanes/laneDesignTokens"; import type { RemoteRuntimeTargetInput } from "../../../shared/types"; export type RemoteTargetFormPrefill = Partial & { @@ -139,10 +139,7 @@ export function RemoteTargetForm({ disabled={busy} /> -
-
- ADE connects over SSH and starts `ade rpc --stdio` on the target. -
+
+ +
+
+ + {manualAddOpen ? ( +
+
+ Add machine +
+ +
+ ) : null} + + {discoveryError ? ( +
+ + {discoveryError} +
+ ) : null} + + {loading ? ( +
+ Loading machines... +
+ ) : null} + +
+ {targets.map((target) => { + const targetStatus = + connectionSnapshot?.connections.find( + (entry) => entry.target.id === target.id, + ) ?? null; + const targetConnected = targetStatus + ? targetStatus.state === "connected" + : connected?.target.id === target.id; + const targetConnecting = + busyId === target.id || targetStatus?.state === "connecting"; + const targetSelected = selectedId === target.id; + const targetError = targetSelected + ? (error ?? selectedConnectionError) + : targetStatus?.lastError + ? formatRemoteTargetError(targetStatus.lastError) + : null; + const targetWarnings = targetSelected + ? selectedCompatibilityWarnings + : targetStatus?.compatibilityWarnings ?? []; + const version = + targetStatus?.version ?? + (connected?.target.id === target.id ? connected.version : null) ?? + target.runtimeBinaryVersion ?? + null; + const arch = + targetStatus?.arch ?? + (connected?.target.id === target.id ? connected.arch : null) ?? + target.lastSeenArch ?? + null; + const statusLabel = connectionStateLabel( + targetStatus, + connected?.target.id === target.id ? connected : null, + ); + const formOpen = formPrefill?.targetId === target.id; + + return ( +
- Connect over SSH -
-
- -
- {loading ? ( -
- Loading machines... -
- ) : targets.length === 0 ? ( -
- No remote machines saved yet. -
- ) : ( -
- {targets.map((target) => { - const active = selectedId === target.id; - const targetStatus = - connectionSnapshot?.connections.find( - (entry) => entry.target.id === target.id, - ) ?? null; - const isConnected = targetStatus - ? targetStatus.state === "connected" - : connected?.target.id === target.id; - return ( - + ) : ( + + )} + - ); - })} -
- )} -
+ Edit + {formOpen ? ( + + ) : ( + + )} + + +
+ -
-
-
-
- NEARBY MACHINES -
-
- LAN and Tailscale discovery -
-
- -
- {discoveryError ? ( -
- - {discoveryError} -
- ) : null} - {loadingDiscovered ? ( -
- Scanning nearby machines... -
- ) : discoveredMachines.length === 0 ? ( -
- No LAN ADE services or Tailscale peers found. -
- ) : ( -
- {discoveredMachines.map((machine) => { - const route = discoveredRoute(machine); - return ( + {targetError ? ( +
+ + {targetError} +
+ ) : null} + + {targetWarnings.length > 0 ? (
+ {targetWarnings.map((warning) => ( +
+ + {warning} +
+ ))} +
+ ) : null} + + {targetSelected && selectedHostKeyTrust ? ( +
-
-
- {machine.machineName} -
-
- {route - ? `${route}:${machine.port}` - : "No route advertised"} -
-
- + + {selectedHostKeyTrust.state === "changed" + ? "Machine identity changed" + : "Trust this machine"} +
+
+ {selectedHostKeyTrust.state === "changed" + ? `ADE found a different SSH identity for ${selectedHostKeyTrust.host}:${selectedHostKeyTrust.port}. Review ${selectedHostKeyTrust.knownHostsPath ?? "known_hosts"} before connecting.` + : `ADE found a new SSH identity for ${selectedHostKeyTrust.host}:${selectedHostKeyTrust.port}. Trust it once to connect.`}
- {discoveredRuntimeLabel(machine)} |{" "} - {discoveredProjectLabel(machine)} + {selectedHostKeyTrust.fingerprintSha256}
+ {selectedHostKeyTrust.state === "needs_trust" ? ( +
+ + +
+ ) : null}
- ); - })} -
- )} -
- - -
-
-
- {editingSavedTarget ? "EDIT MACHINE" : "ADD MACHINE"} -
-
- {editingSavedTarget ? editingSavedTarget.name : "SSH target"} -
-
- -
- - -
-
-
-
- CONNECTION -
-
- {selectedTarget ? selectedTarget.name : "Select a machine"} -
-
- {selectedTarget ? ( -
- - -
- ) : null} -
+ ) : null} +
- {selectedHostKeyTrust ? ( -
-
- - {selectedHostKeyTrust.state === "changed" - ? "Machine identity changed" - : "Trust this machine"} -
-
- {selectedHostKeyTrust.state === "changed" - ? `ADE found a different SSH identity for ${selectedHostKeyTrust.host}:${selectedHostKeyTrust.port}. Review ${selectedHostKeyTrust.knownHostsPath ?? "known_hosts"} before connecting.` - : `ADE found a new SSH identity for ${selectedHostKeyTrust.host}:${selectedHostKeyTrust.port}. Trust it once to connect over Wi-Fi or Tailscale.`} -
-
- {selectedHostKeyTrust.fingerprintSha256} -
- {selectedHostKeyTrust.state === "needs_trust" ? ( -
- - + {formOpen ? ( +
+
+ Edit {target.name} +
+ +
+ ) : null}
- ) : null} -
- ) : null} - - {error || selectedConnectionError ? ( -
- - {error ?? selectedConnectionError} -
- ) : null} + ); + })} - {selectedTarget ? ( -
-
-
-
- {selectedConnectionLabel} -
-
- {targetConnectionLabel(selectedTarget)} -
-
- {selectedConnection?.state === "connected" || - (!selectedConnection && - connected?.target.id === selectedTarget.id) ? ( - - ) : null} -
- {selectedConnection?.state === "connected" || - (!selectedConnection && - connected?.target.id === selectedTarget.id) ? ( - <> -
- ADE service{" "} - {selectedConnection?.version ?? connected?.version ?? "unknown"}{" "} - on {selectedConnection?.arch ?? connected?.arch ?? "unknown"}. -
- {selectedCompatibilityWarnings.length > 0 ? ( + {visibleDiscoveredMachines.map((machine) => { + const route = discoveredRoute(machine); + const formOpen = activeFormKey === `${machine.id}:${machine.lastSeenAt}`; + return ( +
+
- {selectedCompatibilityWarnings.map((warning) => ( -
- - {warning} +
+
+ {machine.machineName}
- ))} +
+ {route ? `${route}:${machine.port}` : "No route advertised"} +
+
+ Detected · {discoveredRuntimeLabel(machine)} ·{" "} + {discoveredProjectLabel(machine)} +
+
+
+ + +
+
+
+ {formOpen ? ( +
+
+ Edit {machine.machineName} +
+
) : null} - - ) : ( -
- Remote projects are opened from Add Project after this machine - is connected.
- )} + ); + })} +
+ + {!loading && targets.length === 0 && !manualAddOpen && !loadingDiscovered && visibleDiscoveredMachines.length === 0 ? ( +
+ {discoveredMachines.length > 0 + ? "Nearby machines are already saved." + : "No saved or detected machines yet."}
- ) : ( -
- Save a machine to keep ADE connected in the background. + ) : null} + {loadingDiscovered ? ( +
+ Scanning nearby machines...
- )} + ) : null} + {!loadingDiscovered && targets.length > 0 && visibleDiscoveredMachines.length === 0 && discoveredMachines.length > 0 ? ( +
Nearby machines are already saved.
+ ) : null}
); diff --git a/apps/desktop/src/renderer/components/run/RunPage.tsx b/apps/desktop/src/renderer/components/run/RunPage.tsx index a3d55ff40..3984a40fb 100644 --- a/apps/desktop/src/renderer/components/run/RunPage.tsx +++ b/apps/desktop/src/renderer/components/run/RunPage.tsx @@ -9,7 +9,7 @@ import { Terminal, X, } from "@phosphor-icons/react"; -import { useAppStore } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; import { COLORS, LABEL_STYLE, @@ -690,7 +690,7 @@ export function RunPage() { const lanes = useAppStore((s) => s.lanes); const showWelcome = useAppStore((s) => s.showWelcome); - const projectRoot = project?.rootPath ?? null; + const projectRoot = useAppStore(selectActiveProjectRoot); const [persistedLaneState, setPersistedLaneState] = useState(() => readRunPageLaneState(projectRoot), diff --git a/apps/desktop/src/renderer/components/settings/LinearSection.tsx b/apps/desktop/src/renderer/components/settings/LinearSection.tsx index 2fbcd2313..24223cf97 100644 --- a/apps/desktop/src/renderer/components/settings/LinearSection.tsx +++ b/apps/desktop/src/renderer/components/settings/LinearSection.tsx @@ -14,7 +14,7 @@ import type { CtoLinearProject, GitHubAutolink, LinearConnectionStatus } from ". import { ADE_DEEPLINK_HTTPS_BASE_URL } from "../../../shared/deeplinks"; import { COLORS, SANS_FONT, MONO_FONT, LABEL_STYLE } from "../lanes/laneDesignTokens"; import { Button } from "../ui/Button"; -import { useAppStore } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; const LINEAR_BRAND = "#5E6AD2"; const LINEAR_API_SETTINGS_URL = "https://linear.app/settings/api"; @@ -41,7 +41,7 @@ export function LinearSection() { // project (credentials are project-scoped). Re-run the loaders whenever the // active project changes so the autolink commands target the right repo and // Linear workspace instead of a stale previously-loaded project. - const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const [connection, setConnection] = useState(null); const [projects, setProjects] = useState([]); const [githubRepo, setGithubRepo] = useState<{ owner: string; name: string } | null>(null); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx index 894427540..b3548a9e0 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx @@ -84,7 +84,7 @@ export const ModelPicker = memo(function ModelPicker({ const [open, setOpen] = useState(false); const [runtimeCatalog, setRuntimeCatalog] = useState(() => getSharedRuntimeCatalog()); const [refreshingProvider, setRefreshingProvider] = useState(null); - const { recents } = useModelRecents(); + const { recents } = useModelRecents({ hydrate: open }); const loadRuntimeCatalog = useCallback(async (args: { mode: "cached" | "refresh-stale" | "force"; diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.test.tsx new file mode 100644 index 000000000..09fd32bc0 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.test.tsx @@ -0,0 +1,81 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { __resetModelRecentsForTests, useModelRecents } from "./useModelRecents"; + +const getRecents = vi.fn<[], Promise<{ recents: string[] }>>(); +const pushRecent = vi.fn<[string], Promise<{ recents: string[] }>>(); + +describe("useModelRecents", () => { + beforeEach(() => { + window.localStorage.clear(); + getRecents.mockResolvedValue({ recents: ["remote/model"] }); + pushRecent.mockImplementation(async (modelId) => ({ recents: [modelId] })); + window.ade = { + modelPicker: { + getRecents, + pushRecent, + }, + } as unknown as typeof window.ade; + __resetModelRecentsForTests(); + }); + + afterEach(() => { + Reflect.deleteProperty(window, "ade"); + window.localStorage.clear(); + vi.clearAllMocks(); + __resetModelRecentsForTests(); + }); + + it("uses cached recents without hydrating when disabled", async () => { + window.localStorage.setItem("ade.modelPicker.recents.v1", JSON.stringify(["cached/model"])); + __resetModelRecentsForTests(); + + const { result } = renderHook(() => useModelRecents({ hydrate: false })); + + expect(result.current.recents).toEqual(["cached/model"]); + await Promise.resolve(); + expect(getRecents).not.toHaveBeenCalled(); + }); + + it("hydrates from the runtime when enabled", async () => { + const { result } = renderHook(() => useModelRecents({ hydrate: true })); + + await waitFor(() => { + expect(result.current.recents).toEqual(["remote/model"]); + }); + expect(getRecents).toHaveBeenCalledTimes(1); + }); + + it("hydrates after the option changes from disabled to enabled", async () => { + const { result, rerender } = renderHook( + ({ hydrate }) => useModelRecents({ hydrate }), + { initialProps: { hydrate: false } }, + ); + + expect(result.current.recents).toEqual([]); + expect(getRecents).not.toHaveBeenCalled(); + + rerender({ hydrate: true }); + + await waitFor(() => { + expect(result.current.recents).toEqual(["remote/model"]); + }); + expect(getRecents).toHaveBeenCalledTimes(1); + }); + + it("records usage optimistically even when hydration is disabled", async () => { + const { result } = renderHook(() => useModelRecents({ hydrate: false })); + + act(() => { + result.current.recordUsage("local/model"); + }); + + expect(result.current.recents).toEqual(["local/model"]); + expect(pushRecent).toHaveBeenCalledWith("local/model"); + await waitFor(() => { + expect(window.localStorage.getItem("ade.modelPicker.recents.v1")).toBe(JSON.stringify(["local/model"])); + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.ts index d38609a13..6ae4fb3dc 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.ts @@ -101,11 +101,23 @@ const useRecentsStore = create((set, get) => ({ }, })); -export function useModelRecents(): { +type UseModelRecentsOptions = { + hydrate?: boolean; +}; + +export function __resetModelRecentsForTests(): void { + useRecentsStore.setState({ + recents: typeof window !== "undefined" ? readPersisted() : [], + hydrated: false, + }); +} + +export function useModelRecents(options: UseModelRecentsOptions = {}): { recents: string[]; recordUsage: (modelId: string) => void; recordRecent: (modelId: string) => void; } { + const hydrate = options.hydrate ?? true; const { recents, recordUsage, hydrateFromRemote, hydrated } = useRecentsStore( useShallow((state) => ({ recents: state.recents, @@ -116,9 +128,10 @@ export function useModelRecents(): { ); useEffect(() => { + if (!hydrate) return; if (hydrated) return; void hydrateFromRemote(); - }, [hydrateFromRemote, hydrated]); + }, [hydrate, hydrateFromRemote, hydrated]); return { recents, recordUsage, recordRecent: recordUsage }; } diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx index f5222574f..cc1d88a6f 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx @@ -2,12 +2,18 @@ import React from "react"; import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { LaneSummary, TerminalSessionSummary } from "../../../shared/types"; +import { useAppStore } from "../../state/appStore"; import { SessionCard } from "./SessionCard"; +const useSessionDeltaMock = vi.hoisted(() => + vi.fn((_sessionId: string | null, _enabled: boolean) => null), +); + vi.mock("./useSessionDelta", () => ({ - useSessionDelta: () => null, + useSessionDelta: (sessionId: string | null, enabled: boolean) => + useSessionDeltaMock(sessionId, enabled), })); function makeSession(overrides: Partial = {}): TerminalSessionSummary { @@ -43,6 +49,18 @@ const lane = { archivedAt: null, } as LaneSummary; +beforeEach(() => { + useSessionDeltaMock.mockClear(); + useAppStore.setState({ + projectBinding: { + kind: "local", + key: "local:/tmp/project", + rootPath: "/tmp/project", + displayName: "project", + }, + }); +}); + describe("SessionCard orchestration identity", () => { it("uses the orchestration role as the primary sidebar label", () => { render( @@ -64,4 +82,58 @@ describe("SessionCard orchestration identity", () => { expect(screen.getByText("WORKER · ui")).toBeTruthy(); expect(screen.getByRole("button", { name: /Worker · ui: Build the plan panel/ })).toBeTruthy(); }); + + it("defers remote delta loading until a session card is selected", () => { + useAppStore.setState({ + projectBinding: { + kind: "remote", + key: "remote:target:project", + targetId: "target", + runtimeName: "Mac Studio", + projectId: "project", + rootPath: "/Users/admin/Projects/perf pass", + displayName: "perf pass", + }, + }); + + render( + , + ); + + expect(useSessionDeltaMock).toHaveBeenCalledWith("session-1", false); + }); + + it("loads deltas for the selected remote session card", () => { + useAppStore.setState({ + projectBinding: { + kind: "remote", + key: "remote:target:project", + targetId: "target", + runtimeName: "Mac Studio", + projectId: "project", + rootPath: "/Users/admin/Projects/perf pass", + displayName: "perf pass", + }, + }); + + render( + , + ); + + expect(useSessionDeltaMock).toHaveBeenCalledWith("session-1", true); + }); }); diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx index 282df406b..b3bce4202 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -9,6 +9,7 @@ import { preferredSessionLabel, } from "../../lib/sessions"; import { relativeTimeCompact } from "../../lib/format"; +import { useAppStore } from "../../state/appStore"; import { useSessionDelta } from "./useSessionDelta"; import { cn } from "../ui/cn"; import { MONO_FONT } from "../lanes/laneDesignTokens"; @@ -137,7 +138,8 @@ export const SessionCard = React.memo(function SessionCard({ compact?: boolean; }) { const dot = sessionStatusDot(session); - const delta = useSessionDelta(session.id, true); + const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote"); + const delta = useSessionDelta(session.id, !isRemoteProject || isSelected); const primaryText = primarySessionLabel(session); const previewLine = getPreviewLine(session, primaryText); const staleAgeHours = getStaleRunningCliSessionAgeHours(session); diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx index 3bd736cec..e3641a571 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx @@ -13,7 +13,7 @@ const mockState = vi.hoisted(() => ({ lastContextLossHandler: null as (() => void) | null, ptyDataListeners: new Set<(event: { ptyId: string; sessionId?: string; projectRoot?: string; data: string }) => void>(), ptyExitListeners: new Set<(event: { ptyId: string; sessionId?: string; projectRoot?: string; exitCode: number | null }) => void>(), - projectRoot: "/project/a", + projectRoot: "/project/a" as string | null, projectRevision: 0, theme: "dark" as const, terminalPreferences: { @@ -45,6 +45,13 @@ class MockIntersectionObserver { } vi.mock("../../state/appStore", () => ({ + selectActiveProjectRoot: (state: { + projectBinding?: { kind?: string; rootPath?: string | null } | null; + project?: { rootPath?: string | null } | null; + }) => { + if (state.projectBinding?.kind === "remote") return state.projectBinding.rootPath?.trim() || null; + return state.project?.rootPath?.trim() || null; + }, useAppStore: vi.fn((selector: (state: { theme: "dark"; terminalPreferences: { @@ -740,6 +747,130 @@ describe("TerminalView", () => { expect(terminal?.options.scrollback).toBe(20_000); }); + it("batches rapid terminal input before writing to the PTY", async () => { + render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + onData: ReturnType; + } | undefined; + const onData = terminal?.onData.mock.calls.at(-1)?.[0] as ((data: string) => void) | undefined; + expect(onData).toBeTruthy(); + + const ptyWrite = window.ade.pty.write as unknown as ReturnType; + ptyWrite.mockClear(); + + onData!("p"); + await act(async () => { + await vi.advanceTimersByTimeAsync(8); + }); + onData!("w"); + await act(async () => { + await vi.advanceTimersByTimeAsync(8); + }); + onData!("d"); + + expect(ptyWrite).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(15); + }); + expect(ptyWrite).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect(ptyWrite).toHaveBeenCalledTimes(1); + expect(ptyWrite).toHaveBeenCalledWith({ + ptyId: "pty-input-batch", + data: "pwd", + }); + }); + + it("flushes queued terminal input immediately when Enter arrives", async () => { + render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + onData: ReturnType; + } | undefined; + const onData = terminal?.onData.mock.calls.at(-1)?.[0] as ((data: string) => void) | undefined; + expect(onData).toBeTruthy(); + + const ptyWrite = window.ade.pty.write as unknown as ReturnType; + ptyWrite.mockClear(); + + onData!("pwd"); + await act(async () => { + await vi.advanceTimersByTimeAsync(8); + }); + expect(ptyWrite).not.toHaveBeenCalled(); + + onData!("\r"); + + expect(ptyWrite).toHaveBeenCalledTimes(1); + expect(ptyWrite).toHaveBeenCalledWith({ + ptyId: "pty-input-enter", + data: "pwd\r", + }); + }); + + it("preserves queued input order inside direct control-key writes", async () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(window.navigator, "platform"); + const originalPlatform = window.navigator.platform; + try { + Object.defineProperty(window.navigator, "platform", { + configurable: true, + value: "MacIntel", + }); + + render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + attachCustomKeyEventHandler: ReturnType; + onData: ReturnType; + } | undefined; + const onData = terminal?.onData.mock.calls.at(-1)?.[0] as ((data: string) => void) | undefined; + const keyHandler = terminal?.attachCustomKeyEventHandler.mock.calls.at(-1)?.[0] as ((ev: KeyboardEvent) => boolean) | undefined; + expect(onData).toBeTruthy(); + expect(keyHandler).toBeTruthy(); + + const ptyWrite = window.ade.pty.write as unknown as ReturnType; + ptyWrite.mockClear(); + + onData!("p"); + const preventDefault = vi.fn(); + const handled = keyHandler!({ + type: "keydown", + key: "c", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + preventDefault, + } as unknown as KeyboardEvent); + + expect(handled).toBe(false); + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(ptyWrite).toHaveBeenCalledTimes(1); + expect(ptyWrite).toHaveBeenCalledWith({ + ptyId: "pty-input-control", + data: "p\x03", + }); + } finally { + if (platformDescriptor) { + Object.defineProperty(window.navigator, "platform", platformDescriptor); + } else { + Object.defineProperty(window.navigator, "platform", { + configurable: true, + value: originalPlatform, + }); + } + } + }); + it("writes text paste contents directly to the PTY", async () => { render(); await flushAllTimers(); @@ -1170,6 +1301,43 @@ describe("TerminalView", () => { expect(getTerminalRuntimeSnapshot("session-switch")).not.toBeNull(); }); + it("does not tear down a mounted exited runtime while the active project clears", async () => { + const view = render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + dispose: ReturnType; + } | undefined; + expect(terminal).toBeTruthy(); + + for (const listener of mockState.ptyExitListeners) { + listener({ + ptyId: "pty-project-clear", + sessionId: "session-project-clear", + projectRoot: "/project/a", + exitCode: 0, + }); + } + await flushPromises(); + expect(getTerminalRuntimeSnapshot("session-project-clear")?.exitCode).toBe(0); + + mockState.projectRoot = null; + mockState.projectRevision += 1; + view.rerender(); + await flushAnimationFrame(); + + expect(terminal?.dispose).not.toHaveBeenCalled(); + expect(getTerminalRuntimeSnapshot("session-project-clear")).not.toBeNull(); + + view.unmount(); + await act(async () => { + await vi.advanceTimersByTimeAsync(15_100); + }); + + expect(terminal?.dispose).toHaveBeenCalledTimes(1); + expect(getTerminalRuntimeSnapshot("session-project-clear")).toBeNull(); + }); + it("hydrates live terminals from serialized snapshots when structured rows are unavailable", async () => { const previewMock = window.ade.terminal.preview as unknown as ReturnType; const readTranscriptTailMock = window.ade.sessions.readTranscriptTail as unknown as ReturnType; diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx index fe1c61b43..51e211ae8 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx @@ -6,6 +6,7 @@ import { cn } from "../ui/cn"; import { DEFAULT_TERMINAL_FONT_FAMILY, DEFAULT_TERMINAL_PREFERENCES, + selectActiveProjectRoot, useAppStore, type TerminalPreferences, type ThemeId, @@ -83,6 +84,9 @@ type CachedRuntime = { pendingHydrationBytes: number; frameWriteChunks: string[]; frameWriteBytes: number; + inputWriteChunks: string[]; + inputWriteBytes: number; + inputFlushTimer: ReturnType | null; liveStreamPaused: boolean; flushRafId: number | null; flushTimer: ReturnType | null; @@ -121,6 +125,8 @@ const HYDRATION_VISIBLE_BLANK_BACKFILL_RETRY_MS = 100; const HYDRATION_BACKFILL_MAX_ATTEMPTS = 120; const MAX_PENDING_HYDRATION_BYTES = 2_000_000; const MAX_FRAME_WRITE_BYTES = 1_000_000; +const MAX_PTY_INPUT_BATCH_BYTES = 16_384; +const PTY_INPUT_BATCH_MS = 16; const EXITED_RUNTIME_KEEPALIVE_MS = 8_000; const MIN_VALID_COLS = 20; const MIN_VALID_ROWS = 6; @@ -627,7 +633,7 @@ function disposeStaleRuntimes(activeProjectRoot: string | null, activeProjectRev for (const runtime of runtimeCache.values()) { const isLiveRuntime = runtime.exitCode == null; if (activeProjectRoot == null) { - if (runtime.projectRoot != null && !isLiveRuntime) { + if (runtime.projectRoot != null && !isLiveRuntime && runtime.refs === 0) { teardownRuntime(runtime); } continue; @@ -752,11 +758,61 @@ function setRuntimeVisibilityState(runtime: CachedRuntime, visible: boolean) { syncRuntimePtyDataStreaming(runtime, wasReceiving); } -function writePtyInput(runtime: CachedRuntime, data: string) { +function clearPtyInputFlushTimer(runtime: CachedRuntime): void { + if (runtime.inputFlushTimer) { + clearTimeout(runtime.inputFlushTimer); + runtime.inputFlushTimer = null; + } +} + +function writePtyInputNow(runtime: CachedRuntime, data: string) { if (!data || runtime.disposed) return; window.ade.pty.write({ ptyId: runtime.ptyId, data }).catch(() => {}); } +function consumePendingPtyInput(runtime: CachedRuntime): string { + clearPtyInputFlushTimer(runtime); + if (!runtime.inputWriteChunks.length || runtime.disposed) return ""; + const data = runtime.inputWriteChunks.join(""); + runtime.inputWriteChunks.length = 0; + runtime.inputWriteBytes = 0; + return data; +} + +function flushPendingPtyInput(runtime: CachedRuntime): void { + const data = consumePendingPtyInput(runtime); + if (!data) return; + writePtyInputNow(runtime, data); +} + +function writePtyInput(runtime: CachedRuntime, data: string) { + if (!data || runtime.disposed) return; + writePtyInputNow(runtime, `${consumePendingPtyInput(runtime)}${data}`); +} + +function shouldFlushPtyInputImmediately(data: string): boolean { + return /[\x00-\x1f\x7f]|\x1b/.test(data); +} + +function schedulePtyInputFlush(runtime: CachedRuntime): void { + clearPtyInputFlushTimer(runtime); + runtime.inputFlushTimer = setTimeout(() => { + runtime.inputFlushTimer = null; + flushPendingPtyInput(runtime); + }, PTY_INPUT_BATCH_MS); +} + +function enqueuePtyInput(runtime: CachedRuntime, data: string) { + if (!data || runtime.disposed) return; + runtime.inputWriteChunks.push(data); + runtime.inputWriteBytes += data.length; + if (shouldFlushPtyInputImmediately(data) || runtime.inputWriteBytes >= MAX_PTY_INPUT_BATCH_BYTES) { + flushPendingPtyInput(runtime); + return; + } + schedulePtyInputFlush(runtime); +} + function updateTerminalMouseTrackingModes(runtime: CachedRuntime, data: string): void { for (const match of data.matchAll(/\x1b\[\?([0-9;]+)([hl])/g)) { const action = match[2]; @@ -867,11 +923,13 @@ async function pasteNativeClipboardImageShortcut(runtime: CachedRuntime): Promis } function teardownRuntime(runtime: CachedRuntime) { + flushPendingPtyInput(runtime); runtime.disposed = true; clearDisposeTimer(runtime); if (runtime.fitRafId != null) cancelAnimationFrame(runtime.fitRafId); if (runtime.flushRafId != null) cancelAnimationFrame(runtime.flushRafId); if (runtime.flushTimer) clearTimeout(runtime.flushTimer); + clearPtyInputFlushTimer(runtime); if (runtime.settleTimer1) clearTimeout(runtime.settleTimer1); if (runtime.settleTimer2) clearTimeout(runtime.settleTimer2); if (runtime.hydrateTimer) clearTimeout(runtime.hydrateTimer); @@ -1716,6 +1774,9 @@ function createRuntime(args: { pendingHydrationBytes: 0, frameWriteChunks: [], frameWriteBytes: 0, + inputWriteChunks: [], + inputWriteBytes: 0, + inputFlushTimer: null, liveStreamPaused: false, flushRafId: null, flushTimer: null, @@ -1834,24 +1895,11 @@ function createRuntime(args: { return true; }); - // Batch rapid onData events (e.g. paste via context-menu) into single PTY writes - let inputBuf: string[] = []; - let inputFlushQueued = false; - + // Debounce rapid terminal input into fewer PTY writes. Remote runtimes pay a + // full RPC round trip per write, so per-character bursts make typing feel + // much slower than a local shell. runtime.termDataSub = term.onData((data) => { - if (runtime.disposed) return; - inputBuf.push(data); - if (!inputFlushQueued) { - inputFlushQueued = true; - queueMicrotask(() => { - inputFlushQueued = false; - const merged = inputBuf.join(""); - inputBuf = []; - if (merged && !runtime.disposed) { - writePtyInput(runtime, merged); - } - }); - } + enqueuePtyInput(runtime, data); }); runtime.ptyDataUnsub = subscribeRuntimePtyData(runtime); @@ -1930,7 +1978,7 @@ export function TerminalView({ }) { const appTheme = useAppStore((s) => s.theme); const terminalPreferences = useAppStore((s) => s.terminalPreferences); - const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const projectRevision = useAppStore((s) => s.projectRevision); const runtimeProjectScopeRef = useRef<{ sessionId: string; diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx index 60888e81d..d8a326fbd 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx @@ -148,6 +148,10 @@ const workMocks = vi.hoisted(() => { baseWork, currentWork: baseWork as any, projectRoot: null as string | null, + projectBinding: null as null | { + kind: "remote"; + rootPath: string; + }, fns, makeTerminalSession, }; @@ -174,9 +178,21 @@ const sessionListPaneProps = vi.hoisted(() => ({ })); vi.mock("../../state/appStore", () => ({ - useAppStore: (selector: (state: { selectedLaneId: string; project: { rootPath: string } | null }) => T): T => + selectActiveProjectRoot: (state: { + projectBinding?: { kind?: string; rootPath?: string | null } | null; + project?: { rootPath?: string | null } | null; + }) => { + if (state.projectBinding?.kind === "remote") return state.projectBinding.rootPath?.trim() || null; + return state.project?.rootPath?.trim() || null; + }, + useAppStore: (selector: (state: { + selectedLaneId: string; + project: { rootPath: string } | null; + projectBinding: typeof workMocks.projectBinding; + }) => T): T => selector({ selectedLaneId: "lane-primary", + projectBinding: workMocks.projectBinding, project: workMocks.projectRoot ? { rootPath: workMocks.projectRoot } : null, @@ -273,6 +289,7 @@ describe("TerminalsPage chat session activation", () => { cleanup(); workMocks.currentWork = { ...workMocks.baseWork, closingPtyIds: new Set() }; workMocks.projectRoot = null; + workMocks.projectBinding = null; sidebarProps.latest = null; sessionListPaneProps.latest = null; vi.clearAllMocks(); @@ -351,6 +368,38 @@ describe("TerminalsPage chat session activation", () => { expect(workMocks.currentWork.setWorkSidebarTab).toHaveBeenCalledWith("browser"); }); + it("ignores Browser sidebar open requests for remote projects", async () => { + workMocks.projectRoot = "/repo-one"; + workMocks.projectBinding = { + kind: "remote", + rootPath: "/repo-one", + }; + const browserEventListener: { + current: ((event: { type?: string; status?: unknown }) => void) | null; + } = { current: null }; + Object.defineProperty(window, "ade", { + configurable: true, + value: { + builtInBrowser: { + onEvent: vi.fn((listener) => { + browserEventListener.current = listener; + return vi.fn(); + }), + }, + }, + }); + + render(); + + await waitFor(() => expect(browserEventListener.current).not.toBeNull()); + browserEventListener.current?.({ + type: "open-request", + status: { profileProjectRoot: "/repo-one" }, + }); + expect(workMocks.currentWork.setViewMode).not.toHaveBeenCalled(); + expect(workMocks.currentWork.setWorkSidebarTab).not.toHaveBeenCalled(); + }); + it("targets the visible Work draft when no saved session is active", async () => { Object.defineProperty(window, "ade", { configurable: true, diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index 231f48f87..8403ec94b 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -12,7 +12,7 @@ import type { AgentChatSessionCreatedOptions } from "../chat/AgentChatPane"; import { canBulkDeleteSession, canBulkStopSession, formatToolTypeLabel, isChatToolType } from "../../lib/sessions"; import { sortLanesForTabs } from "../lanes/laneUtils"; import { invalidateSessionListCache } from "../../lib/sessionListCache"; -import { useAppStore, type WorkDraftKind } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore, type WorkDraftKind } from "../../state/appStore"; import { ADE_OPEN_BUILT_IN_BROWSER_EVENT } from "../../lib/openExternal"; import { ADE_WORK_SIDEBAR_BROWSER_RESIZE_END_EVENT, @@ -103,7 +103,7 @@ async function allSettledWithConcurrency( export function TerminalsPage({ active = true }: { active?: boolean }) { const work = useWorkSessions({ active }); - const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const selectedLaneId = useAppStore((s) => s.selectedLaneId); const sortedLanes = useMemo(() => sortLanesForTabs(work.lanes), [work.lanes]); @@ -551,9 +551,11 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { const workSidebarVisible = active && work.workSidebarOpen && work.viewMode !== "grid"; const { setViewMode, setWorkSidebarTab, showDraftKind } = work; + const isRemoteProject = useAppStore((state) => state.projectBinding?.kind === "remote"); useEffect(() => { if (!active) return; const openBrowserSidebar = () => { + if (isRemoteProject) return; setViewMode("tabs"); setWorkSidebarTab("browser"); }; @@ -578,7 +580,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { window.removeEventListener("ade:work:stop-orchestrator-chat", stopOrchestratorChat); unsubscribeBrowserEvents?.(); }; - }, [active, projectRoot, setViewMode, setWorkSidebarTab, showDraftKind]); + }, [active, isRemoteProject, projectRoot, setViewMode, setWorkSidebarTab, showDraftKind]); const toggleSessionsPane = useCallback(() => { work.setWorkFocusSessionsHidden(!work.workFocusSessionsHidden); diff --git a/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx b/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx index 6854e51ab..9c914d78f 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx @@ -13,7 +13,7 @@ import type { TerminalSessionSummary, } from "../../../shared/types"; import { ADE_WORK_PTY_CONTEXT_INSERTED_EVENT } from "../../lib/workPtyContextEvents"; -import { useAppStore } from "../../state/appStore"; +import { useAppStore, type WorkSidebarTab } from "../../state/appStore"; import { WorkSidebar, type WorkSidebarContextTarget } from "./WorkSidebar"; vi.mock("../chat/ChatIosSimulatorPanel", async () => { @@ -311,12 +311,13 @@ function installAdeMock(options: { } function renderSidebar(args: { - tab: "ios" | "app-control" | "browser"; + tab: WorkSidebarTab; contextTarget: WorkSidebarContextTarget | null; contextDisabledReason?: string | null; laneId?: string; lanes?: LaneSummary[]; activeSession?: TerminalSessionSummary | null; + onTabChange?: (tab: WorkSidebarTab) => void; }) { return render( @@ -326,7 +327,7 @@ function renderSidebar(args: { lanes={args.lanes ?? [lane]} activeSession={args.activeSession ?? activeSession} tab={args.tab} - onTabChange={vi.fn()} + onTabChange={args.onTabChange ?? vi.fn()} onClose={vi.fn()} contextTarget={args.contextTarget} contextDisabledReason={args.contextDisabledReason ?? null} @@ -342,7 +343,7 @@ describe("WorkSidebar context targets", () => { afterEach(() => { cleanup(); - useAppStore.setState({ project: null } as any); + useAppStore.setState({ project: null, projectBinding: null } as any); delete (window as unknown as { ade?: unknown }).ade; vi.restoreAllMocks(); }); @@ -567,4 +568,36 @@ describe("WorkSidebar context targets", () => { }, })).not.toThrow(); }); + + it("only exposes remote-aware tool panes for remote projects", async () => { + const onTabChange = vi.fn(); + useAppStore.setState({ + projectBinding: { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Mac Studio", + projectId: "project-1", + rootPath: "/repo", + displayName: "Repo", + }, + } as any); + + renderSidebar({ + tab: "browser", + contextTarget: { kind: "chat", sessionId: "chat-1" }, + onTabChange, + }); + + expect(screen.getByRole("button", { name: "Git" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Files" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "iOS Sim" })).toBeNull(); + expect(screen.queryByRole("button", { name: "App Control" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Browser" })).toBeNull(); + expect(screen.queryByTestId("browser-panel")).toBeNull(); + await waitFor(() => expect(onTabChange).toHaveBeenCalledWith("git")); + expect(window.ade.builtInBrowser.getStatus).not.toHaveBeenCalled(); + expect(window.ade.iosSimulator.getStatus).not.toHaveBeenCalled(); + expect(window.ade.appControl.getStatus).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx b/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx index f0d16a21b..594ae8a95 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx @@ -21,7 +21,7 @@ import type { TerminalSessionSummary, TerminalToolType, } from "../../../shared/types"; -import { useAppStore, type WorkDraftKind, type WorkSidebarTab } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore, type WorkDraftKind, type WorkSidebarTab } from "../../state/appStore"; import { formatAppControlContextForPrompt, formatBuiltInBrowserContextForPrompt, @@ -79,6 +79,12 @@ const WORK_SIDEBAR_TABS: Array> = [ }, ]; +const REMOTE_WORK_SIDEBAR_TAB_IDS = new Set(["git", "files"]); + +function isRemoteWorkSidebarTab(tab: WorkSidebarTab): boolean { + return REMOTE_WORK_SIDEBAR_TAB_IDS.has(tab); +} + export type WorkSidebarContextTarget = | { kind: "chat"; sessionId: string } | { kind: "draft"; draftTargetId: string; laneId: string; draftKind: WorkDraftKind } @@ -213,9 +219,17 @@ export function WorkSidebar({ const [appControlSession, setAppControlSession] = useState(null); const [iosSession, setIosSession] = useState(null); const [browserStatus, setBrowserStatus] = useState(null); - const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); + const isRemoteProject = useAppStore((state) => state.projectBinding?.kind === "remote"); const sidebarRef = useRef(null); const [compactTabs, setCompactTabs] = useState(false); + const sidebarTabs = useMemo( + () => isRemoteProject + ? WORK_SIDEBAR_TABS.filter((item) => isRemoteWorkSidebarTab(item.id)) + : WORK_SIDEBAR_TABS, + [isRemoteProject], + ); + const effectiveTab: WorkSidebarTab = isRemoteProject && !isRemoteWorkSidebarTab(tab) ? "git" : tab; const activeLane = useMemo( () => (laneId ? lanes.find((lane) => lane.id === laneId) ?? null : null), @@ -229,6 +243,12 @@ export function WorkSidebar({ setSelectedCommit(null); }, [laneId]); + useEffect(() => { + if (isRemoteProject && !isRemoteWorkSidebarTab(tab)) { + onTabChange("git"); + } + }, [isRemoteProject, onTabChange, tab]); + useEffect(() => { const el = sidebarRef.current; if (!el) return undefined; @@ -242,20 +262,20 @@ export function WorkSidebar({ return () => observer.disconnect(); }, []); - const previousBrowserTabRef = useRef(tab === "browser"); + const previousBrowserTabRef = useRef(effectiveTab === "browser"); useEffect(() => { const wasBrowser = previousBrowserTabRef.current; - const isBrowser = active && tab === "browser"; + const isBrowser = active && effectiveTab === "browser"; if (wasBrowser && !isBrowser) hideBuiltInBrowserView(projectRoot); previousBrowserTabRef.current = isBrowser; return () => { if (previousBrowserTabRef.current) hideBuiltInBrowserView(projectRoot); }; - }, [active, projectRoot, tab]); + }, [active, effectiveTab, projectRoot]); useEffect(() => { if (!active) return undefined; - if (tab !== "browser") return undefined; + if (effectiveTab !== "browser") return undefined; const browser = window.ade?.builtInBrowser; if (!browser?.getStatus || !browser.onEvent) return undefined; let cancelled = false; @@ -278,11 +298,11 @@ export function WorkSidebar({ cancelled = true; unsubscribe(); }; - }, [active, projectRoot, tab]); + }, [active, effectiveTab, projectRoot]); useEffect(() => { if (!active) return undefined; - if (tab !== "app-control") return undefined; + if (effectiveTab !== "app-control") return undefined; const appControl = window.ade?.appControl; if (!appControl?.getStatus || !appControl.onEvent) return undefined; let cancelled = false; @@ -304,11 +324,11 @@ export function WorkSidebar({ cancelled = true; unsubscribe(); }; - }, [active, tab]); + }, [active, effectiveTab]); useEffect(() => { if (!active) return undefined; - if (tab !== "ios") return undefined; + if (effectiveTab !== "ios") return undefined; const iosSimulator = window.ade?.iosSimulator; if (!iosSimulator?.getStatus || !iosSimulator.onEvent) return undefined; let cancelled = false; @@ -330,14 +350,14 @@ export function WorkSidebar({ cancelled = true; unsubscribe(); }; - }, [active, tab]); + }, [active, effectiveTab]); function resolveToolAttributionReason(): string | null { if (!laneId) return null; - if (tab === "app-control" && appControlSession?.laneId && appControlSession.laneId !== laneId) { + if (effectiveTab === "app-control" && appControlSession?.laneId && appControlSession.laneId !== laneId) { return laneMismatchMessage("App Control", appControlSession.laneId, laneId, lanes); } - if (tab === "ios" && iosSession?.laneId && iosSession.laneId !== laneId) { + if (effectiveTab === "ios" && iosSession?.laneId && iosSession.laneId !== laneId) { return laneMismatchMessage("iOS Simulator", iosSession.laneId, laneId, lanes); } return null; @@ -459,7 +479,7 @@ export function WorkSidebar({ const content = useMemo(() => { if (!active) return null; - if (tab === "browser") { + if (effectiveTab === "browser") { return (
{warningReason ? : null} @@ -483,7 +503,7 @@ export function WorkSidebar({ ); } - if (tab === "git") { + if (effectiveTab === "git") { const hasDiffSelection = Boolean(selectedPath || selectedCommit); return (
@@ -530,11 +550,11 @@ export function WorkSidebar({ ); } - if (tab === "files") { + if (effectiveTab === "files") { return ; } - const panel = tab === "ios" ? ( + const panel = effectiveTab === "ios" ? ( { - if (tab === "browser" && nextTab !== "browser") hideBuiltInBrowserView(projectRoot); + if (effectiveTab === "browser" && nextTab !== "browser") hideBuiltInBrowserView(projectRoot); onTabChange(nextTab); }} /> @@ -603,7 +623,7 @@ export function WorkSidebar({ className="ade-shell-control inline-flex w-9 shrink-0 items-center justify-center self-stretch rounded-none border-l border-white/[0.08] text-muted-fg/70 transition-colors hover:bg-white/[0.04] hover:text-fg" data-variant="ghost" onClick={() => { - if (tab === "browser") hideBuiltInBrowserView(projectRoot); + if (effectiveTab === "browser") hideBuiltInBrowserView(projectRoot); onClose(); }} title="Close Tools sidebar" diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index b194df093..8b0355b17 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -1123,12 +1123,15 @@ function WorkSidebarToggle({ open: boolean; onToggle?: () => void; }) { + const isRemoteProject = useAppStore((state) => state.projectBinding?.kind === "remote"); if (!onToggle) return null; return (