From 7465fda7856859ec15c603e8c1ccc22b73719491 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 23:01:15 +0000 Subject: [PATCH 01/82] release: v1.4.2-rc.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7e1366af82b..262e804eaa3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.2-rc.1", + "version": "1.4.2-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From dcf5bf0dcee21f7e39f9e4b30632e71eb5cc2c43 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 14 May 2026 16:05:53 -0700 Subject: [PATCH 02/82] Clarify conflict details refresh state (#1875) Co-authored-by: Orca --- .../src/components/GitHubItemDialog.tsx | 2 +- .../components/right-sidebar/ChecksPanel.tsx | 22 ++++++-- .../right-sidebar/SourceControl.tsx | 2 +- .../checks-panel-content.test.tsx | 54 +++++++++++++++++++ ...s-helpers.tsx => checks-panel-content.tsx} | 14 ++++- 5 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx rename src/renderer/src/components/right-sidebar/{checks-helpers.tsx => checks-panel-content.tsx} (98%) diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 6f449118a9f..b8cd117d62e 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -50,7 +50,7 @@ import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { detectLanguage } from '@/lib/language-detect' import { cn } from '@/lib/utils' import { buildDiffTree, type DiffTreeNode } from '@/components/pr-diff-tree' -import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-helpers' +import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content' import { filterPRCommentsByAudience, getPRCommentAudienceCounts, diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 1e644af9a83..b401307d286 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -14,7 +14,7 @@ import { MergeConflictNotice, ChecksList, PRCommentsList -} from './checks-helpers' +} from './checks-panel-content' import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh' import type { PRInfo, PRCheckDetail, PRComment } from '../../../../shared/types' @@ -43,6 +43,7 @@ export default function ChecksPanel(): React.JSX.Element { const [commentsLoading, setCommentsLoading] = useState(false) const [emptyRefreshing, setEmptyRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) + const [conflictDetailsRefreshing, setConflictDetailsRefreshing] = useState(false) const [editingTitle, setEditingTitle] = useState(false) const [titleDraft, setTitleDraft] = useState('') const [titleSaving, setTitleSaving] = useState(false) @@ -67,6 +68,7 @@ export default function ChecksPanel(): React.JSX.Element { setTitleSaving(false) setIsRefreshing(false) setEmptyRefreshing(false) + setConflictDetailsRefreshing(false) conflictSummaryRefreshKeyRef.current = null } @@ -108,6 +110,7 @@ export default function ChecksPanel(): React.JSX.Element { useEffect(() => { if (!repo || isFolder || !branch || !pr || pr.mergeable !== 'CONFLICTING') { conflictSummaryRefreshKeyRef.current = null + setConflictDetailsRefreshing(false) return } @@ -121,7 +124,17 @@ export default function ChecksPanel(): React.JSX.Element { // them so we don't keep rendering cached branch summaries or empty file // lists from an older payload. conflictSummaryRefreshKeyRef.current = refreshKey - void fetchPRForBranch(repo.path, branch, { force: true, linkedPRNumber: linkedPR }) + setConflictDetailsRefreshing(true) + void fetchPRForBranch(repo.path, branch, { force: true, linkedPRNumber: linkedPR }).finally( + () => { + // Why: fetchPRForBranch updates the PR cache before resolving, which + // can rerun this effect. Only the current refresh key may clear the + // spinner so stale requests don't race newer worktrees/branches. + if (conflictSummaryRefreshKeyRef.current === refreshKey) { + setConflictDetailsRefreshing(false) + } + } + ) }, [repo, isFolder, branch, pr, linkedPR, fetchPRForBranch]) // Fetch checks via cached store method @@ -580,7 +593,10 @@ export default function ChecksPanel(): React.JSX.Element { - + {/* Why: when the PR has merge conflicts and no checks have been fetched, showing "No checks configured" is misleading — checks may exist but simply cannot run until conflicts are resolved. Hide the empty state. */} diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 0af146ae5dc..7047bf1e8d4 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -92,7 +92,7 @@ import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' import { getConnectionId } from '@/lib/connection-context' -import { PullRequestIcon } from './checks-helpers' +import { PullRequestIcon } from './checks-panel-content' import type { DiffComment, GitBranchChangeEntry, diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx new file mode 100644 index 00000000000..1d8017fbf7e --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import type { PRInfo } from '../../../../shared/types' +import { MergeConflictNotice } from './checks-panel-content' + +function makePR(overrides: Partial = {}): PRInfo { + return { + number: 42, + title: 'Conflicting PR', + state: 'open', + url: 'https://github.com/acme/widgets/pull/42', + checksStatus: 'pending', + updatedAt: '2026-05-14T00:00:00Z', + mergeable: 'CONFLICTING', + ...overrides + } +} + +function renderNotice(pr: PRInfo, isRefreshingConflictDetails = false): string { + return renderToStaticMarkup( + React.createElement(MergeConflictNotice, { pr, isRefreshingConflictDetails }) + ) +} + +describe('MergeConflictNotice', () => { + it('does not claim conflict details are refreshing after the refresh has settled', () => { + const markup = renderNotice(makePR()) + + expect(markup).toContain('Conflict file details are unavailable') + expect(markup).not.toContain('Refreshing conflict details') + }) + + it('shows refreshing copy while conflict details are actively refreshing', () => { + const markup = renderNotice(makePR(), true) + + expect(markup).toContain('Refreshing conflict details') + }) + + it('hides when the conflicting file list is available', () => { + const markup = renderNotice( + makePR({ + conflictSummary: { + baseRef: 'main', + baseCommit: 'abc1234', + commitsBehind: 2, + files: ['src/conflict.ts'] + } + }) + ) + + expect(markup).toBe('') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/checks-helpers.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx similarity index 98% rename from src/renderer/src/components/right-sidebar/checks-helpers.tsx rename to src/renderer/src/components/right-sidebar/checks-panel-content.tsx index b907dd7dff7..8dce022608d 100644 --- a/src/renderer/src/components/right-sidebar/checks-helpers.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -101,7 +101,13 @@ export function ConflictingFilesSection({ pr }: { pr: PRInfo }): React.JSX.Eleme } /** Fallback shown when GitHub reports merge conflicts but no file list is available yet. */ -export function MergeConflictNotice({ pr }: { pr: PRInfo }): React.JSX.Element | null { +export function MergeConflictNotice({ + pr, + isRefreshingConflictDetails +}: { + pr: PRInfo + isRefreshingConflictDetails: boolean +}): React.JSX.Element | null { if (pr.mergeable !== 'CONFLICTING' || (pr.conflictSummary?.files.length ?? 0) > 0) { return null } @@ -111,7 +117,11 @@ export function MergeConflictNotice({ pr }: { pr: PRInfo }): React.JSX.Element |
This branch has conflicts that must be resolved
-
Refreshing conflict details…
+
+ {isRefreshingConflictDetails + ? 'Refreshing conflict details…' + : 'Conflict file details are unavailable'} +
) } From 2cadcbc4e1ffbda3f04dd0c6fbc02cd816f55ac2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 14 May 2026 16:06:23 -0700 Subject: [PATCH 03/82] Support agent status hooks over SSH (#1865) Co-authored-by: Orca --- config/tsconfig.relay.json | 6 +- .../installer-utils-remote.test.ts | 269 ++++++++++++ .../agent-hooks/installer-utils-remote.ts | 385 ++++++++++++++++++ .../remote-hook-service-installers.test.ts | 224 ++++++++++ src/main/agent-hooks/server.ts | 22 +- src/main/claude/hook-service.test.ts | 166 ++++++++ src/main/claude/hook-service.ts | 87 +++- src/main/codex/config-toml-trust.ts | 18 +- src/main/codex/hook-service.ts | 106 ++++- src/main/cursor/hook-service.ts | 77 +++- src/main/gemini/hook-service.ts | 69 +++- src/main/ipc/pty.test.ts | 4 + src/main/pty/overlay-mirror.ts | 12 +- src/main/pty/shell-startup-env.test.ts | 19 +- src/main/pty/shell-startup-env.ts | 90 ++-- src/main/ssh/ssh-relay-session.test.ts | 67 ++- src/main/ssh/ssh-relay-session.ts | 80 +++- src/relay/agent-hook-server.ts | 12 +- src/relay/plugin-overlay-env.test.ts | 81 ++++ src/relay/plugin-overlay-env.ts | 35 ++ src/relay/plugin-overlay.test.ts | 186 +++++++++ src/relay/plugin-overlay.ts | 276 +++++++++++++ src/relay/plugin-source-limit.test.ts | 24 ++ src/relay/plugin-source-limit.ts | 12 + src/relay/pty-handler.test.ts | 147 ++++++- src/relay/pty-handler.ts | 109 +++-- src/relay/pty-shell-launch.test.ts | 58 +++ src/relay/pty-shell-launch.ts | 165 ++++++++ src/relay/relay.ts | 110 ++++- src/shared/agent-hook-listener.ts | 4 +- tests/e2e/ssh-localhost.spec.ts | 67 ++- 31 files changed, 2829 insertions(+), 158 deletions(-) create mode 100644 src/main/agent-hooks/installer-utils-remote.test.ts create mode 100644 src/main/agent-hooks/installer-utils-remote.ts create mode 100644 src/main/agent-hooks/remote-hook-service-installers.test.ts create mode 100644 src/main/claude/hook-service.test.ts create mode 100644 src/relay/plugin-overlay-env.test.ts create mode 100644 src/relay/plugin-overlay-env.ts create mode 100644 src/relay/plugin-overlay.test.ts create mode 100644 src/relay/plugin-overlay.ts create mode 100644 src/relay/plugin-source-limit.test.ts create mode 100644 src/relay/plugin-source-limit.ts create mode 100644 src/relay/pty-shell-launch.test.ts create mode 100644 src/relay/pty-shell-launch.ts diff --git a/config/tsconfig.relay.json b/config/tsconfig.relay.json index 247b9ac7476..242069f761c 100644 --- a/config/tsconfig.relay.json +++ b/config/tsconfig.relay.json @@ -1,6 +1,10 @@ { "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", - "include": ["../src/relay/**/*"], + "include": [ + "../src/relay/**/*", + "../src/main/pty/overlay-mirror.ts", + "../src/main/pty/shell-startup-env.ts" + ], "exclude": ["../src/relay/integration.test.ts"], "compilerOptions": { "composite": true, diff --git a/src/main/agent-hooks/installer-utils-remote.test.ts b/src/main/agent-hooks/installer-utils-remote.test.ts new file mode 100644 index 00000000000..304ef4484ea --- /dev/null +++ b/src/main/agent-hooks/installer-utils-remote.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it } from 'vitest' +import type { SFTPWrapper } from 'ssh2' + +import { + readHooksJsonRemote, + writeHooksJsonRemote, + writeManagedScriptRemote, + writeTextFileRemoteAtomic +} from './installer-utils-remote' + +type FakeFs = { + files: Map + dirs: Set + modes: Map + openSshRenameCount: number +} + +function createFakeSftp( + opts: { + plainRenameOverwrites?: boolean + openSshRename?: boolean + failDotFileWrites?: boolean + } = {} +): { + sftp: SFTPWrapper + fs: FakeFs +} { + const plainRenameOverwrites = opts.plainRenameOverwrites ?? true + const fs: FakeFs = { + files: new Map(), + dirs: new Set(['/']), + modes: new Map(), + openSshRenameCount: 0 + } + const noEntryError = (path: string): { code: number; message: string } => ({ + code: 2, + message: `ENOENT ${path}` + }) + const fakeStats = (mode: number): { mode: number } => ({ mode }) + + const sftp = { + readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => { + const v = fs.files.get(path) + if (v === undefined) { + cb(noEntryError(path)) + return + } + cb(null, v) + }, + writeFile: ( + path: string, + content: string, + options: string | { mode?: number }, + cb: (err: unknown) => void + ): void => { + if (opts.failDotFileWrites && path.includes('/.')) { + cb({ code: 4, message: `write failed ${path}` }) + return + } + fs.files.set(path, content) + if (typeof options !== 'string' && options.mode !== undefined) { + fs.modes.set(path, options.mode) + } + cb(null) + }, + rename: (src: string, dst: string, cb: (err: unknown) => void): void => { + const v = fs.files.get(src) + if (v === undefined) { + cb(noEntryError(src)) + return + } + if (!plainRenameOverwrites && fs.files.has(dst)) { + cb({ code: 4, message: `SSH_FX_FAILURE destination exists ${dst}` }) + return + } + fs.files.set(dst, v) + fs.files.delete(src) + const mode = fs.modes.get(src) + if (mode !== undefined) { + fs.modes.set(dst, mode) + fs.modes.delete(src) + } + cb(null) + }, + unlink: (path: string, cb: (err: unknown) => void): void => { + if (!fs.files.has(path)) { + cb(noEntryError(path)) + return + } + fs.files.delete(path) + fs.modes.delete(path) + cb(null) + }, + chmod: (path: string, mode: number, cb: (err: unknown) => void): void => { + fs.modes.set(path, mode) + cb(null) + }, + stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => { + if (!fs.files.has(path)) { + cb(noEntryError(path)) + return + } + cb(null, fakeStats(fs.modes.get(path) ?? 0o100644)) + }, + readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => { + if (fs.dirs.has(path)) { + cb(null, []) + return + } + cb(noEntryError(path)) + }, + mkdir: (path: string, cb: (err: unknown) => void): void => { + fs.dirs.add(path) + cb(null) + }, + ...(opts.openSshRename + ? { + ext_openssh_rename: (src: string, dst: string, cb: (err: unknown) => void): void => { + fs.openSshRenameCount += 1 + const v = fs.files.get(src) + if (v === undefined) { + cb(noEntryError(src)) + return + } + fs.files.set(dst, v) + fs.files.delete(src) + const mode = fs.modes.get(src) + if (mode !== undefined) { + fs.modes.set(dst, mode) + fs.modes.delete(src) + } + cb(null) + } + } + : {}) + } as unknown as SFTPWrapper + return { sftp, fs } +} + +describe('installer-utils-remote', () => { + it('returns {} when settings.json does not exist on the remote', async () => { + const { sftp } = createFakeSftp() + const result = await readHooksJsonRemote(sftp, '/home/u/.claude/settings.json') + expect(result).toEqual({}) + }) + + it('returns null when settings.json is malformed JSON', async () => { + const { sftp, fs } = createFakeSftp() + fs.files.set('/home/u/.claude/settings.json', 'not json {{') + const result = await readHooksJsonRemote(sftp, '/home/u/.claude/settings.json') + expect(result).toBeNull() + }) + + it('rethrows non-ENOENT read errors so callers can distinguish I/O failures from parse failures', async () => { + const sftp = { + readFile: (_path: string, _enc: string, cb: (err: unknown) => void): void => { + // Why: SSH_FX_PERMISSION_DENIED (3) is a real I/O failure that should + // not collapse into the same null result the parse-error path uses. + cb({ code: 3, message: 'permission denied' }) + } + } as unknown as SFTPWrapper + await expect(readHooksJsonRemote(sftp, '/home/u/.claude/settings.json')).rejects.toMatchObject({ + code: 3 + }) + }) + + it('atomically writes settings.json via tmp + rename', async () => { + const { sftp, fs } = createFakeSftp() + await writeHooksJsonRemote(sftp, '/home/u/.claude/settings.json', { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'foo' }] }] } + }) + expect(fs.files.has('/home/u/.claude/settings.json')).toBe(true) + expect(fs.dirs.has('/home/u/.claude')).toBe(true) + const contents = fs.files.get('/home/u/.claude/settings.json')! + const parsed = JSON.parse(contents) + expect(parsed.hooks.Stop[0].hooks[0].command).toBe('foo') + // Tmp must be cleaned up. + const tmp = Array.from(fs.files.keys()).find((k) => k.includes('.tmp')) + expect(tmp).toBeUndefined() + expect(fs.modes.get('/home/u/.claude/settings.json')).toBe(0o600) + }) + + it('preserves existing config file mode across atomic replacement', async () => { + const { sftp, fs } = createFakeSftp() + const path = '/home/u/.codex/config.toml' + fs.files.set(path, 'old') + fs.modes.set(path, 0o640) + + await writeTextFileRemoteAtomic(sftp, path, 'new') + + expect(fs.modes.get(path)).toBe(0o640) + }) + + it('uses OpenSSH overwrite rename when an atomic write updates an existing file', async () => { + const { sftp, fs } = createFakeSftp({ + plainRenameOverwrites: false, + openSshRename: true + }) + const path = '/home/u/.claude/settings.json' + fs.files.set(path, JSON.stringify({ hooks: {} })) + + await writeHooksJsonRemote(sftp, path, { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'new' }] }] } + }) + + expect(fs.openSshRenameCount).toBe(1) + expect(JSON.parse(fs.files.get(path)!).hooks.Stop[0].hooks[0].command).toBe('new') + }) + + it('leaves existing files intact when overwrite rename is unavailable', async () => { + const { sftp, fs } = createFakeSftp({ plainRenameOverwrites: false }) + const path = '/home/u/.claude/settings.json' + fs.files.set(path, JSON.stringify({ hooks: { Stop: [] } })) + + await expect( + writeHooksJsonRemote(sftp, path, { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'fallback' }] }] } + }) + ).rejects.toMatchObject({ code: 4 }) + + expect(JSON.parse(fs.files.get(path)!).hooks.Stop).toEqual([]) + expect(Array.from(fs.files.keys()).some((key) => key.includes('.tmp'))).toBe(false) + }) + + it('writes the managed script and chmods 0o755', async () => { + const { sftp, fs } = createFakeSftp() + await writeManagedScriptRemote(sftp, '/home/u/.orca/agent-hooks/claude-hook.sh', '#!/bin/sh\n') + expect(fs.files.get('/home/u/.orca/agent-hooks/claude-hook.sh')).toBe('#!/bin/sh\n') + expect(fs.modes.get('/home/u/.orca/agent-hooks/claude-hook.sh')).toBe(0o755) + }) + + it('replaces an existing managed script atomically via temp file rename', async () => { + const { sftp, fs } = createFakeSftp({ + plainRenameOverwrites: false, + openSshRename: true + }) + const path = '/home/u/.orca/agent-hooks/claude-hook.sh' + fs.files.set(path, 'old script') + + await writeManagedScriptRemote(sftp, path, 'new script') + + expect(fs.files.get(path)).toBe('new script') + expect(fs.modes.get(path)).toBe(0o755) + expect(Array.from(fs.files.keys()).some((key) => key.includes('.orca-backup-'))).toBe(false) + }) + + it('leaves the existing managed script intact when temp write fails', async () => { + const { sftp, fs } = createFakeSftp({ failDotFileWrites: true }) + const path = '/home/u/.orca/agent-hooks/claude-hook.sh' + fs.files.set(path, 'old script') + + await expect(writeManagedScriptRemote(sftp, path, 'new script')).rejects.toMatchObject({ + code: 4 + }) + expect(fs.files.get(path)).toBe('old script') + }) + + it('skips a no-op write when contents already match', async () => { + const { sftp, fs } = createFakeSftp() + const path = '/home/u/.claude/settings.json' + await writeHooksJsonRemote(sftp, path, { hooks: {} }) + const beforeKey = fs.files.get(path) + // Re-writing the same payload should produce the same content; there is + // no rename/tmp cycle visible to a downstream observer beyond the + // identical file body. + await writeHooksJsonRemote(sftp, path, { hooks: {} }) + expect(fs.files.get(path)).toBe(beforeKey) + }) +}) diff --git a/src/main/agent-hooks/installer-utils-remote.ts b/src/main/agent-hooks/installer-utils-remote.ts new file mode 100644 index 00000000000..63e10b45d5f --- /dev/null +++ b/src/main/agent-hooks/installer-utils-remote.ts @@ -0,0 +1,385 @@ +// Why: SFTP-backed equivalents of `installer-utils.ts` for the remote-install +// flow. Each function takes an `sftp` handle plus paths the agent CLI expects +// on the remote (e.g. `~/.claude/settings.json`). Lives in `agent-hooks/` +// because it shares the contract with the local installer (script body, +// hook-event shape, atomic-rename semantics) and any drift between them is +// exactly the bug we want to avoid. +// +// We deliberately keep the JSON merge logic in the existing +// `installer-utils.ts` and only swap fs primitives — the JSON shape and +// managed-command matching must stay identical to the local install. +// +// See docs/design/agent-status-over-ssh.md §8 (commit #8). + +import { randomUUID } from 'crypto' +import type { SFTPWrapper, FileEntryWithStats } from 'ssh2' + +import { isPlainObject, type HooksConfig } from './installer-utils' + +const DEFAULT_REMOTE_CONFIG_MODE = 0o600 + +/** Read+JSON-parse a remote file. Returns `null` on parse failure (caller + * surfaces "could not parse" status to the UI), `{}` on missing file + * (matches local behavior — first-install case). Rethrows on other I/O + * failures (permission denied, EIO, channel closed) so the caller can + * distinguish transient SFTP errors from a malformed-JSON case rather + * than collapsing both into a misleading "could not parse" diagnostic. */ +export async function readHooksJsonRemote( + sftp: SFTPWrapper, + remotePath: string +): Promise { + let body: string + try { + body = await readFile(sftp, remotePath) + } catch (err) { + if (isNoEntryError(err)) { + return {} + } + throw err + } + try { + const parsed = JSON.parse(body) + return isPlainObject(parsed) ? parsed : null + } catch { + return null + } +} + +/** Atomically write a JSON config to the remote — write to a tmp path then + * rename, mirroring the local writeHooksJson contract. The .bak rotation is + * intentionally NOT carried over: the remote file is the user's, and a + * per-target backup convention belongs alongside the remote installer UI + * (out of scope for this commit). */ +export async function writeHooksJsonRemote( + sftp: SFTPWrapper, + remotePath: string, + config: HooksConfig +): Promise { + const dir = dirnamePosix(remotePath) + await mkdirpRemote(sftp, dir) + const serialized = `${JSON.stringify(config, null, 2)}\n` + // Why: skip the write when on-disk content is identical so repeated + // install() calls do not bump the file's mtime / inode unnecessarily. + try { + const existing = await readFile(sftp, remotePath) + if (existing === serialized) { + return + } + } catch { + // ENOENT or read error — fall through to the write below. + } + // Why: tmp + rename so a partial network drop mid-write does not leave a + // truncated settings.json that the agent CLI would refuse to load. + const tmp = `${dir}/.${Date.now()}-${randomUUID()}.tmp` + try { + const mode = await getRemoteFileModeOrDefault(sftp, remotePath, DEFAULT_REMOTE_CONFIG_MODE) + await writeFile(sftp, tmp, serialized, mode) + await chmod(sftp, tmp, mode) + await rename(sftp, tmp, remotePath) + } finally { + // Best-effort cleanup if rename failed. + try { + await unlink(sftp, tmp) + } catch { + // already gone or never created + } + } +} + +/** Write the managed hook script to the remote and chmod 0o755. POSIX-only — + * the relay deliberately does not support Windows-remote in v1 (see design + * doc §3 + §6). */ +export async function writeManagedScriptRemote( + sftp: SFTPWrapper, + remotePath: string, + content: string +): Promise { + const dir = dirnamePosix(remotePath) + await mkdirpRemote(sftp, dir) + try { + const existing = await readFile(sftp, remotePath) + if (existing === content) { + await chmod(sftp, remotePath, 0o755) + return + } + } catch { + // ENOENT or read error — fall through to the atomic write below. + } + + // Why: existing configs may already invoke this script. Write/chmod a temp + // file first, then rename it into place so interrupted reinstalls do not + // leave the configured hook path truncated or non-executable. + const tmp = `${dir}/.${Date.now()}-${randomUUID()}.tmp` + try { + await writeFile(sftp, tmp, content, 0o755) + await chmod(sftp, tmp, 0o755) + await rename(sftp, tmp, remotePath) + } finally { + try { + await unlink(sftp, tmp) + } catch { + // already gone or never created + } + } +} + +export async function readTextFileRemote( + sftp: SFTPWrapper, + remotePath: string +): Promise { + try { + return await readFile(sftp, remotePath) + } catch (err) { + if (isNoEntryError(err)) { + return null + } + throw err + } +} + +export async function writeTextFileRemoteAtomic( + sftp: SFTPWrapper, + remotePath: string, + content: string +): Promise { + const dir = dirnamePosix(remotePath) + await mkdirpRemote(sftp, dir) + try { + const existing = await readFile(sftp, remotePath) + if (existing === content) { + return + } + } catch { + // ENOENT or read error — fall through to the atomic write below. + } + + const tmp = `${dir}/.${Date.now()}-${randomUUID()}.tmp` + try { + const mode = await getRemoteFileModeOrDefault(sftp, remotePath, DEFAULT_REMOTE_CONFIG_MODE) + await writeFile(sftp, tmp, content, mode) + await chmod(sftp, tmp, mode) + await rename(sftp, tmp, remotePath) + } finally { + try { + await unlink(sftp, tmp) + } catch { + // already gone or never created + } + } +} + +// ─── Private SFTP primitives ──────────────────────────────────────── + +async function readFile(sftp: SFTPWrapper, remotePath: string): Promise { + return new Promise((resolve, reject) => { + sftp.readFile(remotePath, 'utf8', (err, data) => { + if (err) { + reject(err) + return + } + resolve(typeof data === 'string' ? data : data.toString('utf8')) + }) + }) +} + +async function writeFile( + sftp: SFTPWrapper, + remotePath: string, + content: string, + mode?: number +): Promise { + return new Promise((resolve, reject) => { + const options = + mode === undefined ? { encoding: 'utf8' as const } : { encoding: 'utf8' as const, mode } + sftp.writeFile(remotePath, content, options, (err) => { + if (err) { + reject(err) + return + } + resolve() + }) + }) +} + +async function statMode(sftp: SFTPWrapper, remotePath: string): Promise { + return new Promise((resolve, reject) => { + sftp.stat(remotePath, (err, stats) => { + if (err) { + reject(err) + return + } + resolve(stats.mode & 0o7777) + }) + }) +} + +async function getRemoteFileModeOrDefault( + sftp: SFTPWrapper, + remotePath: string, + defaultMode: number +): Promise { + try { + return await statMode(sftp, remotePath) + } catch (err) { + if (isNoEntryError(err)) { + return defaultMode + } + throw err + } +} + +async function rename(sftp: SFTPWrapper, src: string, dst: string): Promise { + if (typeof sftp.ext_openssh_rename === 'function') { + try { + await renameOpenSsh(sftp, src, dst) + return + } catch (err) { + if (!isUnsupportedExtensionError(err)) { + throw err + } + } + } + + // Why: servers without OpenSSH overwrite-rename cannot safely replace an + // existing live config path. Renaming dst aside would leave settings.json + // missing if the SFTP channel dies before src is moved into place, so fail + // closed and keep the existing file intact. + await renamePlain(sftp, src, dst) +} + +async function renamePlain(sftp: SFTPWrapper, src: string, dst: string): Promise { + return new Promise((resolve, reject) => { + sftp.rename(src, dst, (err) => { + if (err) { + reject(err) + return + } + resolve() + }) + }) +} + +async function renameOpenSsh(sftp: SFTPWrapper, src: string, dst: string): Promise { + return new Promise((resolve, reject) => { + sftp.ext_openssh_rename(src, dst, (err) => { + if (err) { + reject(err) + return + } + resolve() + }) + }) +} + +async function unlink(sftp: SFTPWrapper, remotePath: string): Promise { + return new Promise((resolve, reject) => { + sftp.unlink(remotePath, (err) => { + if (err) { + reject(err) + return + } + resolve() + }) + }) +} + +async function chmod(sftp: SFTPWrapper, remotePath: string, mode: number): Promise { + return new Promise((resolve, reject) => { + sftp.chmod(remotePath, mode, (err) => { + if (err) { + reject(err) + return + } + resolve() + }) + }) +} + +async function readdir(sftp: SFTPWrapper, remotePath: string): Promise { + return new Promise((resolve, reject) => { + sftp.readdir(remotePath, (err, list) => { + if (err) { + reject(err) + return + } + resolve(list) + }) + }) +} + +async function mkdir(sftp: SFTPWrapper, remotePath: string): Promise { + return new Promise((resolve, reject) => { + sftp.mkdir(remotePath, (err) => { + if (err) { + // SSH_FX_FAILURE (4) often means "already exists" on OpenSSH; we + // probe with stat afterwards rather than parse the error code. + reject(err) + return + } + resolve() + }) + }) +} + +async function mkdirpRemote(sftp: SFTPWrapper, remotePath: string): Promise { + if (remotePath === '/' || remotePath === '' || remotePath === '.') { + return + } + // Why: walk the path top-down rather than bottom-up so an existing parent + // chain doesn't cost a full readdir per segment. POSIX-only — Windows- + // remote is out of scope for v1. + const segments = remotePath.split('/').filter((s) => s.length > 0) + let current = remotePath.startsWith('/') ? '' : '.' + for (const seg of segments) { + current = current === '' ? `/${seg}` : current === '.' ? seg : `${current}/${seg}` + try { + await readdir(sftp, current) + } catch { + try { + await mkdir(sftp, current) + } catch (err) { + // Why: re-raise only when the dir really isn't there. SSH_FX_FAILURE + // on a concurrent mkdir from another client is harmless — readdir on + // the next iteration will succeed. + if (!isAlreadyExistsError(err)) { + throw err + } + } + } + } +} + +function dirnamePosix(p: string): string { + const idx = p.lastIndexOf('/') + if (idx <= 0) { + return idx === 0 ? '/' : '.' + } + return p.slice(0, idx) +} + +function isNoEntryError(err: unknown): boolean { + if (!err || typeof err !== 'object') { + return false + } + // ssh2 surfaces SFTP errors with `code === 2` (SSH_FX_NO_SUCH_FILE). + return (err as { code?: unknown }).code === 2 +} + +function isAlreadyExistsError(err: unknown): boolean { + if (!err || typeof err !== 'object') { + return false + } + // SSH_FX_FAILURE (4) is OpenSSH's catch-all for "exists" alongside other + // mkdir failures; we accept the ambiguity and let the next readdir prove + // success. + return (err as { code?: unknown }).code === 4 +} + +function isUnsupportedExtensionError(err: unknown): boolean { + if (!err || typeof err !== 'object') { + return false + } + const code = (err as { code?: unknown }).code + const message = (err as { message?: unknown }).message + return code === 8 || (typeof message === 'string' && /unsupported/i.test(message)) +} diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts new file mode 100644 index 00000000000..95db6b6ba95 --- /dev/null +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SFTPWrapper } from 'ssh2' + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-user-data' + } +})) + +import { CodexHookService } from '../codex/hook-service' +import { CursorHookService } from '../cursor/hook-service' +import { GeminiHookService } from '../gemini/hook-service' +import { ClaudeHookService } from '../claude/hook-service' + +type FakeFs = { + files: Map + dirs: Set + modes: Map + failRenameTo: Set +} + +function createFakeSftp(): { sftp: SFTPWrapper; fs: FakeFs } { + const fs: FakeFs = { + files: new Map(), + dirs: new Set(['/']), + modes: new Map(), + failRenameTo: new Set() + } + const noEntryError = (path: string): { code: number; message: string } => ({ + code: 2, + message: `ENOENT ${path}` + }) + const fakeStats = (mode: number): { mode: number } => ({ mode }) + + const sftp = { + readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => { + const v = fs.files.get(path) + if (v === undefined) { + cb(noEntryError(path)) + return + } + cb(null, v) + }, + writeFile: ( + path: string, + content: string, + options: string | { mode?: number }, + cb: (err: unknown) => void + ): void => { + fs.files.set(path, content) + if (typeof options !== 'string' && options.mode !== undefined) { + fs.modes.set(path, options.mode) + } + cb(null) + }, + rename: (src: string, dst: string, cb: (err: unknown) => void): void => { + if (fs.failRenameTo.has(dst)) { + cb({ code: 4, message: `rename failed ${dst}` }) + return + } + const v = fs.files.get(src) + if (v === undefined) { + cb(noEntryError(src)) + return + } + fs.files.set(dst, v) + fs.files.delete(src) + const mode = fs.modes.get(src) + if (mode !== undefined) { + fs.modes.set(dst, mode) + fs.modes.delete(src) + } + cb(null) + }, + unlink: (path: string, cb: (err: unknown) => void): void => { + fs.files.delete(path) + fs.modes.delete(path) + cb(null) + }, + chmod: (path: string, mode: number, cb: (err: unknown) => void): void => { + fs.modes.set(path, mode) + cb(null) + }, + stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => { + if (!fs.files.has(path)) { + cb(noEntryError(path)) + return + } + cb(null, fakeStats(fs.modes.get(path) ?? 0o100644)) + }, + readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => { + if (fs.dirs.has(path)) { + cb(null, []) + return + } + cb(noEntryError(path)) + }, + mkdir: (path: string, cb: (err: unknown) => void): void => { + fs.dirs.add(path) + cb(null) + } + } as unknown as SFTPWrapper + return { sftp, fs } +} + +describe('remote hook service installers', () => { + it('always writes POSIX scripts for SSH remotes even from a Windows host', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + try { + const installers = [ + { + path: '/home/dev/.orca/agent-hooks/claude-hook.sh', + install: (sftp: SFTPWrapper) => new ClaudeHookService().installRemote(sftp, '/home/dev') + }, + { + path: '/home/dev/.orca/agent-hooks/codex-hook.sh', + install: (sftp: SFTPWrapper) => new CodexHookService().installRemote(sftp, '/home/dev') + }, + { + path: '/home/dev/.orca/agent-hooks/gemini-hook.sh', + install: (sftp: SFTPWrapper) => new GeminiHookService().installRemote(sftp, '/home/dev') + }, + { + path: '/home/dev/.orca/agent-hooks/cursor-hook.sh', + install: (sftp: SFTPWrapper) => new CursorHookService().installRemote(sftp, '/home/dev') + } + ] + + for (const { install, path } of installers) { + const { sftp, fs } = createFakeSftp() + const status = await install(sftp) + expect(status.state).toBe('installed') + const script = fs.files.get(path) + expect(script).toMatch(/^#!\/bin\/sh\n/) + expect(script).not.toContain('@echo off') + expect(script).not.toContain('powershell -NoProfile') + } + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('installs remote Codex hooks with matching trust entries', async () => { + const { sftp, fs } = createFakeSftp() + + const status = await new CodexHookService().installRemote(sftp, '/home/dev/') + + expect(status.state).toBe('installed') + expect(status.configPath).toBe('/home/dev/.codex/hooks.json') + const hooks = JSON.parse(fs.files.get('/home/dev/.codex/hooks.json')!) as { + hooks: Record + } + for (const eventName of [ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PermissionRequest', + 'PostToolUse', + 'Stop' + ]) { + const command = hooks.hooks[eventName]?.[0]?.hooks?.[0]?.command + expect(command).toContain('/home/dev/.orca/agent-hooks/codex-hook.sh') + expect(command).toMatch(/^if \[ -x /) + } + expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh') + expect(fs.modes.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toBe(0o755) + const toml = fs.files.get('/home/dev/.codex/config.toml') + expect(toml).toContain('/home/dev/.codex/hooks.json:permission_request:0:0') + expect(toml).toContain('trusted_hash = "sha256:') + }) + + it('reports Codex trust-write failures without rolling back installed hooks', async () => { + const { sftp, fs } = createFakeSftp() + fs.failRenameTo.add('/home/dev/.codex/config.toml') + + const status = await new CodexHookService().installRemote(sftp, '/home/dev') + + expect(status.state).toBe('error') + expect(status.managedHooksPresent).toBe(true) + expect(status.detail).toContain('trust entries could not be written') + expect(fs.files.get('/home/dev/.codex/hooks.json')).toContain('codex-hook.sh') + expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh') + }) + + it('installs remote Gemini and Cursor configs using their CLI-specific schemas', async () => { + const gemini = createFakeSftp() + const cursor = createFakeSftp() + + await new GeminiHookService().installRemote(gemini.sftp, '/home/dev') + await new CursorHookService().installRemote(cursor.sftp, '/home/dev') + + const geminiConfig = JSON.parse(gemini.fs.files.get('/home/dev/.gemini/settings.json')!) as { + hooks: Record + } + for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool', 'PreToolUse']) { + const command = geminiConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command + expect(command).toContain('/home/dev/.orca/agent-hooks/gemini-hook.sh') + expect(command).toMatch(/^if \[ -x /) + } + + const cursorConfig = JSON.parse(cursor.fs.files.get('/home/dev/.cursor/hooks.json')!) as { + version: number + hooks: Record + } + expect(cursorConfig.version).toBe(1) + for (const eventName of [ + 'beforeSubmitPrompt', + 'stop', + 'preToolUse', + 'postToolUse', + 'postToolUseFailure', + 'beforeShellExecution', + 'beforeMCPExecution', + 'afterAgentResponse' + ]) { + const definition = cursorConfig.hooks[eventName]?.[0] + expect(definition?.command).toContain('/home/dev/.orca/agent-hooks/cursor-hook.sh') + expect(definition?.hooks).toBeUndefined() + } + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 241a0b2e8b1..cd715bcc598 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -709,6 +709,14 @@ export class AgentHookServer { } } } + + /** Test-only accessor for the per-instance listener state. The `_internals` + * shim needs to reach this without exposing `state` on the public surface + * to renderer/main callers. AGENTS.md disallows `as unknown as X` escapes, + * so we expose a narrow getter rather than casting the private field. */ + _getStateForTests(): HookListenerState { + return this.state + } } export const agentHookServer = new AgentHookServer() @@ -722,19 +730,9 @@ export const _internals = { body: unknown, expectedEnv: string ): AgentHookEventPayload | null => - normalizeHookPayload(_singletonState(), source, body, expectedEnv), + normalizeHookPayload(agentHookServer._getStateForTests(), source, body, expectedEnv), parseFormEncodedBody, resetCachesForTests: (): void => { - clearAllListenerCaches(_singletonState()) + clearAllListenerCaches(agentHookServer._getStateForTests()) } } - -// Why: ergonomic accessor so the `_internals` shim can reach the singleton's -// per-instance state without exposing `state` on the public class surface. -function _singletonState(): HookListenerState { - // The runtime field is private, but tests access this module exclusively - // through `_internals`, which only fires after the module-level - // `agentHookServer` is constructed. The cast keeps the compile-time - // private invariant intact. - return (agentHookServer as unknown as { state: HookListenerState }).state -} diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts new file mode 100644 index 00000000000..00bfdc5cbe1 --- /dev/null +++ b/src/main/claude/hook-service.test.ts @@ -0,0 +1,166 @@ +// Why: locks in the remote-install contract so a refactor cannot silently +// drift the produced settings.json shape, the wrapper-quoted command path, +// or the script body that lands on the remote box. Local install behavior +// is exercised through `installer-utils.test.ts` and the per-CLI status +// audit; this file covers ONLY the SFTP-backed path added in commit #8. +import { vi, describe, expect, it } from 'vitest' + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/userData' + } +})) + +import type { SFTPWrapper } from 'ssh2' +import { ClaudeHookService } from './hook-service' + +type FakeFs = { + files: Map + dirs: Set + modes: Map +} + +function createFakeSftp(): { sftp: SFTPWrapper; fs: FakeFs } { + const fs: FakeFs = { + files: new Map(), + dirs: new Set(['/']), + modes: new Map() + } + const noEntryError = (path: string): { code: number; message: string } => ({ + code: 2, + message: `ENOENT ${path}` + }) + const fakeStats = (mode: number): { mode: number } => ({ mode }) + const sftp = { + readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => { + const v = fs.files.get(path) + if (v === undefined) { + cb(noEntryError(path)) + return + } + cb(null, v) + }, + writeFile: ( + path: string, + content: string, + options: string | { mode?: number }, + cb: (err: unknown) => void + ): void => { + fs.files.set(path, content) + if (typeof options !== 'string' && options.mode !== undefined) { + fs.modes.set(path, options.mode) + } + cb(null) + }, + rename: (src: string, dst: string, cb: (err: unknown) => void): void => { + const v = fs.files.get(src) + if (v === undefined) { + cb(noEntryError(src)) + return + } + fs.files.set(dst, v) + fs.files.delete(src) + const mode = fs.modes.get(src) + if (mode !== undefined) { + fs.modes.set(dst, mode) + fs.modes.delete(src) + } + cb(null) + }, + unlink: (path: string, cb: (err: unknown) => void): void => { + fs.files.delete(path) + fs.modes.delete(path) + cb(null) + }, + chmod: (path: string, mode: number, cb: (err: unknown) => void): void => { + fs.modes.set(path, mode) + cb(null) + }, + stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => { + if (!fs.files.has(path)) { + cb(noEntryError(path)) + return + } + cb(null, fakeStats(fs.modes.get(path) ?? 0o100644)) + }, + readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => { + if (fs.dirs.has(path)) { + cb(null, []) + return + } + cb(noEntryError(path)) + }, + mkdir: (path: string, cb: (err: unknown) => void): void => { + fs.dirs.add(path) + cb(null) + } + } as unknown as SFTPWrapper + return { sftp, fs } +} + +describe('ClaudeHookService.installRemote', () => { + it('writes settings.json + managed script under the remote $HOME', async () => { + const svc = new ClaudeHookService() + const { sftp, fs } = createFakeSftp() + const status = await svc.installRemote(sftp, '/home/dev') + expect(status.state).toBe('installed') + expect(status.configPath).toBe('/home/dev/.claude/settings.json') + const settings = fs.files.get('/home/dev/.claude/settings.json') + expect(settings).toBeTruthy() + const parsed = JSON.parse(settings!) + // Why: every load-bearing event must be present and point at the + // remote-shaped script path with the `if [ -x ... ]; then ... fi` + // wrapper applied. Drift in any of these is a real bug — Claude + // Code rejects unknown shapes silently and the agent-hooks pipeline + // goes dark. + for (const event of [ + 'UserPromptSubmit', + 'Stop', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest' + ]) { + expect(parsed.hooks[event]).toBeTruthy() + const cmd = parsed.hooks[event][0].hooks[0].command as string + expect(cmd).toContain('/home/dev/.orca/agent-hooks/claude-hook.sh') + expect(cmd).toMatch(/^if \[ -x /) + } + // Managed script body + expect(fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toContain('#!/bin/sh') + expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755) + }) + + it('reports parse error when remote settings.json is malformed', async () => { + const svc = new ClaudeHookService() + const { sftp, fs } = createFakeSftp() + fs.files.set('/home/dev/.claude/settings.json', 'not json') + const status = await svc.installRemote(sftp, '/home/dev') + expect(status.state).toBe('error') + expect(status.detail).toContain('Could not parse') + }) + + it('preserves user-authored hook entries on a fresh install', async () => { + const svc = new ClaudeHookService() + const { sftp, fs } = createFakeSftp() + fs.files.set( + '/home/dev/.claude/settings.json', + JSON.stringify({ + hooks: { + Stop: [ + { + hooks: [{ type: 'command', command: '/usr/local/bin/my-user-hook' }] + } + ] + } + }) + ) + await svc.installRemote(sftp, '/home/dev') + const parsed = JSON.parse(fs.files.get('/home/dev/.claude/settings.json')!) + // Original user-authored entry survives alongside the new managed entry. + const stopDefs = parsed.hooks.Stop as { hooks: { command: string }[] }[] + const userCmds = stopDefs.flatMap((d) => d.hooks.map((h) => h.command)) + expect(userCmds).toContain('/usr/local/bin/my-user-hook') + expect(userCmds.some((c) => c.includes('claude-hook.sh'))).toBe(true) + }) +}) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index f2caf488104..d1d31db4438 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -1,5 +1,6 @@ import { homedir } from 'os' import { join } from 'path' +import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { createManagedCommandMatcher, @@ -11,6 +12,11 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { + readHooksJsonRemote, + writeHooksJsonRemote, + writeManagedScriptRemote +} from '../agent-hooks/installer-utils-remote' const CLAUDE_EVENTS = [ { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, @@ -61,8 +67,8 @@ function getManagedCommand(scriptPath: string): string { return wrapPosixHookCommand(scriptPath) } -function getManagedScript(): string { - if (process.platform === 'win32') { +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { return [ '@echo off', 'setlocal', @@ -215,6 +221,83 @@ export class ClaudeHookService { return this.getStatus() } + // Why: install Orca's managed Claude hooks on the remote box rather than + // the local Mac/Linux machine. Caller passes the user's SFTP handle from + // the SshConnection plus the resolved remote `$HOME` (used to compute + // ~/.claude/settings.json on the target). POSIX-only by design — see + // docs/design/agent-status-over-ssh.md §3 / §6 (Windows-remote deferred). + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + // Why: remote-Windows is out of scope for v1 — we ship POSIX-shaped paths + // (`~/.claude/settings.json`) and a `.sh` managed script body. The remote + // platform is gated by the relay's capability RPC at a higher layer; we + // cannot detect it from `process.platform` here (that's the local box). + const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.claude/settings.json` + const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/claude-hook.sh` + // Why: SFTP reads/writes fail far more often than local fs (network drops, + // EACCES on remote dirs, disk full, channel closed). Wrap the entire + // install flow in try/catch so a transient I/O failure surfaces as a + // structured `state: 'error'` result for the UI, not an unstructured + // rejection the caller has to remember to handle. A `null` config + // specifically means "file present but unparseable" — keep that branch + // distinct so the user sees an actionable message. + try { + const config = await readHooksJsonRemote(sftp, remoteConfigPath) + if (!config) { + return { + agent: 'claude', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: 'Could not parse remote Claude settings.json' + } + } + + // Why: the POSIX wrapper is identical regardless of where the script + // lands; only the path differs. Reuse the same wrapper helper. + const command = wrapPosixHookCommand(remoteScriptPath) + const nextHooks = { ...config.hooks } + const isManagedCommand = createManagedCommandMatcher('claude-hook.sh') + + for (const event of CLAUDE_EVENTS) { + const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + const definition: HookDefinition = { + ...event.definition, + hooks: [{ type: 'command', command }] + } + nextHooks[event.eventName] = [...cleaned, definition] + } + config.hooks = nextHooks + + // Why: write the script first, then the settings — settings.json + // referencing a missing script body would fire `command not found` on + // every tool call until the user re-runs install. Doing it in this + // order means a partial-failure mid-install at worst leaves the user + // with a working script no settings.json points at (a no-op), instead + // of broken settings.json. + // Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is + // running on Windows; never derive remote script syntax from local OS. + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) + await writeHooksJsonRemote(sftp, remoteConfigPath, config) + + return { + agent: 'claude', + state: 'installed', + configPath: remoteConfigPath, + managedHooksPresent: true, + detail: null + } + } catch (err) { + return { + agent: 'claude', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + } + remove(): AgentHookInstallStatus { const configPath = getConfigPath() const config = readHooksJson(configPath) diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index 1bd8594e8c9..179ce2572a0 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -190,16 +190,26 @@ export function upsertHookTrustEntries( entries: readonly CodexTrustEntry[] ): void { const existing = existsSync(configPath) ? readTomlFile(configPath) : '' - let updated = existing - for (const entry of entries) { - updated = upsertTrustBlock(updated, computeTrustKey(entry), computeTrustedHash(entry)) - } + const updated = upsertHookTrustEntriesInContent(existing, entries) if (updated === existing) { return } writeConfigAtomically(configPath, updated) } +export function upsertHookTrustEntriesInContent( + existingContent: string, + entries: readonly CodexTrustEntry[] +): string { + const existing = + existingContent.charCodeAt(0) === 0xfeff ? existingContent.slice(1) : existingContent + let updated = existing + for (const entry of entries) { + updated = upsertTrustBlock(updated, computeTrustKey(entry), computeTrustedHash(entry)) + } + return updated +} + // Why: build the canonical block we own. The two field names mirror what // Codex itself writes when the user approves via /hooks (HookStateToml // fields). `enabled` is plumbed through so an existing user-set diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 6d54ff655c8..f7cada61a68 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- Why: getStatus + install + remove all share the managed-command and trust-key derivation. Splitting would hide that the three operations must agree on group index, event label, and command bytes. */ import { homedir } from 'os' import { join } from 'path' +import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { createManagedCommandMatcher, @@ -12,12 +13,20 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { + readHooksJsonRemote, + readTextFileRemote, + writeHooksJsonRemote, + writeManagedScriptRemote, + writeTextFileRemoteAtomic +} from '../agent-hooks/installer-utils-remote' import { computeTrustKey, computeTrustedHash, parseTrustKey, readHookTrustEntries, removeHookTrustEntries, + upsertHookTrustEntriesInContent, upsertHookTrustEntries, type CodexEventLabel, type CodexHookTrustState, @@ -71,8 +80,8 @@ function getManagedCommand(scriptPath: string): string { return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) } -function getManagedScript(): string { - if (process.platform === 'win32') { +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { return [ '@echo off', 'setlocal', @@ -329,6 +338,99 @@ export class CodexHookService { return this.getStatus() } + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.codex/hooks.json` + const remoteTomlPath = `${remoteHome.replace(/\/$/, '')}/.codex/config.toml` + const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh` + try { + const config = await readHooksJsonRemote(sftp, remoteConfigPath) + if (!config) { + return { + agent: 'codex', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: 'Could not parse remote Codex hooks.json' + } + } + + const command = wrapPosixHookCommand(remoteScriptPath) + const nextHooks = { ...config.hooks } + const managedEvents = new Set(CODEX_EVENTS) + const isManagedCommand = createManagedCommandMatcher('codex-hook.sh') + + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (managedEvents.has(eventName) || !Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + const trustEntries: CodexTrustEntry[] = [] + for (const eventName of CODEX_EVENTS) { + const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + const definition: HookDefinition = { + hooks: [{ type: 'command', command }] + } + nextHooks[eventName] = [...cleaned, definition] + trustEntries.push({ + sourcePath: remoteConfigPath, + eventLabel: CODEX_EVENT_LABEL[eventName], + groupIndex: cleaned.length, + handlerIndex: 0, + command + }) + } + + config.hooks = nextHooks + // Why: script/settings first, trust TOML last. A partial trust write + // leaves Codex asking for approval rather than executing a missing script. + // Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is + // running on Windows; never derive remote script syntax from local OS. + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) + await writeHooksJsonRemote(sftp, remoteConfigPath, config) + try { + const existingToml = (await readTextFileRemote(sftp, remoteTomlPath)) ?? '' + const updatedToml = upsertHookTrustEntriesInContent(existingToml, trustEntries) + if (updatedToml !== existingToml) { + await writeTextFileRemoteAtomic(sftp, remoteTomlPath, updatedToml) + } + } catch (error) { + return { + agent: 'codex', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: true, + detail: `Hooks installed but trust entries could not be written: ${ + error instanceof Error ? error.message : String(error) + }. Run /hooks in Codex on the remote host to approve.` + } + } + + return { + agent: 'codex', + state: 'installed', + configPath: remoteConfigPath, + managedHooksPresent: true, + detail: null + } + } catch (err) { + return { + agent: 'codex', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + } + remove(): AgentHookInstallStatus { const configPath = getConfigPath() const config = readHooksJson(configPath) diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index 805a8d905db..ed7f7f3bd0a 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -1,5 +1,6 @@ import { homedir } from 'os' import { join } from 'path' +import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { createManagedCommandMatcher, @@ -11,6 +12,11 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { + readHooksJsonRemote, + writeHooksJsonRemote, + writeManagedScriptRemote +} from '../agent-hooks/installer-utils-remote' // Why: cursor-agent exposes a declarative hooks.json surface at // ~/.cursor/hooks.json (https://cursor.com/docs/hooks) with camelCase event @@ -55,8 +61,8 @@ function getManagedCommand(scriptPath: string): string { return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) } -function getManagedScript(): string { - if (process.platform === 'win32') { +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { return [ '@echo off', 'setlocal', @@ -235,6 +241,73 @@ export class CursorHookService { return this.getStatus() } + // Why: install Orca's managed Cursor hooks on the remote box. Mirrors + // ClaudeHookService.installRemote — POSIX-only, uses the same SFTP-backed + // primitives, and emits Cursor's documented schema (top-level `command` + // on each definition + top-level `version: 1`) so cursor-agent on the + // remote actually invokes the script. See docs/design/agent-status-over-ssh.md + // §8. + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.cursor/hooks.json` + const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/cursor-hook.sh` + try { + const config = await readHooksJsonRemote(sftp, remoteConfigPath) + if (!config) { + return { + agent: 'cursor', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: 'Could not parse remote Cursor hooks.json' + } + } + + const command = wrapPosixHookCommand(remoteScriptPath) + const nextHooks = { ...config.hooks } + const isManagedCommand = createManagedCommandMatcher('cursor-hook.sh') + + for (const eventName of CURSOR_EVENTS) { + const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] + // Why: same dual-shape sweep as the local install — repeated + // installs converge on a single managed entry. + const cleaned = removeManagedCommands(current, isManagedCommand).filter( + (definition) => !isManagedCommand(definition.command as string | undefined) + ) + const definition: HookDefinition = { command } + nextHooks[eventName] = [...cleaned, definition] + } + + const nextConfig: Record = { ...config, hooks: nextHooks } + if (nextConfig.version === undefined) { + nextConfig.version = 1 + } + + // Why: script-then-config order so a partial-failure mid-install at + // worst leaves a working script no settings.json points at — see + // ClaudeHookService.installRemote. + // Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is + // running on Windows; never derive remote script syntax from local OS. + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) + await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig) + + return { + agent: 'cursor', + state: 'installed', + configPath: remoteConfigPath, + managedHooksPresent: true, + detail: null + } + } catch (err) { + return { + agent: 'cursor', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + } + remove(): AgentHookInstallStatus { const configPath = getConfigPath() const config = readHooksJson(configPath) diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 4cd0e401ab3..7b0d418ee71 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -1,5 +1,6 @@ import { homedir } from 'os' import { join } from 'path' +import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { createManagedCommandMatcher, @@ -11,6 +12,11 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { + readHooksJsonRemote, + writeHooksJsonRemote, + writeManagedScriptRemote +} from '../agent-hooks/installer-utils-remote' // Why: Gemini CLI fires `BeforeAgent` when a turn starts and `AfterAgent` when // it completes. `AfterTool` marks the resumption of model work after a tool @@ -41,8 +47,8 @@ function getManagedCommand(scriptPath: string): string { return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) } -function getManagedScript(): string { - if (process.platform === 'win32') { +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { return [ '@echo off', 'setlocal', @@ -183,6 +189,65 @@ export class GeminiHookService { return this.getStatus() } + // Why: install Orca's managed Gemini hooks on the remote box. Mirrors + // ClaudeHookService.installRemote — POSIX-only, uses the same SFTP-backed + // primitives, and lays down the same script body the local install + // generates so a remote-side Gemini CLI behaves identically. See + // docs/design/agent-status-over-ssh.md §8. + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.gemini/settings.json` + const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/gemini-hook.sh` + try { + const config = await readHooksJsonRemote(sftp, remoteConfigPath) + if (!config) { + return { + agent: 'gemini', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: 'Could not parse remote Gemini settings.json' + } + } + + const command = wrapPosixHookCommand(remoteScriptPath) + const nextHooks = { ...config.hooks } + const isManagedCommand = createManagedCommandMatcher('gemini-hook.sh') + + for (const eventName of GEMINI_EVENTS) { + const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + const definition: HookDefinition = { + hooks: [{ type: 'command', command }] + } + nextHooks[eventName] = [...cleaned, definition] + } + config.hooks = nextHooks + + // Why: write the script first so an interrupted install never leaves + // settings.json pointing at a missing script. See ClaudeHookService. + // Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is + // running on Windows; never derive remote script syntax from local OS. + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) + await writeHooksJsonRemote(sftp, remoteConfigPath, config) + + return { + agent: 'gemini', + state: 'installed', + configPath: remoteConfigPath, + managedHooksPresent: true, + detail: null + } + } catch (err) { + return { + agent: 'gemini', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + } + remove(): AgentHookInstallStatus { const configPath = getConfigPath() const config = readHooksJson(configPath) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 195685c90f2..1c5deb8d3cc 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -1277,6 +1277,9 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_TAB_ID).toBeUndefined() expect(env.ORCA_WORKTREE_ID).toBeUndefined() expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined() + // Why: the local hook server's userData-relative endpoint file path + // is meaningless on the remote box; assert it does not leak. + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined() } finally { if (prevFlag === undefined) { delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS @@ -1336,6 +1339,7 @@ describe('registerPtyHandlers', () => { // relay is the source of truth for those. expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined() expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined() } finally { if (prevFlag === undefined) { delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS diff --git a/src/main/pty/overlay-mirror.ts b/src/main/pty/overlay-mirror.ts index d529ac139b2..94e69071473 100644 --- a/src/main/pty/overlay-mirror.ts +++ b/src/main/pty/overlay-mirror.ts @@ -1,8 +1,8 @@ // Why: Pi (PI_CODING_AGENT_DIR) and OpenCode (OPENCODE_CONFIG_DIR) both inject // Orca-owned files into per-PTY overlay directories that mirror a user-owned -// source dir via symlinks/junctions. The safety guarantees here — never +// source dir via symlinks/junctions. The safety guarantees here -- never // descend into a symlink/junction during teardown, refuse to operate outside -// the overlay root, lstat-not-stat to avoid following links — are the result +// the overlay root, lstat-not-stat to avoid following links -- are the result // of debugging issue #1083 (Windows directory junctions causing fs.rmSync to // delete the user's real Pi state). Shared in one module so a new overlay // consumer cannot accidentally diverge from the audited cleanup behavior. @@ -42,7 +42,7 @@ export function mirrorEntry(sourcePath: string, targetPath: string): void { // Exported for tests. A "descend candidate" is an entry whose children we // should recurse into when tearing down the overlay. Anything that is a // symlink (including a Windows directory junction) must NOT be a candidate -// even if it also reports isDirectory() — following it would walk into the +// even if it also reports isDirectory() -- following it would walk into the // link target and delete user data, which is the bug in #1083. export function isSafeDescendCandidate(stats: { isSymbolicLink(): boolean @@ -56,7 +56,7 @@ export function isSafeDescendCandidate(stats: { // Why: the overlay tree contains symlinks/junctions that point back into the // user's real state dir. fs.rmSync with { recursive: true } has repeatedly -// regressed on Windows when walking NTFS junctions — it can follow them and +// regressed on Windows when walking NTFS junctions -- it can follow them and // delete the *target*, destroying the user's data. Never descend into a // symlink/junction here: for any non-real-directory entry we unlink the link // itself; only entries that are truly directories on disk are recursed into. @@ -70,7 +70,7 @@ export function safeRemoveTree(path: string): void { // On Windows, lstat on a directory junction can report BOTH // isSymbolicLink() === true AND isDirectory() === true, so we MUST check - // isSymbolicLink first — otherwise a junction enters the recursive branch + // isSymbolicLink first -- otherwise a junction enters the recursive branch // and readdirSync enumerates the link's target, the exact bug in #1083. if (!isSafeDescendCandidate(stat)) { try { @@ -98,7 +98,7 @@ export function safeRemoveTree(path: string): void { try { unlinkSync(child) } catch { - // best-effort, see above + // Best-effort, see above. } } diff --git a/src/main/pty/shell-startup-env.test.ts b/src/main/pty/shell-startup-env.test.ts index 91418fd0809..d41e6c7fdcb 100644 --- a/src/main/pty/shell-startup-env.test.ts +++ b/src/main/pty/shell-startup-env.test.ts @@ -34,13 +34,17 @@ describe('readShellStartupEnvVar', () => { }) function mockStartupFiles(files: Record) { + const hasAbsoluteKeys = Object.keys(files).some((path) => path.startsWith('/')) existsSyncMock.mockImplementation((p: string) => { const file = p.split('/').pop() ?? '' - return file in files + return p in files || (!hasAbsoluteKeys && file in files) }) readFileSyncMock.mockImplementation((p: string) => { const file = p.split('/').pop() ?? '' - if (file in files) { + if (p in files) { + return files[p] + } + if (!hasAbsoluteKeys && file in files) { return files[file] } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) @@ -96,6 +100,17 @@ describe('readShellStartupEnvVar', () => { expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/newest/zlogin') }) + it('uses ZDOTDIR exported from .zshenv for later zsh startup files', () => { + mockStartupFiles({ + '/home/alice/.zshenv': 'export ZDOTDIR="$HOME/.config/zsh"\n', + '/home/alice/.config/zsh/.zshrc': 'export OPENCODE_CONFIG_DIR="$HOME/company/opencode"\n' + }) + + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice', '/bin/zsh')).toBe( + '/home/alice/company/opencode' + ) + }) + it('handles double-quoted values', () => { mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="/quoted/path"\n' }) expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/quoted/path') diff --git a/src/main/pty/shell-startup-env.ts b/src/main/pty/shell-startup-env.ts index fea07dd0f7f..96ba85aae6d 100644 --- a/src/main/pty/shell-startup-env.ts +++ b/src/main/pty/shell-startup-env.ts @@ -4,25 +4,62 @@ import { basename, join } from 'path' // Why: only files the user's actual shell would source. Mixing zsh and bash // files breaks the "last assignment wins matches the live shell" guarantee — // a stale .bash_profile on a zsh user would clobber the real .zshrc value. -const ZSH_FILES = ['.zshenv', '.zprofile', '.zshrc', '.zlogin'] +const ZSH_ENV_FILE = '.zshenv' +const ZSH_AFTER_ENV_FILES = ['.zprofile', '.zshrc', '.zlogin'] // Why: Orca launches bash as a login shell (see local-pty-shell-ready.ts // getBashShellReadyRcfileContent and daemon/shell-ready.ts) which sources // .bash_profile / .bash_login / .profile but intentionally does NOT force // .bashrc. Scanning .bashrc would mirror values the live Orca bash never sees. const BASH_LOGIN_FILES = ['.bash_profile', '.bash_login', '.profile'] -function shellStartupFiles(shell: string | undefined): readonly string[] { +function parseExportedValue(content: string, name: string, home: string): string | undefined { + const assignment = new RegExp(`^export\\s+${name}=(.+)$`) + let lastMatch: string | undefined + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim() + const match = assignment.exec(line) + if (!match?.[1]) { + continue + } + // Why: strip trailing unquoted `# comment` first so quoted values like + // `"$HOME/.opencode" # note` survive intact for unquoteShellValue. + const decommented = stripTrailingComment(match[1]) + const { text, quoted } = unquoteShellValue(decommented) + // Why: $HOME / ${HOME} / ~ expansion mimics what the live shell would + // do for double-quoted and unquoted values; single-quoted is literal. + const expanded = quoted === "'" ? text : expandHome(text, home) + if (expanded.length > 0) { + lastMatch = expanded + } + } + + return lastMatch +} + +function readStartupFile(path: string): string | null { + if (!existsSync(path)) { + return null + } + try { + return readFileSync(path, 'utf8') + } catch { + return null + } +} + +function shellStartupFilePaths(home: string, shell: string | undefined): readonly string[] { if (!shell) { // Why: Orca's POSIX default shell is /bin/zsh when $SHELL is unset. - return ZSH_FILES + return zshStartupFilePaths(home) } const name = basename(shell).toLowerCase() if (name === 'zsh') { - return ZSH_FILES + return zshStartupFilePaths(home) } if (name === 'bash') { - return BASH_LOGIN_FILES + return BASH_LOGIN_FILES.map((file) => join(home, file)) } // Why: unsupported explicit shells (fish, nushell, custom wrappers) do not // use Orca's zsh/bash shell-ready startup files, so scanning those files @@ -30,6 +67,16 @@ function shellStartupFiles(shell: string | undefined): readonly string[] { return [] } +function zshStartupFilePaths(home: string): readonly string[] { + const zshEnvPath = join(home, ZSH_ENV_FILE) + const zshEnv = readStartupFile(zshEnvPath) + // Why: zsh sources ~/.zshenv first, then uses any ZDOTDIR exported there + // for .zprofile/.zshrc/.zlogin. Mirror that enough for static env discovery + // so users who keep zsh config in ~/.config/zsh do not lose overlay sources. + const zshDir = zshEnv ? (parseExportedValue(zshEnv, 'ZDOTDIR', home) ?? home) : home + return [zshEnvPath, ...ZSH_AFTER_ENV_FILES.map((file) => join(zshDir, file))] +} + function unquoteShellValue(value: string): { text: string; quoted: '"' | "'" | null } { const trimmed = value.trim() if (trimmed.length >= 2) { @@ -118,38 +165,17 @@ export function readShellStartupEnvVar( return cache.get(cacheKey) } - const assignment = new RegExp(`^export\\s+${name}=(.+)$`) let lastMatch: string | undefined - for (const file of shellStartupFiles(shell)) { - const path = join(home, file) - if (!existsSync(path)) { + for (const path of shellStartupFilePaths(home, shell)) { + const content = readStartupFile(path) + if (content === null) { continue } - let content - try { - content = readFileSync(path, 'utf8') - } catch { - continue - } - - for (const rawLine of content.split(/\r?\n/)) { - const line = rawLine.trim() - const match = assignment.exec(line) - if (!match?.[1]) { - continue - } - // Why: strip trailing unquoted `# comment` first so quoted values like - // `"$HOME/.opencode" # note` survive intact for unquoteShellValue. - const decommented = stripTrailingComment(match[1]) - const { text, quoted } = unquoteShellValue(decommented) - // Why: $HOME / ${HOME} / ~ expansion mimics what the live shell would - // do for double-quoted and unquoted values; single-quoted is literal. - const expanded = quoted === "'" ? text : expandHome(text, home) - if (expanded.length > 0) { - lastMatch = expanded - } + const match = parseExportedValue(content, name, home) + if (match !== undefined) { + lastMatch = match } } diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 48c248480e3..52660c51955 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -6,6 +6,11 @@ import { SshRelaySession } from './ssh-relay-session' import type { SshConnection } from './ssh-connection' import type { Store } from '../persistence' import type { SshPortForwardManager } from './ssh-port-forward' +import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay' + +const { muxRequestMock } = vi.hoisted(() => ({ + muxRequestMock: vi.fn() +})) vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() @@ -15,7 +20,7 @@ vi.mock('./ssh-channel-multiplexer', () => { return { SshChannelMultiplexer: class MockSshChannelMultiplexer { notify = vi.fn() - request = vi.fn().mockResolvedValue([]) + request = muxRequestMock onNotification = vi.fn().mockReturnValue(() => {}) onDispose = vi.fn().mockReturnValue(() => {}) dispose = vi.fn() @@ -119,6 +124,9 @@ function mockDeploySuccess() { describe('SshRelaySession', () => { beforeEach(() => { vi.clearAllMocks() + delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS + muxRequestMock.mockReset() + muxRequestMock.mockResolvedValue([]) mockDeploySuccess() vi.mocked(getPtyIdsForConnection).mockReturnValue([]) }) @@ -143,6 +151,63 @@ describe('SshRelaySession', () => { expect(registerSshGitProvider).toHaveBeenCalledWith('target-1', expect.anything()) }) + it('syncs relay-owned plugin assets before registering the SSH PTY provider', async () => { + process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' + muxRequestMock.mockResolvedValue({ ok: true }) + const sftp = { end: vi.fn() } + const { mockStore, mockPortForward, getMainWindow } = createMockDeps() + const mockConn = { + sftp: vi.fn().mockResolvedValue(sftp) + } as unknown as SshConnection + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + + await session.establish(mockConn) + + const installPluginsCallIndex = muxRequestMock.mock.calls.findIndex( + ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD + ) + expect(installPluginsCallIndex).toBeGreaterThanOrEqual(0) + expect(muxRequestMock.mock.invocationCallOrder[installPluginsCallIndex]).toBeLessThan( + vi.mocked(registerSshPtyProvider).mock.invocationCallOrder[0] + ) + // Why: connecting to SSH may upload relay-owned plugin source, but must + // not mutate user-owned agent config files. Remote managed-hook install + // belongs behind an explicit per-host user action. + expect(mockConn.sftp).not.toHaveBeenCalled() + expect(sftp.end).not.toHaveBeenCalled() + }) + + it('does not register providers if dispose wins during initial plugin sync', async () => { + process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' + let resolvePluginInstall!: () => void + muxRequestMock.mockImplementation(async (method: string) => { + if (method === AGENT_HOOK_INSTALL_PLUGINS_METHOD) { + return new Promise((resolve) => { + resolvePluginInstall = () => resolve({ ok: true }) + }) + } + return { ok: true } + }) + const { mockStore, mockPortForward, getMainWindow } = createMockDeps() + const mockConn = {} as SshConnection + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + + const establish = session.establish(mockConn) + await vi.waitFor(() => + expect(muxRequestMock).toHaveBeenCalledWith( + AGENT_HOOK_INSTALL_PLUGINS_METHOD, + expect.anything() + ) + ) + session.dispose() + resolvePluginInstall() + + await expect(establish).rejects.toThrow('Session disposed during establish') + expect(registerSshPtyProvider).not.toHaveBeenCalled() + expect(registerSshFilesystemProvider).not.toHaveBeenCalled() + expect(registerSshGitProvider).not.toHaveBeenCalled() + }) + it('rejects establish when not idle', async () => { const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 61074ed65ea..d75807f86ce 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -18,10 +18,13 @@ import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' import { SshGitProvider } from '../providers/ssh-git-provider' import { agentHookServer } from '../agent-hooks/server' import { + AGENT_HOOK_INSTALL_PLUGINS_METHOD, AGENT_HOOK_NOTIFICATION_METHOD, AGENT_HOOK_REQUEST_REPLAY_METHOD, isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay' +import { _internals as openCodeInternals } from '../opencode/hook-service' +import { getPiAgentStatusExtensionSource } from '../pi/agent-status-extension-source' import { registerSshPtyProvider, unregisterSshPtyProvider, @@ -52,6 +55,11 @@ export class SshRelaySession { private mux: SshChannelMultiplexer | null = null private abortController: AbortController | null = null private muxDisposeCleanup: (() => void) | null = null + // Why: store the notification-handler disposer so teardownProviders can + // release it on reconnect/shutdown. Symmetric with muxDisposeCleanup; while + // the old mux's handler array is GC'd along with the mux today, holding the + // disposer is cheap insurance against future code that retains the old mux. + private muxNotificationCleanup: (() => void) | null = null // Why: when the relay exec channel closes but the SSH connection stays // up, the onStateChange reconnect path never fires. This callback lets // ssh.ts wire up relay-level reconnect from outside the session. @@ -152,7 +160,13 @@ export class SshRelaySession { // here fails fast so doConnect() can report the real error. await mux.request('session.resolveHome', { path: '~' }) - await this.registerProviders(mux) + const registered = await this.registerProviders(mux, ownsAttempt) + if (!registered) { + if (!mux.isDisposed()) { + mux.dispose() + } + throw new Error('Session disposed during establish') + } // Why: the mux's transport can close during registerProviders (e.g. // the --connect bridge exits). registerRelayRoots swallows mux errors @@ -405,6 +419,11 @@ export class SshRelaySession { return false } + await this.installPluginsOnRelay(mux) + if (shouldContinue && !shouldContinue()) { + return false + } + const ptyProvider = new SshPtyProvider(this.targetId, mux) registerSshPtyProvider(this.targetId, ptyProvider) @@ -419,6 +438,47 @@ export class SshRelaySession { return true } + // Why: ship the OpenCode plugin / Pi extension source bodies to the relay + // so it can materialize per-PTY overlay dirs and inject OPENCODE_CONFIG_DIR + // / PI_CODING_AGENT_DIR into spawn env. The strings change as we add agent + // events (recent additions: cursor, pi); pinning them to the relay binary + // would force a relay redeploy on every Orca update. See + // docs/design/agent-status-over-ssh.md §4 + §8 (commit #7). + // + // Best-effort: a -32601 from an older relay (no handler installed) is + // swallowed; the user just doesn't get OpenCode/Pi status reporting until + // they upgrade. Hook-script-based agents use a separate explicit remote + // installer flow because that mutates user-owned agent config files. + private async installPluginsOnRelay(mux: SshChannelMultiplexer): Promise { + if (!isRemoteAgentHooksEnabled()) { + return + } + try { + await mux.request(AGENT_HOOK_INSTALL_PLUGINS_METHOD, { + opencodePluginSource: openCodeInternals.getOpenCodePluginSource(), + piExtensionSource: getPiAgentStatusExtensionSource() + }) + } catch (err) { + // Why: -32601 = older relay without the handler (treat as soft skip). + // CONNECTION_LOST / DISPOSED come from the multiplexer when it tears + // down mid-flight (routine on session shutdown / reconnect race) — not + // a real failure to surface; suppress to avoid log spam on every clean + // disconnect. + const code = (err as { code?: unknown })?.code + if (code === -32601 || code === 'CONNECTION_LOST' || code === 'DISPOSED') { + return + } + if (mux.isDisposed()) { + return + } + console.warn( + `[ssh-relay-session] agent_hook.installPlugins failed for ${this.targetId}: ${ + err instanceof Error ? err.message : String(err) + }` + ) + } + } + // Why: route the relay's `agent.hook` JSON-RPC notification into Orca's // shared `agentHookServer` via `ingestRemote`. The wire envelope carries // `connectionId: null` (the relay does not know Orca's local handle); we @@ -433,7 +493,13 @@ export class SshRelaySession { if (!isRemoteAgentHooksEnabled()) { return } - mux.onNotification((method, params) => { + // Why: capture the disposer so teardownProviders can release the + // notification handler symmetrically with muxDisposeCleanup. Even though + // the disposed mux's handler array is GC'd along with it today, retaining + // the disposer makes "registerProviders called twice on the same mux" + // safe by future-proofing against duplicate handler registration. + this.muxNotificationCleanup?.() + this.muxNotificationCleanup = mux.onNotification((method, params) => { if (method !== AGENT_HOOK_NOTIFICATION_METHOD) { return } @@ -470,10 +536,14 @@ export class SshRelaySession { // *after* the handler is wired so the request-driven replay shape // strictly trails our subscription on the dispatcher's single write // callback. Best-effort: a relay that does not know the method - // (e.g. older relay binary) returns -32601, which we swallow. + // (e.g. older relay binary) returns -32601; CONNECTION_LOST / DISPOSED + // arise from mux teardown mid-flight on routine reconnect/shutdown. void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch((err) => { const code = (err as { code?: unknown })?.code - if (code === -32601) { + if (code === -32601 || code === 'CONNECTION_LOST' || code === 'DISPOSED') { + return + } + if (mux.isDisposed()) { return } // Why: a normal disconnect/teardown rejects the in-flight request with @@ -493,6 +563,8 @@ export class SshRelaySession { private teardownProviders(reason: 'shutdown' | 'connection_lost'): void { this.muxDisposeCleanup?.() this.muxDisposeCleanup = null + this.muxNotificationCleanup?.() + this.muxNotificationCleanup = null if (this.mux && !this.mux.isDisposed()) { this.mux.dispose(reason) } diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 7865d018b54..f2fc8e5d267 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -106,6 +106,11 @@ export class RelayAgentHookServer { await new Promise((resolve, reject) => { const onStartupError = (err: Error): void => { this.server?.off('listening', onListening) + // Why: null the server reference on bind failure so a subsequent + // start() can retry. Without this, a failed bind (e.g. EMFILE) leaves + // this.server populated and the early-return at the top of start() + // wedges the relay into a permanently broken state until stop() runs. + this.server = null reject(err) } const onListening = (): void => { @@ -234,9 +239,14 @@ export class RelayAgentHookServer { } res.writeHead(204) res.end() - } catch { + } catch (err) { // Why: agent hooks must fail open — return success on parse / size / // timeout errors so a buggy agent script never blocks the agent run. + // Log the swallowed error to stderr so future programmer bugs are not + // invisible (the 204 response would otherwise mask them entirely). + process.stderr.write( + `[relay-hook-server] hook request failed: ${err instanceof Error ? err.message : String(err)}\n` + ) res.writeHead(204) res.end() } diff --git a/src/relay/plugin-overlay-env.test.ts b/src/relay/plugin-overlay-env.test.ts new file mode 100644 index 00000000000..d23a767692b --- /dev/null +++ b/src/relay/plugin-overlay-env.test.ts @@ -0,0 +1,81 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { __resetShellStartupEnvCache } from '../main/pty/shell-startup-env' +import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env' + +describe('plugin overlay env source resolution', () => { + let homeDir: string + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'relay-plugin-overlay-env-')) + __resetShellStartupEnvCache() + }) + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }) + __resetShellStartupEnvCache() + }) + + it.skipIf(process.platform === 'win32')( + 'uses zsh startup exports before inherited public overlay env', + () => { + mkdirSync(join(homeDir, 'company-opencode'), { recursive: true }) + mkdirSync(join(homeDir, 'company-pi'), { recursive: true }) + writeFileSync( + join(homeDir, '.zshrc'), + [ + 'export OPENCODE_CONFIG_DIR="$HOME/company-opencode"', + 'export PI_CODING_AGENT_DIR="$HOME/company-pi"' + ].join('\n') + ) + + const env = { + HOME: homeDir, + OPENCODE_CONFIG_DIR: '/tmp/inherited-opencode-overlay', + PI_CODING_AGENT_DIR: '/tmp/inherited-pi-overlay' + } + + expect(resolveOpenCodeSourceConfigDir(env, '/bin/zsh')).toBe( + join(homeDir, 'company-opencode') + ) + expect(resolvePiSourceAgentDir(env, '/bin/zsh')).toBe(join(homeDir, 'company-pi')) + } + ) + + it.skipIf(process.platform === 'win32')( + 'discovers overlay sources from a custom zsh ZDOTDIR', + () => { + const zshDir = join(homeDir, '.config', 'zsh') + mkdirSync(zshDir, { recursive: true }) + writeFileSync(join(homeDir, '.zshenv'), 'export ZDOTDIR="$HOME/.config/zsh"\n') + writeFileSync(join(zshDir, '.zshrc'), 'export OPENCODE_CONFIG_DIR="$HOME/opencode-src"\n') + + expect( + resolveOpenCodeSourceConfigDir( + { + HOME: homeDir, + OPENCODE_CONFIG_DIR: '/tmp/inherited-opencode-overlay' + }, + '/bin/zsh' + ) + ).toBe(join(homeDir, 'opencode-src')) + } + ) + + it('keeps explicit original-source env ahead of startup hints', () => { + writeFileSync(join(homeDir, '.zshrc'), 'export OPENCODE_CONFIG_DIR="$HOME/company-opencode"\n') + + expect( + resolveOpenCodeSourceConfigDir( + { + HOME: homeDir, + ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/remote/original-opencode', + OPENCODE_CONFIG_DIR: '/tmp/inherited-opencode-overlay' + }, + '/bin/zsh' + ) + ).toBe('/remote/original-opencode') + }) +}) diff --git a/src/relay/plugin-overlay-env.ts b/src/relay/plugin-overlay-env.ts new file mode 100644 index 00000000000..431a49430f1 --- /dev/null +++ b/src/relay/plugin-overlay-env.ts @@ -0,0 +1,35 @@ +import { readShellStartupEnvVar } from '../main/pty/shell-startup-env' + +function firstNonEmpty(...values: (string | undefined)[]): string | undefined { + return values.find((value) => typeof value === 'string' && value.length > 0) +} + +function readStartupEnv( + name: string, + env: Record, + shell: string | undefined +): string | undefined { + return readShellStartupEnvVar(name, env.HOME ?? process.env.HOME, shell ?? env.SHELL) +} + +export function resolveOpenCodeSourceConfigDir( + env: Record, + shell: string | undefined +): string | undefined { + return firstNonEmpty( + env.ORCA_OPENCODE_SOURCE_CONFIG_DIR, + readStartupEnv('OPENCODE_CONFIG_DIR', env, shell), + env.OPENCODE_CONFIG_DIR + ) +} + +export function resolvePiSourceAgentDir( + env: Record, + shell: string | undefined +): string | undefined { + return firstNonEmpty( + env.ORCA_PI_SOURCE_AGENT_DIR, + readStartupEnv('PI_CODING_AGENT_DIR', env, shell), + env.PI_CODING_AGENT_DIR + ) +} diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts new file mode 100644 index 00000000000..5d7a7f03c33 --- /dev/null +++ b/src/relay/plugin-overlay.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync +} from 'fs' +import { tmpdir } from 'os' +import { basename, join } from 'path' +import { PluginOverlayManager } from './plugin-overlay' + +describe('PluginOverlayManager', () => { + let homeDir: string + let manager: PluginOverlayManager + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'plugin-overlay-')) + manager = new PluginOverlayManager({ homeDir }) + }) + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }) + }) + + it('reports no source until install runs', () => { + expect(manager.hasOpenCodeSource()).toBe(false) + expect(manager.hasPiSource()).toBe(false) + expect(manager.materializeOpenCode('tab-1:0')).toBeNull() + expect(manager.materializePi('tab-1:0')).toBeNull() + }) + + it('materializes OpenCode plugin into /plugins/', () => { + manager.setSources({ opencodePluginSource: 'export const X = 1' }) + const dir = manager.materializeOpenCode('tab-1:0') + expect(dir).not.toBeNull() + const expected = join(dir!, 'plugins', 'orca-opencode-status.js') + expect(existsSync(expected)).toBe(true) + expect(readFileSync(expected, 'utf8')).toBe('export const X = 1') + }) + + it('mirrors a preexisting remote OpenCode config dir before adding Orca plugin', () => { + const userConfigDir = join(homeDir, 'company-opencode') + mkdirSync(join(userConfigDir, 'plugins'), { recursive: true }) + writeFileSync(join(userConfigDir, 'opencode.json'), '{"provider":"custom"}') + writeFileSync(join(userConfigDir, 'plugins', 'user-plugin.js'), 'user plugin') + writeFileSync(join(userConfigDir, 'plugins', 'orca-opencode-status.js'), 'user same-name') + + manager.setSources({ opencodePluginSource: 'orca plugin' }) + const dir = manager.materializeOpenCode('tab-opencode:0', userConfigDir) + + expect(dir).not.toBeNull() + expect(readFileSync(join(dir!, 'opencode.json'), 'utf8')).toBe('{"provider":"custom"}') + expect(readFileSync(join(dir!, 'plugins', 'user-plugin.js'), 'utf8')).toBe('user plugin') + expect(readFileSync(join(dir!, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe( + 'orca plugin' + ) + expect(readFileSync(join(userConfigDir, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe( + 'user same-name' + ) + }) + + it('does not override a missing preexisting OpenCode config dir', () => { + manager.setSources({ opencodePluginSource: 'orca plugin' }) + + expect(manager.materializeOpenCode('tab-missing:0', join(homeDir, 'missing'))).toBeNull() + }) + + it('materializes Pi extension into /extensions/', () => { + manager.setSources({ piExtensionSource: '// pi extension' }) + const dir = manager.materializePi('tab-2:0') + expect(dir).not.toBeNull() + const file = join(dir!, 'extensions', 'orca-agent-status.ts') + expect(existsSync(file)).toBe(true) + }) + + it('mirrors the remote default Pi agent dir before adding Orca status extension', () => { + const piAgentDir = join(homeDir, '.pi', 'agent') + mkdirSync(join(piAgentDir, 'skills', 'my-skill'), { recursive: true }) + mkdirSync(join(piAgentDir, 'extensions', 'user-ext'), { recursive: true }) + writeFileSync(join(piAgentDir, 'auth.json'), 'secret token') + writeFileSync(join(piAgentDir, 'skills', 'my-skill', 'SKILL.md'), 'critical user skill') + writeFileSync(join(piAgentDir, 'extensions', 'user-ext', 'ext.ts'), 'user extension') + + manager.setSources({ piExtensionSource: '// pi extension' }) + const dir = manager.materializePi('tab-pi:0') + + expect(dir).not.toBeNull() + expect(readFileSync(join(dir!, 'auth.json'), 'utf8')).toBe('secret token') + expect(readFileSync(join(dir!, 'skills', 'my-skill', 'SKILL.md'), 'utf8')).toBe( + 'critical user skill' + ) + expect(readFileSync(join(dir!, 'extensions', 'user-ext', 'ext.ts'), 'utf8')).toBe( + 'user extension' + ) + expect(readdirSync(join(dir!, 'extensions')).sort()).toEqual([ + 'orca-agent-status.ts', + 'user-ext' + ]) + }) + + it('mirrors a preexisting remote Pi agent dir instead of the default', () => { + const defaultAgentDir = join(homeDir, '.pi', 'agent') + const customAgentDir = join(homeDir, 'custom-pi-agent') + mkdirSync(defaultAgentDir, { recursive: true }) + mkdirSync(join(customAgentDir, 'extensions'), { recursive: true }) + writeFileSync(join(defaultAgentDir, 'auth.json'), 'default token') + writeFileSync(join(customAgentDir, 'auth.json'), 'custom token') + writeFileSync(join(customAgentDir, 'extensions', 'custom.ts'), 'custom extension') + + manager.setSources({ piExtensionSource: '// pi extension' }) + const dir = manager.materializePi('tab-custom-pi:0', customAgentDir) + + expect(dir).not.toBeNull() + expect(readFileSync(join(dir!, 'auth.json'), 'utf8')).toBe('custom token') + expect(readFileSync(join(dir!, 'extensions', 'custom.ts'), 'utf8')).toBe('custom extension') + expect(readFileSync(join(dir!, 'extensions', 'orca-agent-status.ts'), 'utf8')).toBe( + '// pi extension' + ) + }) + + it('does not override a missing preexisting Pi agent dir', () => { + manager.setSources({ piExtensionSource: '// pi extension' }) + + expect(manager.materializePi('tab-missing-pi:0', join(homeDir, 'missing-pi'))).toBeNull() + }) + + it('clearOverlay removes both overlay roots for an id', () => { + manager.setSources({ + opencodePluginSource: 'opencode', + piExtensionSource: 'pi' + }) + const opencodeDir = manager.materializeOpenCode('tab-3:0')! + const piRoot = manager.materializePi('tab-3:0')! + expect(existsSync(opencodeDir)).toBe(true) + expect(existsSync(piRoot)).toBe(true) + + manager.clearOverlay('tab-3:0') + + expect(existsSync(opencodeDir)).toBe(false) + expect(existsSync(piRoot)).toBe(false) + }) + + it.skipIf(process.platform === 'win32')( + 'clearOverlay removes OpenCode overlay symlinks without deleting their targets', + () => { + const userConfigDir = join(homeDir, 'company-opencode') + const linkedTarget = join(homeDir, 'linked-plugin-target') + mkdirSync(join(userConfigDir, 'plugins'), { recursive: true }) + mkdirSync(linkedTarget, { recursive: true }) + writeFileSync(join(linkedTarget, 'keep.js'), 'do not delete') + symlinkSync(linkedTarget, join(userConfigDir, 'plugins', 'linked-plugin'), 'dir') + + manager.setSources({ opencodePluginSource: 'orca plugin' }) + const dir = manager.materializeOpenCode('tab-opencode-symlink:0', userConfigDir)! + expect(existsSync(join(dir, 'plugins', 'linked-plugin'))).toBe(true) + + manager.clearOverlay('tab-opencode-symlink:0') + + expect(existsSync(dir)).toBe(false) + expect(readFileSync(join(linkedTarget, 'keep.js'), 'utf8')).toBe('do not delete') + } + ) + + it('produces stable overlay dirs for a given id (idempotent re-materialization)', () => { + manager.setSources({ opencodePluginSource: 'first' }) + const dirA = manager.materializeOpenCode('tab-stable:0')! + manager.setSources({ opencodePluginSource: 'second' }) + const dirB = manager.materializeOpenCode('tab-stable:0')! + expect(dirA).toBe(dirB) + expect(readFileSync(join(dirA, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe('second') + }) + + it('hashes unsafe pane ids into portable overlay directory names', () => { + manager.setSources({ opencodePluginSource: 'plugin' }) + const dir = manager.materializeOpenCode('tab/with\\unsafe:chars\n0') + + expect(dir).not.toBeNull() + expect(basename(dir!)).toMatch(/^[a-f0-9]{32}$/) + expect(dir).not.toContain('tab/with') + expect(existsSync(join(dir!, 'plugins', 'orca-opencode-status.js'))).toBe(true) + }) +}) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts new file mode 100644 index 00000000000..905d4b5f3f1 --- /dev/null +++ b/src/relay/plugin-overlay.ts @@ -0,0 +1,276 @@ +// Why: relay-side equivalent of Orca's userData-backed plugin overlay system. +// Orca's local OpenCodeHookService and PiTitlebarExtensionService each +// materialize a per-PTY overlay and inject OPENCODE_CONFIG_DIR / +// PI_CODING_AGENT_DIR pointing at it. Those paths describe the local +// filesystem and would resolve to nothing on a remote box, so when a PTY runs +// on the relay, the relay must do the same materialization on its own disk. +// +// Plugin source strings ship over the JSON-RPC channel at session-ready +// (commit #7) — they are NOT bundled with the relay binary because the +// relay is versioned independently from Orca and the plugin source changes +// frequently as new agent events get added (see docs/design/agent-status- +// over-ssh.md §4 "Why ship the plugin source over the wire"). +// +// We deliberately do not reuse OpenCodeHookService / PiTitlebarExtensionService +// directly: those modules import `electron` and ride on Orca's userData +// path. The relay's electron-free constraint forces a thin parallel +// implementation rooted at $HOME/.orca-relay/. + +import { createHash } from 'crypto' +import { + existsSync, + mkdirSync, + readdirSync, + realpathSync, + statSync, + unlinkSync, + writeFileSync +} from 'fs' +import { homedir } from 'os' +import { basename, join } from 'path' +import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror' + +const RELAY_HOOKS_DIR = '.orca-relay' +const OPENCODE_OVERLAY_SUBDIR = 'opencode-overlays' +const PI_OVERLAY_SUBDIR = 'pi-overlays' +const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' +const PI_EXTENSION_FILE = 'orca-agent-status.ts' +const PI_AGENT_DIR_NAME = '.pi' +const PI_AGENT_SUBDIR = 'agent' + +function safeDirName(input: string): string { + // Why: paneKey embeds tabId:paneId where tabId may itself contain + // filesystem-unsafe characters in some Orca builds. Hash to a fixed-width + // hex name so any input produces a portable directory name. + return createHash('sha256').update(input).digest('hex').slice(0, 32) +} + +function isUsableId(id: string): boolean { + return typeof id === 'string' && id.length > 0 && id.length <= 1024 +} + +export type PluginSources = { + /** Source body of `orca-opencode-status.js` to drop into /plugins/. */ + opencodePluginSource?: string + /** Source body of `orca-agent-status.ts` to drop into /extensions/. */ + piExtensionSource?: string +} + +export class PluginOverlayManager { + private opencodePluginSource: string | null = null + private piExtensionSource: string | null = null + private homeDir: string + private opencodeRoot: string + private piRoot: string + + constructor(opts?: { homeDir?: string }) { + const home = opts?.homeDir ?? homedir() + this.homeDir = home + this.opencodeRoot = join(home, RELAY_HOOKS_DIR, OPENCODE_OVERLAY_SUBDIR) + this.piRoot = join(home, RELAY_HOOKS_DIR, PI_OVERLAY_SUBDIR) + } + + /** Replace the cached source bodies. Called from relay.ts when Orca sends + * `agent_hook.installPlugins`. The first install enables the augmenter + * output; subsequent installs (e.g. Orca version upgrade in flight) refresh + * the cached source so future spawns see the new strings. + * Note: existing per-PTY overlays already on disk keep the previous source + * until that PTY exits — a long-running PTY does NOT pick up the new + * source, matching the local-Orca behavior where the plugin file is + * written once at spawn time. */ + setSources(sources: PluginSources): void { + if (typeof sources.opencodePluginSource === 'string') { + this.opencodePluginSource = sources.opencodePluginSource + } + if (typeof sources.piExtensionSource === 'string') { + this.piExtensionSource = sources.piExtensionSource + } + } + + hasOpenCodeSource(): boolean { + return this.opencodePluginSource !== null + } + + hasPiSource(): boolean { + return this.piExtensionSource !== null + } + + private mirrorOpenCodeConfig(sourceDir: string, overlayDir: string): void { + for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { + const sourcePath = join(sourceDir, entry.name) + + if (entry.name === 'plugins') { + const isSymlink = entry.isSymbolicLink() + let isLinkPointingToDir = false + if (isSymlink) { + try { + isLinkPointingToDir = statSync(sourcePath).isDirectory() + } catch { + isLinkPointingToDir = false + } + } + + if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { + const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath + const overlayPluginsDir = join(overlayDir, 'plugins') + mkdirSync(overlayPluginsDir, { recursive: true }) + for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { + if (pluginEntry.name === OPENCODE_PLUGIN_FILE) { + continue + } + mirrorEntry( + join(resolvedSource, pluginEntry.name), + join(overlayPluginsDir, pluginEntry.name) + ) + } + continue + } + } + + mirrorEntry(sourcePath, join(overlayDir, entry.name)) + } + } + + private writeOpenCodePlugin(overlayDir: string): void { + const pluginsDir = join(overlayDir, 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + const pluginPath = join(pluginsDir, OPENCODE_PLUGIN_FILE) + try { + unlinkSync(pluginPath) + } catch { + // Fresh overlay or no same-named stale symlink. + } + writeFileSync(pluginPath, this.opencodePluginSource!) + } + + /** Materialize the OpenCode plugin overlay for `id` (typically the + * renderer-supplied paneKey or, fallback, the relay-internal pty-id) and + * return the directory path. Returns null when no source is cached or + * the overlay write fails — caller falls back to no plugin (the agent + * CLI runs without status reporting), which is the existing fail-open + * behavior on the local side. */ + materializeOpenCode(id: string, existingConfigDir?: string): string | null { + if (!this.opencodePluginSource || !isUsableId(id)) { + return null + } + const dir = join(this.opencodeRoot, safeDirName(id)) + try { + safeRemoveOverlay(dir, this.opencodeRoot) + mkdirSync(dir, { recursive: true }) + if (existingConfigDir) { + if (!existsSync(existingConfigDir)) { + return null + } + // Why: OPENCODE_CONFIG_DIR is a single config root. Mirror the user's + // remote root into the overlay before adding Orca's plugin so status + // reporting does not hide their auth, models, keybinds, or plugins. + this.mirrorOpenCodeConfig(existingConfigDir, dir) + } + this.writeOpenCodePlugin(dir) + return dir + } catch (err) { + process.stderr.write( + `[plugin-overlay] failed to materialize OpenCode overlay: ${err instanceof Error ? err.message : String(err)}\n` + ) + return null + } + } + + private getDefaultPiAgentDir(): string { + return join(this.homeDir, PI_AGENT_DIR_NAME, PI_AGENT_SUBDIR) + } + + private mirrorPiAgentDir(sourceAgentDir: string, overlayDir: string): void { + if (!existsSync(sourceAgentDir)) { + return + } + + for (const entry of readdirSync(sourceAgentDir, { withFileTypes: true })) { + const sourcePath = join(sourceAgentDir, entry.name) + + if (entry.name === 'extensions') { + const isSymlink = entry.isSymbolicLink() + let isLinkPointingToDir = false + if (isSymlink) { + try { + isLinkPointingToDir = statSync(sourcePath).isDirectory() + } catch { + isLinkPointingToDir = false + } + } + + if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { + const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath + const overlayExtensionsDir = join(overlayDir, 'extensions') + mkdirSync(overlayExtensionsDir, { recursive: true }) + for (const extensionEntry of readdirSync(resolvedSource, { withFileTypes: true })) { + if (extensionEntry.name === PI_EXTENSION_FILE) { + continue + } + mirrorEntry( + join(resolvedSource, extensionEntry.name), + join(overlayExtensionsDir, extensionEntry.name) + ) + } + continue + } + } + + mirrorEntry(sourcePath, join(overlayDir, basename(sourcePath))) + } + } + + /** Materialize the Pi extension overlay for `id` and return the directory + * path that should be assigned to PI_CODING_AGENT_DIR. */ + materializePi(id: string, existingAgentDir?: string): string | null { + if (!this.piExtensionSource || !isUsableId(id)) { + return null + } + const dir = join(this.piRoot, safeDirName(id)) + try { + // Why: PI_CODING_AGENT_DIR is Pi's whole state root. Mirror the remote + // user's default agent dir so Orca's status extension does not hide auth, + // sessions, skills, prompts, themes, or user extensions inside SSH panes. + safeRemoveOverlay(dir, this.piRoot) + mkdirSync(dir, { recursive: true }) + const sourceAgentDir = existingAgentDir ?? this.getDefaultPiAgentDir() + if (existingAgentDir && !existsSync(existingAgentDir)) { + return null + } + this.mirrorPiAgentDir(sourceAgentDir, dir) + const extensionsDir = join(dir, 'extensions') + mkdirSync(extensionsDir, { recursive: true }) + writeFileSync(join(extensionsDir, PI_EXTENSION_FILE), this.piExtensionSource) + return dir + } catch (err) { + process.stderr.write( + `[plugin-overlay] failed to materialize Pi overlay: ${err instanceof Error ? err.message : String(err)}\n` + ) + return null + } + } + + /** Drop a paneKey's overlay dirs on PTY exit. Best-effort; cleanup over a + * recursive tree may fail on exotic filesystems but the worst-case + * outcome is unbounded growth on a long-lived relay, which the per-pane + * caches alone do not bound. */ + clearOverlay(id: string): void { + if (!isUsableId(id)) { + return + } + const safe = safeDirName(id) + for (const root of [this.opencodeRoot, this.piRoot]) { + try { + safeRemoveOverlay(join(root, safe), root) + } catch (err) { + // Why: log the failed cleanup so a permission/IO error is observable. + // The leak is the failure mode the per-pane cache eviction exists to + // prevent — silent swallows would let it accumulate invisibly on + // long-running relays. + process.stderr.write( + `[plugin-overlay] failed to remove overlay dir ${join(root, safe)}: ${err instanceof Error ? err.message : String(err)}\n` + ) + } + } + } +} diff --git a/src/relay/plugin-source-limit.test.ts b/src/relay/plugin-source-limit.test.ts new file mode 100644 index 00000000000..87068802215 --- /dev/null +++ b/src/relay/plugin-source-limit.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { assertPluginSourceUnderByteCap, PLUGIN_SOURCE_MAX_BYTES } from './plugin-source-limit' + +describe('plugin source byte limit', () => { + it('allows non-string values because installPlugins treats them as absent sources', () => { + expect(() => assertPluginSourceUnderByteCap('piExtensionSource', undefined)).not.toThrow() + }) + + it('allows sources at the byte cap', () => { + const source = 'a'.repeat(PLUGIN_SOURCE_MAX_BYTES) + + expect(() => assertPluginSourceUnderByteCap('opencodePluginSource', source)).not.toThrow() + }) + + it('rejects sources over the byte cap using utf8 byte length, not string length', () => { + const source = 'é'.repeat(Math.floor(PLUGIN_SOURCE_MAX_BYTES / 2) + 1) + + expect(source.length).toBeLessThanOrEqual(PLUGIN_SOURCE_MAX_BYTES) + expect(Buffer.byteLength(source, 'utf8')).toBeGreaterThan(PLUGIN_SOURCE_MAX_BYTES) + expect(() => assertPluginSourceUnderByteCap('piExtensionSource', source)).toThrow( + `piExtensionSource exceeds ${PLUGIN_SOURCE_MAX_BYTES} byte cap` + ) + }) +}) diff --git a/src/relay/plugin-source-limit.ts b/src/relay/plugin-source-limit.ts new file mode 100644 index 00000000000..d06c24248a0 --- /dev/null +++ b/src/relay/plugin-source-limit.ts @@ -0,0 +1,12 @@ +export const PLUGIN_SOURCE_MAX_BYTES = 256 * 1024 + +export function assertPluginSourceUnderByteCap(fieldName: string, value: unknown): void { + if (typeof value !== 'string') { + return + } + // Why: the relay receives JSON strings over the wire; cap actual UTF-8 + // bytes instead of UTF-16 code units so non-ASCII source cannot bypass it. + if (Buffer.byteLength(value, 'utf8') > PLUGIN_SOURCE_MAX_BYTES) { + throw new Error(`${fieldName} exceeds ${PLUGIN_SOURCE_MAX_BYTES} byte cap`) + } +} diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index ec34881e9a2..e806237a429 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -1,5 +1,8 @@ /* oxlint-disable max-lines */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), @@ -435,6 +438,113 @@ describe('PtyHandler', () => { expect(callArgs.env.ORCA_TAB_ID).toBe('tab-1') }) + it('passes the PTY id and renderer paneKey to env augmenters', async () => { + const seenContexts: { id: string; paneKey?: string; env: Record }[] = [] + handler.addEnvAugmenter((ctx) => { + seenContexts.push(ctx) + return { + OVERLAY_ID: ctx.paneKey ?? ctx.id + } + }) + + await dispatcher.callRequest('pty.spawn', { + env: { ORCA_PANE_KEY: 'tab-context:0' } + }) + await dispatcher.callRequest('pty.spawn', {}) + + const firstEnv = mockPtySpawn.mock.calls[0][2] as { env: Record } + const secondEnv = mockPtySpawn.mock.calls[1][2] as { env: Record } + expect(seenContexts[0]).toMatchObject({ + id: 'pty-1', + paneKey: 'tab-context:0', + env: { ORCA_PANE_KEY: 'tab-context:0' } + }) + expect(seenContexts[1]).toMatchObject({ id: 'pty-2', paneKey: undefined }) + expect(firstEnv.env.OVERLAY_ID).toBe('tab-context:0') + expect(secondEnv.env.OVERLAY_ID).toBe('pty-2') + }) + + it('passes process and renderer env to env augmenters before augmenter overrides are applied', async () => { + const oldProcessValue = process.env.OPENCODE_CONFIG_DIR + process.env.OPENCODE_CONFIG_DIR = '/remote/default-opencode' + try { + handler.addEnvAugmenter((ctx) => ({ + SEEN_OPENCODE_CONFIG_DIR: ctx.env.OPENCODE_CONFIG_DIR, + SEEN_PI_CODING_AGENT_DIR: ctx.env.PI_CODING_AGENT_DIR + })) + + await dispatcher.callRequest('pty.spawn', { + env: { + OPENCODE_CONFIG_DIR: '/remote/renderer-opencode', + PI_CODING_AGENT_DIR: '/remote/pi' + } + }) + } finally { + if (oldProcessValue === undefined) { + delete process.env.OPENCODE_CONFIG_DIR + } else { + process.env.OPENCODE_CONFIG_DIR = oldProcessValue + } + } + + const spawnEnv = mockPtySpawn.mock.calls[0][2] as { env: Record } + expect(spawnEnv.env.SEEN_OPENCODE_CONFIG_DIR).toBe('/remote/renderer-opencode') + expect(spawnEnv.env.SEEN_PI_CODING_AGENT_DIR).toBe('/remote/pi') + }) + + it.skipIf(process.platform === 'win32')( + 'wraps bash spawns to restore overlay env after remote startup files', + async () => { + const oldShell = process.env.SHELL + const oldHome = process.env.HOME + const homeDir = mkdtempSync(join(tmpdir(), 'relay-pty-shell-launch-')) + + process.env.SHELL = '/bin/bash' + process.env.HOME = homeDir + try { + if (!existsSync('/bin/bash')) { + return + } + + handler.addEnvAugmenter(() => ({ + OPENCODE_CONFIG_DIR: '/remote/overlay/opencode', + ORCA_OPENCODE_CONFIG_DIR: '/remote/overlay/opencode', + PI_CODING_AGENT_DIR: '/remote/overlay/pi', + ORCA_PI_CODING_AGENT_DIR: '/remote/overlay/pi' + })) + + await dispatcher.callRequest('pty.spawn', { env: { HOME: homeDir } }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + if (oldHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = oldHome + } + } + + const shellArgs = mockPtySpawn.mock.calls[0][1] + const spawnOptions = mockPtySpawn.mock.calls[0][2] as { env: Record } + const rcfile = join(homeDir, '.orca-relay', 'shell-ready', 'bash', 'rcfile') + + expect(shellArgs).toEqual(['--rcfile', rcfile]) + expect(spawnOptions.env.ORCA_OPENCODE_CONFIG_DIR).toBe('/remote/overlay/opencode') + expect(spawnOptions.env.ORCA_PI_CODING_AGENT_DIR).toBe('/remote/overlay/pi') + expect(readFileSync(rcfile, 'utf8')).toContain( + 'export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"' + ) + expect(readFileSync(rcfile, 'utf8')).toContain( + 'export PI_CODING_AGENT_DIR="${ORCA_PI_CODING_AGENT_DIR}"' + ) + + rmSync(homeDir, { recursive: true, force: true }) + } + ) + it('revive restores pane identity env alongside hook-server coordinates', async () => { await dispatcher.callRequest('pty.spawn', { cols: 90, @@ -494,7 +604,32 @@ describe('PtyHandler', () => { expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-2:1' }]) }) - it('dispose kills all PTYs with SIGKILL', async () => { + it('immediate shutdown invokes the exit listener once even if onExit arrives later', async () => { + let onExitCb: ((evt: { exitCode: number }) => void) | undefined + const mockKill = vi.fn() + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + kill: mockKill, + onData: vi.fn(), + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCb = cb + }) + }) + const exits: { id: string; paneKey?: string }[] = [] + handler.setExitListener((evt) => exits.push(evt)) + + await dispatcher.callRequest('pty.spawn', { + env: { ORCA_PANE_KEY: 'tab-shutdown:0' } + }) + await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true }) + onExitCb!({ exitCode: 0 }) + + expect(mockKill).toHaveBeenCalledWith('SIGKILL') + expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-shutdown:0' }]) + expect(handler.activePtyCount).toBe(0) + }) + + it('dispose kills all PTYs with SIGKILL and invokes exit listeners', async () => { const mockKill = vi.fn() mockPtySpawn.mockReturnValue({ ...mockPtyInstance, @@ -502,9 +637,11 @@ describe('PtyHandler', () => { onData: vi.fn(), onExit: vi.fn() }) + const exits: { id: string; paneKey?: string }[] = [] + handler.setExitListener((evt) => exits.push(evt)) - await dispatcher.callRequest('pty.spawn', {}) - await dispatcher.callRequest('pty.spawn', {}) + await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-dispose:0' } }) + await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-dispose:1' } }) expect(handler.activePtyCount).toBe(2) handler.dispose() @@ -513,6 +650,10 @@ describe('PtyHandler', () => { // wedged process, uninterruptible sleep) would survive SIGTERM + immediate // destroy() as an orphan on the remote host. SIGKILL is not ignorable. expect(mockKill).toHaveBeenCalledWith('SIGKILL') + expect(exits).toEqual([ + { id: 'pty-1', paneKey: 'tab-dispose:0' }, + { id: 'pty-2', paneKey: 'tab-dispose:1' } + ]) expect(handler.activePtyCount).toBe(0) }) }) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index b36b7b43180..b1b540cb867 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -9,6 +9,7 @@ import { getForegroundProcessName, listShellProfiles } from './pty-shell-utils' +import { getRelayShellLaunchConfig } from './pty-shell-launch' // Why: node-pty is a native addon that may not be installed on the remote. // Dynamic import keeps the require() lazy so loadPty() returns null gracefully @@ -39,6 +40,10 @@ type ManagedPty = { * entry-point calls into a clean "not found" error instead of a silent no-op * (POSIX proc.kill is neutralized inside disposeManagedPty). */ disposed?: boolean + /** True once external cleanup observers have been notified. Forced cleanup + * paths can run before node-pty emits onExit; this prevents duplicate + * overlay/cache cleanup if onExit arrives later. */ + exitListenerNotified?: boolean /** Renderer-supplied paneKey from spawn env (ORCA_PANE_KEY). Captured so * external observers (the relay-hook-server cache) can evict per-pane * state when this PTY exits. Symmetric with Orca's local pty.ts. */ @@ -102,7 +107,16 @@ type SerializedPtyEntry = { } export type PtyExitListener = (event: { id: string; paneKey?: string }) => void -export type PtyEnvAugmenter = () => Record +/** Returns env to merge into the PTY's spawn env. Receives spawn context so + * augmenters that need a per-PTY identity (e.g. OPENCODE_CONFIG_DIR overlay + * paths derived from the renderer's paneKey) can compute it without pulling + * the renderer's env in twice. */ +export type PtyEnvAugmenter = (ctx: { + id: string + paneKey?: string + shell: string + env: Record +}) => Record export class PtyHandler { private ptys = new Map() @@ -110,11 +124,13 @@ export class PtyHandler { private dispatcher: RelayDispatcher private graceTimeMs: number private graceTimer: ReturnType | null = null - // Why: external observers (the relay's hook-server cache) need to drop - // per-pane state when a PTY exits. Multiple listeners is unnecessary today - // — the hook server is the only consumer — so a single optional callback - // keeps the surface tight. A throw inside the listener is swallowed so it - // can never block disposeManagedPty / map cleanup. + // Why: external observers need to drop per-pane state when a PTY exits. + // Today the relay composes multiple consumers (hook-server cache eviction + // and plugin-overlay dir cleanup) into a single callback at the call site + // (see relay.ts setExitListener). A single optional slot is intentional — + // callers compose externally rather than us maintaining a listener list. + // A throw inside the listener is swallowed so it can never block + // disposeManagedPty / map cleanup. private exitListener: PtyExitListener | null = null // Why: env augmenters injected at relay boot (currently the relay-hook // server's ORCA_AGENT_HOOK_* coords). Run on every spawn so every PTY @@ -156,18 +172,22 @@ export class PtyHandler { * drift between the two paths — revived shells after a relay restart must * see the fresh ORCA_AGENT_HOOK_* coords just like freshly-spawned ones, * otherwise agent-status over SSH silently breaks on every revive. */ - private buildSpawnEnv(rendererEnv?: Record): Record { + private buildSpawnEnv( + rendererEnv: Record | undefined, + ctx: { id: string; paneKey?: string; shell: string } + ): Record { + const baseEnv = { ...process.env, ...rendererEnv } as Record const augmented: Record = {} for (const augmenter of this.envAugmenters) { try { - Object.assign(augmented, augmenter()) + Object.assign(augmented, augmenter({ ...ctx, env: baseEnv })) } catch (err) { process.stderr.write( `[pty-handler] env augmenter threw: ${err instanceof Error ? err.message : String(err)}\n` ) } } - return { ...process.env, ...rendererEnv, ...augmented } as Record + return { ...baseEnv, ...augmented } } /** Wire onData/onExit listeners for a managed PTY and store it. */ @@ -181,6 +201,9 @@ export class PtyHandler { this.dispatcher.notify('pty.data', { id: managed.id, data }) }) managed.pty.onExit(({ exitCode }: { exitCode: number }) => { + if (managed.disposed) { + return + } // Why: neutralize managed.pty.kill synchronously BEFORE anything else // in this callback. node-pty's UnixTerminal has // `_socket.once('close', () => this.kill('SIGHUP'))` wired at destroy @@ -199,19 +222,7 @@ export class PtyHandler { managed.killTimer = undefined } this.dispatcher.notify('pty.exit', { id: managed.id, code: exitCode }) - // Why: notify external observers BEFORE deleting the map entry so a - // listener that needs to read paneKey from the managed entry still - // can. Wrap in try/catch so a throwing listener cannot block fd - // release or map cleanup. - if (this.exitListener) { - try { - this.exitListener({ id: managed.id, paneKey: managed.paneKey }) - } catch (err) { - process.stderr.write( - `[pty-handler] onExit listener threw: ${err instanceof Error ? err.message : String(err)}\n` - ) - } - } + this.notifyExitListener(managed) this.ptys.delete(managed.id) // Why: release the ptmx fd on the natural-exit path. Without this the // node-pty wrapper's _socket stays alive until GC and the master fd @@ -220,6 +231,26 @@ export class PtyHandler { }) } + private notifyExitListener(managed: ManagedPty): void { + if (managed.exitListenerNotified) { + return + } + managed.exitListenerNotified = true + // Why: external observers own relay-hook cache eviction and plugin-overlay + // cleanup. Natural exits, immediate shutdown, SIGKILL fallback, and relay + // process disposal all need the same cleanup even when node-pty never + // delivers onExit. + if (this.exitListener) { + try { + this.exitListener({ id: managed.id, paneKey: managed.paneKey }) + } catch (err) { + process.stderr.write( + `[pty-handler] exit listener threw: ${err instanceof Error ? err.message : String(err)}\n` + ) + } + } + } + private registerHandlers(): void { this.dispatcher.onRequest('pty.spawn', (p, context) => this.spawn(p, context)) this.dispatcher.onRequest('pty.attach', (p) => this.attach(p)) @@ -262,28 +293,31 @@ export class PtyHandler { const shell = resolveDefaultShell() const id = `pty-${this.nextId++}` - // Why: server-side augmenter values (ORCA_AGENT_HOOK_*) override any - // renderer-supplied env so the live hook-server coords always reach the - // agent CLI — they come from the relay, not the renderer. See - // buildSpawnEnv for the precedence contract. - const spawnEnv = this.buildSpawnEnv(env) + // Why: server-side augmenter values (ORCA_AGENT_HOOK_* and plugin overlay + // dirs) override renderer-supplied env so live remote paths and hook coords + // win over local userData paths. The context lets overlay augmenters derive + // per-PTY OpenCode/Pi directories from the stable paneKey when present. + const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined + const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell }) + const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv) // Why: SSH exec channels give the relay a minimal environment without // .zprofile/.bash_profile sourced. Spawning a login shell ensures PATH // includes Homebrew, nvm, and user-installed CLIs (claude, codex, gh). - const term = pty.spawn(shell, ['-l'], { + // When overlays are injected, the launch wrapper keeps those paths after + // user startup files re-export their defaults. + const term = pty.spawn(shell, shellLaunch.args, { name: 'xterm-256color', cols, rows, cwd, - env: spawnEnv + env: { ...spawnEnv, ...shellLaunch.env } }) // Why: capture the renderer-supplied paneKey on the managed entry so the // exit listener can evict per-pane caches without the relay needing a // separate ptyId→paneKey map. ORCA_PANE_KEY is shaped `${tabId}:${paneId}` // and is bounded by the renderer; the relay treats it as opaque. - const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined const tabId = typeof env?.ORCA_TAB_ID === 'string' ? env.ORCA_TAB_ID : undefined const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined const managed: ManagedPty = { @@ -309,6 +343,7 @@ export class PtyHandler { // SIGKILL's onExit is missed (kernel edge case, uninterruptible // sleep), the managed entry + ptmx fd would leak forever. Dispose // synchronously so the entry is gone regardless of onExit timing. + this.notifyExitListener(still) disposeManagedPty(still) this.ptys.delete(id) } @@ -391,6 +426,7 @@ export class PtyHandler { // here makes the map hygiene a hard guarantee, not "hopefully onExit // runs". If onExit DOES fire later, its own `this.ptys.delete(id)` is // a no-op. + this.notifyExitListener(managed) this.ptys.delete(id) } else { managed.pty.kill('SIGTERM') @@ -414,6 +450,7 @@ export class PtyHandler { // graceful-shutdown's SIGKILL fallback is a hard guarantee, not // "hopefully onExit will run". The disposed guard inside // disposeManagedPty makes a later onExit's dispose a no-op. + this.notifyExitListener(still) disposeManagedPty(still) this.ptys.delete(id) } @@ -545,12 +582,19 @@ export class PtyHandler { if (entry.worktreeId) { revivedEnv.ORCA_WORKTREE_ID = entry.worktreeId } - const term = ptyMod.spawn(resolveDefaultShell(), ['-l'], { + const shell = resolveDefaultShell() + const spawnEnv = this.buildSpawnEnv(revivedEnv, { + id: entry.id, + paneKey: entry.paneKey, + shell + }) + const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv) + const term = ptyMod.spawn(shell, shellLaunch.args, { name: 'xterm-256color', cols: entry.cols, rows: entry.rows, cwd: entry.cwd, - env: this.buildSpawnEnv(revivedEnv) + env: { ...spawnEnv, ...shellLaunch.env } }) this.wireAndStore({ id: entry.id, @@ -612,6 +656,7 @@ export class PtyHandler { } catch { /* child may already be dead */ } + this.notifyExitListener(managed) disposeManagedPty(managed) } this.ptys.clear() diff --git a/src/relay/pty-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts new file mode 100644 index 00000000000..9307f22f865 --- /dev/null +++ b/src/relay/pty-shell-launch.test.ts @@ -0,0 +1,58 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { getRelayShellLaunchConfig } from './pty-shell-launch' + +describe('getRelayShellLaunchConfig', () => { + let homeDir: string + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'relay-shell-launch-')) + }) + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }) + }) + + it.skipIf(process.platform === 'win32')( + 'preserves a user ZDOTDIR exported from .zshenv for later startup files', + () => { + const config = getRelayShellLaunchConfig('/bin/zsh', { + HOME: homeDir, + ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-overlay' + }) + const zshRoot = join(homeDir, '.orca-relay', 'shell-ready', 'zsh') + + expect(config.args).toEqual(['-l']) + expect(config.env.ZDOTDIR).toBe(zshRoot) + expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain( + 'export ORCA_USER_ZDOTDIR="${ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"' + ) + expect(readFileSync(join(zshRoot, '.zprofile'), 'utf8')).toContain( + '_orca_home="${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"' + ) + expect(readFileSync(join(zshRoot, '.zshrc'), 'utf8')).toContain( + '_orca_home="${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"' + ) + expect(readFileSync(join(zshRoot, '.zlogin'), 'utf8')).toContain( + '_orca_home="${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"' + ) + } + ) + + it.skipIf(process.platform === 'win32')('rewrites stale persistent wrapper files', () => { + const zshRoot = join(homeDir, '.orca-relay', 'shell-ready', 'zsh') + mkdirSync(zshRoot, { recursive: true }) + writeFileSync(join(zshRoot, '.zshenv'), '# stale relay wrapper\n') + + getRelayShellLaunchConfig('/bin/zsh', { + HOME: homeDir, + ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-overlay' + }) + + expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain( + 'export ORCA_USER_ZDOTDIR="${ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"' + ) + }) +}) diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts new file mode 100644 index 00000000000..f2f3f739bd9 --- /dev/null +++ b/src/relay/pty-shell-launch.ts @@ -0,0 +1,165 @@ +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'fs' +import { homedir } from 'os' +import { basename, dirname, join } from 'path' + +const RELAY_SHELL_READY_DIR = '.orca-relay/shell-ready' +const POSIX_LOGIN_ARGS = ['-l'] + +export type RelayShellLaunchConfig = { + args: string[] + env: Record +} + +function quotePosixSingle(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function hasOverlayRestoreEnv(env: Record): boolean { + return Boolean(env.ORCA_OPENCODE_CONFIG_DIR || env.ORCA_PI_CODING_AGENT_DIR) +} + +function getWrapperRoot(env: Record): string { + return join(env.HOME || process.env.HOME || homedir(), RELAY_SHELL_READY_DIR) +} + +function normalizeOriginalZdotdirCandidate(value: string | undefined): string | null { + if (!value) { + return null + } + const normalized = value.replace(/\/+$/, '') + if (!normalized || normalized.endsWith('/shell-ready/zsh')) { + return null + } + return value +} + +function resolveOriginalZdotdir(env: Record): string { + return ( + normalizeOriginalZdotdirCandidate(env.ZDOTDIR) || + normalizeOriginalZdotdirCandidate(env.ORCA_ORIG_ZDOTDIR) || + env.HOME || + process.env.HOME || + '' + ) +} + +function ensureOverlayRestoreWrappers(root: string): void { + const zshDir = join(root, 'zsh') + const bashDir = join(root, 'bash') + + const zshEnv = `# Orca relay zsh overlay wrapper +export ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR:-$HOME}" +case "\${ORCA_ORIG_ZDOTDIR%/}" in + */shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;; +esac +[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv" +export ORCA_USER_ZDOTDIR="\${ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}" +case "\${ORCA_USER_ZDOTDIR%/}" in + */shell-ready/zsh) export ORCA_USER_ZDOTDIR="$HOME" ;; +esac +export ZDOTDIR=${quotePosixSingle(zshDir)} +` + const zshProfile = `# Orca relay zsh overlay wrapper +_orca_home="\${ORCA_USER_ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}" +case "\${_orca_home%/}" in + */shell-ready/zsh) _orca_home="$HOME" ;; +esac +[[ -f "$_orca_home/.zprofile" ]] && source "$_orca_home/.zprofile" +` + const zshRc = `# Orca relay zsh overlay wrapper +_orca_home="\${ORCA_USER_ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}" +case "\${_orca_home%/}" in + */shell-ready/zsh) _orca_home="$HOME" ;; +esac +if [[ -o interactive && -f "$_orca_home/.zshrc" ]]; then + source "$_orca_home/.zshrc" +fi +if [[ ! -o login ]]; then + # Why: remote startup files can re-export user defaults after relay spawn. + [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" + [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" +fi +` + const zshLogin = `# Orca relay zsh overlay wrapper +_orca_home="\${ORCA_USER_ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}" +case "\${_orca_home%/}" in + */shell-ready/zsh) _orca_home="$HOME" ;; +esac +if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then + source "$_orca_home/.zlogin" +fi +# Why: .zlogin is the final zsh login startup file before the prompt. +[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" +` + const bashRc = `# Orca relay bash overlay wrapper +[[ -f /etc/profile ]] && source /etc/profile +if [[ -f "$HOME/.bash_profile" ]]; then + source "$HOME/.bash_profile" +elif [[ -f "$HOME/.bash_login" ]]; then + source "$HOME/.bash_login" +elif [[ -f "$HOME/.profile" ]]; then + source "$HOME/.profile" +fi +# Why: remote startup files can re-export user defaults after relay spawn. +[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" +` + + const files = [ + [join(zshDir, '.zshenv'), zshEnv], + [join(zshDir, '.zprofile'), zshProfile], + [join(zshDir, '.zshrc'), zshRc], + [join(zshDir, '.zlogin'), zshLogin], + [join(bashDir, 'rcfile'), bashRc] + ] as const + + for (const [path, content] of files) { + mkdirSync(dirname(path), { recursive: true }) + let existing: string | null = null + try { + existing = readFileSync(path, 'utf8') + } catch { + existing = null + } + // Why: relay wrapper files persist under ~/.orca-relay across app + // upgrades. Existence alone is not enough; stale wrappers would miss + // later fixes such as preserving post-.zshenv ZDOTDIR. + if (existing !== content) { + writeFileSync(path, content, 'utf8') + } + chmodSync(path, 0o644) + } +} + +export function getRelayShellLaunchConfig( + shellPath: string, + env: Record +): RelayShellLaunchConfig { + if (!hasOverlayRestoreEnv(env) || process.platform === 'win32') { + return { args: POSIX_LOGIN_ARGS, env: {} } + } + + const shellName = basename(shellPath).toLowerCase() + if (shellName !== 'zsh' && shellName !== 'bash') { + return { args: POSIX_LOGIN_ARGS, env: {} } + } + + const root = getWrapperRoot(env) + ensureOverlayRestoreWrappers(root) + + if (shellName === 'zsh') { + return { + args: POSIX_LOGIN_ARGS, + env: { + ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(env), + ZDOTDIR: join(root, 'zsh') + } + } + } + + return { + args: ['--rcfile', join(root, 'bash', 'rcfile')], + env: {} + } +} diff --git a/src/relay/relay.ts b/src/relay/relay.ts index df6e075adb7..b1bc2df32a5 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -1,4 +1,9 @@ #!/usr/bin/env node +/* oxlint-disable max-lines -- Why: the relay entry point centralizes process + lifecycle (stdio, --connect bridge, grace timer, signal handlers, socket + server) and handler registration in one file so the boot sequence stays in + topological order. Splitting by line count would scatter ordered side- + effects across modules and obscure the lifecycle. */ // Orca Relay — lightweight daemon deployed to remote hosts. // Communicates over stdin/stdout using the framed JSON-RPC protocol. @@ -24,11 +29,14 @@ import { GitHandler } from './git-handler' import { PreflightHandler } from './preflight-handler' import { PortScanHandler } from './port-scan-handler' import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server' +import { PluginOverlayManager } from './plugin-overlay' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD, AGENT_HOOK_NOTIFICATION_METHOD, AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../shared/agent-hook-relay' +import { assertPluginSourceUnderByteCap } from './plugin-source-limit' +import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env' const DEFAULT_GRACE_MS = 5 * 60 * 1000 const SOCK_NAME = 'relay.sock' @@ -247,26 +255,77 @@ async function main(): Promise { ) } }) - // Why: wait for hook-server startup before the readiness sentinel. A PTY - // spawned before the augmenter exists can never receive ORCA_AGENT_HOOK_* - // later, so success registers the augmenter first; failure is the deliberate - // fail-open path where agent status is disabled for this relay process. + // Why: await the hook-server bind before announcing readiness so the very + // first PTY spawn (which can land within milliseconds of the sentinel) + // already sees populated ORCA_AGENT_HOOK_* env. The bind is a local-loopback + // listen — measured in ms — so the latency cost is trivial and removes a + // class of "first agent invocation has no status" races. Bind failure is + // treated as soft: log and continue, the augmenter returns {} and agent + // status simply does not flow. try { await hookServer.start() - ptyHandler.addEnvAugmenter(() => hookServer.buildPtyEnv()) } catch (err) { process.stderr.write( `[relay] agent-hook server failed to start: ${err instanceof Error ? err.message : String(err)}\n` ) } - // Why: evict the per-pane last-status cache when the backing PTY exits so - // a terminated pane's last working/done payload cannot resurface as a - // ghost event after a later reconnect — see §5 Path 3. - ptyHandler.setExitListener(({ paneKey }) => { + // Why: every relay-spawned PTY needs the live ORCA_AGENT_HOOK_* coords. The + // augmenter is read on every spawn so a hook-server bind that succeeded + // late (or after a stop/start) lands in the next PTY's env without a + // restart. + ptyHandler.addEnvAugmenter(() => hookServer.buildPtyEnv()) + + // Why: per-PTY plugin overlays for OpenCode and Pi. `OPENCODE_CONFIG_DIR` + // and `PI_CODING_AGENT_DIR` only make sense on the relay's own filesystem + // — paths the renderer would synthesize for the Orca host's userData are + // meaningless on the remote. The overlay manager materializes a per-PTY + // dir on the remote (rooted at $HOME/.orca-relay/) so the agent CLI inside + // the relay-spawned PTY loads the bundled status plugin and posts to the + // relay's hook server. Source bodies arrive over JSON-RPC (see + // `agent_hook.installPlugins` below) — not bundled with the relay binary. + const pluginOverlay = new PluginOverlayManager() + ptyHandler.addEnvAugmenter((ctx) => { + const env: Record = {} + // Why: prefer paneKey for overlay identity so a renderer-side remount + // that reuses the paneKey lands in the same overlay dir. Falls back to + // the relay-internal pty-id when paneKey is absent (e.g. CLI-launched + // PTYs that don't go through the renderer). + const overlayId = ctx.paneKey ?? ctx.id + if (pluginOverlay.hasOpenCodeSource()) { + const sourceDir = resolveOpenCodeSourceConfigDir(ctx.env, ctx.shell) + const dir = pluginOverlay.materializeOpenCode(overlayId, sourceDir) + if (dir) { + env.OPENCODE_CONFIG_DIR = dir + env.ORCA_OPENCODE_CONFIG_DIR = dir + if (sourceDir) { + env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = sourceDir + } + } + } + if (pluginOverlay.hasPiSource()) { + const sourceDir = resolvePiSourceAgentDir(ctx.env, ctx.shell) + const dir = pluginOverlay.materializePi(overlayId, sourceDir) + if (dir) { + env.PI_CODING_AGENT_DIR = dir + env.ORCA_PI_CODING_AGENT_DIR = dir + if (sourceDir) { + env.ORCA_PI_SOURCE_AGENT_DIR = sourceDir + } + } + } + return env + }) + + // Why: evict the per-pane last-status cache AND any plugin overlay dirs + // when the backing PTY exits so terminated panes do not (a) resurface as + // ghost events after a later reconnect (§5 Path 3) or (b) leak overlay + // dirs on a long-lived relay. + ptyHandler.setExitListener(({ paneKey, id }) => { if (paneKey) { hookServer.clearPaneState(paneKey) } + pluginOverlay.clearOverlay(paneKey ?? id) }) // Why: request-driven replay. Orca issues this *after* it re-wires the @@ -280,12 +339,31 @@ async function main(): Promise { return { replayed } }) - // Why: stub for the plugin-source sync handler used by OpenCode/Pi. The - // real implementation is wired in commit #7 (deferred to keep this commit - // tight). Stubbed out here as a method-found handler so a probing client - // can detect support without -32601 noise on first connect. - dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async () => { - return { installed: false } + // Why: Orca ships the OpenCode plugin / Pi extension source bodies over + // the wire at session-ready (the renderer's bundled hook-service strings + // change as new agent events are added — pinning them to the relay binary + // would force a relay redeploy on every Orca update). Cache them so each + // subsequent PTY spawn can materialize a per-PTY overlay rooted under + // $HOME/.orca-relay/. See docs/design/agent-status-over-ssh.md §4. + // Why: bound the per-source size so a buggy/hostile Orca can't OOM the + // relay by pushing a giant string. The HTTP path has HOOK_REQUEST_MAX_BYTES + // = 1 MB; the JSON-RPC path needs an equivalent ceiling. Real plugin sources + // are <50 KB today; 256 KB leaves generous headroom. + dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => { + const opencode = params.opencodePluginSource + const pi = params.piExtensionSource + assertPluginSourceUnderByteCap('opencodePluginSource', opencode) + assertPluginSourceUnderByteCap('piExtensionSource', pi) + pluginOverlay.setSources({ + opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, + piExtensionSource: typeof pi === 'string' ? pi : undefined + }) + return { + installed: { + opencode: pluginOverlay.hasOpenCodeSource(), + pi: pluginOverlay.hasPiSource() + } + } }) // ── Socket server for reconnection ────────────────────────────────── @@ -488,7 +566,7 @@ function cleanupSocket(sockPath: string): void { void main().catch((err) => { process.stderr.write( - `[relay] Fatal startup error: ${err instanceof Error ? err.message : String(err)}\n` + `[relay] Fatal startup error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n` ) process.exit(1) }) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index f8469ee723d..f2887b30846 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -42,7 +42,9 @@ const MAX_WARNED_KEYS = 32 export const HOOK_REQUEST_SLOWLORIS_MS = 5_000 /** Bound paneKey size — `${tabId}:${paneId}` is well under 200 chars in - * practice; cap defends per-pane caches against pathological input. */ + * practice; cap defends per-pane caches against pathological input. + * Exported so non-HTTP ingest paths (e.g. Orca's `ingestRemote`) can apply + * the same cap as defense-in-depth. */ export const MAX_PANE_KEY_LEN = 200 /** Per-listener-instance state that holds caches needing per-PTY teardown diff --git a/tests/e2e/ssh-localhost.spec.ts b/tests/e2e/ssh-localhost.spec.ts index 14747355cc0..75aae779999 100644 --- a/tests/e2e/ssh-localhost.spec.ts +++ b/tests/e2e/ssh-localhost.spec.ts @@ -1,5 +1,4 @@ import os from 'os' -import path from 'path' import { test, expect } from './helpers/orca-app' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' @@ -204,27 +203,6 @@ test.describe('Localhost SSH', () => { await execInTerminal(orcaPage, ptyId, emitMarkerCommand(terminalMarker)) await waitForTerminalOutput(orcaPage, terminalMarker, 20_000) - // Why: managed hook scripts intentionally ignore Electron userData so - // dev/prod Orca instances converge on the same installed command. - const codexHookPath = path.join(os.homedir(), '.orca', 'agent-hooks', 'codex-hook.sh') - const quotedCodexHookPath = shellQuote(codexHookPath) - const codexHookStatus = await orcaPage.evaluate(() => window.api.agentHooks.codexStatus()) - expect(codexHookStatus.state).toBe('installed') - const installMarker = marker('CODEX_HOOK_INSTALLED') - const installFailedMarker = marker('CODEX_HOOK_INSTALL_FAILED') - await execInTerminal( - orcaPage, - ptyId, - [ - `if [ -x ${quotedCodexHookPath} ] && grep -F ${quotedCodexHookPath} "$HOME/.codex/hooks.json" >/dev/null 2>&1; then`, - ` ${emitMarkerCommand(installMarker)}`, - 'else', - ` ${emitMarkerCommand(installFailedMarker)}`, - 'fi' - ].join('\n') - ) - await waitForTerminalOutput(orcaPage, installMarker, 20_000) - const envMarker = marker('AGENT_HOOK_ENV_OK') const envFailedMarker = marker('AGENT_HOOK_ENV_BAD') await execInTerminal( @@ -241,25 +219,44 @@ test.describe('Localhost SSH', () => { ) await waitForTerminalOutput(orcaPage, envMarker, 20_000) - const prompt = `orca ssh e2e prompt ${Date.now()}` - const hookPostedMarker = marker('AGENT_HOOK_POSTED') - const hookPayloadFile = `/tmp/orca-e2e-hook-payload-${Date.now()}.json` + const pluginOverlayMarker = marker('AGENT_PLUGIN_OVERLAYS_OK') + const pluginOverlayFailedMarker = marker('AGENT_PLUGIN_OVERLAYS_BAD') await execInTerminal( orcaPage, ptyId, [ - `if [ ! -x ${quotedCodexHookPath} ]; then`, - ' echo __ORCA_CODEX_HOOK_SCRIPT_MISSING__', - 'elif [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + 'opencode_status_file="$OPENCODE_CONFIG_DIR/plugins/orca-opencode-status.js"', + 'pi_status_file="$PI_CODING_AGENT_DIR/extensions/orca-agent-status.ts"', + 'if [ -n "$OPENCODE_CONFIG_DIR" ] && [ -f "$opencode_status_file" ] && [ -n "$PI_CODING_AGENT_DIR" ] && [ -f "$pi_status_file" ]; then', + ` ${emitMarkerCommand(pluginOverlayMarker)}`, + 'else', + ` printf '%s opencode=%s opencode_file=%s pi=%s pi_file=%s\\n' ${shellQuote(pluginOverlayFailedMarker)} "$OPENCODE_CONFIG_DIR" "$opencode_status_file" "$PI_CODING_AGENT_DIR" "$pi_status_file"`, + 'fi' + ].join('\n') + ) + await waitForTerminalOutput(orcaPage, pluginOverlayMarker, 20_000) + + const prompt = `orca ssh e2e prompt ${Date.now()}` + const hookPostedMarker = marker('AGENT_HOOK_POSTED') + await execInTerminal( + orcaPage, + ptyId, + [ + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' echo __ORCA_AGENT_HOOK_ENV_MISSING__', 'else', - ` printf '%s' ${shellQuote( - JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt }) - )} > ${shellQuote(hookPayloadFile)}`, - ` /bin/sh ${quotedCodexHookPath} < ${shellQuote(hookPayloadFile)}`, - ' hook_status=$?', - ` rm -f ${shellQuote(hookPayloadFile)}`, - ` if [ "$hook_status" -eq 0 ]; then ${emitMarkerCommand(hookPostedMarker)}; fi`, + ` hook_payload=${shellQuote(JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt }))}`, + ' if curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload=${hook_payload}" >/dev/null; then', + ` ${emitMarkerCommand(hookPostedMarker)}`, + ' fi', 'fi' ].join('\n') ) From cb58b109e0df43aa590fed44f9d1f430e3fe5c1b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 14 May 2026 17:29:58 -0700 Subject: [PATCH 04/82] feat: add workspace space analyzer (#1877) --- .gitignore | 3 + .../macos-computer-use-permissions.test.ts | 27 + .../macos-computer-use-permissions.ts | 45 +- src/main/ipc/register-core-handlers.test.ts | 12 +- src/main/ipc/register-core-handlers.ts | 2 + src/main/ipc/workspace-space.test.ts | 69 ++ src/main/ipc/workspace-space.ts | 19 + src/main/workspace-space-analysis.test.ts | 239 ++++++ src/main/workspace-space-analysis.ts | 689 ++++++++++++++++++ src/preload/api-types.ts | 4 + src/preload/index.ts | 5 + src/renderer/src/App.tsx | 27 +- .../onboarding-feature-setup.test.ts | 4 + .../components/settings/AppearancePane.tsx | 20 +- .../components/settings/ComputerUsePane.tsx | 27 +- .../sidebar/DeleteWorktreeDialog.tsx | 20 +- .../sidebar/delete-worktree-flow.test.ts | 31 +- .../sidebar/delete-worktree-flow.ts | 53 +- src/renderer/src/components/sidebar/index.tsx | 2 - .../status-bar/ResourceUsageStatusSegment.tsx | 24 +- .../src/components/status-bar/StatusBar.tsx | 2 +- .../status-bar/WorkspaceSpaceCompactPanel.tsx | 92 +++ .../status-bar/WorkspaceSpaceManagerPanel.tsx | 685 +++++++++++++++++ .../status-bar/mergeSnapshotAndSessions.ts | 2 +- .../status-bar/workspace-space-format.test.ts | 21 + .../status-bar/workspace-space-format.ts | 59 ++ .../status-bar/workspace-space-layout.test.ts | 24 + .../status-bar/workspace-space-layout.ts | 124 ++++ .../workspace-space-presentation.test.ts | 79 ++ .../workspace-space-presentation.ts | 78 ++ .../workspace-space/WorkspaceSpacePage.tsx | 59 ++ src/renderer/src/hooks/resolve-zoom-target.ts | 2 +- src/renderer/src/store/index.ts | 2 + .../src/store/slices/diffComments.test.ts | 2 + src/renderer/src/store/slices/memory.ts | 2 +- .../slices/store-session-cascades.test.ts | 2 + .../src/store/slices/store-test-helpers.ts | 2 + src/renderer/src/store/slices/tabs.test.ts | 2 + src/renderer/src/store/slices/ui.test.ts | 30 + src/renderer/src/store/slices/ui.ts | 24 +- .../src/store/slices/workspace-space.ts | 96 +++ src/renderer/src/store/types.ts | 2 + src/shared/computer-use-permissions-types.ts | 2 + src/shared/workspace-space-types.ts | 63 ++ 44 files changed, 2682 insertions(+), 96 deletions(-) create mode 100644 src/main/ipc/workspace-space.test.ts create mode 100644 src/main/ipc/workspace-space.ts create mode 100644 src/main/workspace-space-analysis.test.ts create mode 100644 src/main/workspace-space-analysis.ts create mode 100644 src/renderer/src/components/status-bar/WorkspaceSpaceCompactPanel.tsx create mode 100644 src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx create mode 100644 src/renderer/src/components/status-bar/workspace-space-format.test.ts create mode 100644 src/renderer/src/components/status-bar/workspace-space-format.ts create mode 100644 src/renderer/src/components/status-bar/workspace-space-layout.test.ts create mode 100644 src/renderer/src/components/status-bar/workspace-space-layout.ts create mode 100644 src/renderer/src/components/status-bar/workspace-space-presentation.test.ts create mode 100644 src/renderer/src/components/status-bar/workspace-space-presentation.ts create mode 100644 src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx create mode 100644 src/renderer/src/store/slices/workspace-space.ts create mode 100644 src/shared/workspace-space-types.ts diff --git a/.gitignore b/.gitignore index 28e3868ff1c..2afd28d2a43 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,6 @@ playwright-report/ /.claude/skills/ /.agents/skills/ /skills-lock.json + +# Agent hook runtime endpoints (machine-local secrets) +/agent-hooks/ diff --git a/src/main/computer/macos-computer-use-permissions.test.ts b/src/main/computer/macos-computer-use-permissions.test.ts index 8345965c056..2f78b3cab23 100644 --- a/src/main/computer/macos-computer-use-permissions.test.ts +++ b/src/main/computer/macos-computer-use-permissions.test.ts @@ -135,6 +135,15 @@ describe('openComputerUsePermissions', () => { expect(() => openComputerUsePermissions()).toThrow('Orca Computer Use.app was not found') }) + it('throws when the helper executable is missing during setup', () => { + resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') + resolveHelperExecutablePathMock.mockReturnValue(null) + + expect(() => openComputerUsePermissions('accessibility')).toThrow( + '/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos was not found' + ) + }) + it('reads permission status through the helper app executable', async () => { const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') @@ -142,6 +151,8 @@ describe('openComputerUsePermissions', () => { expect(getComputerUsePermissionStatus()).toEqual({ platform: 'darwin', + helperAppPath: '/Applications/Orca Computer Use.app', + helperUnavailableReason: null, permissions: [ { id: 'accessibility', status: 'granted' }, { id: 'screenshots', status: 'not-granted' } @@ -153,6 +164,22 @@ describe('openComputerUsePermissions', () => { { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] } ) }) + + it('returns unavailable permission status when the helper app is missing on macOS', async () => { + const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') + resolveHelperAppPathMock.mockReturnValue(null) + + expect(getComputerUsePermissionStatus()).toEqual({ + platform: 'darwin', + helperAppPath: null, + helperUnavailableReason: 'Orca Computer Use.app was not found', + permissions: [ + { id: 'accessibility', status: 'not-granted' }, + { id: 'screenshots', status: 'not-granted' } + ] + }) + expect(execFileSync).not.toHaveBeenCalled() + }) }) function mockPermissionStatus(json: string): void { diff --git a/src/main/computer/macos-computer-use-permissions.ts b/src/main/computer/macos-computer-use-permissions.ts index 3de460eb29b..9ec40ef2d7d 100644 --- a/src/main/computer/macos-computer-use-permissions.ts +++ b/src/main/computer/macos-computer-use-permissions.ts @@ -34,6 +34,9 @@ export function openComputerUsePermissions( throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found') } const status = getComputerUsePermissionStatus() + if (status.helperUnavailableReason) { + throw new RuntimeClientError('accessibility_error', status.helperUnavailableReason) + } const nextStep = nextPermissionStep(status.permissions) if (!permissionId && !nextStep) { @@ -80,6 +83,8 @@ export function getComputerUsePermissionStatus(): ComputerUsePermissionStatusRes if (process.platform !== 'darwin') { return { platform: process.platform, + helperAppPath: null, + helperUnavailableReason: null, permissions: [ { id: 'accessibility', status: 'unsupported' }, { id: 'screenshots', status: 'unsupported' } @@ -89,13 +94,23 @@ export function getComputerUsePermissionStatus(): ComputerUsePermissionStatusRes const helperAppPath = resolveMacOSComputerUseAppPath() if (!helperAppPath) { - throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found') + return createUnavailablePermissionStatus('Orca Computer Use.app was not found', null) } - const raw = readPermissionStatusFromHelperApp(helperAppPath) + const executablePath = resolveMacOSComputerUseExecutablePath() + if (!executablePath) { + return createUnavailablePermissionStatus( + `${helperAppPath}/Contents/MacOS/orca-computer-use-macos was not found`, + helperAppPath + ) + } + + const raw = readPermissionStatusFromHelperExecutable(executablePath) return { platform: process.platform, + helperAppPath, + helperUnavailableReason: null, permissions: [ { id: 'accessibility', status: raw.accessibility ?? 'not-granted' }, { id: 'screenshots', status: raw.screenshots ?? 'not-granted' } @@ -103,16 +118,24 @@ export function getComputerUsePermissionStatus(): ComputerUsePermissionStatusRes } } -function readPermissionStatusFromHelperApp( - helperAppPath: string -): Partial> { - const executablePath = resolveMacOSComputerUseExecutablePath() - if (!executablePath) { - throw new RuntimeClientError( - 'accessibility_error', - `${helperAppPath}/Contents/MacOS/orca-computer-use-macos was not found` - ) +function createUnavailablePermissionStatus( + reason: string, + helperAppPath: string | null +): ComputerUsePermissionStatusResult { + return { + platform: process.platform, + helperAppPath, + helperUnavailableReason: reason, + permissions: [ + { id: 'accessibility', status: 'not-granted' }, + { id: 'screenshots', status: 'not-granted' } + ] } +} + +function readPermissionStatusFromHelperExecutable( + executablePath: string +): Partial> { // Why: launching the nested helper via LaunchServices can make TCC evaluate // Orca.app as responsible; the signed helper executable owns this grant. const output = execFileSync(executablePath, ['--permission-status'], { diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 94897ad71dd..eb6fdd43d6a 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -41,7 +41,8 @@ const { registerHostedReviewHandlersMock, registerExportHandlersMock, registerOnboardingHandlersMock, - registerSpeechHandlersMock + registerSpeechHandlersMock, + registerWorkspaceSpaceHandlersMock } = vi.hoisted(() => ({ registerCliHandlersMock: vi.fn(), registerPreflightHandlersMock: vi.fn(), @@ -81,7 +82,8 @@ const { registerHostedReviewHandlersMock: vi.fn(), registerExportHandlersMock: vi.fn(), registerOnboardingHandlersMock: vi.fn(), - registerSpeechHandlersMock: vi.fn() + registerSpeechHandlersMock: vi.fn(), + registerWorkspaceSpaceHandlersMock: vi.fn() })) vi.mock('./onboarding', () => ({ @@ -152,6 +154,10 @@ vi.mock('./settings', () => ({ registerSettingsHandlers: registerSettingsHandlersMock })) +vi.mock('./workspace-space', () => ({ + registerWorkspaceSpaceHandlers: registerWorkspaceSpaceHandlersMock +})) + vi.mock('./telemetry', () => ({ registerTelemetryHandlers: registerTelemetryHandlersMock })) @@ -273,6 +279,7 @@ describe('registerCoreHandlers', () => { registerHostedReviewHandlersMock.mockReset() registerExportHandlersMock.mockReset() registerSpeechHandlersMock.mockReset() + registerWorkspaceSpaceHandlersMock.mockReset() }) it('passes the store through to handler registrars that need it', () => { @@ -315,6 +322,7 @@ describe('registerCoreHandlers', () => { expect(registerDeveloperPermissionHandlersMock).toHaveBeenCalled() expect(registerComputerUsePermissionHandlersMock).toHaveBeenCalled() expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store) + expect(registerWorkspaceSpaceHandlersMock).toHaveBeenCalledWith(store) expect(registerTelemetryHandlersMock).toHaveBeenCalledWith(store) expect(registerSessionHandlersMock).toHaveBeenCalledWith(store) expect(registerUIHandlersMock).toHaveBeenCalledWith(store) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index ca013544ad9..e445a8e659f 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -27,6 +27,7 @@ import { registerComputerUsePermissionHandlers } from './computer-use-permission import { setTrustedBrowserRendererWebContentsId, setAgentBrowserBridgeRef } from './browser' import { registerSessionHandlers } from './session' import { registerSettingsHandlers } from './settings' +import { registerWorkspaceSpaceHandlers } from './workspace-space' import { registerAutomationHandlers } from './automations' import { registerTelemetryHandlers } from './telemetry' import { registerBrowserHandlers } from './browser' @@ -115,6 +116,7 @@ export function registerCoreHandlers( registerPetHandlers() registerSessionHandlers(store) registerUIHandlers(store) + registerWorkspaceSpaceHandlers(store) registerFilesystemHandlers(store) registerFilesystemWatcherHandlers() registerRuntimeHandlers(runtime) diff --git a/src/main/ipc/workspace-space.test.ts b/src/main/ipc/workspace-space.test.ts new file mode 100644 index 00000000000..24f590786d8 --- /dev/null +++ b/src/main/ipc/workspace-space.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest' +import type { WorkspaceSpaceAnalysis } from '../../shared/workspace-space-types' +import type { Store } from '../persistence' + +const { handlers, analyzeWorkspaceSpaceMock, removeHandlerMock, handleMock } = vi.hoisted(() => ({ + handlers: new Map Promise>(), + analyzeWorkspaceSpaceMock: vi.fn(), + removeHandlerMock: vi.fn(), + handleMock: vi.fn((channel: string, handler: () => Promise) => { + handlers.set(channel, handler) + }) +})) + +vi.mock('electron', () => ({ + ipcMain: { + removeHandler: removeHandlerMock, + handle: handleMock + } +})) + +vi.mock('../workspace-space-analysis', () => ({ + analyzeWorkspaceSpace: analyzeWorkspaceSpaceMock +})) + +import { registerWorkspaceSpaceHandlers } from './workspace-space' + +function createAnalysis(scannedAt: number): WorkspaceSpaceAnalysis { + return { + scannedAt, + totalSizeBytes: 0, + reclaimableBytes: 0, + worktreeCount: 0, + scannedWorktreeCount: 0, + unavailableWorktreeCount: 0, + repos: [], + worktrees: [] + } +} + +describe('registerWorkspaceSpaceHandlers', () => { + it('shares an in-flight analysis request', async () => { + const store = {} as Store + let resolveFirstScan: (analysis: WorkspaceSpaceAnalysis) => void = () => {} + const firstScan = new Promise((resolve) => { + resolveFirstScan = resolve + }) + const secondScan = Promise.resolve(createAnalysis(2)) + analyzeWorkspaceSpaceMock.mockReturnValueOnce(firstScan).mockReturnValueOnce(secondScan) + + registerWorkspaceSpaceHandlers(store) + expect(removeHandlerMock).toHaveBeenCalledWith('workspaceSpace:analyze') + + const handler = handlers.get('workspaceSpace:analyze') + expect(handler).toBeDefined() + + const first = handler!() + const duplicate = handler!() + expect(analyzeWorkspaceSpaceMock).toHaveBeenCalledTimes(1) + expect(analyzeWorkspaceSpaceMock).toHaveBeenCalledWith(store) + + const firstResult = createAnalysis(1) + resolveFirstScan(firstResult) + await expect(first).resolves.toBe(firstResult) + await expect(duplicate).resolves.toBe(firstResult) + + await expect(handler!()).resolves.toEqual(createAnalysis(2)) + expect(analyzeWorkspaceSpaceMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/ipc/workspace-space.ts b/src/main/ipc/workspace-space.ts new file mode 100644 index 00000000000..8302503bdf8 --- /dev/null +++ b/src/main/ipc/workspace-space.ts @@ -0,0 +1,19 @@ +import { ipcMain } from 'electron' +import type { Store } from '../persistence' +import type { WorkspaceSpaceAnalysis } from '../../shared/workspace-space-types' +import { analyzeWorkspaceSpace } from '../workspace-space-analysis' + +export function registerWorkspaceSpaceHandlers(store: Store): void { + let inFlightScan: Promise | null = null + ipcMain.removeHandler('workspaceSpace:analyze') + ipcMain.handle('workspaceSpace:analyze', async (): Promise => { + if (!inFlightScan) { + // Why: large worktree fleets require real disk traversal; duplicate + // requests should share that IO instead of starting competing scans. + inFlightScan = analyzeWorkspaceSpace(store).finally(() => { + inFlightScan = null + }) + } + return inFlightScan + }) +} diff --git a/src/main/workspace-space-analysis.test.ts b/src/main/workspace-space-analysis.test.ts new file mode 100644 index 00000000000..041219501c8 --- /dev/null +++ b/src/main/workspace-space-analysis.test.ts @@ -0,0 +1,239 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../shared/types' +import type { Store } from './persistence' + +const { listRepoWorktreesMock, getSshFilesystemProviderMock, getSshGitProviderMock } = vi.hoisted( + () => ({ + listRepoWorktreesMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn(), + getSshGitProviderMock: vi.fn() + }) +) + +vi.mock('./repo-worktrees', () => ({ + createFolderWorktree: (repo: Repo) => ({ + path: repo.path, + head: '', + branch: '', + isBare: false, + isMainWorktree: true + }), + listRepoWorktrees: listRepoWorktreesMock +})) + +vi.mock('./providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getSshFilesystemProviderMock +})) + +vi.mock('./providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock +})) + +import { analyzeWorkspaceSpace } from './workspace-space-analysis' + +function createStore(repos: Repo[]): Store { + return { + getRepos: () => repos, + getWorktreeMeta: (worktreeId: string) => { + if (worktreeId.endsWith('feature')) { + return { displayName: 'Feature Workspace', lastActivityAt: 200 } + } + return undefined + } + } as Store +} + +async function writeSizedFile(filePath: string, size: number): Promise { + await writeFile(filePath, Buffer.alloc(size, 1)) +} + +describe('analyzeWorkspaceSpace', () => { + let tempDir: string | null = null + + beforeEach(async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-14T12:00:00Z')) + tempDir = await mkdtemp(join(tmpdir(), 'orca-space-')) + listRepoWorktreesMock.mockReset() + getSshFilesystemProviderMock.mockReset() + getSshGitProviderMock.mockReset() + }) + + afterEach(async () => { + vi.useRealTimers() + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }) + tempDir = null + } + }) + + it('scans local worktrees and counts only linked worktrees as reclaimable', async () => { + const root = tempDir! + const mainPath = join(root, 'repo') + const featurePath = join(root, 'feature') + await mkdir(join(mainPath, 'src'), { recursive: true }) + await mkdir(join(featurePath, 'node_modules'), { recursive: true }) + await mkdir(join(featurePath, 'src'), { recursive: true }) + await writeSizedFile(join(mainPath, 'src', 'main.ts'), 256) + await writeSizedFile(join(featurePath, 'node_modules', 'pkg.js'), 2048) + await writeSizedFile(join(featurePath, 'src', 'feature.ts'), 512) + + const repo: Repo = { + id: 'repo-1', + path: mainPath, + displayName: 'orca', + badgeColor: '#000', + addedAt: 0 + } + listRepoWorktreesMock.mockResolvedValue([ + { + path: mainPath, + head: 'a', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: featurePath, + head: 'b', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await analyzeWorkspaceSpace(createStore([repo])) + const main = result.worktrees.find((row) => row.path === mainPath) + const feature = result.worktrees.find((row) => row.path === featurePath) + + expect(result.scannedAt).toBe(Date.parse('2026-05-14T12:00:00Z')) + expect(result.worktreeCount).toBe(2) + expect(main?.status).toBe('ok') + expect(main?.canDelete).toBe(false) + expect(main?.reclaimableBytes).toBe(0) + expect(feature?.status).toBe('ok') + expect(feature?.displayName).toBe('Feature Workspace') + expect(feature?.canDelete).toBe(true) + expect(feature?.sizeBytes).toBeGreaterThanOrEqual(2048 + 512) + expect(feature?.reclaimableBytes).toBe(feature?.sizeBytes) + expect(feature?.topLevelItems[0]?.name).toBe('node_modules') + expect(result.reclaimableBytes).toBe(feature?.sizeBytes) + }) + + it('isolates missing worktrees as row-level scan failures', async () => { + const root = tempDir! + const repoPath = join(root, 'repo') + const missingPath = join(root, 'missing') + await mkdir(repoPath, { recursive: true }) + + const repo: Repo = { + id: 'repo-1', + path: repoPath, + displayName: 'orca', + badgeColor: '#000', + addedAt: 0 + } + listRepoWorktreesMock.mockResolvedValue([ + { + path: missingPath, + head: 'b', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await analyzeWorkspaceSpace(createStore([repo])) + + expect(result.worktreeCount).toBe(1) + expect(result.scannedWorktreeCount).toBe(0) + expect(result.unavailableWorktreeCount).toBe(1) + expect(result.worktrees[0]?.status).toBe('missing') + expect(result.worktrees[0]?.sizeBytes).toBe(0) + }) + + it('scans SSH worktrees through the remote filesystem provider without following symlinks', async () => { + const repo: Repo = { + id: 'repo-remote', + path: '/remote/repo', + displayName: 'remote', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + getSshGitProviderMock.mockReturnValue({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/feature', + head: 'c', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + }) + + const statByPath = new Map([ + ['/remote/feature', { size: 10, type: 'directory' as const, mtime: 0 }], + ['/remote/feature/node_modules', { size: 5, type: 'directory' as const, mtime: 0 }], + ['/remote/feature/node_modules/pkg.js', { size: 1000, type: 'file' as const, mtime: 0 }], + ['/remote/feature/file.log', { size: 200, type: 'file' as const, mtime: 0 }] + ]) + const readDirMock = vi.fn(async (dirPath: string) => { + if (dirPath === '/remote/feature') { + return [ + { name: 'node_modules', isDirectory: true, isSymlink: false }, + { name: 'linked-cache', isDirectory: true, isSymlink: true }, + { name: 'file.log', isDirectory: false, isSymlink: false } + ] + } + if (dirPath === '/remote/feature/node_modules') { + return [{ name: 'pkg.js', isDirectory: false, isSymlink: false }] + } + return [] + }) + const statMock = vi.fn(async (filePath: string) => { + const stat = statByPath.get(filePath) + if (!stat) { + throw Object.assign(new Error(`missing ${filePath}`), { code: 'ENOENT' }) + } + return stat + }) + getSshFilesystemProviderMock.mockReturnValue({ + readDir: readDirMock, + stat: statMock + }) + + const result = await analyzeWorkspaceSpace(createStore([repo])) + + expect(result.worktrees[0]?.status).toBe('ok') + expect(result.worktrees[0]?.sizeBytes).toBe(1215) + expect(result.worktrees[0]?.topLevelItems.map((item) => item.name)).toEqual([ + 'node_modules', + 'file.log', + 'linked-cache' + ]) + expect(statMock).not.toHaveBeenCalledWith('/remote/feature/linked-cache') + }) + + it('reports disconnected SSH repos without failing the whole analysis', async () => { + const repo: Repo = { + id: 'repo-remote', + path: '/remote/repo', + displayName: 'remote', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + getSshGitProviderMock.mockReturnValue(undefined) + + const result = await analyzeWorkspaceSpace(createStore([repo])) + + expect(result.worktrees).toEqual([]) + expect(result.repos[0]?.error).toContain('not connected') + expect(result.unavailableWorktreeCount).toBe(1) + }) +}) diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts new file mode 100644 index 00000000000..950f05c78bb --- /dev/null +++ b/src/main/workspace-space-analysis.ts @@ -0,0 +1,689 @@ +/* eslint-disable max-lines -- Why: this module keeps local and SSH directory-walk + semantics paired so reclaimable-byte, symlink, and partial-failure behavior cannot drift. */ +import { lstat, readdir } from 'node:fs/promises' +import { execFile } from 'node:child_process' +import { basename, posix, win32 } from 'node:path' +import { platform } from 'node:process' +import { promisify } from 'node:util' +import type { Dirent } from 'node:fs' +import type { Store } from './persistence' +import { isFolderRepo } from '../shared/repo-kind' +import type { GitWorktreeInfo, Repo, Worktree } from '../shared/types' +import type { + WorkspaceSpaceAnalysis, + WorkspaceSpaceItem, + WorkspaceSpaceItemKind, + WorkspaceSpaceRepoSummary, + WorkspaceSpaceScanStatus, + WorkspaceSpaceWorktree +} from '../shared/workspace-space-types' +import type { IFilesystemProvider } from './providers/types' +import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' +import { getSshGitProvider } from './providers/ssh-git-dispatch' +import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees' +import { mergeWorktree } from './ipc/worktree-logic' + +const MAX_TOP_LEVEL_ITEMS = 48 +const WORKTREE_SCAN_CONCURRENCY = 3 +const LOCAL_FS_CONCURRENCY = 48 +const REMOTE_FS_CONCURRENCY = 10 +const DU_TIMEOUT_MS = 120_000 +const DU_MAX_BUFFER_BYTES = 16 * 1024 * 1024 +const execFileAsync = promisify(execFile) + +type AsyncLimiter = (task: () => Promise) => Promise + +type ScanStats = { + name: string + path: string + kind: WorkspaceSpaceItemKind + sizeBytes: number + skippedEntryCount: number + children?: ScanStats[] +} + +type WorktreeListResult = + | { ok: true; worktrees: GitWorktreeInfo[] } + | { ok: false; status: Exclude; error: string } + +type RepoScanResult = { + summary: WorkspaceSpaceRepoSummary + worktrees: WorkspaceSpaceWorktree[] +} + +function createAsyncLimiter(maxConcurrent: number): AsyncLimiter { + let active = 0 + const queue: (() => void)[] = [] + + const acquire = async (): Promise => { + if (active < maxConcurrent) { + active += 1 + return + } + await new Promise((resolve) => queue.push(resolve)) + active += 1 + } + + return async (task: () => Promise): Promise => { + await acquire() + try { + return await task() + } finally { + active -= 1 + const next = queue.shift() + if (next) { + next() + } + } + } +} + +async function mapLimit( + items: readonly T[], + maxConcurrent: number, + mapper: (item: T) => Promise +): Promise { + const limit = createAsyncLimiter(maxConcurrent) + return Promise.all(items.map((item) => limit(() => mapper(item)))) +} + +function looksLikeWindowsPath(pathValue: string): boolean { + return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') +} + +function joinFilesystemPath(parent: string, child: string): string { + return looksLikeWindowsPath(parent) ? win32.join(parent, child) : posix.join(parent, child) +} + +function normalizeLocalDuPath(pathValue: string): string { + const trimmed = pathValue.replace(/[\\/]+$/, '') + return trimmed.length > 0 ? trimmed : pathValue +} + +function parseDuDepthOneOutput(stdout: string): Map { + const sizes = new Map() + for (const line of stdout.split('\n')) { + const trimmed = line.trim() + if (!trimmed) { + continue + } + const match = /^(\d+)\s+(.+)$/.exec(trimmed) + if (!match) { + continue + } + sizes.set(normalizeLocalDuPath(match[2]), Number(match[1]) * 1024) + } + return sizes +} + +async function readLocalDuDepthOne(rootPath: string): Promise> { + const { stdout } = await execFileAsync('du', ['-k', '-d', '1', rootPath], { + encoding: 'utf8', + maxBuffer: DU_MAX_BUFFER_BYTES, + timeout: DU_TIMEOUT_MS + }) + return parseDuDepthOneOutput(stdout) +} + +function classifyError(error: unknown): { + status: Exclude + message: string +} { + const code = + error && typeof error === 'object' && 'code' in error + ? String((error as { code?: unknown }).code) + : '' + const message = error instanceof Error ? error.message : String(error) + + if (code === 'ENOENT' || code === 'ENOTDIR') { + return { status: 'missing', message } + } + if (code === 'EACCES' || code === 'EPERM') { + return { status: 'permission-denied', message } + } + return { status: 'error', message } +} + +function toWorkspaceSpaceItem(stats: ScanStats): WorkspaceSpaceItem { + return { + name: stats.name, + path: stats.path, + kind: stats.kind, + sizeBytes: stats.sizeBytes + } +} + +function compactTopLevelItems(items: WorkspaceSpaceItem[]): { + topLevelItems: WorkspaceSpaceItem[] + omittedTopLevelItemCount: number + omittedTopLevelSizeBytes: number +} { + const sorted = [...items].sort( + (a, b) => b.sizeBytes - a.sizeBytes || a.name.localeCompare(b.name) + ) + if (sorted.length <= MAX_TOP_LEVEL_ITEMS) { + return { + topLevelItems: sorted, + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0 + } + } + + const visible = sorted.slice(0, MAX_TOP_LEVEL_ITEMS - 1) + const omitted = sorted.slice(MAX_TOP_LEVEL_ITEMS - 1) + const other = omitted.reduce( + (acc, item) => ({ + ...acc, + sizeBytes: acc.sizeBytes + item.sizeBytes + }), + { + name: 'Other', + path: '', + kind: 'other', + sizeBytes: 0 + } + ) + + return { + topLevelItems: [...visible, other], + omittedTopLevelItemCount: omitted.length, + omittedTopLevelSizeBytes: other.sizeBytes + } +} + +function createBaseWorktreeRow( + repo: Repo, + worktree: Worktree, + scannedAt: number +): Omit< + WorkspaceSpaceWorktree, + | 'status' + | 'error' + | 'sizeBytes' + | 'reclaimableBytes' + | 'skippedEntryCount' + | 'topLevelItems' + | 'omittedTopLevelItemCount' + | 'omittedTopLevelSizeBytes' +> { + const canDelete = !worktree.isMainWorktree + return { + worktreeId: worktree.id, + repoId: repo.id, + repoDisplayName: repo.displayName, + repoPath: repo.path, + displayName: worktree.displayName, + path: worktree.path, + branch: worktree.branch, + isMainWorktree: worktree.isMainWorktree, + isRemote: Boolean(repo.connectionId), + isSparse: worktree.isSparse === true, + canDelete, + lastActivityAt: worktree.lastActivityAt, + scannedAt + } +} + +function createUnavailableWorktreeRow( + repo: Repo, + worktree: Worktree, + scannedAt: number, + status: Exclude, + error: string +): WorkspaceSpaceWorktree { + return { + ...createBaseWorktreeRow(repo, worktree, scannedAt), + status, + error, + sizeBytes: 0, + reclaimableBytes: 0, + skippedEntryCount: 0, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0 + } +} + +async function scanLocalEntry( + entryPath: string, + name: string, + limit: AsyncLimiter +): Promise { + const stats = await limit(() => lstat(entryPath)) + + if (stats.isSymbolicLink()) { + return { + name, + path: entryPath, + kind: 'symlink', + // Why: symlink targets may be shared outside the worktree. Counting the + // link itself reflects what deleting this worktree can actually reclaim. + sizeBytes: stats.size, + skippedEntryCount: 0 + } + } + + if (!stats.isDirectory()) { + return { + name, + path: entryPath, + kind: 'file', + sizeBytes: stats.size, + skippedEntryCount: 0 + } + } + + let entries: Dirent[] + try { + entries = await limit(() => readdir(entryPath, { withFileTypes: true })) + } catch { + return { + name, + path: entryPath, + kind: 'directory', + sizeBytes: stats.size, + skippedEntryCount: 1 + } + } + + const childStats = await Promise.all( + entries.map(async (entry): Promise => { + try { + return await scanLocalEntry(joinFilesystemPath(entryPath, entry.name), entry.name, limit) + } catch { + return null + } + }) + ) + + let sizeBytes = stats.size + let skippedEntryCount = 0 + for (const child of childStats) { + if (!child) { + skippedEntryCount += 1 + continue + } + sizeBytes += child.sizeBytes + skippedEntryCount += child.skippedEntryCount + } + + return { + name, + path: entryPath, + kind: 'directory', + sizeBytes, + skippedEntryCount, + children: childStats.filter((child): child is ScanStats => child !== null) + } +} + +async function scanRemoteEntry( + entryPath: string, + name: string, + provider: IFilesystemProvider, + limit: AsyncLimiter, + knownSymlink = false +): Promise { + if (knownSymlink) { + return { + name, + path: entryPath, + kind: 'symlink', + sizeBytes: 0, + skippedEntryCount: 0 + } + } + + const stats = await limit(() => provider.stat(entryPath)) + if (stats.type === 'symlink') { + return { + name, + path: entryPath, + kind: 'symlink', + sizeBytes: stats.size, + skippedEntryCount: 0 + } + } + + if (stats.type !== 'directory') { + return { + name, + path: entryPath, + kind: 'file', + sizeBytes: stats.size, + skippedEntryCount: 0 + } + } + + let entries + try { + entries = await limit(() => provider.readDir(entryPath)) + } catch { + return { + name, + path: entryPath, + kind: 'directory', + sizeBytes: stats.size, + skippedEntryCount: 1 + } + } + + const childStats = await Promise.all( + entries.map(async (entry): Promise => { + try { + return await scanRemoteEntry( + joinFilesystemPath(entryPath, entry.name), + entry.name, + provider, + limit, + entry.isSymlink + ) + } catch { + return null + } + }) + ) + + let sizeBytes = stats.size + let skippedEntryCount = 0 + for (const child of childStats) { + if (!child) { + skippedEntryCount += 1 + continue + } + sizeBytes += child.sizeBytes + skippedEntryCount += child.skippedEntryCount + } + + return { + name, + path: entryPath, + kind: 'directory', + sizeBytes, + skippedEntryCount, + children: childStats.filter((child): child is ScanStats => child !== null) + } +} + +async function scanLocalTopLevelEntry( + entryPath: string, + name: string, + duSizes: Map, + limit: AsyncLimiter +): Promise { + const stats = await limit(() => lstat(entryPath)) + + if (stats.isSymbolicLink()) { + return { + name, + path: entryPath, + kind: 'symlink', + sizeBytes: stats.size, + skippedEntryCount: 0 + } + } + + if (!stats.isDirectory()) { + return { + name, + path: entryPath, + kind: 'file', + sizeBytes: stats.size, + skippedEntryCount: 0 + } + } + + return { + name, + path: entryPath, + kind: 'directory', + sizeBytes: duSizes.get(normalizeLocalDuPath(entryPath)) ?? stats.size, + skippedEntryCount: 0 + } +} + +async function scanLocalWorktreeWithDu( + repo: Repo, + worktree: Worktree, + scannedAt: number +): Promise { + const rootStats = await lstat(worktree.path) + if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) { + const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY) + const root = await scanLocalEntry(worktree.path, basename(worktree.path), limit) + const compact = compactTopLevelItems((root.children ?? []).map(toWorkspaceSpaceItem)) + return { + ...createBaseWorktreeRow(repo, worktree, scannedAt), + status: 'ok', + error: null, + sizeBytes: root.sizeBytes, + reclaimableBytes: worktree.isMainWorktree ? 0 : root.sizeBytes, + skippedEntryCount: root.skippedEntryCount, + ...compact + } + } + + const [entries, duSizes] = await Promise.all([ + readdir(worktree.path, { withFileTypes: true }), + readLocalDuDepthOne(worktree.path) + ]) + const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY) + const childStats = await Promise.all( + entries.map(async (entry): Promise => { + try { + return await scanLocalTopLevelEntry( + joinFilesystemPath(worktree.path, entry.name), + entry.name, + duSizes, + limit + ) + } catch { + return null + } + }) + ) + const children = childStats.filter((child): child is ScanStats => child !== null) + const skippedEntryCount = childStats.length - children.length + const rootSize = + duSizes.get(normalizeLocalDuPath(worktree.path)) ?? + rootStats.size + children.reduce((sum, child) => sum + child.sizeBytes, 0) + const compact = compactTopLevelItems(children.map(toWorkspaceSpaceItem)) + + return { + ...createBaseWorktreeRow(repo, worktree, scannedAt), + status: 'ok', + error: null, + sizeBytes: rootSize, + reclaimableBytes: worktree.isMainWorktree ? 0 : rootSize, + skippedEntryCount, + ...compact + } +} + +async function scanLocalWorktreeWithNode( + repo: Repo, + worktree: Worktree, + scannedAt: number +): Promise { + try { + const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY) + const root = await scanLocalEntry(worktree.path, basename(worktree.path), limit) + const compact = compactTopLevelItems((root.children ?? []).map(toWorkspaceSpaceItem)) + return { + ...createBaseWorktreeRow(repo, worktree, scannedAt), + status: 'ok', + error: null, + sizeBytes: root.sizeBytes, + reclaimableBytes: worktree.isMainWorktree ? 0 : root.sizeBytes, + skippedEntryCount: root.skippedEntryCount, + ...compact + } + } catch (error) { + const classified = classifyError(error) + return createUnavailableWorktreeRow( + repo, + worktree, + scannedAt, + classified.status, + classified.message + ) + } +} + +async function scanLocalWorktree( + repo: Repo, + worktree: Worktree, + scannedAt: number +): Promise { + if (platform !== 'win32') { + try { + // Why: JS per-file stats are too slow for large local workspace fleets; + // POSIX du gives bounded top-level sizing without following symlinks. + return await scanLocalWorktreeWithDu(repo, worktree, scannedAt) + } catch { + // Fall through to the portable scanner so unsupported du variants or + // permission edge cases still produce partial rows instead of failing. + } + } + return scanLocalWorktreeWithNode(repo, worktree, scannedAt) +} + +async function scanRemoteWorktree( + repo: Repo, + worktree: Worktree, + scannedAt: number, + provider: IFilesystemProvider +): Promise { + try { + const limit = createAsyncLimiter(REMOTE_FS_CONCURRENCY) + const root = await scanRemoteEntry(worktree.path, basename(worktree.path), provider, limit) + const compact = compactTopLevelItems((root.children ?? []).map(toWorkspaceSpaceItem)) + return { + ...createBaseWorktreeRow(repo, worktree, scannedAt), + status: 'ok', + error: null, + sizeBytes: root.sizeBytes, + reclaimableBytes: worktree.isMainWorktree ? 0 : root.sizeBytes, + skippedEntryCount: root.skippedEntryCount, + ...compact + } + } catch (error) { + const classified = classifyError(error) + return createUnavailableWorktreeRow( + repo, + worktree, + scannedAt, + classified.status, + classified.message + ) + } +} + +async function listWorktreesForSpaceScan(repo: Repo): Promise { + try { + if (isFolderRepo(repo)) { + return { ok: true, worktrees: [createFolderWorktree(repo)] } + } + if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + return { + ok: false, + status: 'unavailable', + error: `SSH connection "${repo.connectionId}" is not connected.` + } + } + return { ok: true, worktrees: await provider.listWorktrees(repo.path) } + } + return { ok: true, worktrees: await listRepoWorktrees(repo) } + } catch (error) { + const classified = classifyError(error) + return { ok: false, status: classified.status, error: classified.message } + } +} + +function mergeForSpaceScan(repo: Repo, gitWorktree: GitWorktreeInfo, store: Store): Worktree { + const worktreeId = `${repo.id}::${gitWorktree.path}` + return mergeWorktree(repo.id, gitWorktree, store.getWorktreeMeta(worktreeId), repo.displayName) +} + +async function scanRepo(repo: Repo, scannedAt: number, store: Store): Promise { + const listed = await listWorktreesForSpaceScan(repo) + if (!listed.ok) { + return { + worktrees: [], + summary: { + repoId: repo.id, + displayName: repo.displayName, + path: repo.path, + isRemote: Boolean(repo.connectionId), + worktreeCount: 0, + scannedWorktreeCount: 0, + unavailableWorktreeCount: 1, + totalSizeBytes: 0, + reclaimableBytes: 0, + error: listed.error + } + } + } + + const worktrees = listed.worktrees.map((gitWorktree) => + mergeForSpaceScan(repo, gitWorktree, store) + ) + const remoteProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : undefined + const rows = await mapLimit(worktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => { + if (repo.connectionId) { + if (!remoteProvider) { + return createUnavailableWorktreeRow( + repo, + worktree, + scannedAt, + 'unavailable', + `SSH filesystem for "${repo.connectionId}" is not connected.` + ) + } + return scanRemoteWorktree(repo, worktree, scannedAt, remoteProvider) + } + return scanLocalWorktree(repo, worktree, scannedAt) + }) + + return { + worktrees: rows, + summary: { + repoId: repo.id, + displayName: repo.displayName, + path: repo.path, + isRemote: Boolean(repo.connectionId), + worktreeCount: rows.length, + scannedWorktreeCount: rows.filter((row) => row.status === 'ok').length, + unavailableWorktreeCount: rows.filter((row) => row.status !== 'ok').length, + totalSizeBytes: rows.reduce((sum, row) => sum + row.sizeBytes, 0), + reclaimableBytes: rows.reduce((sum, row) => sum + row.reclaimableBytes, 0), + error: null + } + } +} + +export async function analyzeWorkspaceSpace(store: Store): Promise { + const scannedAt = Date.now() + const repoResults = await mapLimit(store.getRepos(), 2, (repo) => + scanRepo(repo, scannedAt, store) + ) + const repos = repoResults.map((result) => result.summary) + const worktrees = repoResults + .flatMap((result) => result.worktrees) + .sort((a, b) => b.sizeBytes - a.sizeBytes || a.displayName.localeCompare(b.displayName)) + + return { + scannedAt, + totalSizeBytes: worktrees.reduce((sum, row) => sum + row.sizeBytes, 0), + reclaimableBytes: worktrees.reduce((sum, row) => sum + row.reclaimableBytes, 0), + worktreeCount: worktrees.length, + scannedWorktreeCount: worktrees.filter((row) => row.status === 'ok').length, + unavailableWorktreeCount: + worktrees.filter((row) => row.status !== 'ok').length + + repos.filter((repo) => repo.error !== null).length, + repos, + worktrees + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index c988c5561af..f46f7e977ce 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -165,6 +165,7 @@ import type { } from '../shared/claude-usage-types' import type { RateLimitState } from '../shared/rate-limit-types' import type { SpeechModelManifest, SpeechModelState } from '../shared/speech-types' +import type { WorkspaceSpaceAnalysis } from '../shared/workspace-space-types' import type { GhAuthDiagnostic } from '../shared/github-auth-types' import type { SshConnectionState, @@ -526,6 +527,9 @@ export type PreloadApi = { callback: (data: WorktreeRemoteBranchConflictEvent) => void ) => () => void } + workspaceSpace: { + analyze: () => Promise + } pty: { spawn: (opts: { cols: number diff --git a/src/preload/index.ts b/src/preload/index.ts index 88bdf910a7c..c744b23d047 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -38,6 +38,7 @@ import type { RuntimeMobileMarkdownResponse } from '../shared/mobile-markdown-document' import type { RateLimitState } from '../shared/rate-limit-types' +import type { WorkspaceSpaceAnalysis } from '../shared/workspace-space-types' import type { GhAuthDiagnostic } from '../shared/github-auth-types' import type { AddIssueCommentBySlugArgs, @@ -489,6 +490,10 @@ const api = { } }, + workspaceSpace: { + analyze: (): Promise => ipcRenderer.invoke('workspaceSpace:analyze') + }, + pty: { spawn: (opts: { cols: number diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 68a809ddd05..b70bbbd11c0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -49,6 +49,7 @@ import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSu import { ZoomOverlay } from './components/ZoomOverlay' import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding' import { SshPassphraseDialog } from './components/settings/SshPassphraseDialog' +import DeleteWorktreeDialog from './components/sidebar/DeleteWorktreeDialog' import { FloatingTerminalPanel, FloatingTerminalToggleButton @@ -159,6 +160,7 @@ const TaskPage = lazy(() => import('./components/TaskPage')) const AutomationsPage = lazy(() => import('./components/automations/AutomationsPage')) const ActivityPrototypePage = lazy(() => import('./components/activity/ActivityPrototypePage')) const Settings = lazy(() => import('./components/settings/Settings')) +const WorkspaceSpacePage = lazy(() => import('./components/workspace-space/WorkspaceSpacePage')) const QuickOpen = lazy(() => import('./components/QuickOpen')) const WorktreeJumpPalette = lazy(() => import('./components/WorktreeJumpPalette')) const NewWorkspaceComposerModal = lazy(() => import('./components/NewWorkspaceComposerModal')) @@ -802,10 +804,10 @@ function App(): React.JSX.Element { activeWorktreeId !== null && !hasTabBar && effectiveActiveTabExpanded - // Why: Activity is a full-page navigation surface — same treatment as - // Settings — so the worktree sidebar is removed for that view, letting the - // thread list + agent terminal span edge-to-edge. - const showSidebar = activeView !== 'settings' && activeView !== 'activity' + // Why: Activity and Space are full-page navigation surfaces — same + // treatment as Settings — so the worktree sidebar is removed for those views. + const showSidebar = + activeView !== 'settings' && activeView !== 'activity' && activeView !== 'space' // Why: only the terminal workspace replaces the full-width titlebar with // split-column chrome. Full-page navigation views keep the draggable app // titlebar so their page-level controls can live in that window strip. @@ -816,7 +818,8 @@ function App(): React.JSX.Element { activeView !== 'settings' && activeView !== 'tasks' && activeView !== 'activity' && - activeView !== 'automations' + activeView !== 'automations' && + activeView !== 'space' const handleToggleExpand = (): void => { if (!effectiveActiveTabId) { @@ -903,7 +906,12 @@ function App(): React.JSX.Element { // Why: full-page navigation surfaces should not reveal the right sidebar; // they are designed as distraction-free content areas. - if (activeView === 'tasks' || activeView === 'activity' || activeView === 'automations') { + if ( + activeView === 'tasks' || + activeView === 'activity' || + activeView === 'automations' || + activeView === 'space' + ) { return } @@ -1137,7 +1145,10 @@ function App(): React.JSX.Element { useEffect(() => { if ( - (activeView === 'tasks' || activeView === 'activity' || activeView === 'automations') && + (activeView === 'tasks' || + activeView === 'activity' || + activeView === 'automations' || + activeView === 'space') && rightSidebarOpen ) { // Why: hide the right sidebar immediately when entering full-page @@ -1307,6 +1318,7 @@ function App(): React.JSX.Element { {activeView === 'tasks' ? : null} {activeView === 'automations' ? : null} {activeView === 'activity' ? : null} + {activeView === 'space' ? : null} {activeView === 'terminal' && !activeWorktreeId ? : null} @@ -1370,6 +1382,7 @@ function App(): React.JSX.Element { + {onboarding && shouldShowOnboarding(onboarding) ? ( diff --git a/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts b/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts index 7fc319534ca..29c6a2fa58d 100644 --- a/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts +++ b/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts @@ -52,6 +52,8 @@ const INSTALLED_CLI_STATUS: CliInstallStatus = { const GRANTED_COMPUTER_USE_STATUS: ComputerUsePermissionStatusResult = { platform: 'darwin', + helperAppPath: '/Applications/Orca Computer Use.app', + helperUnavailableReason: null, permissions: [ { id: 'accessibility', status: 'granted' }, { id: 'screenshots', status: 'granted' } @@ -157,6 +159,8 @@ describe('onboarding feature setup runner', () => { getComputerUsePermissionStatus: vi.fn( async (): Promise => ({ platform: 'darwin', + helperAppPath: '/Applications/Orca Computer Use.app', + helperUnavailableReason: null, permissions: [ { id: 'accessibility', status: 'not-granted' }, { id: 'screenshots', status: 'granted' } diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index ad34aa40038..ee1d6567d75 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -61,23 +61,11 @@ const STATUS_BAR_TOGGLES: readonly { }, { id: 'resource-usage', - title: 'Resource Usage', - description: 'Show CPU, memory, and terminal session indicators in the status bar.', - keywords: [ - 'status bar', - 'resource', - 'usage', - 'memory', - 'ram', - 'cpu', - 'terminal', - 'sessions', - 'pty', - 'monitoring', - 'performance' - ], + title: 'Resource Manager', + description: 'Show CPU, memory, terminal sessions, and workspace disk usage in the status bar.', + keywords: ['status bar', 'resource', 'manager', 'memory', 'cpu', 'terminal', 'disk', 'space'], toggleDescription: - 'Show CPU, memory, and terminal session counts. Click it for a per-workspace breakdown and daemon controls.' + 'Show the Resource Manager. Click it for CPU, memory, sessions, daemon controls, and workspace disk scans.' } ] diff --git a/src/renderer/src/components/settings/ComputerUsePane.tsx b/src/renderer/src/components/settings/ComputerUsePane.tsx index 79bd613b37e..bf31a526a4b 100644 --- a/src/renderer/src/components/settings/ComputerUsePane.tsx +++ b/src/renderer/src/components/settings/ComputerUsePane.tsx @@ -67,20 +67,12 @@ function statusClass(status: ComputerUsePermissionStatus | undefined): string { return 'border-border bg-muted text-muted-foreground' } -function isExpectedDevMissingHelperError(error: unknown): boolean { - return ( - import.meta.env.DEV && - error instanceof Error && - error.message.includes('Orca Computer Use.app was not found') - ) -} - export function ComputerUsePane(): React.JSX.Element { const [platform, setPlatform] = useState(null) const [states, setStates] = useState([]) const [loading, setLoading] = useState(true) const [pendingId, setPendingId] = useState(null) - const [helperUnavailableInDev, setHelperUnavailableInDev] = useState(false) + const [helperUnavailableReason, setHelperUnavailableReason] = useState(null) const stateById = useMemo( () => new Map(states.map((state) => [state.id, state.status] as const)), @@ -93,14 +85,8 @@ export function ComputerUsePane(): React.JSX.Element { const result = await window.api.computerUsePermissions.getStatus() setPlatform(result.platform) setStates(result.permissions) - setHelperUnavailableInDev(false) + setHelperUnavailableReason(result.helperUnavailableReason) } catch (error) { - if (isExpectedDevMissingHelperError(error)) { - setPlatform('darwin') - setStates(PERMISSIONS.map((permission) => ({ id: permission.id, status: 'not-granted' }))) - setHelperUnavailableInDev(true) - return - } toast.error( error instanceof Error ? error.message : 'Could not load Computer Use permissions' ) @@ -166,10 +152,9 @@ export function ComputerUsePane(): React.JSX.Element { Computer Use needs macOS privacy permissions before agents can inspect and operate app windows.

- {helperUnavailableInDev ? ( + {helperUnavailableReason ? (

- Computer Use permissions are unavailable in this dev build because the helper app - has not been built. + Computer Use permissions are unavailable because {helperUnavailableReason}.

) : null} @@ -208,7 +193,9 @@ export function ComputerUsePane(): React.JSX.Element { )} + + {/* Why: Radix Dialog must not be a descendant of PopoverContent — when the popover unmounts (e.g. clicking outside, focus moving to the diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index d33152a00ec..e5b935f5e02 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -985,7 +985,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele onCheckedChange={() => toggleStatusBarItem('resource-usage')} > - Resource Usage + Resource Manager diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceCompactPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceCompactPanel.tsx new file mode 100644 index 00000000000..217816d5491 --- /dev/null +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceCompactPanel.tsx @@ -0,0 +1,92 @@ +import { useCallback } from 'react' +import { AlertTriangle, HardDrive, Loader2, RefreshCw } from 'lucide-react' +import { useAppStore } from '../../store' +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' +import { formatBytes, getWorkspaceSpaceScanTimeLabel } from './workspace-space-format' + +export function WorkspaceSpaceCompactPanel({ + onOpenFullPage +}: { + onOpenFullPage: () => void +}): React.JSX.Element { + const analysis = useAppStore((state) => state.workspaceSpaceAnalysis) + const scanError = useAppStore((state) => state.workspaceSpaceScanError) + const isScanning = useAppStore((state) => state.workspaceSpaceScanning) + const refreshWorkspaceSpace = useAppStore((state) => state.refreshWorkspaceSpace) + + const scan = useCallback((): void => { + void refreshWorkspaceSpace().catch(() => { + /* scanError is stored by the slice */ + }) + }, [refreshWorkspaceSpace]) + + return ( +
+
+
+ +
+
+ Space + + Beta + +
+
+ {analysis + ? `${formatBytes(analysis.reclaimableBytes)} reclaimable · ${analysis.scannedWorktreeCount} workspaces` + : isScanning + ? 'Scanning workspace sizes.' + : 'Workspace disk usage is not scanned.'} +
+
+
+ +
+ + +
+
+ + {analysis ? ( +
+
+
Scanned
+
+ {formatBytes(analysis.totalSizeBytes)} +
+
+
+
Freeable
+
+ {formatBytes(analysis.reclaimableBytes)} +
+
+
+
Updated
+
+ {getWorkspaceSpaceScanTimeLabel(analysis.scannedAt)} +
+
+
+ ) : null} + + {scanError ? ( +
+ + {scanError} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx new file mode 100644 index 00000000000..9c8452003da --- /dev/null +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -0,0 +1,685 @@ +/* eslint-disable max-lines -- Why: the analyzer's private treemap, selection, + breakdown, and table pieces share one scan state and should evolve as one resource-manager surface. */ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + AlertTriangle, + ArrowDown, + ArrowUp, + Check, + Circle, + GitBranch, + HardDrive, + Loader2, + RefreshCw, + Search, + Server, + Trash2 +} from 'lucide-react' +import type { + WorkspaceSpaceItem, + WorkspaceSpaceWorktree +} from '../../../../shared/workspace-space-types' +import { cn } from '@/lib/utils' +import { toast } from 'sonner' +import { useAppStore } from '../../store' +import { runWorktreeBatchDelete } from '../sidebar/delete-worktree-flow' +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { + formatBytes, + formatCompactCount, + getWorkspaceSpaceBranchLabel, + getWorkspaceSpaceScanTimeLabel, + getWorkspaceSpaceStatusLabel +} from './workspace-space-format' +import { buildTreemapLayout, type TreemapRect } from './workspace-space-layout' +import { + filterWorkspaceSpaceRows, + getSelectedDeletableWorkspaceIds, + sortWorkspaceSpaceRows, + type WorkspaceSpaceSortDirection, + type WorkspaceSpaceSortKey +} from './workspace-space-presentation' + +const TREEMAP_FILLS = [ + 'color-mix(in srgb, var(--chart-2) 34%, var(--card))', + 'color-mix(in srgb, var(--foreground) 20%, var(--card))', + 'color-mix(in srgb, var(--chart-4) 28%, var(--card))', + 'color-mix(in srgb, var(--primary) 24%, var(--card))', + 'color-mix(in srgb, var(--chart-1) 38%, var(--card))' +] + +function getTreemapFill(rect: TreemapRect, selected: boolean): string { + if (selected) { + return 'color-mix(in srgb, var(--ring) 40%, var(--card))' + } + return TREEMAP_FILLS[rect.index % TREEMAP_FILLS.length] +} + +function Metric({ label, value }: { label: string; value: string }): React.JSX.Element { + return ( +
+
+ {label} +
+
{value}
+
+ ) +} + +function CheckButton({ + checked, + disabled, + label, + onClick +}: { + checked: boolean + disabled?: boolean + label: string + onClick: () => void +}): React.JSX.Element { + return ( + + ) +} + +function SortIndicator({ + sortKey, + activeKey, + direction +}: { + sortKey: WorkspaceSpaceSortKey + activeKey: WorkspaceSpaceSortKey + direction: WorkspaceSpaceSortDirection +}): React.JSX.Element { + if (sortKey !== activeKey) { + return + } + return direction === 'asc' ? : +} + +function StatusBadge({ worktree }: { worktree: WorkspaceSpaceWorktree }): React.JSX.Element { + if (worktree.status !== 'ok') { + return ( + + {getWorkspaceSpaceStatusLabel(worktree.status)} + + ) + } + if (worktree.isMainWorktree) { + return Main + } + return Deletable +} + +function WorkspaceTreemap({ + rows, + isScanning, + selectedWorktreeId, + onSelect +}: { + rows: WorkspaceSpaceWorktree[] + isScanning: boolean + selectedWorktreeId: string | null + onSelect: (worktreeId: string) => void +}): React.JSX.Element { + const rects = useMemo( + () => + buildTreemapLayout( + rows + .filter((row) => row.status === 'ok' && row.sizeBytes > 0) + .map((row) => ({ + id: row.worktreeId, + label: row.displayName, + sizeBytes: row.sizeBytes + })) + ), + [rows] + ) + + if (rects.length === 0) { + return ( +
+ + {isScanning ? : null} + {isScanning + ? 'Scanning workspace sizes. You can leave this page.' + : 'No scanned workspace sizes yet.'} + +
+ ) + } + + return ( +
+ {rects.map((rect) => { + const area = rect.width * rect.height + const selected = rect.id === selectedWorktreeId + return ( + + ) + })} +
+ ) +} + +function SizeBar({ value, max }: { value: number; max: number }): React.JSX.Element { + const pct = max > 0 ? Math.max(2, Math.min(100, (value / max) * 100)) : 0 + return ( +
+
+
+ ) +} + +function BreakdownList({ + worktree, + isScanning +}: { + worktree: WorkspaceSpaceWorktree | null + isScanning: boolean +}): React.JSX.Element { + if (!worktree) { + return ( +
+ + {isScanning ? : null} + {isScanning + ? 'Scanning workspace sizes. You can leave this page.' + : 'Select a workspace to inspect.'} + +
+ ) + } + + const maxChildSize = Math.max(...worktree.topLevelItems.map((item) => item.sizeBytes), 0) + const topLevelItemCount = worktree.topLevelItems.length + worktree.omittedTopLevelItemCount + return ( +
+
+
+
+
{worktree.displayName}
+
+ {worktree.repoDisplayName} +
+
+
+
+ {formatBytes(worktree.sizeBytes)} +
+
+ {formatCompactCount(topLevelItemCount)} top-level items +
+
+
+
+ + {worktree.status !== 'ok' ? ( +
+ + {worktree.error ?? 'Scan failed.'} +
+ ) : worktree.topLevelItems.length === 0 ? ( +
No files found.
+ ) : ( +
+
+ {worktree.topLevelItems.slice(0, 12).map((item) => ( + + ))} +
+
+ )} +
+ ) +} + +function BreakdownRow({ + item, + maxSize +}: { + item: WorkspaceSpaceItem + maxSize: number +}): React.JSX.Element { + return ( +
+
+ {item.name} + + {formatBytes(item.sizeBytes)} + +
+ +
+ ) +} + +function WorkspaceRow({ + worktree, + maxSize, + selected, + inspected, + onToggleSelected, + onInspect +}: { + worktree: WorkspaceSpaceWorktree + maxSize: number + selected: boolean + inspected: boolean + onToggleSelected: () => void + onInspect: () => void +}): React.JSX.Element { + return ( +
{ + if (event.key !== 'Enter' && event.key !== ' ') { + return + } + event.preventDefault() + onInspect() + }} + className={cn( + 'grid w-full cursor-pointer grid-cols-[1.75rem_minmax(0,1.35fr)_minmax(9rem,0.65fr)_8rem_6rem] items-center gap-3 border-b border-border/45 px-3 py-2.5 text-left text-sm transition-colors last:border-b-0 hover:bg-accent/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', + inspected && 'bg-accent/55' + )} + > + + +
+
+ {worktree.displayName} + {worktree.isRemote ? ( + + ) : null} + {worktree.isSparse ? Sparse : null} +
+
+ + {getWorkspaceSpaceBranchLabel(worktree)} +
+
+ {worktree.path} +
+
+ +
+
{worktree.repoDisplayName}
+
+ {worktree.repoPath} +
+
+ +
+
+ {worktree.status === 'ok' ? formatBytes(worktree.sizeBytes) : '—'} +
+ +
+ +
+ +
+
+ ) +} + +export function WorkspaceSpaceManagerPanel(): React.JSX.Element { + const analysis = useAppStore((state) => state.workspaceSpaceAnalysis) + const scanError = useAppStore((state) => state.workspaceSpaceScanError) + const isScanning = useAppStore((state) => state.workspaceSpaceScanning) + const refreshWorkspaceSpace = useAppStore((state) => state.refreshWorkspaceSpace) + const removeWorkspaceSpaceWorktrees = useAppStore((state) => state.removeWorkspaceSpaceWorktrees) + const [query, setQuery] = useState('') + const [onlyDeletable, setOnlyDeletable] = useState(false) + const [sortKey, setSortKey] = useState('size') + const [sortDirection, setSortDirection] = useState('desc') + const [selectedIds, setSelectedIds] = useState>(() => new Set()) + const [inspectedWorktreeId, setInspectedWorktreeId] = useState(null) + + const refresh = useCallback((): void => { + void refreshWorkspaceSpace().catch(() => { + /* scanError is stored by the slice */ + }) + }, [refreshWorkspaceSpace]) + + const sourceRows = useMemo(() => analysis?.worktrees ?? [], [analysis?.worktrees]) + + const rows = useMemo( + () => + sortWorkspaceSpaceRows( + filterWorkspaceSpaceRows(sourceRows, query, onlyDeletable), + sortKey, + sortDirection + ), + [onlyDeletable, query, sortDirection, sortKey, sourceRows] + ) + + const inspectedWorktree = + rows.find((row) => row.worktreeId === inspectedWorktreeId) ?? + rows.find((row) => row.status === 'ok') ?? + null + const maxSize = Math.max(...rows.map((row) => row.sizeBytes), 0) + const selectedDeletableIds = getSelectedDeletableWorkspaceIds(rows, selectedIds) + const visibleDeletableIds = rows + .filter((row) => row.canDelete && row.status === 'ok') + .map((row) => row.worktreeId) + const allVisibleSelected = + visibleDeletableIds.length > 0 && visibleDeletableIds.every((id) => selectedIds.has(id)) + const isInitialScan = isScanning && !analysis + + useEffect(() => { + if (!analysis) { + setInspectedWorktreeId(null) + return + } + setInspectedWorktreeId((current) => + current && analysis.worktrees.some((worktree) => worktree.worktreeId === current) + ? current + : (analysis.worktrees.find((worktree) => worktree.status === 'ok')?.worktreeId ?? null) + ) + }, [analysis]) + + useEffect(() => { + setSelectedIds((current) => { + const valid = new Set(sourceRows.map((row) => row.worktreeId)) + const next = new Set([...current].filter((id) => valid.has(id))) + return next.size === current.size ? current : next + }) + }, [sourceRows]) + + const toggleSort = (key: WorkspaceSpaceSortKey): void => { + if (sortKey === key) { + setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc')) + return + } + setSortKey(key) + setSortDirection(key === 'name' || key === 'repo' ? 'asc' : 'desc') + } + + const toggleSelection = (worktreeId: string): void => { + setSelectedIds((current) => { + const next = new Set(current) + if (next.has(worktreeId)) { + next.delete(worktreeId) + } else { + next.add(worktreeId) + } + return next + }) + } + + const toggleVisibleSelection = (): void => { + setSelectedIds((current) => { + const next = new Set(current) + if (allVisibleSelected) { + for (const id of visibleDeletableIds) { + next.delete(id) + } + } else { + for (const id of visibleDeletableIds) { + next.add(id) + } + } + return next + }) + } + + const deleteSelected = (): void => { + if (selectedDeletableIds.length === 0) { + return + } + const started = runWorktreeBatchDelete(selectedDeletableIds, { + onDeleted: (deletedIds) => { + removeWorkspaceSpaceWorktrees(deletedIds) + setInspectedWorktreeId((current) => + current && deletedIds.includes(current) ? null : current + ) + toast.success(deletedIds.length === 1 ? 'Workspace deleted' : 'Workspaces deleted', { + description: `${deletedIds.length} ${deletedIds.length === 1 ? 'workspace' : 'workspaces'} removed from Space.` + }) + } + }) + if (started) { + setSelectedIds(new Set()) + } + } + + return ( +
+
+ + + + +
+ +
+
+ + + {analysis + ? `${formatBytes(analysis.reclaimableBytes)} can be reclaimed from linked worktrees.` + : isScanning + ? 'Scanning workspace sizes in the background. You can leave this page.' + : 'Run a scan to inspect workspace sizes.'} + +
+ +
+ + {scanError ? ( +
+ + {scanError} +
+ ) : null} + +
+ + +
+ +
+
+ + setQuery(event.target.value)} + placeholder="Filter workspaces" + className="pl-9" + /> +
+ + + + + + + + +
+ +
+
+
+ + + +
State
+
+ +
+ {isInitialScan ? ( +
+ + Scanning workspaces. You can leave this page. +
+ ) : rows.length === 0 ? ( +
+ No matching workspaces. +
+ ) : ( + rows.map((worktree) => ( + toggleSelection(worktree.worktreeId)} + onInspect={() => setInspectedWorktreeId(worktree.worktreeId)} + /> + )) + )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts index 665b3b620db..8bf4c3e9e93 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts @@ -4,7 +4,7 @@ in ResourceUsageStatusSegment.tsx. Splitting would scatter logic that has exactly one consumer. See docs/resource-usage-merge-spec.md. */ /** - * Resource Usage popover merge helper. + * Resource Manager popover merge helper. * * Produces a single grouped list (repo → worktree → session) by unifying: * diff --git a/src/renderer/src/components/status-bar/workspace-space-format.test.ts b/src/renderer/src/components/status-bar/workspace-space-format.test.ts new file mode 100644 index 00000000000..9f239b0e075 --- /dev/null +++ b/src/renderer/src/components/status-bar/workspace-space-format.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { + formatBytes, + formatCompactCount, + getWorkspaceSpaceStatusLabel +} from './workspace-space-format' + +describe('workspace space format helpers', () => { + it('formats byte values with stable units', () => { + expect(formatBytes(0)).toBe('0 B') + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(1536)).toBe('1.50 KB') + expect(formatBytes(5 * 1024 * 1024)).toBe('5.00 MB') + }) + + it('formats counts and statuses for dense table UI', () => { + expect(formatCompactCount(1530)).toBe('1.5k') + expect(formatCompactCount(25_000)).toBe('25k') + expect(getWorkspaceSpaceStatusLabel('permission-denied')).toBe('No access') + }) +}) diff --git a/src/renderer/src/components/status-bar/workspace-space-format.ts b/src/renderer/src/components/status-bar/workspace-space-format.ts new file mode 100644 index 00000000000..9566e4e3911 --- /dev/null +++ b/src/renderer/src/components/status-bar/workspace-space-format.ts @@ -0,0 +1,59 @@ +import type { + WorkspaceSpaceScanStatus, + WorkspaceSpaceWorktree +} from '../../../../shared/workspace-space-types' + +const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const + +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) { + return '0 B' + } + + let value = bytes + let unitIndex = 0 + while (value >= 1024 && unitIndex < BYTE_UNITS.length - 1) { + value /= 1024 + unitIndex += 1 + } + + const precision = value >= 100 || unitIndex === 0 ? 0 : value >= 10 ? 1 : 2 + return `${value.toFixed(precision)} ${BYTE_UNITS[unitIndex]}` +} + +export function formatCompactCount(count: number): string { + if (!Number.isFinite(count) || count <= 0) { + return '0' + } + if (count < 1000) { + return String(count) + } + if (count < 1_000_000) { + return `${(count / 1000).toFixed(count >= 10_000 ? 0 : 1)}k` + } + return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}m` +} + +export function getWorkspaceSpaceScanTimeLabel(scannedAt: number): string { + return new Date(scannedAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) +} + +export function getWorkspaceSpaceStatusLabel(status: WorkspaceSpaceScanStatus): string { + switch (status) { + case 'ok': + return 'Scanned' + case 'missing': + return 'Missing' + case 'permission-denied': + return 'No access' + case 'unavailable': + return 'Unavailable' + case 'error': + return 'Failed' + } +} + +export function getWorkspaceSpaceBranchLabel(worktree: WorkspaceSpaceWorktree): string { + const branch = worktree.branch.replace(/^refs\/heads\//, '').trim() + return branch || (worktree.isMainWorktree ? 'main worktree' : 'detached') +} diff --git a/src/renderer/src/components/status-bar/workspace-space-layout.test.ts b/src/renderer/src/components/status-bar/workspace-space-layout.test.ts new file mode 100644 index 00000000000..d1857a17e74 --- /dev/null +++ b/src/renderer/src/components/status-bar/workspace-space-layout.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { buildTreemapLayout } from './workspace-space-layout' + +describe('buildTreemapLayout', () => { + it('lays out positive-size items inside a 100x100 treemap', () => { + const rects = buildTreemapLayout([ + { id: 'a', label: 'A', sizeBytes: 60 }, + { id: 'b', label: 'B', sizeBytes: 30 }, + { id: 'c', label: 'C', sizeBytes: 10 }, + { id: 'empty', label: 'Empty', sizeBytes: 0 } + ]) + + expect(rects.map((rect) => rect.id)).toEqual(['a', 'b', 'c']) + for (const rect of rects) { + expect(rect.x).toBeGreaterThanOrEqual(0) + expect(rect.y).toBeGreaterThanOrEqual(0) + expect(rect.x + rect.width).toBeLessThanOrEqual(100) + expect(rect.y + rect.height).toBeLessThanOrEqual(100) + } + const area = rects.reduce((sum, rect) => sum + rect.width * rect.height, 0) + expect(area).toBeCloseTo(10_000, 5) + expect(rects[0].width * rects[0].height).toBeGreaterThan(rects[1].width * rects[1].height) + }) +}) diff --git a/src/renderer/src/components/status-bar/workspace-space-layout.ts b/src/renderer/src/components/status-bar/workspace-space-layout.ts new file mode 100644 index 00000000000..18f739ad99f --- /dev/null +++ b/src/renderer/src/components/status-bar/workspace-space-layout.ts @@ -0,0 +1,124 @@ +export type TreemapInput = { + id: string + label: string + sizeBytes: number +} + +export type TreemapRect = TreemapInput & { + x: number + y: number + width: number + height: number + depth: number + index: number +} + +type Bounds = { + x: number + y: number + width: number + height: number +} + +function sumSizes(items: readonly TreemapInput[]): number { + return items.reduce((sum, item) => sum + Math.max(0, item.sizeBytes), 0) +} + +function splitBalanced(items: readonly TreemapInput[]): { + first: TreemapInput[] + second: TreemapInput[] +} { + const total = sumSizes(items) + if (items.length <= 1 || total <= 0) { + return { first: [...items], second: [] } + } + + const target = total / 2 + let running = 0 + let splitIndex = 0 + for (let index = 0; index < items.length; index += 1) { + const next = running + Math.max(0, items[index].sizeBytes) + if (index > 0 && Math.abs(target - running) < Math.abs(target - next)) { + break + } + running = next + splitIndex = index + 1 + } + + splitIndex = Math.min(items.length - 1, Math.max(1, splitIndex)) + return { + first: items.slice(0, splitIndex), + second: items.slice(splitIndex) + } +} + +function layoutTreemapRecursive( + items: readonly TreemapInput[], + bounds: Bounds, + depth: number, + output: TreemapRect[] +): void { + if (items.length === 0 || bounds.width <= 0 || bounds.height <= 0) { + return + } + + if (items.length === 1) { + const item = items[0] + output.push({ + ...item, + ...bounds, + depth, + index: output.length + }) + return + } + + const total = sumSizes(items) + if (total <= 0) { + return + } + + const { first, second } = splitBalanced(items) + const firstSize = sumSizes(first) + const ratio = firstSize / total + + if (bounds.width >= bounds.height) { + const firstWidth = bounds.width * ratio + layoutTreemapRecursive(first, { ...bounds, width: firstWidth }, depth + 1, output) + layoutTreemapRecursive( + second, + { + x: bounds.x + firstWidth, + y: bounds.y, + width: bounds.width - firstWidth, + height: bounds.height + }, + depth + 1, + output + ) + return + } + + const firstHeight = bounds.height * ratio + layoutTreemapRecursive(first, { ...bounds, height: firstHeight }, depth + 1, output) + layoutTreemapRecursive( + second, + { + x: bounds.x, + y: bounds.y + firstHeight, + width: bounds.width, + height: bounds.height - firstHeight + }, + depth + 1, + output + ) +} + +export function buildTreemapLayout(items: readonly TreemapInput[]): TreemapRect[] { + const filtered = items + .filter((item) => item.sizeBytes > 0) + .sort((a, b) => b.sizeBytes - a.sizeBytes || a.label.localeCompare(b.label)) + const output: TreemapRect[] = [] + layoutTreemapRecursive(filtered, { x: 0, y: 0, width: 100, height: 100 }, 0, output) + return output +} diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts new file mode 100644 index 00000000000..7c386b07867 --- /dev/null +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types' +import { + filterWorkspaceSpaceRows, + getSelectedDeletableWorkspaceIds, + sortWorkspaceSpaceRows +} from './workspace-space-presentation' + +function row(overrides: Partial): WorkspaceSpaceWorktree { + return { + worktreeId: 'wt', + repoId: 'repo', + repoDisplayName: 'repo', + repoPath: '/repo', + displayName: 'workspace', + path: '/workspace', + branch: 'refs/heads/main', + isMainWorktree: false, + isRemote: false, + isSparse: false, + canDelete: true, + lastActivityAt: 0, + status: 'ok', + error: null, + scannedAt: 0, + sizeBytes: 0, + reclaimableBytes: 0, + skippedEntryCount: 0, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0, + ...overrides + } +} + +describe('workspace space presentation helpers', () => { + it('sorts rows by the selected key and direction', () => { + const rows = [ + row({ worktreeId: 'small', displayName: 'Small', sizeBytes: 10 }), + row({ worktreeId: 'large', displayName: 'Large', sizeBytes: 100 }), + row({ worktreeId: 'mid', displayName: 'Mid', sizeBytes: 50 }) + ] + + expect(sortWorkspaceSpaceRows(rows, 'size', 'desc').map((item) => item.worktreeId)).toEqual([ + 'large', + 'mid', + 'small' + ]) + expect(sortWorkspaceSpaceRows(rows, 'name', 'asc').map((item) => item.worktreeId)).toEqual([ + 'large', + 'mid', + 'small' + ]) + }) + + it('filters by search text and deletable status', () => { + const rows = [ + row({ worktreeId: 'a', displayName: 'Frontend Cache', repoDisplayName: 'app' }), + row({ worktreeId: 'b', displayName: 'Main', repoDisplayName: 'api', canDelete: false }) + ] + + expect(filterWorkspaceSpaceRows(rows, 'cache', false).map((item) => item.worktreeId)).toEqual([ + 'a' + ]) + expect(filterWorkspaceSpaceRows(rows, '', true).map((item) => item.worktreeId)).toEqual(['a']) + }) + + it('returns only selected worktrees that can be deleted', () => { + const rows = [ + row({ worktreeId: 'ok', canDelete: true, status: 'ok' }), + row({ worktreeId: 'main', canDelete: false, status: 'ok' }), + row({ worktreeId: 'failed', canDelete: true, status: 'error' }) + ] + + expect(getSelectedDeletableWorkspaceIds(rows, new Set(['ok', 'main', 'failed']))).toEqual([ + 'ok' + ]) + }) +}) diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.ts new file mode 100644 index 00000000000..4dd3c1d5059 --- /dev/null +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.ts @@ -0,0 +1,78 @@ +import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types' + +export type WorkspaceSpaceSortKey = 'size' | 'name' | 'repo' | 'activity' +export type WorkspaceSpaceSortDirection = 'asc' | 'desc' + +export function getWorkspaceSpaceSearchText(worktree: WorkspaceSpaceWorktree): string { + return [ + worktree.displayName, + worktree.repoDisplayName, + worktree.path, + worktree.branch, + worktree.status + ] + .join(' ') + .toLowerCase() +} + +function compareRows( + left: WorkspaceSpaceWorktree, + right: WorkspaceSpaceWorktree, + sortKey: WorkspaceSpaceSortKey +): number { + switch (sortKey) { + case 'size': + return left.sizeBytes - right.sizeBytes + case 'name': + return left.displayName.localeCompare(right.displayName) + case 'repo': + return ( + left.repoDisplayName.localeCompare(right.repoDisplayName) || + left.displayName.localeCompare(right.displayName) + ) + case 'activity': + return left.lastActivityAt - right.lastActivityAt + } +} + +export function sortWorkspaceSpaceRows( + rows: readonly WorkspaceSpaceWorktree[], + sortKey: WorkspaceSpaceSortKey, + direction: WorkspaceSpaceSortDirection +): WorkspaceSpaceWorktree[] { + const multiplier = direction === 'asc' ? 1 : -1 + return [...rows].sort((left, right) => { + const primary = compareRows(left, right, sortKey) * multiplier + return ( + primary || + right.sizeBytes - left.sizeBytes || + left.displayName.localeCompare(right.displayName) + ) + }) +} + +export function filterWorkspaceSpaceRows( + rows: readonly WorkspaceSpaceWorktree[], + query: string, + onlyDeletable: boolean +): WorkspaceSpaceWorktree[] { + const normalizedQuery = query.trim().toLowerCase() + return rows.filter((row) => { + if (onlyDeletable && !row.canDelete) { + return false + } + if (!normalizedQuery) { + return true + } + return getWorkspaceSpaceSearchText(row).includes(normalizedQuery) + }) +} + +export function getSelectedDeletableWorkspaceIds( + rows: readonly WorkspaceSpaceWorktree[], + selectedIds: ReadonlySet +): string[] { + return rows + .filter((row) => row.canDelete && row.status === 'ok' && selectedIds.has(row.worktreeId)) + .map((row) => row.worktreeId) +} diff --git a/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx b/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx new file mode 100644 index 00000000000..0fed37c3afa --- /dev/null +++ b/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx @@ -0,0 +1,59 @@ +import { useEffect } from 'react' +import { ArrowLeft, HardDrive } from 'lucide-react' +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' +import { WorkspaceSpaceManagerPanel } from '../status-bar/WorkspaceSpaceManagerPanel' +import { useAppStore } from '../../store' + +export default function WorkspaceSpacePage(): React.JSX.Element { + const closeSpacePage = useAppStore((state) => state.closeSpacePage) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape' || event.defaultPrevented) { + return + } + // Why: confirmation dialogs own Escape first; page-level Escape should + // only leave the full Space surface when no modal is active. + if (document.querySelector('[role="dialog"]')) { + return + } + event.preventDefault() + closeSpacePage() + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [closeSpacePage]) + + return ( +
+
+ +
+
+ +
+
+
+

Space

+ Beta +
+

+ Workspace disk usage and reclaimable worktree storage. +

+
+
+
+ +
+
+ +
+
+
+ ) +} diff --git a/src/renderer/src/hooks/resolve-zoom-target.ts b/src/renderer/src/hooks/resolve-zoom-target.ts index 255fbf386ce..f03b3786cd0 100644 --- a/src/renderer/src/hooks/resolve-zoom-target.ts +++ b/src/renderer/src/hooks/resolve-zoom-target.ts @@ -3,7 +3,7 @@ * based on current view, tab type, and focused element. */ export function resolveZoomTarget(args: { - activeView: 'terminal' | 'settings' | 'tasks' | 'activity' | 'automations' + activeView: 'terminal' | 'settings' | 'tasks' | 'activity' | 'automations' | 'space' activeTabType: 'terminal' | 'editor' | 'browser' | 'notes' activeElement: unknown }): 'terminal' | 'editor' | 'ui' { diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index d7e07344be9..76a8c91ed87 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -13,6 +13,7 @@ import { createLinearSlice } from './slices/linear' import { createEditorSlice } from './slices/editor' import { createStatsSlice } from './slices/stats' import { createMemorySlice } from './slices/memory' +import { createWorkspaceSpaceSlice } from './slices/workspace-space' import { createClaudeUsageSlice } from './slices/claude-usage' import { createCodexUsageSlice } from './slices/codex-usage' import { createBrowserSlice } from './slices/browser' @@ -40,6 +41,7 @@ export const useAppStore = create()((...a) => ({ ...createEditorSlice(...a), ...createStatsSlice(...a), ...createMemorySlice(...a), + ...createWorkspaceSpaceSlice(...a), ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), ...createBrowserSlice(...a), diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index b989a7667dd..827d9e3831e 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -85,6 +85,7 @@ import { createLinearSlice } from './linear' import { createEditorSlice } from './editor' import { createStatsSlice } from './stats' import { createMemorySlice } from './memory' +import { createWorkspaceSpaceSlice } from './workspace-space' import { createClaudeUsageSlice } from './claude-usage' import { createCodexUsageSlice } from './codex-usage' import { createBrowserSlice } from './browser' @@ -111,6 +112,7 @@ function createTestStore() { ...createEditorSlice(...a), ...createStatsSlice(...a), ...createMemorySlice(...a), + ...createWorkspaceSpaceSlice(...a), ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), ...createBrowserSlice(...a), diff --git a/src/renderer/src/store/slices/memory.ts b/src/renderer/src/store/slices/memory.ts index b24de6d0c89..a011adaf87c 100644 --- a/src/renderer/src/store/slices/memory.ts +++ b/src/renderer/src/store/slices/memory.ts @@ -17,7 +17,7 @@ export const createMemorySlice: StateCreator = (s const snapshot = await window.api.memory.getSnapshot() set({ memorySnapshot: snapshot, memorySnapshotError: null }) } catch (err) { - // Why: the always-on Resource Usage status-bar segment needs to know when + // Why: the always-on Resource Manager status-bar segment needs to know when // the snapshot IPC is failing so it can surface a "daemon not responding" // banner with a Restart CTA. Prior code only console.error'd. console.error('Failed to fetch memory snapshot:', err) diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index 80495de0ceb..2e86742c240 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -102,6 +102,7 @@ import { createLinearSlice } from './linear' import { createEditorSlice } from './editor' import { createStatsSlice } from './stats' import { createMemorySlice } from './memory' +import { createWorkspaceSpaceSlice } from './workspace-space' import { createClaudeUsageSlice } from './claude-usage' import { createCodexUsageSlice } from './codex-usage' import { createBrowserSlice } from './browser' @@ -128,6 +129,7 @@ function createTestStore() { ...createEditorSlice(...a), ...createStatsSlice(...a), ...createMemorySlice(...a), + ...createWorkspaceSpaceSlice(...a), ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), ...createBrowserSlice(...a), diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index 21b9fff0da4..e4c43e4dfb8 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -21,6 +21,7 @@ import { createLinearSlice } from './linear' import { createEditorSlice } from './editor' import { createStatsSlice } from './stats' import { createMemorySlice } from './memory' +import { createWorkspaceSpaceSlice } from './workspace-space' import { createClaudeUsageSlice } from './claude-usage' import { createCodexUsageSlice } from './codex-usage' import { createBrowserSlice } from './browser' @@ -55,6 +56,7 @@ export function createTestStore() { ...createEditorSlice(...a), ...createStatsSlice(...a), ...createMemorySlice(...a), + ...createWorkspaceSpaceSlice(...a), ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), ...createBrowserSlice(...a), diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index 2af817e444e..3394284cf3e 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -97,6 +97,7 @@ import { createLinearSlice } from './linear' import { createEditorSlice } from './editor' import { createStatsSlice } from './stats' import { createMemorySlice } from './memory' +import { createWorkspaceSpaceSlice } from './workspace-space' import { createClaudeUsageSlice } from './claude-usage' import { createCodexUsageSlice } from './codex-usage' import { createBrowserSlice } from './browser' @@ -125,6 +126,7 @@ function createTestStore() { ...createEditorSlice(...a), ...createStatsSlice(...a), ...createMemorySlice(...a), + ...createWorkspaceSpaceSlice(...a), ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), ...createBrowserSlice(...a), diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index bc12e7e1007..ce28fbca880 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -446,3 +446,33 @@ describe('createUISlice feature tour nudge', () => { expect(store.getState().featureTourNudgeVisible).toBe(false) }) }) + +describe('createUISlice space navigation', () => { + it('returns to the tasks page after opening Space from an in-progress draft', () => { + const store = createUIStore() + + store.getState().openTaskPage({ preselectedRepoId: 'repo-1' }) + store.getState().openSpacePage() + + expect(store.getState().activeView).toBe('space') + expect(store.getState().previousViewBeforeSpace).toBe('tasks') + + store.getState().closeSpacePage() + + expect(store.getState().activeView).toBe('tasks') + }) + + it('keeps the original return target when Space is reopened while already visible', () => { + const store = createUIStore() + + store.getState().openTaskPage() + store.getState().openSpacePage() + store.getState().openSpacePage() + + expect(store.getState().previousViewBeforeSpace).toBe('tasks') + + store.getState().closeSpacePage() + + expect(store.getState().activeView).toBe('tasks') + }) +}) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 44acdabf574..8fce0f6cb15 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -194,11 +194,12 @@ export type UISlice = { acknowledgedAgentsByPaneKey: Record acknowledgeAgents: (paneKeys: string[]) => void unacknowledgeAgents: (paneKeys: string[]) => void - activeView: 'terminal' | 'settings' | 'tasks' | 'activity' | 'automations' - previousViewBeforeTasks: 'terminal' | 'settings' | 'activity' | 'automations' - previousViewBeforeSettings: 'terminal' | 'tasks' | 'activity' | 'automations' - previousViewBeforeActivity: 'terminal' | 'settings' | 'tasks' | 'automations' - previousViewBeforeAutomations: 'terminal' | 'settings' | 'tasks' | 'activity' + activeView: 'terminal' | 'settings' | 'tasks' | 'activity' | 'automations' | 'space' + previousViewBeforeTasks: 'terminal' | 'settings' | 'activity' | 'automations' | 'space' + previousViewBeforeSettings: 'terminal' | 'tasks' | 'activity' | 'automations' | 'space' + previousViewBeforeActivity: 'terminal' | 'settings' | 'tasks' | 'automations' | 'space' + previousViewBeforeAutomations: 'terminal' | 'settings' | 'tasks' | 'activity' | 'space' + previousViewBeforeSpace: 'terminal' | 'settings' | 'tasks' | 'activity' | 'automations' setActiveView: (view: UISlice['activeView']) => void taskPageData: { preselectedRepoId?: string @@ -238,6 +239,8 @@ export type UISlice = { setSelectedAutomationId: (id: string | null) => void openAutomationsPage: () => void closeAutomationsPage: () => void + openSpacePage: () => void + closeSpacePage: () => void setNewWorkspaceDraft: (draft: NonNullable) => void clearNewWorkspaceDraft: () => void openSettingsPage: () => void @@ -423,6 +426,7 @@ export const createUISlice: StateCreator = (set, get) previousViewBeforeSettings: 'terminal', previousViewBeforeActivity: 'terminal', previousViewBeforeAutomations: 'terminal', + previousViewBeforeSpace: 'terminal', setActiveView: (view) => set({ activeView: view }), taskPageData: {}, taskResumeState: undefined, @@ -536,6 +540,16 @@ export const createUISlice: StateCreator = (set, get) set((state) => ({ activeView: state.previousViewBeforeAutomations })), + openSpacePage: () => + set((state) => ({ + activeView: 'space', + previousViewBeforeSpace: + state.activeView === 'space' ? state.previousViewBeforeSpace : state.activeView + })), + closeSpacePage: () => + set((state) => ({ + activeView: state.previousViewBeforeSpace + })), setNewWorkspaceDraft: (draft) => set({ newWorkspaceDraft: draft }), clearNewWorkspaceDraft: () => set({ newWorkspaceDraft: null }), openSettingsPage: () => diff --git a/src/renderer/src/store/slices/workspace-space.ts b/src/renderer/src/store/slices/workspace-space.ts new file mode 100644 index 00000000000..2d1bc99c000 --- /dev/null +++ b/src/renderer/src/store/slices/workspace-space.ts @@ -0,0 +1,96 @@ +import type { StateCreator } from 'zustand' +import type { WorkspaceSpaceAnalysis } from '../../../../shared/workspace-space-types' +import type { AppState } from '../types' + +let inFlightScan: Promise | null = null + +export type WorkspaceSpaceSlice = { + workspaceSpaceAnalysis: WorkspaceSpaceAnalysis | null + workspaceSpaceScanError: string | null + workspaceSpaceScanning: boolean + refreshWorkspaceSpace: () => Promise + removeWorkspaceSpaceWorktrees: (worktreeIds: readonly string[]) => void +} + +function removeDeletedWorktreesFromAnalysis( + analysis: WorkspaceSpaceAnalysis, + deletedWorktreeIds: readonly string[] +): WorkspaceSpaceAnalysis { + const deletedSet = new Set(deletedWorktreeIds) + const worktrees = analysis.worktrees.filter((worktree) => !deletedSet.has(worktree.worktreeId)) + const rowsByRepoId = new Map() + for (const worktree of worktrees) { + const repoRows = rowsByRepoId.get(worktree.repoId) ?? [] + repoRows.push(worktree) + rowsByRepoId.set(worktree.repoId, repoRows) + } + const repos = analysis.repos.map((repo) => { + const repoRows = rowsByRepoId.get(repo.repoId) ?? [] + return { + ...repo, + worktreeCount: repoRows.length, + scannedWorktreeCount: repoRows.filter((row) => row.status === 'ok').length, + unavailableWorktreeCount: repoRows.filter((row) => row.status !== 'ok').length, + totalSizeBytes: repoRows.reduce((sum, row) => sum + row.sizeBytes, 0), + reclaimableBytes: repoRows.reduce((sum, row) => sum + row.reclaimableBytes, 0) + } + }) + + return { + ...analysis, + totalSizeBytes: worktrees.reduce((sum, row) => sum + row.sizeBytes, 0), + reclaimableBytes: worktrees.reduce((sum, row) => sum + row.reclaimableBytes, 0), + worktreeCount: worktrees.length, + scannedWorktreeCount: worktrees.filter((row) => row.status === 'ok').length, + unavailableWorktreeCount: + worktrees.filter((row) => row.status !== 'ok').length + + repos.filter((repo) => repo.error !== null).length, + repos, + worktrees + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export const createWorkspaceSpaceSlice: StateCreator = ( + set +) => ({ + workspaceSpaceAnalysis: null, + workspaceSpaceScanError: null, + workspaceSpaceScanning: false, + refreshWorkspaceSpace: async () => { + if (inFlightScan) { + return inFlightScan + } + set({ workspaceSpaceScanning: true, workspaceSpaceScanError: null }) + // Why: the compact Resource Manager card and the full Space page share + // one manual scan result; duplicate button presses should join the same IO. + inFlightScan = window.api.workspaceSpace + .analyze() + .then((analysis) => { + set({ workspaceSpaceAnalysis: analysis, workspaceSpaceScanning: false }) + return analysis + }) + .catch((error: unknown) => { + set({ workspaceSpaceScanError: errorMessage(error), workspaceSpaceScanning: false }) + throw error + }) + .finally(() => { + inFlightScan = null + }) + return inFlightScan + }, + removeWorkspaceSpaceWorktrees: (worktreeIds) => + set((state) => + state.workspaceSpaceAnalysis + ? { + workspaceSpaceAnalysis: removeDeletedWorktreesFromAnalysis( + state.workspaceSpaceAnalysis, + worktreeIds + ) + } + : state + ) +}) diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index dce995c3fd9..4c8f148985f 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -11,6 +11,7 @@ import type { LinearSlice } from './slices/linear' import type { EditorSlice } from './slices/editor' import type { StatsSlice } from './slices/stats' import type { MemorySlice } from './slices/memory' +import type { WorkspaceSpaceSlice } from './slices/workspace-space' import type { ClaudeUsageSlice } from './slices/claude-usage' import type { CodexUsageSlice } from './slices/codex-usage' import type { BrowserSlice } from './slices/browser' @@ -35,6 +36,7 @@ export type AppState = RepoSlice & EditorSlice & StatsSlice & MemorySlice & + WorkspaceSpaceSlice & ClaudeUsageSlice & CodexUsageSlice & BrowserSlice & diff --git a/src/shared/computer-use-permissions-types.ts b/src/shared/computer-use-permissions-types.ts index 761dba95d42..d1f4c488fb0 100644 --- a/src/shared/computer-use-permissions-types.ts +++ b/src/shared/computer-use-permissions-types.ts @@ -9,6 +9,8 @@ export type ComputerUsePermissionState = { export type ComputerUsePermissionStatusResult = { platform: NodeJS.Platform + helperAppPath: string | null + helperUnavailableReason: string | null permissions: ComputerUsePermissionState[] } diff --git a/src/shared/workspace-space-types.ts b/src/shared/workspace-space-types.ts new file mode 100644 index 00000000000..9913ec87200 --- /dev/null +++ b/src/shared/workspace-space-types.ts @@ -0,0 +1,63 @@ +export type WorkspaceSpaceScanStatus = + | 'ok' + | 'missing' + | 'permission-denied' + | 'unavailable' + | 'error' + +export type WorkspaceSpaceItemKind = 'directory' | 'file' | 'symlink' | 'other' + +export type WorkspaceSpaceItem = { + name: string + path: string + kind: WorkspaceSpaceItemKind + sizeBytes: number +} + +export type WorkspaceSpaceWorktree = { + worktreeId: string + repoId: string + repoDisplayName: string + repoPath: string + displayName: string + path: string + branch: string + isMainWorktree: boolean + isRemote: boolean + isSparse: boolean + canDelete: boolean + lastActivityAt: number + status: WorkspaceSpaceScanStatus + error: string | null + scannedAt: number + sizeBytes: number + reclaimableBytes: number + skippedEntryCount: number + topLevelItems: WorkspaceSpaceItem[] + omittedTopLevelItemCount: number + omittedTopLevelSizeBytes: number +} + +export type WorkspaceSpaceRepoSummary = { + repoId: string + displayName: string + path: string + isRemote: boolean + worktreeCount: number + scannedWorktreeCount: number + unavailableWorktreeCount: number + totalSizeBytes: number + reclaimableBytes: number + error: string | null +} + +export type WorkspaceSpaceAnalysis = { + scannedAt: number + totalSizeBytes: number + reclaimableBytes: number + worktreeCount: number + scannedWorktreeCount: number + unavailableWorktreeCount: number + repos: WorkspaceSpaceRepoSummary[] + worktrees: WorkspaceSpaceWorktree[] +} From 8a0b9b6bc56841185c695db9996569737d5d9da6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 00:45:31 +0000 Subject: [PATCH 05/82] release: v1.4.2-rc.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 262e804eaa3..75544a91102 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.2-rc.2", + "version": "1.4.2-rc.3", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 2d9cf720845e6e94829d0feebec1f87f1488e918 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 14 May 2026 19:02:37 -0700 Subject: [PATCH 06/82] fix: verify macOS entitlements before release --- .github/workflows/pr.yml | 3 + .github/workflows/release-cut.yml | 6 + config/scripts/verify-macos-entitlements.mjs | 136 +++++++++++++++++++ package.json | 1 + resources/build/entitlements.mac.plist | 2 - 5 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 config/scripts/verify-macos-entitlements.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b70a92d35dc..22b332c1a21 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -71,6 +71,9 @@ jobs: - name: Check feature wall asset budget run: pnpm check:feature-wall-assets + - name: Verify macOS entitlements + run: pnpm verify:macos-entitlements + - name: Typecheck run: pnpm typecheck diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 0f2dc8ba6c0..5128f74d079 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -634,6 +634,12 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # Why: `plutil -lint` accepts duplicate plist keys, but `codesign` + # rejects duplicate entitlements after the expensive app build. + - name: Verify macOS entitlements + if: matrix.platform == 'mac' + run: pnpm verify:macos-entitlements + # Why: telemetry's transport gate (`src/main/telemetry/client.ts:IS_OFFICIAL_BUILD`) # requires the build identity to be the literal string `stable` or `rc`, # substituted by electron-vite's `define` block at build time. Derive diff --git a/config/scripts/verify-macos-entitlements.mjs b/config/scripts/verify-macos-entitlements.mjs new file mode 100644 index 00000000000..20bb7230510 --- /dev/null +++ b/config/scripts/verify-macos-entitlements.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) +const repoRoot = resolve(__dirname, '../..') + +const defaultPlists = [ + 'resources/build/entitlements.mac.plist', + 'resources/build/entitlements.computer-use.mac.plist' +] + +const plistPaths = process.argv.slice(2) +const pathsToCheck = plistPaths.length > 0 ? plistPaths : defaultPlists + +let failed = false + +for (const plistPath of pathsToCheck) { + const absolutePath = resolve(repoRoot, plistPath) + const xml = readFileSync(absolutePath, 'utf8') + const problems = findDuplicateDictKeys(xml) + + if (problems.length === 0) { + console.log(`${plistPath}: OK`) + continue + } + + failed = true + console.error(`${plistPath}: duplicate plist dict keys found`) + for (const problem of problems) { + console.error( + `- ${problem.key} first appears on line ${problem.firstLine}, duplicated on line ${problem.duplicateLine}` + ) + } +} + +if (failed) { + process.exit(1) +} + +function findDuplicateDictKeys(xml) { + const problems = [] + const lineStarts = buildLineStarts(xml) + const dictStack = [] + // Why: `plutil -lint` accepts duplicate keys, but `codesign` rejects + // duplicate entitlements during release signing. + const tokenPattern = + /||]*>([\s\S]*?)<\/key>|<(\/?)dict\b[^>]*>/g + + for (const match of xml.matchAll(tokenPattern)) { + if (match[0].startsWith(' B[End]\n```') + } finally { + editor.destroy() + } + }) + + it('inserts inline math as a rendered rich editor node', () => { + const editor = runCommand('inline-math') + + try { + expect(editor.getMarkdown()).toBe('$x$') + expect(editor.getJSON()).toMatchObject({ + content: [ + { + content: [ + { + type: 'inlineMath', + attrs: { latex: 'x' } + } + ] + } + ] + }) + } finally { + editor.destroy() + } + }) + + it('inserts display math as a rendered rich editor node', () => { + const editor = runCommand('math-block') + + try { + expect(editor.getMarkdown()).toBe('$$\nx\n$$') + expect(editor.getJSON()).toMatchObject({ + content: [ + { + type: 'blockMath', + attrs: { latex: 'x' } + } + ] + }) + } finally { + editor.destroy() + } + }) + + it('parses existing display math as a rendered rich editor node', () => { + const editor = createEditor('Before\n\n$$\nx + y\n$$\n\nAfter') + + try { + expect(editor.getJSON()).toMatchObject({ + content: [ + { type: 'paragraph' }, + { + type: 'blockMath', + attrs: { latex: 'x + y' } + }, + { type: 'paragraph' } + ] + }) + expect(editor.getMarkdown()).toBe('Before\n\n$$\nx + y\n$$\n\nAfter') + } finally { + editor.destroy() + } + }) + + it('inserts a plain unicode emoji', () => { + const editor = runCommand('emoji') + + try { + expect(editor.getMarkdown()).toBe('🙂') + } finally { + editor.destroy() + } + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-commands.tsx b/src/renderer/src/components/editor/rich-markdown-commands.tsx index aee9405ce83..03da78f3920 100644 --- a/src/renderer/src/components/editor/rich-markdown-commands.tsx +++ b/src/renderer/src/components/editor/rich-markdown-commands.tsx @@ -1,6 +1,20 @@ +/* eslint-disable max-lines -- Why: slash commands and doc-link command helpers share trigger-range contracts used by the editor keyboard path. */ import React from 'react' import type { Editor } from '@tiptap/react' -import { Heading1, Heading2, Heading3, ImageIcon, List, ListOrdered, Quote } from 'lucide-react' +import { TextSelection } from '@tiptap/pm/state' +import type {} from '@tiptap/extension-mathematics' +import { + Heading1, + Heading2, + Heading3, + ImageIcon, + List, + ListOrdered, + Quote, + Sigma, + Table2, + Workflow +} from 'lucide-react' import type { MarkdownDocument } from '../../../../shared/types' import { stripMarkdownExtension } from './markdown-doc-links' @@ -41,16 +55,72 @@ export type SlashCommandId = | 'code-block' | 'divider' | 'image' + | 'table' + | 'mermaid' + | 'inline-math' + | 'math-block' + | 'emoji' + +export type SlashCommandIcon = + | { kind: 'component'; component: React.ComponentType<{ className?: string }> } + | { kind: 'text'; value: string } + +export type SlashCommandGroup = 'Headings' | 'Basic blocks' | 'Advanced' | 'Media' | 'Others' export type SlashCommand = { id: SlashCommandId label: string aliases: string[] - icon: React.ComponentType<{ className?: string }> + icon: SlashCommandIcon + group: SlashCommandGroup description: string run: (editor: Editor) => void } +function icon(component: React.ComponentType<{ className?: string }>): SlashCommandIcon { + return { kind: 'component', component } +} + +function textIcon(value: string): SlashCommandIcon { + return { kind: 'text', value } +} + +function insertTextWithSelection( + editor: Editor, + text: string, + selectionStartOffset?: number, + selectionEndOffset = selectionStartOffset +): void { + editor.commands.command(({ state, dispatch }) => { + const from = state.selection.from + const tr = state.tr.insertText(text, from, state.selection.to) + + if (selectionStartOffset !== undefined) { + const selectionFrom = from + selectionStartOffset + const selectionTo = from + (selectionEndOffset ?? selectionStartOffset) + tr.setSelection(TextSelection.create(tr.doc, selectionFrom, selectionTo)) + } + + dispatch?.(tr.scrollIntoView()) + return true + }) +} + +function insertCodeBlock(editor: Editor, language: string, text: string): void { + editor.commands.command(({ state, dispatch }) => { + const codeBlockType = state.schema.nodes.codeBlock + if (!codeBlockType) { + return false + } + const node = codeBlockType.create({ language }, text ? state.schema.text(text) : undefined) + const tr = state.tr.replaceSelectionWith(node).scrollIntoView() + const cursor = tr.selection.from + 1 + tr.setSelection(TextSelection.create(tr.doc, cursor, cursor)) + dispatch?.(tr) + return true + }) +} + /** * Executes a slash command by first deleting the typed slash text, then * delegating to the command's run method. Image is special-cased because @@ -60,7 +130,8 @@ export function runSlashCommand( editor: Editor, slashMenu: { from: number; to: number }, command: SlashCommand, - onImageCommand?: () => void + onImageCommand?: () => void, + onEmojiCommand?: () => void ): void { editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run() // Why: image insertion cannot rely on window.prompt() in Electron, so this @@ -69,25 +140,20 @@ export function runSlashCommand( onImageCommand() return } + if (command.id === 'emoji' && onEmojiCommand) { + onEmojiCommand() + return + } command.run(editor) } export const slashCommands: SlashCommand[] = [ - { - id: 'text', - label: 'Text', - aliases: ['paragraph', 'plain'], - icon: List, - description: 'Start a normal paragraph.', - run: (editor) => { - editor.chain().focus().setParagraph().run() - } - }, { id: 'heading-1', label: 'Heading 1', aliases: ['h1', 'title'], - icon: Heading1, + icon: icon(Heading1), + group: 'Headings', description: 'Large section heading.', run: (editor) => { // Use setHeading (not toggleHeading) so the slash command is idempotent — @@ -99,7 +165,8 @@ export const slashCommands: SlashCommand[] = [ id: 'heading-2', label: 'Heading 2', aliases: ['h2'], - icon: Heading2, + icon: icon(Heading2), + group: 'Headings', description: 'Medium section heading.', run: (editor) => { // Use setHeading (not toggleHeading) so the slash command is idempotent — @@ -111,7 +178,8 @@ export const slashCommands: SlashCommand[] = [ id: 'heading-3', label: 'Heading 3', aliases: ['h3'], - icon: Heading3, + icon: icon(Heading3), + group: 'Headings', description: 'Small section heading.', run: (editor) => { // Use setHeading (not toggleHeading) so the slash command is idempotent — @@ -119,51 +187,67 @@ export const slashCommands: SlashCommand[] = [ editor.chain().focus().setHeading({ level: 3 }).run() } }, - { - id: 'task-list', - label: 'To-do List', - aliases: ['todo', 'task', 'checkbox'], - icon: List, - description: 'Create a checklist.', - run: (editor) => { - editor.chain().focus().toggleTaskList().run() - } - }, - { - id: 'bullet-list', - label: 'Bullet List', - aliases: ['bullet', 'ul', 'list'], - icon: List, - description: 'Create an unordered list.', - run: (editor) => { - editor.chain().focus().toggleBulletList().run() - } - }, - { - id: 'ordered-list', - label: 'Numbered List', - aliases: ['ordered', 'ol', 'numbered'], - icon: ListOrdered, - description: 'Create an ordered list.', - run: (editor) => { - editor.chain().focus().toggleOrderedList().run() - } - }, { id: 'blockquote', label: 'Quote', aliases: ['quote', 'blockquote'], - icon: Quote, + icon: icon(Quote), + group: 'Basic blocks', description: 'Insert a blockquote.', run: (editor) => { editor.chain().focus().toggleBlockquote().run() } }, + { + id: 'ordered-list', + label: 'Numbered List', + aliases: ['ordered', 'ol', 'numbered'], + icon: icon(ListOrdered), + group: 'Basic blocks', + description: 'Create an ordered list.', + run: (editor) => { + editor.chain().focus().toggleOrderedList().run() + } + }, + { + id: 'bullet-list', + label: 'Bullet List', + aliases: ['bullet', 'ul', 'list'], + icon: icon(List), + group: 'Basic blocks', + description: 'Create an unordered list.', + run: (editor) => { + editor.chain().focus().toggleBulletList().run() + } + }, + { + id: 'task-list', + label: 'Check List', + aliases: ['todo', 'task', 'checkbox'], + icon: icon(List), + group: 'Basic blocks', + description: 'Create a checklist.', + run: (editor) => { + editor.chain().focus().toggleTaskList().run() + } + }, + { + id: 'text', + label: 'Paragraph', + aliases: ['paragraph', 'plain'], + icon: icon(List), + group: 'Basic blocks', + description: 'Start a normal paragraph.', + run: (editor) => { + editor.chain().focus().setParagraph().run() + } + }, { id: 'code-block', label: 'Code Block', aliases: ['code', 'snippet'], - icon: List, + icon: icon(List), + group: 'Basic blocks', description: 'Insert a fenced code block.', run: (editor) => { editor.chain().focus().toggleCodeBlock().run() @@ -173,23 +257,80 @@ export const slashCommands: SlashCommand[] = [ id: 'divider', label: 'Divider', aliases: ['divider', 'rule', 'hr'], - icon: List, + icon: icon(List), + group: 'Basic blocks', description: 'Insert a horizontal rule.', run: (editor) => { editor.chain().focus().setHorizontalRule().run() } }, + { + id: 'table', + label: 'Table', + aliases: ['grid', 'columns', 'rows'], + icon: icon(Table2), + group: 'Advanced', + description: 'Insert a 3x3 markdown table.', + run: (editor) => { + editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() + } + }, + { + id: 'mermaid', + label: 'Mermaid Diagram', + aliases: ['diagram', 'flowchart', 'chart', 'graph'], + icon: icon(Workflow), + group: 'Advanced', + description: 'Insert a Mermaid fenced block.', + run: (editor) => { + insertCodeBlock(editor, 'mermaid', 'graph TD\n A[Start] --> B[End]') + } + }, + { + id: 'inline-math', + label: 'Inline Math', + aliases: ['math', 'latex', 'equation', 'formula'], + icon: icon(Sigma), + group: 'Advanced', + description: 'Insert inline LaTeX math.', + run: (editor) => { + editor.commands.insertInlineMath({ latex: 'x' }) + } + }, + { + id: 'math-block', + label: 'Math Block', + aliases: ['display math', 'latex block', 'equation block'], + icon: icon(Sigma), + group: 'Advanced', + description: 'Insert display LaTeX math.', + run: (editor) => { + editor.commands.insertBlockMath({ latex: 'x' }) + } + }, { id: 'image', label: 'Image', aliases: ['image', 'img'], - icon: ImageIcon, + icon: icon(ImageIcon), + group: 'Media', description: 'Insert an image from your computer.', // Why: window.prompt() is not supported in Electron's renderer process, // so image URL input is handled by an inline input bar in RichMarkdownEditor. run: (editor) => { editor.chain().focus().run() } + }, + { + id: 'emoji', + label: 'Emoji', + aliases: ['smile', 'reaction', 'icon'], + icon: textIcon('🙂'), + group: 'Others', + description: 'Insert a plain Unicode emoji.', + run: (editor) => { + insertTextWithSelection(editor, '🙂') + } } ] diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index 00479bfd89f..4cc560b2636 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -10,6 +10,7 @@ import { Table } from '@tiptap/extension-table' import { TableCell } from '@tiptap/extension-table-cell' import { TableHeader } from '@tiptap/extension-table-header' import { TableRow } from '@tiptap/extension-table-row' +import { BlockMath, InlineMath } from '@tiptap/extension-mathematics' import { Markdown } from '@tiptap/markdown' import { createLowlight, common } from 'lowlight' import { loadLocalImageSrc, onImageCacheInvalidated } from './useLocalImageSrc' @@ -132,6 +133,17 @@ export function createRichMarkdownExtensions({ TableRow, TableHeader, TableCell, + InlineMath.configure({ + katexOptions: { + throwOnError: false + } + }), + BlockMath.configure({ + katexOptions: { + displayMode: true, + throwOnError: false + } + }), RawMarkdownHtmlInline, RawMarkdownHtmlBlock, MarkdownDocLink, diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts index 059c0a0448f..17bfb45df53 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts @@ -45,6 +45,7 @@ function createContext(editor: Editor, typedMarker: boolean): KeyHandlerContext filteredDocLinkRowsRef: { current: [] }, selectedDocLinkIndexRef: { current: 0 }, handleLocalImagePickRef: { current: vi.fn() }, + handleEmojiPickRef: { current: vi.fn() }, typedEmptyOrderedListMarkerRef: { current: typedMarker }, flushPendingSerialization: vi.fn(), openSearchRef: { current: vi.fn() }, @@ -117,4 +118,31 @@ describe('rich markdown key handler', () => { editor.destroy() } }) + + it('keeps slash-menu filter input out of the document', () => { + const editor = createEditor({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: '/' }] }] + }) + + try { + editor.commands.setTextSelection(2) + let slashMenu = { query: '', from: 1, to: 2, left: 0, top: 0 } + const ctx = createContext(editor, false) + ctx.slashMenuRef.current = slashMenu + ctx.filteredSlashCommandsRef.current = [{ id: 'heading-1' } as never] + ctx.setSlashMenu = vi.fn((next) => { + slashMenu = typeof next === 'function' ? next(slashMenu) : next + ctx.slashMenuRef.current = slashMenu + }) + const event = keyEvent('h') + + expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(true) + expect(event.preventDefault).toHaveBeenCalled() + expect(editor.getText()).toBe('/') + expect(ctx.slashMenuRef.current?.query).toBe('h') + } finally { + editor.destroy() + } + }) }) diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts index 70591cb00f9..c1bbd9851e1 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts @@ -31,6 +31,7 @@ export type KeyHandlerContext = { filteredDocLinkRowsRef: MutableRefObject selectedDocLinkIndexRef: MutableRefObject handleLocalImagePickRef: MutableRefObject<() => void> + handleEmojiPickRef: MutableRefObject<(menu: SlashMenuState) => void> typedEmptyOrderedListMarkerRef: MutableRefObject flushPendingSerialization: () => void openSearchRef: MutableRefObject<() => void> @@ -38,7 +39,7 @@ export type KeyHandlerContext = { setLinkBubble: (bubble: LinkBubbleState | null) => void setSelectedCommandIndex: Dispatch> setSelectedDocLinkIndex: Dispatch> - setSlashMenu: (menu: SlashMenuState | null) => void + setSlashMenu: Dispatch> setDocLinkMenu: (menu: DocLinkMenuState | null) => void } @@ -231,6 +232,20 @@ export function createRichMarkdownKeyHandler( return false } + if (!event.metaKey && !event.ctrlKey && !event.altKey && event.key.length === 1) { + event.preventDefault() + ctx.setSlashMenu((menu) => (menu ? { ...menu, query: `${menu.query}${event.key}` } : menu)) + ctx.setSelectedCommandIndex(0) + return true + } + + if (event.key === 'Backspace' && currentSlashMenu.query.length > 0) { + event.preventDefault() + ctx.setSlashMenu((menu) => (menu ? { ...menu, query: menu.query.slice(0, -1) } : menu)) + ctx.setSelectedCommandIndex(0) + return true + } + const currentFilteredSlashCommands = ctx.filteredSlashCommandsRef.current if (currentFilteredSlashCommands.length === 0) { return false @@ -265,8 +280,12 @@ export function createRichMarkdownKeyHandler( // mirrors the latest highlighted slash-menu item for keyboard picks. const selectedCommand = currentFilteredSlashCommands[ctx.selectedCommandIndexRef.current] if (selectedCommand) { - runSlashCommand(activeEditor, currentSlashMenu, selectedCommand, () => - ctx.handleLocalImagePickRef.current() + runSlashCommand( + activeEditor, + currentSlashMenu, + selectedCommand, + () => ctx.handleLocalImagePickRef.current(), + () => ctx.handleEmojiPickRef.current(currentSlashMenu) ) } return true From b7a00aaf0e65be033ec89c7428c4884dcbbef7bb Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 14 May 2026 22:15:44 -0700 Subject: [PATCH 16/82] fix: normalize empty markdown list items (#1897) --- .../editor/rich-markdown-normalize.test.ts | 35 +++++++++++++++++ .../editor/rich-markdown-normalize.ts | 38 +++++++++++++++---- 2 files changed, 65 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-normalize.test.ts diff --git a/src/renderer/src/components/editor/rich-markdown-normalize.test.ts b/src/renderer/src/components/editor/rich-markdown-normalize.test.ts new file mode 100644 index 00000000000..7f81eb795c0 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-normalize.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { Editor } from '@tiptap/core' +import StarterKit from '@tiptap/starter-kit' +import { Markdown } from '@tiptap/markdown' +import { normalizeSoftBreaks } from './rich-markdown-normalize' + +const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })] + +function createEditor(content: string): Editor { + return new Editor({ + element: null, + extensions, + content, + contentType: 'markdown' + }) +} + +describe('rich markdown normalization', () => { + it('normalizes empty ordered list items into caret targets', () => { + const editor = createEditor('1. Item 1\n2. Item 2\n3. \n\n## Next section\n') + + try { + normalizeSoftBreaks(editor) + + const list = editor.state.doc.child(0) + const emptyItem = list.child(2) + expect(emptyItem.type.name).toBe('listItem') + expect(emptyItem.childCount).toBe(1) + expect(emptyItem.child(0).type.name).toBe('paragraph') + expect(emptyItem.child(0).content.size).toBe(0) + } finally { + editor.destroy() + } + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-normalize.ts b/src/renderer/src/components/editor/rich-markdown-normalize.ts index a87aa972020..a3ead9a798b 100644 --- a/src/renderer/src/components/editor/rich-markdown-normalize.ts +++ b/src/renderer/src/components/editor/rich-markdown-normalize.ts @@ -10,8 +10,9 @@ import { Fragment, type Node as PmNode } from '@tiptap/pm/model' * of just one logical line. * * This function normalises the ProseMirror document by splitting any paragraph whose - * text nodes contain `\n` into separate paragraph nodes — one per line. Inline marks - * (bold, italic, links, etc.) are preserved on each resulting paragraph. This is + * text nodes contain `\n` into separate paragraph nodes — one per line — and by + * giving empty parsed list items a paragraph caret target. Inline marks (bold, + * italic, links, etc.) are preserved on each resulting paragraph. This is * structurally correct for the editing model: each visual line becomes its own block, * so the cut handler (and all other block-level operations) work on a per-line basis. */ @@ -30,9 +31,24 @@ export function normalizeSoftBreaks(editor: Editor): void { // Why: doc.forEach only iterates top-level children, so paragraphs nested inside // blockquotes, table cells, or other container nodes would be missed. // doc.descendants walks every node at every depth and provides absolute positions. - const replacements: { from: number; to: number; paragraphs: Fragment[] }[] = [] + const replacements: ( + | { from: number; to: number; kind: 'soft-break-paragraphs'; paragraphs: Fragment[] } + | { from: number; to: number; kind: 'empty-list-item'; node: PmNode } + )[] = [] doc.descendants((node, pos) => { + if (node.type.name === 'listItem' && node.childCount === 0) { + // Why: marked parses `3. ` immediately before a heading as a list item + // with no paragraph. It renders a marker but has no editable caret target. + replacements.push({ + from: pos, + to: pos + node.nodeSize, + kind: 'empty-list-item', + node: node.type.create(node.attrs, paragraphType.create(), node.marks) + }) + return false + } + if (node.type !== paragraphType) { return true // continue descending into container nodes } @@ -77,6 +93,7 @@ export function normalizeSoftBreaks(editor: Editor): void { replacements.push({ from: pos, to: pos + node.nodeSize, + kind: 'soft-break-paragraphs', paragraphs: lines }) @@ -90,11 +107,16 @@ export function normalizeSoftBreaks(editor: Editor): void { // Capture the transaction lazily — only after all replacements are collected. const tr = editor.view.state.tr - // Apply replacements in reverse order to preserve positions. - for (let i = replacements.length - 1; i >= 0; i--) { - const { from, to, paragraphs } = replacements[i] - const newNodes = paragraphs.map((content) => paragraphType.create(null, content)) - tr.replaceWith(from, to, newNodes) + // Apply replacements in reverse document order to preserve positions. + replacements.sort((a, b) => b.from - a.from) + for (const replacement of replacements) { + if (replacement.kind === 'empty-list-item') { + tr.replaceWith(replacement.from, replacement.to, replacement.node) + continue + } + + const newNodes = replacement.paragraphs.map((content) => paragraphType.create(null, content)) + tr.replaceWith(replacement.from, replacement.to, newNodes) } // Why: this normalization is a structural housekeeping step, not a user edit. From a9b47759fa88e1b0478dbddd3be8b6d710dbf7d8 Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 22:34:22 -0700 Subject: [PATCH 17/82] fix: pr-bug-scan validated finding from #1857 (#1886) * fix: address pr-bug-scan validated finding from #1857 Restored alt-buffer guards at both the double-rAF and 200ms phases of scheduleSplitScrollRestore so TUI panes skip restoreScrollState+refresh, blocking the #1298 cursor-drift regression. * fix: preserve split scroll state around alternate buffers --------- Co-authored-by: orca-bug-scan-bot Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- .../pane-fit-resize-observer.test.ts | 1 + .../lib/pane-manager/pane-manager-types.ts | 1 + .../src/lib/pane-manager/pane-scroll.ts | 3 +- .../pane-manager/pane-split-scroll.test.ts | 180 ++++++++++++++++++ .../src/lib/pane-manager/pane-split-scroll.ts | 83 +++++++- .../lib/pane-manager/pane-tree-ops.test.ts | 1 + 6 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts index c2cdb6e03a8..2085a3fa407 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts @@ -62,6 +62,7 @@ function createPane(): ManagedPaneInternal { compositionHandler: null, debugLabel: null, pendingSplitScrollState: { + bufferType: 'normal', wasAtBottom: true, firstVisibleLineContent: '', viewportY: 0, diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 32041bd811f..08d1661f135 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -67,6 +67,7 @@ export type ManagedPane = { // --------------------------------------------------------------------------- export type ScrollState = { + bufferType: 'normal' | 'alternate' wasAtBottom: boolean firstVisibleLineContent: string viewportY: number diff --git a/src/renderer/src/lib/pane-manager/pane-scroll.ts b/src/renderer/src/lib/pane-manager/pane-scroll.ts index 6f430476246..ff895174c1e 100644 --- a/src/renderer/src/lib/pane-manager/pane-scroll.ts +++ b/src/renderer/src/lib/pane-manager/pane-scroll.ts @@ -51,11 +51,12 @@ export function findLineByContent(terminal: Terminal, content: string, hintRatio export function captureScrollState(terminal: Terminal): ScrollState { const buf = terminal.buffer.active + const bufferType = buf.type const viewportY = buf.viewportY const wasAtBottom = viewportY >= buf.baseY const firstVisibleLineContent = buf.getLine(viewportY)?.translateToString(true)?.trimEnd() ?? '' const totalLines = buf.baseY + terminal.rows - return { wasAtBottom, firstVisibleLineContent, viewportY, totalLines } + return { bufferType, wasAtBottom, firstVisibleLineContent, viewportY, totalLines } } export function restoreScrollState(terminal: Terminal, state: ScrollState): void { diff --git a/src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts b/src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts new file mode 100644 index 00000000000..4b059bb2991 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPaneInternal, ScrollState } from './pane-manager-types' + +const restoreScrollState = vi.hoisted(() => vi.fn()) + +vi.mock('./pane-scroll', () => ({ + restoreScrollState +})) + +import { scheduleSplitScrollRestore } from './pane-split-scroll' + +const scrollState = { + bufferType: 'normal', + wasAtBottom: true, + firstVisibleLineContent: '', + viewportY: 0, + totalLines: 24 +} satisfies ScrollState + +const alternateScrollState = { + ...scrollState, + bufferType: 'alternate' +} satisfies ScrollState + +function createPane(bufferType: 'normal' | 'alternate'): { + pane: ManagedPaneInternal + bufferChangeDisposable: { dispose: ReturnType } + triggerBufferChange: (bufferType: 'normal' | 'alternate') => void +} { + let bufferChangeHandler: ((buffer: { type: 'normal' | 'alternate' }) => void) | null = null + const bufferChangeDisposable = { dispose: vi.fn() } + const pane: ManagedPaneInternal = { + id: 1, + terminal: { + rows: 24, + refresh: vi.fn(), + buffer: { + active: { + type: bufferType, + length: 24 + }, + onBufferChange: vi.fn((handler: (buffer: { type: 'normal' | 'alternate' }) => void) => { + bufferChangeHandler = handler + return bufferChangeDisposable + }) + } + } as never, + container: { + querySelectorAll: vi.fn(() => []) + } as never, + xtermContainer: {} as never, + linkTooltip: {} as never, + terminalGpuAcceleration: 'auto', + gpuRenderingEnabled: true, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + webglAddon: null, + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + fitAddon: {} as never, + searchAddon: {} as never, + serializeAddon: { + serialize: vi.fn(() => '') + } as never, + unicode11Addon: {} as never, + webLinksAddon: {} as never, + compositionHandler: null, + pendingSplitScrollState: scrollState, + debugLabel: null + } + return { + pane, + bufferChangeDisposable, + triggerBufferChange: (bufferType) => bufferChangeHandler?.({ type: bufferType }) + } +} + +describe('scheduleSplitScrollRestore', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(16) + return 1 + }) + restoreScrollState.mockClear() + }) + + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('restores and refreshes normal-screen panes after split reparenting settles', () => { + const { pane } = createPane('normal') + const reattachWebgl = vi.fn() + + scheduleSplitScrollRestore( + () => pane, + pane.id, + scrollState, + () => false, + reattachWebgl + ) + + expect(restoreScrollState).toHaveBeenCalledTimes(1) + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) + + vi.advanceTimersByTime(200) + + expect(pane.pendingSplitScrollState).toBeNull() + expect(reattachWebgl).toHaveBeenCalledWith(pane) + expect(restoreScrollState).toHaveBeenCalledTimes(2) + expect(pane.terminal.refresh).toHaveBeenCalledTimes(2) + }) + + it('defers WebGL reattach and skips scroll restore for alternate-screen panes', () => { + const { pane, bufferChangeDisposable, triggerBufferChange } = createPane('alternate') + const reattachWebgl = vi.fn() + + scheduleSplitScrollRestore( + () => pane, + pane.id, + alternateScrollState, + () => false, + reattachWebgl + ) + + expect(restoreScrollState).not.toHaveBeenCalled() + expect(pane.terminal.refresh).not.toHaveBeenCalled() + expect(pane.pendingSplitScrollState).toBe(scrollState) + + vi.advanceTimersByTime(200) + + expect(pane.pendingSplitScrollState).toBeNull() + expect(reattachWebgl).not.toHaveBeenCalled() + expect(restoreScrollState).not.toHaveBeenCalled() + expect(pane.terminal.refresh).not.toHaveBeenCalled() + + triggerBufferChange('alternate') + + expect(reattachWebgl).not.toHaveBeenCalled() + expect(bufferChangeDisposable.dispose).not.toHaveBeenCalled() + + triggerBufferChange('normal') + + expect(bufferChangeDisposable.dispose).toHaveBeenCalledTimes(1) + expect(reattachWebgl).toHaveBeenCalledWith(pane) + expect(restoreScrollState).not.toHaveBeenCalled() + expect(pane.terminal.refresh).not.toHaveBeenCalled() + }) + + it('defers normal-screen scroll restore until an active TUI exits alternate screen', () => { + const { pane, triggerBufferChange } = createPane('alternate') + const reattachWebgl = vi.fn() + + scheduleSplitScrollRestore( + () => pane, + pane.id, + scrollState, + () => false, + reattachWebgl + ) + + vi.advanceTimersByTime(200) + + expect(pane.pendingSplitScrollState).toBe(scrollState) + expect(restoreScrollState).not.toHaveBeenCalled() + expect(pane.terminal.refresh).not.toHaveBeenCalled() + + triggerBufferChange('normal') + + expect(pane.pendingSplitScrollState).toBeNull() + expect(reattachWebgl).toHaveBeenCalledWith(pane) + expect(restoreScrollState).toHaveBeenCalledWith(pane.terminal, scrollState) + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-split-scroll.ts b/src/renderer/src/lib/pane-manager/pane-split-scroll.ts index ac102135d15..b8b00747cad 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-scroll.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-scroll.ts @@ -1,3 +1,4 @@ +import type { IBuffer, IDisposable } from '@xterm/xterm' import type { ManagedPaneInternal, ScrollState } from './pane-manager-types' import { restoreScrollState } from './pane-scroll' @@ -9,6 +10,43 @@ function refreshAfterReparent(pane: ManagedPaneInternal): void { } } +function runAfterNormalBuffer( + pane: ManagedPaneInternal, + getPaneById: (id: number) => ManagedPaneInternal | undefined, + paneId: number, + isDestroyed: () => boolean, + callback: (pane: ManagedPaneInternal) => void +): void { + let disposable: IDisposable | null = null + disposable = pane.terminal.buffer.onBufferChange((buffer: IBuffer) => { + if (buffer.type === 'alternate') { + return + } + disposable?.dispose() + disposable = null + if (isDestroyed()) { + return + } + const live = getPaneById(paneId) + if (live) { + callback(live) + } + }) +} + +function restoreCapturedScrollState( + pane: ManagedPaneInternal, + scrollState: ScrollState, + reattachWebgl?: (pane: ManagedPaneInternal) => void +): void { + pane.pendingSplitScrollState = null + if (reattachWebgl) { + reattachWebgl(pane) + } + restoreScrollState(pane.terminal, scrollState) + refreshAfterReparent(pane) +} + function logPaneHealth(pane: ManagedPaneInternal, phase: string): void { const canvases = pane.container.querySelectorAll('canvas') const canvasInfo = Array.from(canvases).map((c) => { @@ -89,10 +127,19 @@ export function scheduleSplitScrollRestore( return } const live = getPaneById(paneId) - if (live?.pendingSplitScrollState) { - restoreScrollState(live.terminal, scrollState) - refreshAfterReparent(live) + if (!live?.pendingSplitScrollState) { + return } + // Why: see the 200ms timer below — the alt-screen buffer belongs to a + // TUI and restore-during-draw knocks its cursor one row off (#1298). + if ( + scrollState.bufferType === 'alternate' || + live.terminal.buffer.active.type === 'alternate' + ) { + return + } + restoreScrollState(live.terminal, scrollState) + refreshAfterReparent(live) }) }) @@ -104,12 +151,32 @@ export function scheduleSplitScrollRestore( if (!live) { return } - live.pendingSplitScrollState = null - if (reattachWebgl) { - reattachWebgl(live) + // Why: the alt-screen buffer belongs to a full-screen TUI (Claude Code, + // vim, less) that owns its cursor position. Re-running scroll restore + // and a full refresh here clobbers an in-progress draw — refresh(0, + // rows-1) repaints rows from xterm's buffer, racing the TUI's next + // write and leaving its cursor one row off (#1298 regression). + // WebGL reattach also refreshes, so defer it until the TUI exits the + // alternate buffer. Alt-screen has no scrollback, so scroll restore has + // nothing legitimate to do. + if (scrollState.bufferType === 'alternate') { + live.pendingSplitScrollState = null + if (live.terminal.buffer.active.type === 'alternate' && reattachWebgl) { + runAfterNormalBuffer(live, getPaneById, paneId, isDestroyed, reattachWebgl) + return + } + if (reattachWebgl) { + reattachWebgl(live) + } + return } - restoreScrollState(live.terminal, scrollState) - refreshAfterReparent(live) + if (live.terminal.buffer.active.type === 'alternate') { + runAfterNormalBuffer(live, getPaneById, paneId, isDestroyed, (normalPane) => { + restoreCapturedScrollState(normalPane, scrollState, reattachWebgl) + }) + return + } + restoreCapturedScrollState(live, scrollState, reattachWebgl) }, 200) setTimeout(() => { diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts index 695f3aaeb2d..f0c78cce205 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts @@ -106,6 +106,7 @@ describe('safeFit', () => { terminalRows: 32 }) pane.pendingSplitScrollState = { + bufferType: 'normal', wasAtBottom: true, firstVisibleLineContent: '', viewportY: 0, From 89827117e589b1b1ddc9adc42f8aa87efa261ac9 Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 22:54:34 -0700 Subject: [PATCH 18/82] fix: pr-bug-scan validated finding from #1865 (#1888) * fix: address pr-bug-scan validated finding from #1865 SIGKILL fallback in graceful pty.shutdown now emits pty.exit before notifyExitListener; renderer sees the exit even though onExit short-circuits on disposed. * test: cover relay pty kill fallback exit --------- Co-authored-by: orca-bug-scan-bot Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- src/relay/pty-handler.test.ts | 26 ++++++++++++++++++++++++++ src/relay/pty-handler.ts | 5 +++++ 2 files changed, 31 insertions(+) diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index e806237a429..d52b5b1ea5b 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -284,6 +284,32 @@ describe('PtyHandler', () => { expect(mockKill).toHaveBeenCalledWith('SIGTERM') }) + it('notifies pty.exit when graceful shutdown falls back to SIGKILL', async () => { + let onExitCb: ((evt: { exitCode: number }) => void) | undefined + const mockKill = vi.fn() + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + kill: mockKill, + onData: vi.fn(), + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCb = cb + }) + }) + const exits: { id: string; paneKey?: string }[] = [] + handler.setExitListener((evt) => exits.push(evt)) + + await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-fallback:0' } }) + await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false }) + vi.advanceTimersByTime(5000) + onExitCb!({ exitCode: 137 }) + + expect(mockKill).toHaveBeenCalledWith('SIGTERM') + expect(mockKill).toHaveBeenCalledWith('SIGKILL') + expect(dispatcher.notify).toHaveBeenCalledWith('pty.exit', { id: 'pty-1', code: -1 }) + expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-fallback:0' }]) + expect(handler.activePtyCount).toBe(0) + }) + it('kills PTY on shutdown with SIGKILL when immediate', async () => { const mockKill = vi.fn() mockPtySpawn.mockReturnValue({ diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index b1b540cb867..deb6658b838 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -444,6 +444,11 @@ export class PtyHandler { const still = this.ptys.get(id) if (still && !still.disposed) { still.pty.kill('SIGKILL') + // Why: emit pty.exit BEFORE disposeManagedPty sets disposed=true. + // The natural onExit short-circuits on `managed.disposed`, so + // without this notify the renderer never learns the pane is dead + // when the SIGKILL fallback fires for a SIGTERM-ignoring child. + this.dispatcher.notify('pty.exit', { id, code: -1 }) // Why: if SIGKILL's onExit never fires (kernel edge case, // uninterruptible sleep, child wedged on a bad NFS mount), the // fd and map entry would leak forever. Dispose synchronously so From a22717bb35d50fcef57348039bbe0f4a7c4fa714 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 15 May 2026 02:13:37 -0400 Subject: [PATCH 19/82] Refactor runtime app architecture (#1878) Co-authored-by: Orca --- .gitignore | 3 + config/electron-builder.config.cjs | 4 + .../expo-two-way-audio/ios/AudioEngine.swift | 116 +- mobile/scripts/test-subscribe.ts | 9 +- mobile/src/transport/protocol-version.ts | 10 +- package.json | 2 +- src/cli/args.ts | 3 +- src/cli/dispatch.ts | 2 + src/cli/format.ts | 28 + src/cli/handlers/core.ts | 27 + src/cli/handlers/environment.ts | 75 + src/cli/handlers/repo.ts | 31 +- src/cli/handlers/terminal.ts | 6 + src/cli/help.ts | 15 + src/cli/index.test.ts | 369 +- src/cli/index.ts | 14 +- src/cli/runtime-client.ts | 1 + src/cli/runtime/client.ts | 137 +- src/cli/runtime/envelope-schema.ts | 67 +- src/cli/runtime/environments.test.ts | 68 + src/cli/runtime/environments.ts | 81 + src/cli/runtime/index.ts | 1 + src/cli/runtime/launch.test.ts | 88 + src/cli/runtime/launch.ts | 89 +- src/cli/runtime/status.ts | 2 +- src/cli/runtime/transport.ts | 6 +- src/cli/runtime/types.ts | 33 +- src/cli/runtime/websocket-transport.test.ts | 237 ++ src/cli/runtime/websocket-transport.ts | 22 + src/cli/selectors.ts | 29 +- src/cli/specs/core.ts | 18 + src/cli/specs/environment.ts | 30 + src/cli/specs/index.ts | 2 + src/main/cli/packaged-cli-assets.test.ts | 19 + src/main/git/status.test.ts | 12 +- src/main/git/status.ts | 13 +- src/main/index.ts | 202 +- .../ipc/filesystem-import-ssh-ops.test.ts | 11 +- src/main/ipc/filesystem-import-ssh.test.ts | 49 +- src/main/ipc/filesystem-import-ssh.ts | 10 +- src/main/ipc/filesystem-import.test.ts | 290 +- src/main/ipc/filesystem-mutations.test.ts | 46 +- src/main/ipc/filesystem-mutations.ts | 350 +- src/main/ipc/filesystem-watcher.test.ts | 31 + src/main/ipc/filesystem-watcher.ts | 12 +- src/main/ipc/mobile.test.ts | 72 + src/main/ipc/mobile.ts | 41 +- src/main/ipc/pty.ts | 35 + src/main/ipc/register-core-handlers.test.ts | 8 + src/main/ipc/register-core-handlers.ts | 2 + .../ipc/runtime-environment-call-queue.ts | 92 + ...runtime-environment-request-connections.ts | 46 + src/main/ipc/runtime-environments.test.ts | 615 +++ src/main/ipc/runtime-environments.ts | 313 ++ src/main/ipc/runtime.test.ts | 32 +- src/main/ipc/runtime.ts | 18 + src/main/ipc/worktree-remote.ts | 4 +- src/main/powershell-osc133-bootstrap.test.ts | 11 +- src/main/powershell-osc133-bootstrap.ts | 10 +- .../providers/ssh-filesystem-provider.test.ts | 167 + src/main/providers/ssh-filesystem-provider.ts | 94 +- src/main/providers/types.ts | 3 + src/main/providers/windows-shell-args.test.ts | 7 +- src/main/repo-worktrees.ts | 8 + src/main/runtime/device-registry.ts | 37 +- src/main/runtime/e2ee-keypair.ts | 7 +- src/main/runtime/mobile-presence-lock.test.ts | 21 + src/main/runtime/orca-runtime-browser.ts | 1455 +++++++ src/main/runtime/orca-runtime-files.test.ts | 112 + src/main/runtime/orca-runtime-files.ts | 918 +++++ src/main/runtime/orca-runtime-git.ts | 308 ++ src/main/runtime/orca-runtime.test.ts | 554 ++- src/main/runtime/orca-runtime.ts | 3586 +++++++++-------- .../orchestration-cli-subprocess.test.ts} | 14 +- ...ime-request-connection.integration.test.ts | 67 + src/main/runtime/rpc/core.ts | 8 + src/main/runtime/rpc/dispatcher.ts | 14 +- src/main/runtime/rpc/e2ee-channel.test.ts | 16 +- src/main/runtime/rpc/e2ee-channel.ts | 13 + src/main/runtime/rpc/e2ee-crypto.ts | 61 +- src/main/runtime/rpc/methods/browser-core.ts | 16 + .../runtime/rpc/methods/browser-schemas.ts | 6 + src/main/runtime/rpc/methods/browser.test.ts | 144 + .../rpc/methods/file-watch-event-batcher.ts | 65 + src/main/runtime/rpc/methods/files.test.ts | 394 ++ src/main/runtime/rpc/methods/files.ts | 286 +- src/main/runtime/rpc/methods/git.test.ts | 157 + src/main/runtime/rpc/methods/git.ts | 175 + src/main/runtime/rpc/methods/github.test.ts | 519 +++ src/main/runtime/rpc/methods/github.ts | 442 ++ .../runtime/rpc/methods/hosted-review.test.ts | 48 + src/main/runtime/rpc/methods/hosted-review.ts | 26 + src/main/runtime/rpc/methods/index.ts | 8 + src/main/runtime/rpc/methods/linear.test.ts | 101 + src/main/runtime/rpc/methods/linear.ts | 136 + src/main/runtime/rpc/methods/notes.test.ts | 148 + src/main/runtime/rpc/methods/notes.ts | 88 +- .../runtime/rpc/methods/orchestration.test.ts | 36 + src/main/runtime/rpc/methods/orchestration.ts | 6 + src/main/runtime/rpc/methods/repo.test.ts | 96 + src/main/runtime/rpc/methods/repo.ts | 94 +- src/main/runtime/rpc/methods/terminal.ts | 642 ++- src/main/runtime/rpc/methods/worktree.test.ts | 64 + src/main/runtime/rpc/methods/worktree.ts | 104 +- src/main/runtime/rpc/streaming.test.ts | 55 + .../runtime/rpc/terminal-multiplex.test.ts | 395 ++ .../rpc/terminal-output-batching.test.ts | 249 ++ src/main/runtime/rpc/terminal-send.test.ts | 104 + .../rpc/terminal-subscribe-buffer.test.ts | 104 + src/main/runtime/rpc/ws-transport.test.ts | 108 +- src/main/runtime/rpc/ws-transport.ts | 84 +- src/main/runtime/runtime-metadata.test.ts | 95 +- src/main/runtime/runtime-metadata.ts | 97 +- src/main/runtime/runtime-relative-paths.ts | 29 + src/main/runtime/runtime-rpc.test.ts | 484 +++ src/main/runtime/runtime-rpc.ts | 245 +- src/main/ssh/sftp-upload.test.ts | 63 + src/main/ssh/sftp-upload.ts | 114 +- src/main/ssh/ssh-relay-session.ts | 16 +- src/preload/api-types.ts | 71 + src/preload/index.ts | 79 + .../runtime-environment-subscriptions.test.ts | 103 + .../runtime-environment-subscriptions.ts | 99 + src/relay/fs-handler.test.ts | 64 + src/relay/fs-handler.ts | 30 +- src/renderer/src/App.tsx | 8 +- src/renderer/src/app-startup-routing.test.ts | 20 + .../src/components/GitHubItemDialog.tsx | 304 +- .../src/components/LinearItemDrawer.tsx | 53 +- src/renderer/src/components/QuickOpen.tsx | 20 +- src/renderer/src/components/TaskPage.tsx | 107 +- .../TelemetryFirstLaunchSurface.tsx | 5 +- src/renderer/src/components/Terminal.tsx | 9 +- .../src/components/WorktreeJumpPalette.tsx | 64 +- .../automations/CreateFromPicker.tsx | 18 +- .../components/browser-pane/BrowserPane.tsx | 524 ++- .../components/editor/CombinedDiffViewer.tsx | 70 +- .../src/components/editor/EditorContent.tsx | 1 + .../src/components/editor/EditorPanel.tsx | 136 +- .../src/components/editor/MarkdownPreview.tsx | 61 +- .../editor/MonacoGutterContextMenu.tsx | 16 +- .../components/editor/RichMarkdownEditor.tsx | 58 +- .../editor/UntitledFileRenameDialog.tsx | 7 +- .../editor/editor-autosave-controller.test.ts | 74 + .../editor/editor-autosave-controller.ts | 24 +- .../editor/rich-markdown-extensions.ts | 8 +- .../src/components/editor/useLinkBubble.ts | 5 +- .../components/editor/useLocalImagePick.ts | 62 +- .../editor/useLocalImageSrc.test.ts | 25 + .../src/components/editor/useLocalImageSrc.ts | 72 +- .../components/editor/useMarkdownDocuments.ts | 48 +- .../FloatingTerminalPanel.tsx | 6 +- .../components/github-project/ProjectCell.tsx | 105 +- .../github-project/ProjectPicker.tsx | 75 +- .../github-project/ProjectViewWrapper.tsx | 36 +- .../slug-dialog/AssigneesEditor.tsx | 51 +- .../github-project/slug-dialog/Comments.tsx | 48 +- .../slug-dialog/LabelsEditor.tsx | 41 +- .../components/github/GitHubRateLimitPill.tsx | 51 +- .../new-workspace/SmartWorkspaceNameField.tsx | 152 +- .../notes/ProjectNotesTabContent.tsx | 42 +- .../components/onboarding/OnboardingFlow.tsx | 6 + .../src/components/onboarding/RepoStep.tsx | 120 +- .../onboarding/use-onboarding-flow.ts | 119 +- .../components/right-sidebar/ChecksPanel.tsx | 20 +- .../components/right-sidebar/FileExplorer.tsx | 2 +- .../right-sidebar/FileExplorerRow.tsx | 22 +- .../components/right-sidebar/NotesPanel.tsx | 18 +- .../components/right-sidebar/PRActions.tsx | 22 +- .../src/components/right-sidebar/Search.tsx | 36 +- .../right-sidebar/SourceControl.tsx | 137 +- .../right-sidebar/git-status-refresh.ts | 9 +- .../right-sidebar/useFileDeletion.ts | 46 +- .../right-sidebar/useFileDuplicate.ts | 18 +- .../right-sidebar/useFileExplorerDragDrop.ts | 13 +- .../right-sidebar/useFileExplorerImport.ts | 28 +- .../useFileExplorerInlineInput.ts | 23 +- .../right-sidebar/useFileExplorerTree.ts | 12 +- .../useFileExplorerWatch.test.ts | 40 + .../right-sidebar/useFileExplorerWatch.ts | 126 +- .../right-sidebar/useGitStatusPolling.test.ts | 8 +- .../right-sidebar/useGitStatusPolling.ts | 6 +- .../src/components/settings/BaseRefPicker.tsx | 21 +- .../settings/ManageSessionsSection.tsx | 36 +- .../settings/RepositoryHooksSection.tsx | 14 +- .../settings/RuntimeEnvironmentsPane.tsx | 413 ++ .../src/components/settings/Settings.tsx | 35 +- .../settings/WorktreeSymlinksSection.tsx | 8 +- .../components/sidebar/AddRepoCreateStep.tsx | 50 +- .../src/components/sidebar/AddRepoDialog.tsx | 144 +- .../src/components/sidebar/AddRepoSteps.tsx | 5 +- .../src/components/sidebar/WorktreeCard.tsx | 5 +- .../sidebar/WorktreeContextMenu.tsx | 11 +- .../src/components/sidebar/WorktreeList.tsx | 11 +- .../status-bar/ResourceUsageStatusSegment.tsx | 83 +- .../src/components/tab-bar/EditorFileTab.tsx | 16 +- .../src/components/tab-bar/TabBar.tsx | 6 +- .../editor-tab-local-open-guard.test.ts | 22 + .../tab-bar/editor-tab-local-open-guard.ts | 13 + .../tab-group/useTabGroupWorkspaceModel.ts | 26 +- .../components/terminal-pane/TerminalPane.tsx | 60 +- .../terminal-pane/pty-connection.test.ts | 74 +- .../terminal-pane/pty-connection.ts | 79 +- .../terminal-pane/pty-transport.test.ts | 220 + .../components/terminal-pane/pty-transport.ts | 286 +- .../remote-runtime-pty-batching.ts | 81 + .../remote-runtime-pty-binary-control.ts | 47 + .../remote-runtime-pty-transport.test.ts | 512 +++ .../remote-runtime-pty-transport.ts | 324 ++ .../terminal-pane/resolve-split-cwd.test.ts | 20 +- .../terminal-pane/resolve-split-cwd.ts | 3 +- .../terminal-drop-handler.test.ts | 166 + .../terminal-pane/terminal-drop-handler.ts | 139 +- .../terminal-link-handlers.test.ts | 150 +- .../terminal-pane/terminal-link-handlers.ts | 70 +- .../use-terminal-pane-lifecycle.ts | 20 +- src/renderer/src/hooks/useComposerState.ts | 353 +- .../src/hooks/useEditorExternalWatch.test.ts | 108 +- .../src/hooks/useEditorExternalWatch.ts | 111 +- .../src/hooks/useGitHubSlugMetadata.ts | 70 +- .../src/hooks/useGlobalFileDrop.test.ts | 20 + src/renderer/src/hooks/useGlobalFileDrop.ts | 82 +- src/renderer/src/hooks/useIpcEvents.ts | 98 +- src/renderer/src/hooks/useIssueMetadata.ts | 157 +- src/renderer/src/lib/agent-paste-draft.ts | 28 +- src/renderer/src/lib/agent-ready-wait.ts | 7 +- .../src/lib/codex-session-restart.test.ts | 68 +- src/renderer/src/lib/codex-session-restart.ts | 7 +- .../src/lib/create-untitled-markdown.test.ts | 89 +- .../src/lib/create-untitled-markdown.ts | 17 +- .../src/lib/ensure-hooks-confirmed.ts | 5 +- .../src/lib/http-link-routing.test.ts | 14 +- src/renderer/src/lib/http-link-routing.ts | 8 +- .../launch-agent-background-session.test.ts | 73 +- .../lib/launch-agent-background-session.ts | 101 +- .../src/lib/launch-work-item-direct.ts | 3 +- .../src/lib/local-path-open-guard.test.ts | 18 + src/renderer/src/lib/local-path-open-guard.ts | 15 + src/renderer/src/lib/new-workspace.ts | 12 +- .../src/lib/open-project-notes-tab.ts | 6 +- src/renderer/src/lib/rename-file.ts | 13 +- src/renderer/src/lib/repo-slug-index.ts | 65 +- src/renderer/src/lib/workspace-session.ts | 3 +- .../src/runtime/mobile-markdown-bridge.ts | 8 +- .../src/runtime/remote-file-client.ts | 22 + .../remote-runtime-terminal-multiplexer.ts | 379 ++ .../runtime-compatibility-test-fixture.ts | 37 + .../src/runtime/runtime-file-client.test.ts | 1186 ++++++ .../src/runtime/runtime-file-client.ts | 860 ++++ .../src/runtime/runtime-git-client.test.ts | 144 + .../src/runtime/runtime-git-client.ts | 340 ++ .../src/runtime/runtime-hooks-client.test.ts | 96 + .../src/runtime/runtime-hooks-client.ts | 66 + .../src/runtime/runtime-linear-client.test.ts | 148 + .../src/runtime/runtime-linear-client.ts | 222 + .../src/runtime/runtime-notes-client.test.ts | 228 ++ .../src/runtime/runtime-notes-client.ts | 229 ++ .../src/runtime/runtime-protocol-compat.ts | 19 + .../src/runtime/runtime-repo-client.ts | 42 + .../src/runtime/runtime-rpc-client.test.ts | 155 + .../src/runtime/runtime-rpc-client.ts | 108 + .../runtime-terminal-inspection.test.ts | 72 + .../runtime/runtime-terminal-inspection.ts | 62 + .../runtime/runtime-terminal-stream.test.ts | 120 + .../src/runtime/runtime-terminal-stream.ts | 163 + src/renderer/src/store/slices/browser.test.ts | 1144 ++---- src/renderer/src/store/slices/browser.ts | 280 +- .../src/store/slices/diffComments.test.ts | 56 + src/renderer/src/store/slices/diffComments.ts | 27 +- src/renderer/src/store/slices/editor.test.ts | 295 +- src/renderer/src/store/slices/editor.ts | 149 +- src/renderer/src/store/slices/github.test.ts | 130 +- src/renderer/src/store/slices/github.ts | 251 +- .../src/store/slices/hosted-review.test.ts | 51 +- .../src/store/slices/hosted-review.ts | 34 +- src/renderer/src/store/slices/linear.ts | 26 +- src/renderer/src/store/slices/repos.test.ts | 256 ++ src/renderer/src/store/slices/repos.ts | 119 +- .../src/store/slices/settings.test.ts | 246 ++ src/renderer/src/store/slices/settings.ts | 236 +- src/renderer/src/store/slices/terminals.ts | 15 +- src/renderer/src/store/slices/ui.ts | 1 + .../src/store/slices/worktrees.test.ts | 169 + src/renderer/src/store/slices/worktrees.ts | 116 +- src/shared/constants.ts | 3 + src/shared/cross-platform-path.test.ts | 26 + src/shared/cross-platform-path.ts | 57 + src/shared/e2ee-crypto.ts | 65 + src/shared/protocol-compat.test.ts | 85 +- src/shared/protocol-compat.ts | 69 +- src/shared/protocol-version.ts | 45 +- src/shared/remote-runtime-client.test.ts | 140 + src/shared/remote-runtime-client.ts | 552 +++ ...e-runtime-request-connection-stale.test.ts | 115 + .../remote-runtime-request-connection.test.ts | 142 + .../remote-runtime-request-connection.ts | 292 ++ src/shared/remote-runtime-request-frames.ts | 98 + .../remote-runtime-request-websocket.ts | 96 + src/shared/runtime-environment-store.test.ts | 47 + src/shared/runtime-environment-store.ts | 166 + src/shared/runtime-environments.ts | 99 + src/shared/runtime-rpc-envelope.ts | 73 + src/shared/runtime-types.ts | 58 +- src/shared/secure-file.ts | 109 + src/shared/terminal-stream-protocol.test.ts | 55 + src/shared/terminal-stream-protocol.ts | 12 +- src/shared/types.ts | 4 + src/shared/workspace-session-schema.ts | 3 +- 308 files changed, 32140 insertions(+), 4464 deletions(-) create mode 100644 src/cli/handlers/environment.ts create mode 100644 src/cli/runtime/environments.test.ts create mode 100644 src/cli/runtime/environments.ts create mode 100644 src/cli/runtime/launch.test.ts create mode 100644 src/cli/runtime/websocket-transport.test.ts create mode 100644 src/cli/runtime/websocket-transport.ts create mode 100644 src/cli/specs/environment.ts create mode 100644 src/main/cli/packaged-cli-assets.test.ts create mode 100644 src/main/ipc/mobile.test.ts create mode 100644 src/main/ipc/runtime-environment-call-queue.ts create mode 100644 src/main/ipc/runtime-environment-request-connections.ts create mode 100644 src/main/ipc/runtime-environments.test.ts create mode 100644 src/main/ipc/runtime-environments.ts create mode 100644 src/main/runtime/orca-runtime-browser.ts create mode 100644 src/main/runtime/orca-runtime-files.test.ts create mode 100644 src/main/runtime/orca-runtime-files.ts create mode 100644 src/main/runtime/orca-runtime-git.ts rename src/{cli/orchestration.subprocess.test.ts => main/runtime/orchestration-cli-subprocess.test.ts} (93%) create mode 100644 src/main/runtime/remote-runtime-request-connection.integration.test.ts create mode 100644 src/main/runtime/rpc/methods/browser.test.ts create mode 100644 src/main/runtime/rpc/methods/file-watch-event-batcher.ts create mode 100644 src/main/runtime/rpc/methods/git.test.ts create mode 100644 src/main/runtime/rpc/methods/git.ts create mode 100644 src/main/runtime/rpc/methods/github.test.ts create mode 100644 src/main/runtime/rpc/methods/github.ts create mode 100644 src/main/runtime/rpc/methods/hosted-review.test.ts create mode 100644 src/main/runtime/rpc/methods/hosted-review.ts create mode 100644 src/main/runtime/rpc/methods/linear.test.ts create mode 100644 src/main/runtime/rpc/methods/linear.ts create mode 100644 src/main/runtime/rpc/methods/notes.test.ts create mode 100644 src/main/runtime/rpc/methods/repo.test.ts create mode 100644 src/main/runtime/rpc/methods/worktree.test.ts create mode 100644 src/main/runtime/rpc/terminal-multiplex.test.ts create mode 100644 src/main/runtime/rpc/terminal-output-batching.test.ts create mode 100644 src/main/runtime/rpc/terminal-send.test.ts create mode 100644 src/main/runtime/rpc/terminal-subscribe-buffer.test.ts create mode 100644 src/main/runtime/runtime-relative-paths.ts create mode 100644 src/main/ssh/sftp-upload.test.ts create mode 100644 src/preload/runtime-environment-subscriptions.test.ts create mode 100644 src/preload/runtime-environment-subscriptions.ts create mode 100644 src/renderer/src/app-startup-routing.test.ts create mode 100644 src/renderer/src/components/editor/useLocalImageSrc.test.ts create mode 100644 src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx create mode 100644 src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts create mode 100644 src/renderer/src/components/tab-bar/editor-tab-local-open-guard.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-binary-control.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts create mode 100644 src/renderer/src/hooks/useGlobalFileDrop.test.ts create mode 100644 src/renderer/src/lib/local-path-open-guard.test.ts create mode 100644 src/renderer/src/lib/local-path-open-guard.ts create mode 100644 src/renderer/src/runtime/remote-file-client.ts create mode 100644 src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts create mode 100644 src/renderer/src/runtime/runtime-compatibility-test-fixture.ts create mode 100644 src/renderer/src/runtime/runtime-file-client.test.ts create mode 100644 src/renderer/src/runtime/runtime-file-client.ts create mode 100644 src/renderer/src/runtime/runtime-git-client.test.ts create mode 100644 src/renderer/src/runtime/runtime-git-client.ts create mode 100644 src/renderer/src/runtime/runtime-hooks-client.test.ts create mode 100644 src/renderer/src/runtime/runtime-hooks-client.ts create mode 100644 src/renderer/src/runtime/runtime-linear-client.test.ts create mode 100644 src/renderer/src/runtime/runtime-linear-client.ts create mode 100644 src/renderer/src/runtime/runtime-notes-client.test.ts create mode 100644 src/renderer/src/runtime/runtime-notes-client.ts create mode 100644 src/renderer/src/runtime/runtime-protocol-compat.ts create mode 100644 src/renderer/src/runtime/runtime-repo-client.ts create mode 100644 src/renderer/src/runtime/runtime-rpc-client.test.ts create mode 100644 src/renderer/src/runtime/runtime-rpc-client.ts create mode 100644 src/renderer/src/runtime/runtime-terminal-inspection.test.ts create mode 100644 src/renderer/src/runtime/runtime-terminal-inspection.ts create mode 100644 src/renderer/src/runtime/runtime-terminal-stream.test.ts create mode 100644 src/renderer/src/runtime/runtime-terminal-stream.ts create mode 100644 src/renderer/src/store/slices/repos.test.ts create mode 100644 src/renderer/src/store/slices/settings.test.ts create mode 100644 src/shared/cross-platform-path.test.ts create mode 100644 src/shared/cross-platform-path.ts create mode 100644 src/shared/e2ee-crypto.ts create mode 100644 src/shared/remote-runtime-client.test.ts create mode 100644 src/shared/remote-runtime-client.ts create mode 100644 src/shared/remote-runtime-request-connection-stale.test.ts create mode 100644 src/shared/remote-runtime-request-connection.test.ts create mode 100644 src/shared/remote-runtime-request-connection.ts create mode 100644 src/shared/remote-runtime-request-frames.ts create mode 100644 src/shared/remote-runtime-request-websocket.ts create mode 100644 src/shared/runtime-environment-store.test.ts create mode 100644 src/shared/runtime-environment-store.ts create mode 100644 src/shared/runtime-environments.ts create mode 100644 src/shared/runtime-rpc-envelope.ts create mode 100644 src/shared/secure-file.ts diff --git a/.gitignore b/.gitignore index 2afd28d2a43..92b3d28c436 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,9 @@ design-docs/ .context/ .atl/ +# Machine-local agent hook endpoint files may contain auth tokens. +/agent-hooks/ + # Local-only design/planning docs (not checked in) docs/*.md !docs/README*.md diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 30fc98ae895..e1a63218fe7 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -39,6 +39,8 @@ module.exports = { // integration — dependencies inside the asar archive are invisible to // require(). Unpack CLI runtime deps so they resolve from // app.asar.unpacked/node_modules/. + // Why: remote runtime connections use WebSocket + E2EE from the packaged CLI + // before the GUI process starts, so those deps need the same treatment. // Why: sherpa-onnx native bindings (platform-specific subpackages) must be // unpacked because they ship .node addons + .dylib/.so files that cannot be // dlopen()'d from inside the asar archive. @@ -49,6 +51,8 @@ module.exports = { 'out/main/computer-sidecar.js', 'out/main/chunks/**', 'resources/**', + 'node_modules/ws/**', + 'node_modules/tweetnacl/**', 'node_modules/zod/**', 'node_modules/sherpa-onnx*/**' ], diff --git a/mobile/packages/expo-two-way-audio/ios/AudioEngine.swift b/mobile/packages/expo-two-way-audio/ios/AudioEngine.swift index f079c89b7d3..ac259e3794c 100644 --- a/mobile/packages/expo-two-way-audio/ios/AudioEngine.swift +++ b/mobile/packages/expo-two-way-audio/ios/AudioEngine.swift @@ -7,41 +7,41 @@ class AudioEngine { private var engineConfigChangeObserver: Any? private var sessionInterruptionObserver: Any? private var mediaServicesResetObserver: Any? - + public private(set) var voiceIOFormat: AVAudioFormat public private(set) var isRecording = false private var wasRecordingBeforeInterruption = false - + public var onMicDataCallback: ((Data) -> Void)? public var onInputVolumeCallback: ((Float) -> Void)? public var onOutputVolumeCallback: ((Float) -> Void)? public var onAudioInterruptionCallback: ((String) -> Void)? - + private var inputLevelTimer: Timer? private var outputLevelTimer: Timer? - + private var inputBuffer = [Float](repeating: 0, count: 2048) private var outputBuffer = [Float](repeating: 0, count: 2048) private var inputBufferIndex = 0 private var outputBufferIndex = 0 - + private var hasFirstInputBeenDiscarded = false private var discardRecording = false private var discardFirstInputMillis = 2000 - + enum AudioEngineError: Error { case audioFormatError } - + init() throws { avAudioEngine.attach(speechPlayer) - + guard let format = AVAudioFormat(standardFormatWithSampleRate: 16000, channels: 1) else { throw AudioEngineError.audioFormatError } voiceIOFormat = format print("Voice IO format: \(String(describing: voiceIOFormat))") - + engineConfigChangeObserver = NotificationCenter.default.addObserver( forName: .AVAudioEngineConfigurationChange, object: avAudioEngine, @@ -60,12 +60,12 @@ class AudioEngine { queue: .main) { [weak self] _ in self?.handleMediaServicesWereReset() } - + self.setupAudioSession() self.setup() self.start() } - + deinit { if let observer = engineConfigChangeObserver { NotificationCenter.default.removeObserver(observer) @@ -77,29 +77,29 @@ class AudioEngine { NotificationCenter.default.removeObserver(observer) } } - + func setupAudioSession() { let session = AVAudioSession.sharedInstance() - + do { try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP]) } catch { print("Could not set the audio category: \(error.localizedDescription)") } - + do { try session.setPreferredSampleRate(voiceIOFormat.sampleRate) } catch { print("Could not set the preferred sample rate: \(error.localizedDescription)") } - + do { try session.setActive(true) } catch { print("Could not set the audio session as active") } } - + func setup() { let input = avAudioEngine.inputNode do { @@ -108,15 +108,15 @@ class AudioEngine { print("Could not enable voice processing \(error)") return } - + avAudioEngine.inputNode.isVoiceProcessingInputMuted = !isRecording - + let output = avAudioEngine.outputNode let mainMixer = avAudioEngine.mainMixerNode - + avAudioEngine.connect(speechPlayer, to: mainMixer, format: voiceIOFormat) avAudioEngine.connect(mainMixer, to: output, format: voiceIOFormat) - + input.installTap(onBus: 0, bufferSize: 2048, format: voiceIOFormat) { [weak self] buffer, when in // We don't do any input processing (no volume calculation or passing mic data to the callback) if discardRecording == true // See comment in the playPCMData function @@ -125,48 +125,48 @@ class AudioEngine { self?.updateInputVolume() } } - + mainMixer.installTap(onBus: 0, bufferSize: 2048, format: voiceIOFormat) { [weak self] buffer, when in self?.processOutputBuffer(buffer) self?.updateOutputVolume() } - + avAudioEngine.prepare() } - + func processMicrophoneBuffer(_ buffer: AVAudioPCMBuffer) { guard let channelData = buffer.floatChannelData?[0] else { print("Error: Could not access channel data") return } - + let frameCount = Int(buffer.frameLength) var int16Samples = [Int16](repeating: 0, count: frameCount) - + // Convert float samples to Int16 and update input buffer for volume calculation for i in 0...size) - + // Send the data to the callback onMicDataCallback?(data) } - + func processOutputBuffer(_ buffer: AVAudioPCMBuffer) { guard let channelData = buffer.floatChannelData?[0] else { print("Error: Could not access channel data") return } - + let frameCount = Int(buffer.frameLength) - + // Update output buffer for volume calculation for i in 0.. AVAudioPCMBuffer? { let frameCount = UInt32(data.count) / 2 // 16-bit input = 2 bytes per frame - + let format = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 16000, channels: 1, interleaved: false)! - + guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { return nil } - + buffer.frameLength = frameCount - + data.withUnsafeBytes { (rawBufferPointer: UnsafeRawBufferPointer) in if let sourcePtr = rawBufferPointer.baseAddress?.assumingMemoryBound(to: Int16.self), let destPtr = buffer.floatChannelData?[0] { @@ -231,15 +231,15 @@ class AudioEngine { } } } - + return buffer } - + func bypassVoiceProcessing(_ bypass: Bool) { let input = avAudioEngine.inputNode input.isVoiceProcessingBypassed = bypass } - + func toggleRecording(_ val: Bool) -> Bool { isRecording = val if !isRecording { @@ -251,10 +251,10 @@ class AudioEngine { avAudioEngine.inputNode.isVoiceProcessingInputMuted = false } print("Recording \(isRecording ? "started" : "stopped")") - + return isRecording } - + func stopRecordingAndPlayer(clearInterruptionResume: Bool = true){ if clearInterruptionResume { wasRecordingBeforeInterruption = false @@ -268,7 +268,7 @@ class AudioEngine { speechPlayer.stop() updateOutputVolume() } - + func resumeRecordingAndPlayer(){ do { try AVAudioSession.sharedInstance().setActive(true) @@ -279,12 +279,12 @@ class AudioEngine { isRecording = toggleRecording(true) speechPlayer.play() } - + func tearDown() { stopRecordingAndPlayer() avAudioEngine.stop() } - + var isPlaying: Bool { return speechPlayer.isPlaying } @@ -308,13 +308,13 @@ class AudioEngine { print("Playback resumed") } } - + private func checkEngineIsRunning() { if !avAudioEngine.isRunning { start() } } - + private func handleAudioSessionInterruption(_ notification: Notification) { guard let userInfo = notification.userInfo, let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, @@ -343,39 +343,39 @@ class AudioEngine { fatalError("Unknown type: \(type)") } } - + private func handleMediaServicesWereReset() { self.avAudioEngine.stop() self.setup() self.start() } - + private func updateInputVolume() { let volume = calculateRMSLevel(from: inputBuffer) onInputVolumeCallback?(volume) } - + private func updateOutputVolume() { let volume = calculateRMSLevel(from: outputBuffer) onOutputVolumeCallback?(volume) } - + private func calculateRMSLevel(from buffer: [Float]) -> Float { let epsilon: Float = 1e-5 // To avoid log(0) let rmsValue = sqrt(buffer.reduce(0) { $0 + $1 * $1 } / Float(buffer.count)) - + // Convert to decibels let dbValue = 20 * log10(max(rmsValue, epsilon)) - + // Normalize decibel value to 0-1 range // Assuming minimum audible is -60dB and maximum is 0dB let minDb: Float = -80.0 let normalizedValue = max(0.0, min(1.0, (dbValue - minDb) / abs(minDb))) - + // Optional: Apply exponential factor to push smaller values down let expFactor: Float = 2.0 // Adjust this value to change the curve let adjustedValue = pow(normalizedValue, expFactor) - + return adjustedValue } } diff --git a/mobile/scripts/test-subscribe.ts b/mobile/scripts/test-subscribe.ts index 6fa8ffe4b42..a33ddd0c22b 100644 --- a/mobile/scripts/test-subscribe.ts +++ b/mobile/scripts/test-subscribe.ts @@ -267,7 +267,14 @@ ws.on('open', () => { }) ws.on('message', (data) => { - const plaintext = decrypt(data.toString()) + let plaintext: string | null = null + try { + plaintext = decrypt(data.toString()) + } catch { + // Plaintext handshake/control frames such as e2ee_ready are handled by the + // connect flow above. The global listener only cares about encrypted RPC. + return + } if (!plaintext) return const response = JSON.parse(plaintext) as RpcResponse if (response._meta?.runtimeId) { diff --git a/mobile/src/transport/protocol-version.ts b/mobile/src/transport/protocol-version.ts index 3ba4ede3878..4c1cf137738 100644 --- a/mobile/src/transport/protocol-version.ts +++ b/mobile/src/transport/protocol-version.ts @@ -1,20 +1,20 @@ -// Why: declares the mobile's pairing protocol version and the minimum -// desktop version it can talk to. Duplicates the desktop's +// Why: declares the mobile client's runtime protocol version and the minimum +// server protocol it can talk to. Duplicates the desktop's // `src/shared/protocol-version.ts` because Metro/Expo doesn't resolve // outside `mobile/`. Manual sync is acceptable — these constants are // expected to bump less than once a quarter. // // Bump MOBILE_PROTOCOL_VERSION when: // - You change the meaning of an RPC mobile sends. -// - You stop relying on a desktop-side feature in a way old desktops +// - You stop relying on a server-side feature in a way old servers // would notice. // Do NOT bump for: // - Adding new optional fields to outbound requests. // - Reading new optional fields on incoming responses. // // Bump MIN_COMPATIBLE_DESKTOP_VERSION when mobile starts relying on a -// desktop feature added at a specific desktop protocol version. This -// triggers a hard-block screen for users paired to older desktops. +// server feature added at a specific runtime protocol version. This +// triggers a hard-block screen for users paired to older servers. export const MOBILE_PROTOCOL_VERSION = 2 export const MIN_COMPATIBLE_DESKTOP_VERSION = 2 diff --git a/package.json b/package.json index 39e335bc753..5da12543545 100644 --- a/package.json +++ b/package.json @@ -88,8 +88,8 @@ "cmdk": "^1.1.1", "dompurify": "^3.4.2", "electron-updater": "^6.8.3", - "entities": "^6.0.1", "emoji-picker-react": "^4.19.1", + "entities": "^6.0.1", "github-slugger": "^2.0.0", "hosted-git-info": "^9.0.3", "html-to-image": "^1.11.13", diff --git a/src/cli/args.ts b/src/cli/args.ts index ece4215d8f5..9af56d69065 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -14,7 +14,7 @@ export type CommandSpec = { notes?: string[] } -export const GLOBAL_FLAGS = ['help', 'json'] +export const GLOBAL_FLAGS = ['help', 'json', 'pairing-code', 'environment'] export function parseArgs(argv: string[]): ParsedArgs { const commandPath: string[] = [] @@ -93,6 +93,7 @@ export function isCommandGroup(commandPath: string[]): boolean { 'storage', 'orchestration', 'computer', + 'environment', 'note' ].includes(commandPath[0])) || (commandPath.length === 2 && diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 1937a4593ad..00896007067 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -14,6 +14,7 @@ import { BROWSER_ENV_HANDLERS } from './handlers/browser-env' import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage' import { ORCHESTRATION_HANDLERS } from './handlers/orchestration' import { COMPUTER_HANDLERS } from './handlers/computer' +import { ENVIRONMENT_HANDLERS } from './handlers/environment' import { NOTE_HANDLERS } from './handlers/note' export type HandlerContext = { @@ -42,6 +43,7 @@ function buildHandlers(): Map { BROWSER_STORAGE_HANDLERS, ORCHESTRATION_HANDLERS, COMPUTER_HANDLERS, + ENVIRONMENT_HANDLERS, NOTE_HANDLERS ] for (const group of groups) { diff --git a/src/cli/format.ts b/src/cli/format.ts index b8cce4771af..0b99b8bcae4 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -33,6 +33,7 @@ import type { RuntimeWorktreePsResult, RuntimeWorktreeRecord } from '../shared/runtime-types' +import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' import type { NoteListResult, NoteMutationResult, NoteShowResult } from '../shared/notes-types' import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client' import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client' @@ -101,6 +102,33 @@ export function formatStatus(status: CliStatusResult): string { return formatCliStatus(status) } +export function formatEnvironmentList(result: { + environments: PublicKnownRuntimeEnvironment[] +}): string { + if (result.environments.length === 0) { + return 'No saved environments.' + } + return result.environments + .map( + (environment) => + `${environment.id} ${environment.name} ${environment.endpoints[0]?.endpoint ?? 'no-endpoint'}` + ) + .join('\n') +} + +export function formatEnvironment(environment: PublicKnownRuntimeEnvironment): string { + return [ + `id: ${environment.id}`, + `name: ${environment.name}`, + `runtimeId: ${environment.runtimeId ?? 'unknown'}`, + `lastUsedAt: ${environment.lastUsedAt ?? 'never'}`, + `preferredEndpointId: ${environment.preferredEndpointId}`, + ...environment.endpoints.map( + (endpoint) => `endpoint: ${endpoint.id} ${endpoint.kind} ${endpoint.endpoint}` + ) + ].join('\n') +} + export function formatTerminalList(result: RuntimeTerminalListResult): string { if (result.terminals.length === 0) { return 'No live terminals.' diff --git a/src/cli/handlers/core.ts b/src/cli/handlers/core.ts index f96a941daf9..895aa7abca8 100644 --- a/src/cli/handlers/core.ts +++ b/src/cli/handlers/core.ts @@ -1,11 +1,38 @@ import type { CommandHandler } from '../dispatch' import { formatCliStatus, formatStatus, printResult } from '../format' +import { RuntimeClientError, serveOrcaApp } from '../runtime-client' export const CORE_HANDLERS: Record = { open: async ({ client, json }) => { const result = await client.openOrca() printResult(result, json, formatCliStatus) }, + serve: async ({ flags, json }) => { + if (flags.get('no-pairing') === true && flags.get('mobile-pairing') === true) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --mobile-pairing or --no-pairing, not both.' + ) + } + const rawPort = flags.get('port') + if (typeof rawPort === 'string') { + const port = Number(rawPort) + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new RuntimeClientError('invalid_argument', `Invalid --port value: ${rawPort}`) + } + } + const exitCode = await serveOrcaApp({ + json, + port: typeof rawPort === 'string' ? rawPort : null, + pairingAddress: + typeof flags.get('pairing-address') === 'string' + ? (flags.get('pairing-address') as string) + : null, + noPairing: flags.get('no-pairing') === true, + mobilePairing: flags.get('mobile-pairing') === true + }) + process.exitCode = exitCode + }, status: async ({ client, json }) => { const result = await client.getCliStatus() if (!json && !result.result.runtime.reachable) { diff --git a/src/cli/handlers/environment.ts b/src/cli/handlers/environment.ts new file mode 100644 index 00000000000..08530ea29c3 --- /dev/null +++ b/src/cli/handlers/environment.ts @@ -0,0 +1,75 @@ +import type { CommandHandler } from '../dispatch' +import { formatEnvironment, formatEnvironmentList, printResult } from '../format' +import { getDefaultUserDataPath } from '../runtime-client' +import type { RuntimeRpcSuccess } from '../runtime-client' +import { RuntimeClientError } from '../runtime-client' +import { redactRuntimeEnvironment } from '../../shared/runtime-environments' +import { + addEnvironmentFromPairingCode, + listEnvironments, + removeEnvironment, + resolveEnvironment, + type EnvironmentAddResult, + type EnvironmentRemoveResult +} from '../runtime/environments' + +export const ENVIRONMENT_HANDLERS: Record = { + 'environment add': async ({ flags, json }) => { + const name = getRequiredStringFlag(flags, 'name') + const pairingCode = getRequiredStringFlag(flags, 'pairing-code') + const environment = redactRuntimeEnvironment( + addEnvironmentFromPairingCode(getDefaultUserDataPath(), { + name, + pairingCode + }) + ) + printResult( + localSuccess({ environment }), + json, + (result: EnvironmentAddResult) => + `Saved environment ${result.environment.name} (${result.environment.id}).` + ) + }, + 'environment list': async ({ json }) => { + const environments = listEnvironments(getDefaultUserDataPath()).map(redactRuntimeEnvironment) + printResult(localSuccess({ environments }), json, formatEnvironmentList) + }, + 'environment show': async ({ flags, json }) => { + const selector = getRequiredStringFlag(flags, 'environment') + const environment = redactRuntimeEnvironment( + resolveEnvironment(getDefaultUserDataPath(), selector) + ) + printResult(localSuccess({ environment }), json, ({ environment: value }) => + formatEnvironment(value) + ) + }, + 'environment rm': async ({ flags, json }) => { + const selector = getRequiredStringFlag(flags, 'environment') + const removed = redactRuntimeEnvironment(removeEnvironment(getDefaultUserDataPath(), selector)) + printResult( + localSuccess({ removed }), + json, + (result: EnvironmentRemoveResult) => + `Removed environment ${result.removed.name} (${result.removed.id}).` + ) + } +} + +function getRequiredStringFlag(flags: Map, name: string): string { + const value = flags.get(name) + if (typeof value !== 'string' || value.length === 0) { + throw new RuntimeClientError('invalid_argument', `Missing required --${name}`) + } + return value +} + +function localSuccess(result: TResult): RuntimeRpcSuccess { + return { + id: 'local', + ok: true, + result, + _meta: { + runtimeId: 'local' + } + } +} diff --git a/src/cli/handlers/repo.ts b/src/cli/handlers/repo.ts index a1061ee271b..fc4c9f22809 100644 --- a/src/cli/handlers/repo.ts +++ b/src/cli/handlers/repo.ts @@ -1,16 +1,43 @@ +import { resolve as resolvePath } from 'path' import type { RuntimeRepoList, RuntimeRepoSearchRefs } from '../../shared/runtime-types' import type { CommandHandler } from '../dispatch' import { formatRepoList, formatRepoRefs, formatRepoShow, printResult } from '../format' import { getOptionalPositiveIntegerFlag, getRequiredStringFlag } from '../flags' +import { RuntimeClientError } from '../runtime-client' + +function isAbsoluteServerPath(value: string): boolean { + return ( + value.startsWith('/') || + /^[A-Za-z]:[\\/]/.test(value) || + value.startsWith('\\\\') || + value.startsWith('//') + ) +} + +function resolveRepoAddPath(inputPath: string, cwd: string, isRemote: boolean): string { + if (!isRemote) { + return resolvePath(cwd, inputPath) + } + // Why: the local CLI cwd is unrelated to a paired runtime's filesystem. + // Relative remote paths would silently target the wrong machine. + if (!isAbsoluteServerPath(inputPath)) { + throw new RuntimeClientError( + 'invalid_argument', + 'Remote repo add requires --path to be an absolute path on the remote server.' + ) + } + return inputPath +} export const REPO_HANDLERS: Record = { 'repo list': async ({ client, json }) => { const result = await client.call('repo.list') printResult(result, json, formatRepoList) }, - 'repo add': async ({ flags, client, json }) => { + 'repo add': async ({ flags, client, cwd, json }) => { + const repoPath = getRequiredStringFlag(flags, 'path') const result = await client.call<{ repo: Record }>('repo.add', { - path: getRequiredStringFlag(flags, 'path') + path: resolveRepoAddPath(repoPath, cwd, client.isRemote) }) printResult(result, json, formatRepoShow) }, diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index e59ad46e712..831796a488f 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -116,6 +116,12 @@ export const TERMINAL_HANDLERS: Record = { printResult(result, json, formatTerminalRename) }, 'terminal create': async ({ flags, client, cwd, json }) => { + if (client.isRemote && !flags.has('worktree')) { + throw new RuntimeClientError( + 'invalid_argument', + 'Remote terminal create requires --worktree because the client cwd cannot identify a server worktree.' + ) + } const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', { worktree: await getBrowserWorktreeSelector(flags, cwd, client), command: getOptionalStringFlag(flags, 'command'), diff --git a/src/cli/help.ts b/src/cli/help.ts index 411ddd98715..b129306953f 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -8,8 +8,15 @@ Usage: orca [options] Startup: open Launch Orca and wait for the runtime to be reachable + serve Start a headless Orca runtime server status Show app/runtime/graph readiness +Environments: + environment add Save a remote Orca runtime from a pairing code + environment list List saved remote Orca runtimes + environment show Show one saved remote Orca runtime + environment rm Remove a saved remote Orca runtime + Repos: repo list List repos registered in Orca repo add Add a project to Orca by filesystem path @@ -138,7 +145,12 @@ Browser Automation: Common Commands: orca open [--json] + orca serve [--port ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--json] orca status [--json] + orca environment add --name --pairing-code [--json] + orca environment list [--json] + orca environment show --environment [--json] + orca environment rm --environment [--json] orca worktree list [--repo ] [--limit ] [--json] orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--run-hooks] [--activate] [--json] orca worktree show --worktree [--json] @@ -178,10 +190,13 @@ Wait Options: Output Options: --json Emit machine-readable JSON instead of human text + --pairing-code Connect to a remote Orca runtime using an orca://pair#... code + --environment Connect using a saved environment id or name --help Show this help message Behavior: Most commands require a running Orca runtime. If Orca is not open yet, run \`orca open\` first. + Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT. Use selectors for discovery and handles for repeated live terminal operations. Browser Workflow: diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 668354a2607..6758745bd0d 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -2,13 +2,47 @@ import path from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const callMock = vi.fn() +const { + callMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock +} = vi.hoisted(() => ({ + callMock: vi.fn(), + serveOrcaAppMock: vi.fn(), + getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'), + addEnvironmentFromPairingCodeMock: vi.fn(), + listEnvironmentsMock: vi.fn() +})) vi.mock('./runtime-client', () => { class RuntimeClient { + readonly isRemote: boolean call = callMock getCliStatus = vi.fn() openOrca = vi.fn() + + constructor( + _userDataPath?: string, + _requestTimeoutMs?: number, + remotePairingCode?: string | null, + environmentSelector?: string | null + ) { + const effectivePairingCode = + remotePairingCode === undefined + ? (process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING) + : remotePairingCode + const effectiveEnvironment = + environmentSelector === undefined ? process.env.ORCA_ENVIRONMENT : environmentSelector + if (effectivePairingCode && effectiveEnvironment) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --pairing-code or --environment, not both.' + ) + } + this.isRemote = Boolean(effectivePairingCode || effectiveEnvironment) + } } class RuntimeClientError extends Error { @@ -32,10 +66,19 @@ vi.mock('./runtime-client', () => { return { RuntimeClient, RuntimeClientError, - RuntimeRpcFailureError + RuntimeRpcFailureError, + serveOrcaApp: serveOrcaAppMock, + getDefaultUserDataPath: getDefaultUserDataPathMock } }) +vi.mock('./runtime/environments', () => ({ + addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock, + listEnvironments: listEnvironmentsMock, + removeEnvironment: vi.fn(), + resolveEnvironment: vi.fn() +})) + import { buildCurrentWorktreeSelector, COMMAND_SPECS, @@ -58,9 +101,36 @@ describe('COMMAND_SPECS collision check', () => { describe('orca cli worktree awareness', () => { const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE const originalUserDataPath = process.env.ORCA_USER_DATA_PATH + const originalPairingCode = process.env.ORCA_PAIRING_CODE + const originalRemotePairing = process.env.ORCA_REMOTE_PAIRING + const originalEnvironment = process.env.ORCA_ENVIRONMENT beforeEach(() => { callMock.mockReset() + serveOrcaAppMock.mockReset() + getDefaultUserDataPathMock.mockClear() + addEnvironmentFromPairingCodeMock.mockReset() + listEnvironmentsMock.mockReset() + addEnvironmentFromPairingCodeMock.mockReturnValue({ + id: 'env-1', + name: 'desk', + createdAt: 100, + updatedAt: 100, + lastUsedAt: null, + runtimeId: null, + endpoints: [ + { + id: 'ws-env-1', + kind: 'websocket', + label: 'WebSocket', + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: 'pk' + } + ], + preferredEndpointId: 'ws-env-1' + }) + listEnvironmentsMock.mockReturnValue([]) }) afterEach(() => { @@ -75,6 +145,21 @@ describe('orca cli worktree awareness', () => { } else { process.env.ORCA_USER_DATA_PATH = originalUserDataPath } + if (originalPairingCode === undefined) { + delete process.env.ORCA_PAIRING_CODE + } else { + process.env.ORCA_PAIRING_CODE = originalPairingCode + } + if (originalRemotePairing === undefined) { + delete process.env.ORCA_REMOTE_PAIRING + } else { + process.env.ORCA_REMOTE_PAIRING = originalRemotePairing + } + if (originalEnvironment === undefined) { + delete process.env.ORCA_ENVIRONMENT + } else { + process.env.ORCA_ENVIRONMENT = originalEnvironment + } }) it('builds the current worktree selector from cwd', () => { @@ -115,6 +200,25 @@ describe('orca cli worktree awareness', () => { expect(logSpy).toHaveBeenCalledTimes(1) }) + it('rejects remote `worktree current` without listing worktrees from client cwd', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + ['worktree', 'current', '--pairing-code', 'remote-runtime', '--json'], + '/tmp/repo/src' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'current is a local cwd shortcut and cannot be resolved against a remote runtime.' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + it('uses cwd when active is passed to worktree.set', async () => { queueFixtures( callMock, @@ -171,6 +275,192 @@ describe('orca cli worktree awareness', () => { }) }) + it('starts a foreground headless server through `serve`', async () => { + serveOrcaAppMock.mockResolvedValue(0) + process.env.ORCA_ENVIRONMENT = 'stale-env' + + await main( + ['serve', '--json', '--port', '6768', '--pairing-address', '100.64.1.20', '--no-pairing'], + '/tmp/repo' + ) + + expect(serveOrcaAppMock).toHaveBeenCalledWith({ + json: true, + port: '6768', + pairingAddress: '100.64.1.20', + noPairing: true, + mobilePairing: false + }) + }) + + it('starts a foreground headless server with mobile pairing enabled', async () => { + serveOrcaAppMock.mockResolvedValue(0) + + await main( + ['serve', '--pairing-address', '100.64.1.20', '--mobile-pairing', '--json'], + '/tmp/repo' + ) + + expect(serveOrcaAppMock).toHaveBeenCalledWith({ + json: true, + port: null, + pairingAddress: '100.64.1.20', + noPairing: false, + mobilePairing: true + }) + }) + + it('rejects contradictory serve pairing flags', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main(['serve', '--mobile-pairing', '--no-pairing', '--json'], '/tmp/repo') + + expect(serveOrcaAppMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Use either --mobile-pairing or --no-pairing, not both.' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('rejects invalid serve ports before launching the app', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main(['serve', '--port', 'not-a-port', '--json'], '/tmp/repo') + + expect(serveOrcaAppMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Invalid --port value: not-a-port' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('lists saved environments even when ORCA_ENVIRONMENT is set', async () => { + process.env.ORCA_ENVIRONMENT = 'stale-env' + listEnvironmentsMock.mockReturnValue([addEnvironmentFromPairingCodeMock()]) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['environment', 'list', '--json'], '/tmp/repo') + + expect(listEnvironmentsMock).toHaveBeenCalledWith('/tmp/orca-user-data') + expect(callMock).not.toHaveBeenCalled() + expect(logSpy.mock.calls[0]?.[0]).not.toContain('token') + expect(logSpy.mock.calls[0]?.[0]).not.toContain('publicKeyB64') + }) + + it('adds saved environments even when ORCA_ENVIRONMENT is set', async () => { + process.env.ORCA_ENVIRONMENT = 'stale-env' + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['environment', 'add', '--name', 'desk', '--pairing-code', 'orca://pair#abc', '--json'], + '/tmp/repo' + ) + + expect(addEnvironmentFromPairingCodeMock).toHaveBeenCalledWith('/tmp/orca-user-data', { + name: 'desk', + pairingCode: 'orca://pair#abc' + }) + expect(callMock).not.toHaveBeenCalled() + expect(logSpy.mock.calls[0]?.[0]).not.toContain('token') + expect(logSpy.mock.calls[0]?.[0]).not.toContain('publicKeyB64') + }) + + it('resolves repo.add paths against the invoking cli cwd', async () => { + queueFixtures( + callMock, + okFixture('req_repo_add', { + repo: { + id: 'repo-1', + path: path.resolve('/tmp/repo/apps/web'), + displayName: 'web' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['repo', 'add', '--path', './apps/web', '--json'], '/tmp/repo') + + expect(callMock).toHaveBeenCalledWith('repo.add', { + path: path.resolve('/tmp/repo/apps/web') + }) + }) + + it('rejects remote repo.add relative paths instead of resolving against client cwd', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + ['repo', 'add', '--path', './apps/web', '--pairing-code', 'remote-runtime', '--json'], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Remote repo add requires --path to be an absolute path on the remote server.' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('sends remote repo.add absolute paths unchanged', async () => { + queueFixtures( + callMock, + okFixture('req_repo_add', { + repo: { + id: 'repo-1', + path: '/srv/orca/web', + displayName: 'web' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['repo', 'add', '--path', '/srv/orca/web', '--pairing-code', 'remote-runtime', '--json'], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('repo.add', { + path: '/srv/orca/web' + }) + }) + + it.each(['C:\\repo', 'C:/repo', '\\\\server\\share\\repo', '//server/share/repo'])( + 'sends remote repo.add server absolute path %s unchanged', + async (serverPath) => { + queueFixtures( + callMock, + okFixture('req_repo_add', { + repo: { + id: 'repo-1', + path: serverPath, + displayName: 'web' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['repo', 'add', '--path', serverPath, '--pairing-code', 'remote-runtime', '--json'], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('repo.add', { + path: serverPath + }) + } + ) + it('opts into setup and activation when worktree.create runs hooks', async () => { queueFixtures( callMock, @@ -352,4 +642,79 @@ describe('orca cli worktree awareness', () => { limit: undefined }) }) + + it('rejects implicit remote terminal create instead of resolving from client cwd', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + ['terminal', 'create', '--pairing-code', 'remote-runtime', '--json'], + '/tmp/client/repo/src' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Remote terminal create requires --worktree' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('sends explicit remote terminal create worktree selectors unchanged', async () => { + queueFixtures( + callMock, + okFixture('req_terminal_create', { + terminal: { + handle: 'term_1', + worktreeId: 'repo-1::/srv/orca/feature', + title: 'Server terminal' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'terminal', + 'create', + '--worktree', + 'id:repo-1::/srv/orca/feature', + '--pairing-code', + 'remote-runtime', + '--json' + ], + '/tmp/client/repo/src' + ) + + expect(callMock).toHaveBeenCalledWith('terminal.create', { + worktree: 'id:repo-1::/srv/orca/feature', + command: undefined, + title: undefined, + focus: false + }) + }) + + it('does not resolve implicit remote browser targets from client cwd', async () => { + queueFixtures( + callMock, + okFixture('req_tab_current', { + tab: { + browserPageId: 'page-1', + index: 0, + url: 'https://example.com', + title: 'Example', + active: true, + worktreeId: 'repo-1::/srv/orca/feature' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['tab', 'current', '--pairing-code', 'remote-runtime', '--json'], '/tmp/client/src') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', { worktree: undefined }) + }) }) diff --git a/src/cli/index.ts b/src/cli/index.ts index ed015d1185d..00a96e9043f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -15,6 +15,10 @@ import { COMMAND_SPECS } from './specs' export { COMMAND_SPECS } from './specs' export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors' +function shouldIgnoreRemoteSelection(commandPath: string[]): boolean { + return commandPath[0] === 'environment' || commandPath[0] === 'serve' +} + export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise { const parsed = parseArgs(argv) const helpPath = resolveHelpPath(parsed) @@ -40,7 +44,15 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P // lookup so users do not get misleading "Orca is not running" failures for // simple command typos or unsupported flags. validateCommandAndFlags(COMMAND_SPECS, parsed) - const client = new RuntimeClient() + const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath) + const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code') + const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment') + const client = new RuntimeClient( + undefined, + undefined, + typeof pairingCode === 'string' ? pairingCode : undefined, + typeof environmentSelector === 'string' ? environmentSelector : undefined + ) await dispatch(parsed.commandPath, { flags: parsed.flags, client, diff --git a/src/cli/runtime-client.ts b/src/cli/runtime-client.ts index 480637847eb..f88b116981b 100644 --- a/src/cli/runtime-client.ts +++ b/src/cli/runtime-client.ts @@ -6,6 +6,7 @@ export { RuntimeClient, RuntimeClientError, RuntimeRpcFailureError, + serveOrcaApp, getDefaultUserDataPath, type RuntimeRpcFailure, type RuntimeRpcResponse, diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index ae65cd4f34c..ab082aa16ad 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -1,9 +1,17 @@ -import type { CliStatusResult } from '../../shared/runtime-types' +import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' +import { parsePairingCode, type PairingOffer } from '../../shared/pairing' import { launchOrcaApp } from './launch' import { getDefaultUserDataPath, readMetadata } from './metadata' import { getCliStatus } from './status' import { sendRequest } from './transport' import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' +import { sendWebSocketRequest } from './websocket-transport' +import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environments' +import { describeRuntimeCompatBlock, evaluateRuntimeCompat } from '../../shared/protocol-compat' +import { + MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../shared/protocol-version' // Why: for `orchestration.check --wait` the caller's method-level // `params.timeoutMs` is the inner waiter budget; we extend the client-side @@ -16,13 +24,27 @@ const LONG_POLL_CLIENT_GRACE_MS = 10_000 export class RuntimeClient { private readonly userDataPath: string private readonly requestTimeoutMs: number + private readonly remotePairing: PairingOffer | null + private readonly environmentSelector: string | null + private remoteCompatChecked = false // Why: browser commands trigger first-time session init (agent-browser connect + // CDP proxy setup) which can take 15-30s. 60s accommodates cold start without // being so large that genuine hangs go unnoticed. - constructor(userDataPath = getDefaultUserDataPath(), requestTimeoutMs = 60_000) { + constructor( + userDataPath = getDefaultUserDataPath(), + requestTimeoutMs = 60_000, + remotePairingCode = process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING ?? null, + environmentSelector = process.env.ORCA_ENVIRONMENT ?? null + ) { this.userDataPath = userDataPath this.requestTimeoutMs = requestTimeoutMs + this.environmentSelector = environmentSelector + this.remotePairing = resolveRemotePairing(userDataPath, remotePairingCode, environmentSelector) + } + + get isRemote(): boolean { + return this.remotePairing !== null } async call( @@ -32,10 +54,30 @@ export class RuntimeClient { timeoutMs?: number } ): Promise> { - const metadata = readMetadata(this.userDataPath) const effectiveTimeoutMs = options?.timeoutMs ?? this.resolveMethodTimeoutMs(method, params) + if (this.remotePairing) { + if (method !== 'status.get') { + await this.ensureRemoteRuntimeCompatible(effectiveTimeoutMs) + } + const response = await sendWebSocketRequest( + this.remotePairing, + method, + params, + effectiveTimeoutMs + ) + if (response.ok === false) { + throw new RuntimeRpcFailureError(response) + } + if (this.environmentSelector) { + markEnvironmentUsed(this.userDataPath, this.environmentSelector, { + runtimeId: response._meta.runtimeId + }) + } + return response + } + const metadata = readMetadata(this.userDataPath) const response = await sendRequest(metadata, method, params, effectiveTimeoutMs) - if (!response.ok) { + if (response.ok === false) { throw new RuntimeRpcFailureError(response) } return response @@ -58,9 +100,69 @@ export class RuntimeClient { } async getCliStatus(): Promise> { + if (this.remotePairing) { + const response = await this.call('status.get') + this.assertRemoteRuntimeStatusCompatible(response.result) + this.remoteCompatChecked = true + const graphState = response.result.graphStatus + return { + id: response.id, + ok: true, + result: { + app: { + running: true, + pid: null + }, + runtime: { + state: graphState === 'ready' ? 'ready' : 'graph_not_ready', + reachable: true, + runtimeId: response.result.runtimeId + }, + graph: { + state: graphState + } + }, + _meta: response._meta + } + } return getCliStatus(this.userDataPath) } + private async ensureRemoteRuntimeCompatible(timeoutMs: number): Promise { + if (!this.remotePairing || this.remoteCompatChecked) { + return + } + const response = await sendWebSocketRequest( + this.remotePairing, + 'status.get', + undefined, + timeoutMs + ) + if (response.ok === false) { + throw new RuntimeRpcFailureError(response) + } + this.assertRemoteRuntimeStatusCompatible(response.result) + this.remoteCompatChecked = true + if (this.environmentSelector) { + markEnvironmentUsed(this.userDataPath, this.environmentSelector, { + runtimeId: response._meta.runtimeId + }) + } + } + + private assertRemoteRuntimeStatusCompatible(status: RuntimeStatus): void { + const verdict = evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion, + serverMinCompatibleClientProtocolVersion: + status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion + }) + if (verdict.kind === 'blocked') { + throw new RuntimeClientError('incompatible_runtime', describeRuntimeCompatBlock(verdict)) + } + } + async openOrca(timeoutMs = 15_000): Promise> { const initial = await this.getCliStatus() if (initial.result.runtime.reachable) { @@ -84,6 +186,33 @@ export class RuntimeClient { } } +function resolveRemotePairing( + userDataPath: string, + pairingCode: string | null, + environmentSelector: string | null +): PairingOffer | null { + if (pairingCode && environmentSelector) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --pairing-code or --environment, not both.' + ) + } + if (environmentSelector) { + return resolveEnvironmentPairingOffer(userDataPath, environmentSelector) + } + if (!pairingCode) { + return null + } + const pairing = parsePairingCode(pairingCode) + if (!pairing) { + throw new RuntimeClientError( + 'invalid_argument', + 'Invalid remote pairing code. Expected an orca://pair#... URL or bare pairing payload.' + ) + } + return pairing +} + function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } diff --git a/src/cli/runtime/envelope-schema.ts b/src/cli/runtime/envelope-schema.ts index f674aab11af..3447c007769 100644 --- a/src/cli/runtime/envelope-schema.ts +++ b/src/cli/runtime/envelope-schema.ts @@ -1,62 +1,5 @@ -// Why: the Orca runtime is a separate process and may drift in version from -// the CLI (older CLI talking to newer app, or vice versa during dev HMR). A -// Zod schema at the decode boundary means a malformed frame surfaces as a -// single legible error instead of a silent mis-typed access downstream. -// -// The envelope shape mirrors src/main/runtime/rpc/core.ts. `result` is left -// unknown here — method-level types are checked by the caller via generics — -// so only the frame is validated, not the payload. -import { z } from 'zod' - -const MetaSuccess = z.object({ - runtimeId: z.string() -}) - -const MetaFailure = z - .object({ - runtimeId: z.union([z.string(), z.null()]) - }) - .optional() - -const Success = z.object({ - id: z.string(), - ok: z.literal(true), - result: z.unknown(), - _meta: MetaSuccess -}) - -const Failure = z.object({ - id: z.string(), - ok: z.literal(false), - error: z.object({ - code: z.string(), - message: z.string(), - data: z.unknown().optional() - }), - _meta: MetaFailure -}) - -// Why: transport-layer keepalive frame (server→client only). Not a terminal -// frame — the client reads past it and keeps waiting for the real -// success/failure. `id` and `_meta` are deliberately absent: keepalives carry -// no method-level semantics and aren't tied to a particular request (one -// connection handles one request today). See design doc §3.1. -const Keepalive = z.object({ - _keepalive: z.literal(true) -}) - -// Why: switched from z.discriminatedUnion('ok', …) to z.union because -// keepalives have no `ok` field. Client code must branch on -// `'_keepalive' in frame` before treating the frame as Success/Failure. -export const RuntimeRpcEnvelopeSchema = z.union([Success, Failure, Keepalive]) - -export type RuntimeRpcKeepaliveFrame = z.infer - -export function isKeepaliveFrame(frame: unknown): frame is RuntimeRpcKeepaliveFrame { - return ( - typeof frame === 'object' && - frame !== null && - '_keepalive' in frame && - (frame as { _keepalive: unknown })._keepalive === true - ) -} +export { + isKeepaliveFrame, + RuntimeRpcEnvelopeSchema, + type RuntimeRpcKeepaliveFrame +} from '../../shared/runtime-rpc-envelope' diff --git a/src/cli/runtime/environments.test.ts b/src/cli/runtime/environments.test.ts new file mode 100644 index 00000000000..8c27060cdba --- /dev/null +++ b/src/cli/runtime/environments.test.ts @@ -0,0 +1,68 @@ +import { mkdtempSync, statSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, expect, it } from 'vitest' +import { encodePairingOffer } from '../../shared/pairing' +import { + addEnvironmentFromPairingCode, + getEnvironmentStorePath, + listEnvironments, + removeEnvironment, + resolveEnvironmentPairingOffer +} from './environments' + +function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string { + return encodePairingOffer({ + v: 2, + endpoint, + deviceToken: 'device-token', + publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64') + }) +} + +describe('CLI runtime environments', () => { + it('saves, resolves, and removes a paired environment', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-env-store-')) + const saved = addEnvironmentFromPairingCode(userDataPath, { + name: 'workstation', + pairingCode: pairingCode(), + now: 100 + }) + + expect(listEnvironments(userDataPath)).toHaveLength(1) + expect(resolveEnvironmentPairingOffer(userDataPath, 'workstation')).toMatchObject({ + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'device-token' + }) + expect(resolveEnvironmentPairingOffer(userDataPath, saved.id)).toMatchObject({ + endpoint: 'ws://127.0.0.1:6768' + }) + expect((statSync(getEnvironmentStorePath(userDataPath)).mode & 0o777).toString(8)).toBe('600') + + const removed = removeEnvironment(userDataPath, 'workstation') + expect(removed.id).toBe(saved.id) + expect(listEnvironments(userDataPath)).toEqual([]) + }) + + it('rejects an environment with the same name', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-env-store-')) + const first = addEnvironmentFromPairingCode(userDataPath, { + name: 'workstation', + pairingCode: pairingCode('ws://127.0.0.1:1111'), + now: 100 + }) + + expect(() => + addEnvironmentFromPairingCode(userDataPath, { + name: 'workstation', + pairingCode: pairingCode('ws://127.0.0.1:2222'), + now: 200 + }) + ).toThrow('A server named "workstation" already exists.') + expect(listEnvironments(userDataPath)).toHaveLength(1) + expect(resolveEnvironmentPairingOffer(userDataPath, 'workstation').endpoint).toBe( + 'ws://127.0.0.1:1111' + ) + expect(listEnvironments(userDataPath)[0]?.id).toBe(first.id) + }) +}) diff --git a/src/cli/runtime/environments.ts b/src/cli/runtime/environments.ts new file mode 100644 index 00000000000..3009b11c9cb --- /dev/null +++ b/src/cli/runtime/environments.ts @@ -0,0 +1,81 @@ +import { + addEnvironmentFromPairingCode as addEnvironmentFromPairingCodeInStore, + getEnvironmentStorePath, + listEnvironments, + markEnvironmentUsed as markEnvironmentUsedInStore, + removeEnvironment as removeEnvironmentFromStore, + resolveEnvironment as resolveEnvironmentFromStore, + resolveEnvironmentPairingOffer as resolveEnvironmentPairingOfferFromStore, + RuntimeEnvironmentStoreError, + type RuntimeEnvironmentStoreErrorCode +} from '../../shared/runtime-environment-store' +import type { + KnownRuntimeEnvironment, + PublicKnownRuntimeEnvironment +} from '../../shared/runtime-environments' +import type { PairingOffer } from '../../shared/pairing' +import { RuntimeClientError } from './types' + +export type EnvironmentAddResult = { + environment: PublicKnownRuntimeEnvironment +} + +export type EnvironmentListResult = { + environments: PublicKnownRuntimeEnvironment[] +} + +export type EnvironmentRemoveResult = { + removed: PublicKnownRuntimeEnvironment +} + +export { getEnvironmentStorePath, listEnvironments } + +export function addEnvironmentFromPairingCode( + userDataPath: string, + args: { name: string; pairingCode: string; now?: number } +): KnownRuntimeEnvironment { + return translateStoreError(() => addEnvironmentFromPairingCodeInStore(userDataPath, args)) +} + +export function removeEnvironment(userDataPath: string, selector: string): KnownRuntimeEnvironment { + return translateStoreError(() => removeEnvironmentFromStore(userDataPath, selector)) +} + +export function resolveEnvironment( + userDataPath: string, + selector: string +): KnownRuntimeEnvironment { + return translateStoreError(() => resolveEnvironmentFromStore(userDataPath, selector)) +} + +export function resolveEnvironmentPairingOffer( + userDataPath: string, + selector: string +): PairingOffer { + return translateStoreError(() => resolveEnvironmentPairingOfferFromStore(userDataPath, selector)) +} + +export function markEnvironmentUsed( + userDataPath: string, + selector: string, + args: { runtimeId?: string | null; now?: number } = {} +): void { + translateStoreError(() => markEnvironmentUsedInStore(userDataPath, selector, args)) +} + +function translateStoreError(fn: () => TResult): TResult { + try { + return fn() + } catch (error) { + if (error instanceof RuntimeEnvironmentStoreError) { + throw new RuntimeClientError(toRuntimeClientErrorCode(error.code), error.message) + } + throw error + } +} + +function toRuntimeClientErrorCode( + code: RuntimeEnvironmentStoreErrorCode +): 'invalid_argument' | 'runtime_error' { + return code +} diff --git a/src/cli/runtime/index.ts b/src/cli/runtime/index.ts index b061c042b21..81d9fdf16c6 100644 --- a/src/cli/runtime/index.ts +++ b/src/cli/runtime/index.ts @@ -1,4 +1,5 @@ export { RuntimeClient } from './client' +export { serveOrcaApp } from './launch' export { getDefaultUserDataPath } from './metadata' export { RuntimeClientError, diff --git a/src/cli/runtime/launch.test.ts b/src/cli/runtime/launch.test.ts new file mode 100644 index 00000000000..807df9dc721 --- /dev/null +++ b/src/cli/runtime/launch.test.ts @@ -0,0 +1,88 @@ +import { resolve } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + spawn: spawnMock +})) + +import { serveOrcaApp } from './launch' + +describe('serveOrcaApp', () => { + beforeEach(() => { + spawnMock.mockReset() + process.env.ORCA_APP_EXECUTABLE = '/Applications/Orca.app/Contents/MacOS/Orca' + }) + + afterEach(() => { + delete process.env.ORCA_APP_EXECUTABLE + }) + + it('pins the Electron child cwd to the app root instead of the caller cwd', async () => { + const child = { + kill: vi.fn(), + once: vi.fn( + (event: string, handler: (code: number | null, signal: string | null) => void) => { + if (event === 'exit') { + queueMicrotask(() => handler(0, null)) + } + return child + } + ) + } + spawnMock.mockReturnValue(child) + + await expect(serveOrcaApp({ json: true })).resolves.toBe(0) + + expect(spawnMock).toHaveBeenCalledWith( + '/Applications/Orca.app/Contents/MacOS/Orca', + ['--serve', '--serve-json'], + expect.objectContaining({ + cwd: resolve(__dirname, '../../..') + }) + ) + }) + + it('passes mobile pairing through to the foreground server child', async () => { + const child = { + kill: vi.fn(), + once: vi.fn( + (event: string, handler: (code: number | null, signal: string | null) => void) => { + if (event === 'exit') { + queueMicrotask(() => handler(0, null)) + } + return child + } + ) + } + spawnMock.mockReturnValue(child) + + await expect( + serveOrcaApp({ + json: true, + port: '6768', + pairingAddress: '100.64.1.20', + mobilePairing: true + }) + ).resolves.toBe(0) + + expect(spawnMock).toHaveBeenCalledWith( + '/Applications/Orca.app/Contents/MacOS/Orca', + [ + '--serve', + '--serve-json', + '--serve-port', + '6768', + '--serve-pairing-address', + '100.64.1.20', + '--serve-mobile-pairing' + ], + expect.objectContaining({ + cwd: resolve(__dirname, '../../..') + }) + ) + }) +}) diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index b86cfb9b4d1..71de311177b 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -1,5 +1,5 @@ import { spawn as spawnProcess } from 'child_process' -import { dirname } from 'path' +import { dirname, resolve } from 'path' import { RuntimeClientError } from './types' export function launchOrcaApp(): void { @@ -53,6 +53,93 @@ export function launchOrcaApp(): void { ) } +export function serveOrcaApp( + args: { + json?: boolean + port?: string | null + pairingAddress?: string | null + noPairing?: boolean + mobilePairing?: boolean + } = {} +): Promise { + const executable = resolveForegroundOrcaExecutable() + const childArgs = ['--serve'] + if (args.json) { + childArgs.push('--serve-json') + } + if (args.port) { + childArgs.push('--serve-port', args.port) + } + if (args.pairingAddress) { + childArgs.push('--serve-pairing-address', args.pairingAddress) + } + if (args.noPairing) { + childArgs.push('--serve-no-pairing') + } + if (args.mobilePairing) { + childArgs.push('--serve-mobile-pairing') + } + + const child = spawnProcess(executable, childArgs, { + cwd: resolveAppRoot(), + stdio: 'inherit', + env: stripElectronRunAsNode(process.env) + }) + + return new Promise((resolve, reject) => { + let forceKillTimer: ReturnType | null = null + const forwardSignal = (signal: NodeJS.Signals): void => { + child.kill(signal) + forceKillTimer ??= setTimeout(() => { + child.kill('SIGKILL') + }, 5000) + } + const cleanup = (): void => { + process.off('SIGINT', forwardSignal) + process.off('SIGTERM', forwardSignal) + if (forceKillTimer) { + clearTimeout(forceKillTimer) + forceKillTimer = null + } + } + process.on('SIGINT', forwardSignal) + process.on('SIGTERM', forwardSignal) + child.once('error', (error) => { + cleanup() + reject(error) + }) + child.once('exit', (code, signal) => { + cleanup() + if (typeof code === 'number') { + resolve(code) + return + } + reject(new RuntimeClientError('runtime_serve_failed', `Orca serve exited via ${signal}`)) + }) + }) +} + +function resolveAppRoot(): string { + // Why: dev-mode resource resolution in the Electron child may consult + // process.cwd(). Pin it to the app root so `orca serve` behaves the same + // regardless of the shell directory it was launched from. + return resolve(__dirname, '../../..') +} + +function resolveForegroundOrcaExecutable(): string { + const overrideExecutable = process.env.ORCA_APP_EXECUTABLE + if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) { + return overrideExecutable + } + if (process.env.ELECTRON_RUN_AS_NODE === '1') { + return process.execPath + } + throw new RuntimeClientError( + 'runtime_serve_failed', + 'Could not determine how to start Orca server. Set ORCA_APP_EXECUTABLE to the Orca executable.' + ) +} + function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const next = { ...env } delete next.ELECTRON_RUN_AS_NODE diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index 3a0f445a40f..bbf5e7e272f 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -29,7 +29,7 @@ export async function getCliStatus( try { const response = await sendRequest(metadata, 'status.get', undefined, 1000) - if (!response.ok) { + if (response.ok === false) { throw new RuntimeRpcFailureError(response) } const graphState = response.result.graphStatus diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index ca8ca746274..ee80e484a6d 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -49,10 +49,10 @@ export async function sendRequest( settled = true clearTimeout(timeout) socket.end() - if (result.ok) { - resolve(result.response) - } else { + if (result.ok === false) { reject(result.error) + } else { + resolve(result.response) } } diff --git a/src/cli/runtime/types.ts b/src/cli/runtime/types.ts index e427c87b5de..220d3e3aba5 100644 --- a/src/cli/runtime/types.ts +++ b/src/cli/runtime/types.ts @@ -1,31 +1,10 @@ -// Why: the RPC envelope shape is the contract the CLI shares with the main -// runtime. Keeping the types and error classes in one leaf module lets every -// other runtime module depend on them without pulling in transport or launch -// code. +import type { RuntimeRpcFailure } from '../../shared/runtime-rpc-envelope' -export type RuntimeRpcSuccess = { - id: string - ok: true - result: TResult - _meta: { - runtimeId: string - } -} - -export type RuntimeRpcFailure = { - id: string - ok: false - error: { - code: string - message: string - data?: unknown - } - _meta?: { - runtimeId: string | null - } -} - -export type RuntimeRpcResponse = RuntimeRpcSuccess | RuntimeRpcFailure +export type { + RuntimeRpcFailure, + RuntimeRpcResponse, + RuntimeRpcSuccess +} from '../../shared/runtime-rpc-envelope' export class RuntimeClientError extends Error { readonly code: string diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts new file mode 100644 index 00000000000..38ccf06f956 --- /dev/null +++ b/src/cli/runtime/websocket-transport.test.ts @@ -0,0 +1,237 @@ +import { createServer, type Server } from 'http' +import { mkdtempSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { WebSocketServer } from 'ws' +import { encodePairingOffer, type PairingOffer } from '../../shared/pairing' +import { + decrypt, + deriveSharedKey, + encrypt, + generateKeyPair, + publicKeyToBase64 +} from '../../shared/e2ee-crypto' +import { RuntimeClient } from './client' +import { addEnvironmentFromPairingCode } from './environments' +import { RuntimeClientError } from './types' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../shared/protocol-version' + +type TestRuntime = { + endpoint: string + publicKeyB64: string + deviceToken: string + close: () => Promise +} + +describe('CLI remote WebSocket transport', () => { + const servers: TestRuntime[] = [] + + afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.close())) + }) + + it('calls a remote runtime through a mobile pairing offer', async () => { + const runtime = await startTestRuntime('runtime-ws-1') + servers.push(runtime) + + const pairingUrl = encodePairingOffer({ + v: 2, + endpoint: runtime.endpoint, + deviceToken: runtime.deviceToken, + publicKeyB64: runtime.publicKeyB64 + }) + const client = new RuntimeClient('/tmp/unused', 5_000, pairingUrl) + const response = await client.call<{ runtimeId: string }>('status.get') + + expect(response.ok).toBe(true) + expect(response.result.runtimeId).toBe('runtime-ws-1') + }) + + it('rejects malformed remote pairing codes before local runtime lookup', () => { + expect(() => new RuntimeClient('/tmp/unused', 5_000, 'not-a-pairing-code')).toThrow( + RuntimeClientError + ) + }) + + it('accepts a bare pairing payload as well as the orca URL wrapper', async () => { + const runtime = await startTestRuntime('runtime-ws-2') + servers.push(runtime) + const offer: PairingOffer = { + v: 2, + endpoint: runtime.endpoint, + deviceToken: runtime.deviceToken, + publicKeyB64: runtime.publicKeyB64 + } + const barePayload = encodePairingOffer(offer).split('#')[1]! + + const client = new RuntimeClient('/tmp/unused', 5_000, barePayload) + const status = await client.getCliStatus() + + expect(status.result.runtime.reachable).toBe(true) + expect(status.result.runtime.runtimeId).toBe('runtime-ws-2') + }) + + it('connects through a saved environment selector', async () => { + const runtime = await startTestRuntime('runtime-env-1') + servers.push(runtime) + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-cli-env-')) + addEnvironmentFromPairingCode(userDataPath, { + name: 'remote-dev', + pairingCode: encodePairingOffer({ + v: 2, + endpoint: runtime.endpoint, + deviceToken: runtime.deviceToken, + publicKeyB64: runtime.publicKeyB64 + }) + }) + + const client = new RuntimeClient(userDataPath, 5_000, null, 'remote-dev') + const status = await client.getCliStatus() + + expect(status.result.runtime.reachable).toBe(true) + expect(status.result.runtime.runtimeId).toBe('runtime-env-1') + }) + + it('blocks remote RPCs when the server protocol is too old', async () => { + const runtime = await startTestRuntime('runtime-old', { runtimeProtocolVersion: 1 }) + servers.push(runtime) + + const client = new RuntimeClient( + '/tmp/unused', + 5_000, + encodePairingOffer({ + v: 2, + endpoint: runtime.endpoint, + deviceToken: runtime.deviceToken, + publicKeyB64: runtime.publicKeyB64 + }) + ) + + await expect(client.call('repo.list')).rejects.toMatchObject({ + code: 'incompatible_runtime', + message: expect.stringContaining('server is too old') + }) + }) +}) + +async function startTestRuntime( + runtimeId: string, + statusOverrides: { + runtimeProtocolVersion?: number + minCompatibleRuntimeClientVersion?: number + } = {} +): Promise { + const serverKeyPair = generateKeyPair() + const deviceToken = `token-${runtimeId}` + const httpServer = createServer() + const wss = new WebSocketServer({ server: httpServer }) + + wss.on('connection', (ws) => { + let sharedKey: Uint8Array | null = null + let authenticated = false + + ws.on('message', (data) => { + const frame = data.toString() + if (!sharedKey) { + const hello = JSON.parse(frame) as { type?: string; publicKeyB64?: string } + const clientPublicKey = Buffer.from(hello.publicKeyB64 ?? '', 'base64') + sharedKey = deriveSharedKey(serverKeyPair.secretKey, clientPublicKey) + ws.send(JSON.stringify({ type: 'e2ee_ready' })) + return + } + + const plaintext = decrypt(frame, sharedKey) + if (!plaintext) { + ws.close(4003, 'decrypt failed') + return + } + if (!authenticated) { + const auth = JSON.parse(plaintext) as { type?: string; deviceToken?: string } + if (auth.type !== 'e2ee_auth' || auth.deviceToken !== deviceToken) { + ws.send(encrypt(JSON.stringify({ type: 'e2ee_error' }), sharedKey)) + ws.close(4001, 'auth failed') + return + } + authenticated = true + ws.send(encrypt(JSON.stringify({ type: 'e2ee_authenticated' }), sharedKey)) + return + } + + const request = JSON.parse(plaintext) as { id: string; method: string } + const response = + request.method === 'status.get' + ? { + id: request.id, + ok: true, + result: { + runtimeId, + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: + statusOverrides.runtimeProtocolVersion ?? RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: + statusOverrides.minCompatibleRuntimeClientVersion ?? + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + }, + _meta: { runtimeId } + } + : { + id: request.id, + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId } + } + ws.send(encrypt(JSON.stringify(response), sharedKey)) + }) + }) + + await listen(httpServer) + const address = httpServer.address() + if (!address || typeof address === 'string') { + throw new Error('Expected TCP test server') + } + + return { + endpoint: `ws://127.0.0.1:${address.port}`, + publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey), + deviceToken, + close: async () => { + await new Promise((resolve) => { + wss.close(() => resolve()) + for (const client of wss.clients) { + client.close() + } + }) + await closeHttpServer(httpServer) + } + } +} + +async function listen(server: Server): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) +} + +async function closeHttpServer(server: Server): Promise { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) +} diff --git a/src/cli/runtime/websocket-transport.ts b/src/cli/runtime/websocket-transport.ts new file mode 100644 index 00000000000..ac5fc677919 --- /dev/null +++ b/src/cli/runtime/websocket-transport.ts @@ -0,0 +1,22 @@ +import type { PairingOffer } from '../../shared/pairing' +import { + RemoteRuntimeClientError, + sendRemoteRuntimeRequest +} from '../../shared/remote-runtime-client' +import { RuntimeClientError, type RuntimeRpcResponse } from './types' + +export async function sendWebSocketRequest( + pairing: PairingOffer, + method: string, + params: unknown, + timeoutMs: number +): Promise> { + try { + return await sendRemoteRuntimeRequest(pairing, method, params, timeoutMs) + } catch (error) { + if (error instanceof RemoteRuntimeClientError) { + throw new RuntimeClientError(error.code, error.message) + } + throw error + } +} diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index 14420cebb69..c56d4f152e0 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -1,5 +1,6 @@ import { isAbsolute, relative, resolve as resolvePath } from 'path' import type { ComputerAppQuery, RuntimeWorktreeListResult } from '../shared/runtime-types' +import { isPathInsideOrEqual } from '../shared/cross-platform-path' import type { RuntimeClient } from './runtime-client' import { RuntimeClientError } from './runtime-client' import { getOptionalStringFlag, getRequiredStringFlag } from './flags' @@ -26,7 +27,22 @@ export function normalizeWorktreeSelector(selector: string, cwd: string): string return selector } +function assertLocalCwdWorktreeSelector(selector: string, client: RuntimeClient): void { + if (!client.isRemote) { + return + } + // Why: a paired CLI's cwd belongs to the client machine, not the runtime + // server, so cwd-derived worktree selectors are only valid locally. + throw new RuntimeClientError( + 'invalid_argument', + `${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as id:, branch:, issue:, or path:.` + ) +} + function isWithinPath(parentPath: string, childPath: string): boolean { + if (isPathInsideOrEqual(parentPath, childPath)) { + return true + } const relativePath = relative(parentPath, childPath) return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) } @@ -35,6 +51,8 @@ export async function resolveCurrentWorktreeSelector( cwd: string, client: RuntimeClient ): Promise { + assertLocalCwdWorktreeSelector('current', client) + const currentPath = resolvePath(cwd) const worktrees = await client.call('worktree.list', { limit: 10_000 @@ -68,6 +86,7 @@ export async function getOptionalWorktreeSelector( return undefined } if (value === 'active' || value === 'current') { + assertLocalCwdWorktreeSelector(value, client) return await resolveCurrentWorktreeSelector(cwd, client) } return normalizeWorktreeSelector(value, cwd) @@ -81,13 +100,14 @@ export async function getRequiredWorktreeSelector( ): Promise { const value = getRequiredStringFlag(flags, name) if (value === 'active' || value === 'current') { + assertLocalCwdWorktreeSelector(value, client) return await resolveCurrentWorktreeSelector(cwd, client) } return normalizeWorktreeSelector(value, cwd) } -// Why: browser commands default to the current worktree (auto-resolve from cwd). -// --worktree all bypasses filtering. Omitting --worktree auto-resolves. +// Why: local browser commands default to the current worktree by auto-resolving +// from cwd. Remote commands omit worktree so the runtime uses server-side focus. export async function getBrowserWorktreeSelector( flags: Map, cwd: string, @@ -99,10 +119,14 @@ export async function getBrowserWorktreeSelector( } if (value) { if (value === 'active' || value === 'current') { + assertLocalCwdWorktreeSelector(value, client) return await resolveCurrentWorktreeSelector(cwd, client) } return normalizeWorktreeSelector(value, cwd) } + if (client.isRemote) { + return undefined + } // Default: auto-resolve from cwd try { return await resolveCurrentWorktreeSelector(cwd, client) @@ -146,6 +170,7 @@ export async function getBrowserCommandTarget( return { page } } if (explicitWorktree === 'active' || explicitWorktree === 'current') { + assertLocalCwdWorktreeSelector(explicitWorktree, client) return { page, worktree: await resolveCurrentWorktreeSelector(cwd, client) diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index b85424e8ac0..f8f6d8ffb00 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -9,6 +9,24 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ allowedFlags: [...GLOBAL_FLAGS], examples: ['orca open', 'orca open --json'] }, + { + path: ['serve'], + summary: 'Start an Orca runtime server without opening a desktop window', + usage: + 'orca serve [--port ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'port', 'pairing-address', 'mobile-pairing', 'no-pairing'], + notes: [ + 'Runs in the foreground and prints the runtime endpoint. Stop it with Ctrl+C.', + 'Use --pairing-address when clients should connect through a LAN, Tailscale, SSH-forward, or public tunnel address.', + 'Use --mobile-pairing to print a mobile-scoped pairing QR/link instead of the default runtime-environment pairing link.' + ], + examples: [ + 'orca serve', + 'orca serve --json', + 'orca serve --port 6768 --pairing-address 100.64.1.20', + 'orca serve --pairing-address 100.64.1.20 --mobile-pairing' + ] + }, { path: ['status'], summary: 'Show app/runtime/graph readiness', diff --git a/src/cli/specs/environment.ts b/src/cli/specs/environment.ts new file mode 100644 index 00000000000..6bfe1940b65 --- /dev/null +++ b/src/cli/specs/environment.ts @@ -0,0 +1,30 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['environment', 'add'], + summary: 'Save a remote Orca runtime environment from a pairing code', + usage: 'orca environment add --name --pairing-code [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'name'], + examples: ['orca environment add --name work-laptop --pairing-code orca://pair#...'] + }, + { + path: ['environment', 'list'], + summary: 'List saved Orca runtime environments', + usage: 'orca environment list [--json]', + allowedFlags: [...GLOBAL_FLAGS] + }, + { + path: ['environment', 'show'], + summary: 'Show one saved Orca runtime environment', + usage: 'orca environment show --environment [--json]', + allowedFlags: [...GLOBAL_FLAGS] + }, + { + path: ['environment', 'rm'], + summary: 'Remove one saved Orca runtime environment', + usage: 'orca environment rm --environment [--json]', + allowedFlags: [...GLOBAL_FLAGS] + } +] diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index 0828ef9b557..ebcbb022fcd 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -4,6 +4,7 @@ import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic' import { CORE_COMMAND_SPECS } from './core' import { ORCHESTRATION_COMMAND_SPECS } from './orchestration' import { COMPUTER_COMMAND_SPECS } from './computer' +import { ENVIRONMENT_COMMAND_SPECS } from './environment' import { NOTE_COMMAND_SPECS } from './note' export const COMMAND_SPECS: CommandSpec[] = [ @@ -12,5 +13,6 @@ export const COMMAND_SPECS: CommandSpec[] = [ ...BROWSER_ADVANCED_COMMAND_SPECS, ...ORCHESTRATION_COMMAND_SPECS, ...COMPUTER_COMMAND_SPECS, + ...ENVIRONMENT_COMMAND_SPECS, ...NOTE_COMMAND_SPECS ] diff --git a/src/main/cli/packaged-cli-assets.test.ts b/src/main/cli/packaged-cli-assets.test.ts new file mode 100644 index 00000000000..615f382cf18 --- /dev/null +++ b/src/main/cli/packaged-cli-assets.test.ts @@ -0,0 +1,19 @@ +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const builderConfig = require('../../../config/electron-builder.config.cjs') as { + asarUnpack?: string[] +} + +describe('packaged CLI assets', () => { + it('unpacks runtime dependencies used before Electron asar integration is available', () => { + expect(builderConfig.asarUnpack).toEqual( + expect.arrayContaining([ + 'node_modules/ws/**', + 'node_modules/tweetnacl/**', + 'node_modules/zod/**' + ]) + ) + }) +}) diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index c9fc0ecc357..3a815558c18 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -220,10 +220,14 @@ describe('getDiff', () => { const result = await getDiff('/repo', 'src/file.ts', false) - expect(gitExecFileAsyncBufferMock).toHaveBeenNthCalledWith(2, ['show', 'HEAD:src/file.ts'], { - cwd: '/repo', - maxBuffer: 10 * 1024 * 1024 - }) + expect(gitExecFileAsyncBufferMock).toHaveBeenNthCalledWith( + 2, + ['show', '--end-of-options', 'HEAD:src/file.ts'], + { + cwd: '/repo', + maxBuffer: 10 * 1024 * 1024 + } + ) expect(result.originalContent).toBe('head-content\n') expect(result.modifiedContent).toBe('working-tree-content') }) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 0934e6d1013..b24535d4454 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -551,7 +551,7 @@ async function resolveCompareRef(worktreePath: string): Promise { } async function resolveRefOid(worktreePath: string, ref: string): Promise { - const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', ref], { + const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', '--end-of-options', ref], { cwd: worktreePath }) return stdout.trim() @@ -613,10 +613,13 @@ async function readGitBlobAtOidPath( filePath: string ): Promise { try { - const { stdout } = await gitExecFileAsyncBuffer(['show', `${oid}:${filePath}`], { - cwd: worktreePath, - maxBuffer: MAX_GIT_SHOW_BYTES - }) + const { stdout } = await gitExecFileAsyncBuffer( + ['show', '--end-of-options', `${oid}:${filePath}`], + { + cwd: worktreePath, + maxBuffer: MAX_GIT_SHOW_BYTES + } + ) return { ...bufferToBlob(stdout, filePath), exists: true } } catch { diff --git a/src/main/index.ts b/src/main/index.ts index 47de211cc30..19c20b07e10 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,7 @@ import { grantDirAcl } from './win32-utils' import { app, BrowserWindow, nativeImage, nativeTheme } from 'electron' import { electronApp, is } from '@electron-toolkit/utils' +import * as QRCode from 'qrcode' import devIcon from '../../resources/icon-dev.png?asset' import { Store, initDataPath } from './persistence' import { StatsCollector, initStatsPath } from './stats/collector' @@ -51,7 +52,12 @@ import { codexHookService } from './codex/hook-service' import { geminiHookService } from './gemini/hook-service' import { cursorHookService } from './cursor/hook-service' import { droidHookService } from './droid/hook-service' -import { getPtyIdForPaneKey, registerPaneKeyTeardownListener, getLocalPtyProvider } from './ipc/pty' +import { + getPtyIdForPaneKey, + registerPaneKeyTeardownListener, + getLocalPtyProvider, + registerHeadlessPtyRuntime +} from './ipc/pty' import { AgentBrowserBridge } from './browser/agent-browser-bridge' import { browserManager } from './browser/browser-manager' import { setUnreadDockBadgeCount } from './dock/unread-badge' @@ -79,6 +85,7 @@ let disposeFeatureWallFirstAgentTour: (() => void) | null = null let watcherShutdownPromise: Promise | null = null let watcherShutdownDone = false let automations: AutomationService | null = null +const isServeMode = process.argv.includes('--serve') installUncaughtPipeErrorGuard() // Why: propagate the Orca app version into `process.env` so PTY-env @@ -141,7 +148,8 @@ function focusExistingWindow(): void { // agent work, so that routing ambiguity is acceptable. Packaged Orca keeps // the lock to protect against the corruption documented in PR #1326 / // issue #1312. -const hasSingleInstanceLock = is.dev ? true : acquireSingleInstanceLock(app, focusExistingWindow) +const hasSingleInstanceLock = + is.dev && !isServeMode ? true : acquireSingleInstanceLock(app, focusExistingWindow) if (!hasSingleInstanceLock) { if (is.dev) { // Why: packaged runs have no attached console, but dev runs do. Emit a @@ -160,8 +168,12 @@ if (!hasSingleInstanceLock) { // below happen — those handlers only fire after whenReady, which app.quit() // prevents from ever dispatching. if (hasSingleInstanceLock) { - installDevParentDisconnectQuit(is.dev) - installDevParentWatchdog(is.dev) + // Why: dev parent shutdown coupling is only for electron-vite desktop runs. + // `orca serve` may be launched through a CLI shim or background shell whose + // parent lifetime is not the intended server lifetime. + const shouldCoupleToDevParent = is.dev && !isServeMode + installDevParentDisconnectQuit(shouldCoupleToDevParent) + installDevParentWatchdog(shouldCoupleToDevParent) // Why: must run after configureDevUserDataPath (which redirects userData to // orca-dev in dev mode) but before app.setName('Orca') inside whenReady // (which would change the resolved path on case-sensitive filesystems). @@ -392,6 +404,108 @@ const syntheticTitleSpinnerByPaneKey = new Map< { timer: ReturnType; frame: number; profile: SyntheticTitleProfile } >() +type ServeOptions = { + json: boolean + wsPort?: number + pairingAddress: string | null + noPairing: boolean + mobilePairing: boolean +} + +function getServeOptions(argv = process.argv): ServeOptions { + const valueAfter = (flag: string): string | null => { + const index = argv.indexOf(flag) + if (index === -1) { + return null + } + const value = argv[index + 1] + return value && !value.startsWith('--') ? value : null + } + const rawPort = valueAfter('--serve-port') + let wsPort: number | undefined + if (rawPort) { + const parsedPort = Number(rawPort) + if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) { + throw new Error(`Invalid --serve-port value: ${rawPort}`) + } + wsPort = parsedPort + } + return { + json: argv.includes('--serve-json'), + ...(wsPort !== undefined ? { wsPort } : {}), + pairingAddress: valueAfter('--serve-pairing-address'), + noPairing: argv.includes('--serve-no-pairing'), + mobilePairing: argv.includes('--serve-mobile-pairing') + } +} + +async function renderTerminalPairingQr(pairingUrl: string): Promise { + try { + return await QRCode.toString(pairingUrl, { type: 'terminal', small: true }) + } catch { + try { + return await QRCode.toString(pairingUrl, { type: 'utf8' }) + } catch { + return null + } + } +} + +async function printServeReady(options: ServeOptions): Promise { + if (!runtime || !runtimeRpc) { + throw new Error('Runtime server must be initialized before printing serve readiness') + } + const endpoint = runtimeRpc.getWebSocketEndpoint() + const pairing = options.noPairing + ? ({ available: false } as const) + : runtimeRpc.createPairingOffer({ + address: options.pairingAddress, + name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`, + scope: options.mobilePairing ? 'mobile' : 'runtime' + }) + const pairingQr = + pairing.available && options.mobilePairing + ? await renderTerminalPairingQr(pairing.pairingUrl) + : null + if (options.json) { + console.log( + JSON.stringify({ + type: 'orca_server_ready', + runtimeId: runtime.getRuntimeId(), + endpoint, + pairing: pairing.available + ? { + url: pairing.pairingUrl, + endpoint: pairing.endpoint, + deviceId: pairing.deviceId, + scope: options.mobilePairing ? 'mobile' : 'runtime', + qr: pairingQr + } + : null + }) + ) + return + } + console.log(`Orca server ready: ${endpoint ?? 'websocket unavailable'}`) + if (pairing.available) { + if (options.mobilePairing && pairingQr) { + console.log(`Mobile pairing QR:\n${pairingQr}`) + } + console.log(`Pairing URL: ${pairing.pairingUrl}`) + } +} + +function installServeSignalHandlers(): void { + const quit = (): void => { + // Why: foreground `orca serve` is controlled by the parent CLI/terminal, + // so POSIX termination signals should follow Electron's normal quit path + // and flush runtime metadata, daemon checkpoints, and telemetry. + app.quit() + } + process.once('SIGINT', quit) + process.once('SIGTERM', quit) +} + // Why: on PTY teardown the paneKey→ptyId mapping is dropped, so the spinner // interval would keep firing but sendSyntheticTitle would no-op forever. // Stop the interval explicitly so the process doesn't carry a timer per dead @@ -622,38 +736,70 @@ app.whenReady().then(async () => { // ws://127.0.0.1:6769 is stable; a second dev instance still falls back via // ws-transport's EADDRINUSE handler. const devWsPort = is.dev && !isE2E ? 6769 : undefined + let serveOptions: ServeOptions | null = null + try { + serveOptions = isServeMode ? getServeOptions() : null + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + app.exit(1) + return + } runtimeRpc = new OrcaRuntimeRpcServer({ runtime, userDataPath: app.getPath('userData'), enableWebSocket: true, ...(isE2E ? { wsPort: 0 } : {}), - ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}) + ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}), + ...(serveOptions?.wsPort !== undefined ? { wsPort: serveOptions.wsPort } : {}) }) registerMobileHandlers(runtimeRpc) - await startFirstWindowStartupServices({ - // Why: the persistent-terminal daemon is always started. If it fails, the - // LocalPtyProvider remains as the implicit fallback — terminals work, just - // without cross-restart persistence. - startDaemonPtyProvider: () => initDaemonPtyProvider(), - // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state, - // so the hook server must start before restored terminals can mount. - startAgentHookServer: () => - agentHookServer.start({ - env: app.isPackaged ? 'production' : 'development', - // Why: hooks source this endpoint file at invocation time, so old PTY - // env still reaches the current Orca process after an app restart. - userDataPath: app.getPath('userData') - }), - onDaemonError: (error) => { - console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error) - }, - onAgentHookServerError: (error) => { - // Why: Claude/Codex/Gemini/OpenCode/Cursor hook callbacks are sidebar - // enrichment only. Orca must still boot if the loopback receiver fails. - console.error('[agent-hooks] Failed to start local hook server:', error) - } - }) + if (!isServeMode) { + await startFirstWindowStartupServices({ + // Why: the persistent-terminal daemon is desktop-only. Headless + // `orca serve` registers its PTY runtime below and must not spawn the + // desktop daemon or hook loopback listener. + startDaemonPtyProvider: () => initDaemonPtyProvider(), + // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state, + // so the hook server must start before restored terminals can mount. + startAgentHookServer: () => + agentHookServer.start({ + env: app.isPackaged ? 'production' : 'development', + // Why: hooks source this endpoint file at invocation time, so old PTY + // env still reaches the current Orca process after an app restart. + userDataPath: app.getPath('userData') + }), + onDaemonError: (error) => { + console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error) + }, + onAgentHookServerError: (error) => { + // Why: Claude/Codex/Gemini/OpenCode/Cursor hook callbacks are sidebar + // enrichment only. Orca must still boot if the loopback receiver fails. + console.error('[agent-hooks] Failed to start local hook server:', error) + } + }) + } + + if (serveOptions) { + registerHeadlessPtyRuntime( + runtime, + () => codexRuntimeHome!.prepareForCodexLaunch(), + () => store!.getSettings(), + () => claudeRuntimeAuth!.prepareForClaudeLaunch(), + store + ) + // Why: headless servers have no renderer graph publisher. Publish an + // explicit empty graph so status clients see a ready server while + // renderer-only operations still fail at their own window boundary. + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + await runtimeRpc.start().catch((error) => { + console.error('[runtime] Failed to start headless RPC transport:', error) + throw error + }) + installServeSignalHandlers() + await printServeReady(serveOptions) + return + } // Why: once the hook server is ready (or has already failed open), window // creation and runtime RPC startup are independent. diff --git a/src/main/ipc/filesystem-import-ssh-ops.test.ts b/src/main/ipc/filesystem-import-ssh-ops.test.ts index a67e6d8a787..376e867da43 100644 --- a/src/main/ipc/filesystem-import-ssh-ops.test.ts +++ b/src/main/ipc/filesystem-import-ssh-ops.test.ts @@ -179,7 +179,16 @@ describe('fs:importExternalPaths — SSH operations', () => { connectionId: connId }) expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' }) - expect(mkdirSftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`) + expect(mkdirSftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`, { + allowExisting: false + }) + expect(uploadDirMock).toHaveBeenCalledWith( + mockSftp, + path.resolve('/tmp/dropped/assets'), + `${destDir}/assets`, + path.resolve('/tmp/dropped/assets'), + { exclusive: true } + ) }) it('reports per-item failure when deconfliction throws', async () => { diff --git a/src/main/ipc/filesystem-import-ssh.test.ts b/src/main/ipc/filesystem-import-ssh.test.ts index 210fbfe1105..e348f2f2979 100644 --- a/src/main/ipc/filesystem-import-ssh.test.ts +++ b/src/main/ipc/filesystem-import-ssh.test.ts @@ -1,4 +1,6 @@ import path from 'path' +import { constants } from 'fs' +import { Readable, Writable } from 'stream' import { beforeEach, describe, expect, it, vi } from 'vitest' const handlers = new Map Promise>() @@ -8,7 +10,9 @@ const { mkdirMock, realpathMock, copyFileMock, + openMock, readdirMock, + unlinkMock, sftpExistsMock, uploadFileMock, uploadDirMock, @@ -20,7 +24,9 @@ const { mkdirMock: vi.fn(), realpathMock: vi.fn(), copyFileMock: vi.fn(), + openMock: vi.fn(), readdirMock: vi.fn(), + unlinkMock: vi.fn(), sftpExistsMock: vi.fn(), uploadFileMock: vi.fn(), uploadDirMock: vi.fn(), @@ -36,7 +42,10 @@ vi.mock('fs/promises', () => ({ writeFile: vi.fn(), realpath: realpathMock, copyFile: copyFileMock, - readdir: readdirMock + open: openMock, + readdir: readdirMock, + unlink: unlinkMock, + rm: vi.fn() })) vi.mock('../ssh/sftp-upload', () => ({ sftpPathExists: sftpExistsMock, @@ -92,7 +101,9 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { mkdirMock, realpathMock, copyFileMock, + openMock, readdirMock, + unlinkMock, sftpExistsMock, uploadFileMock, uploadDirMock, @@ -105,6 +116,30 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { }) realpathMock.mockImplementation(async (p: string) => p) lstatMock.mockRejectedValue(enoent()) + openMock.mockImplementation(async (_p: string, flags: unknown) => { + if (flags === 'wx') { + return { + createWriteStream: () => + new Writable({ + write(_chunk, _encoding, callback) { + callback() + } + }), + close: vi.fn().mockResolvedValue(undefined) + } + } + return { + stat: vi.fn().mockResolvedValue({ + size: 12, + ino: 1, + dev: 1, + isFile: () => true + }), + createReadStream: () => Readable.from([Buffer.from('file-content')]), + close: vi.fn().mockResolvedValue(undefined) + } + }) + unlinkMock.mockResolvedValue(undefined) sftpExistsMock.mockResolvedValue(false) uploadFileMock.mockResolvedValue(undefined) uploadDirMock.mockResolvedValue(undefined) @@ -121,7 +156,12 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { connectionId: connId }) expect(results[0]).toMatchObject({ status: 'imported', kind: 'file' }) - expect(uploadFileMock).toHaveBeenCalled() + expect(uploadFileMock).toHaveBeenCalledWith( + mockSftp, + path.resolve('/tmp/dropped/file.txt'), + `${destDir}/file.txt`, + { exclusive: true } + ) expect(copyFileMock).not.toHaveBeenCalled() }) @@ -132,7 +172,10 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { destDir: path.resolve('/workspace/repo/src') }) expect(results[0]).toMatchObject({ status: 'imported' }) - expect(copyFileMock).toHaveBeenCalled() + expect(openMock).toHaveBeenCalledWith( + path.resolve('/tmp/dropped/file.txt'), + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) + ) }) it('returns empty results without opening SFTP', async () => { diff --git a/src/main/ipc/filesystem-import-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts index 8554b466547..c29dbd6ee04 100644 --- a/src/main/ipc/filesystem-import-ssh.ts +++ b/src/main/ipc/filesystem-import-ssh.ts @@ -1,4 +1,4 @@ -import { lstat, readdir } from 'fs/promises' +import { lstat, readdir, realpath } from 'fs/promises' import { basename, join, posix, resolve } from 'path' import type { SFTPWrapper } from 'ssh2' import { authorizeExternalPath, isENOENT } from './filesystem-auth' @@ -124,10 +124,12 @@ async function importOneSourceSsh( const renamed = finalName !== originalName if (isDir) { - await mkdirSftp(sftp, destPath) - await uploadDirectory(sftp, resolvedSource, destPath) + await mkdirSftp(sftp, destPath, { allowExisting: false }) + await uploadDirectory(sftp, resolvedSource, destPath, await realpath(resolvedSource), { + exclusive: true + }) } else { - await uploadFile(sftp, resolvedSource, destPath) + await uploadFile(sftp, resolvedSource, destPath, { exclusive: true }) } return { diff --git a/src/main/ipc/filesystem-import.test.ts b/src/main/ipc/filesystem-import.test.ts index a6dc5df44b1..318e2d120fc 100644 --- a/src/main/ipc/filesystem-import.test.ts +++ b/src/main/ipc/filesystem-import.test.ts @@ -1,17 +1,34 @@ +/* eslint-disable max-lines -- Why: import tests cover local copy, SSH routing, +symlink safety, and runtime-upload staging against one shared IPC fixture. */ import path from 'path' +import { constants } from 'fs' +import { Readable, Writable } from 'stream' import { beforeEach, describe, expect, it, vi } from 'vitest' const handlers = new Map Promise>() -const { handleMock, lstatMock, mkdirMock, realpathMock, copyFileMock, readdirMock } = vi.hoisted( - () => ({ - handleMock: vi.fn(), - lstatMock: vi.fn(), - mkdirMock: vi.fn(), - realpathMock: vi.fn(), - copyFileMock: vi.fn(), - readdirMock: vi.fn() - }) -) +const { + handleMock, + lstatMock, + mkdirMock, + realpathMock, + copyFileMock, + openMock, + readFileMock, + readdirMock, + rmMock, + unlinkMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + lstatMock: vi.fn(), + mkdirMock: vi.fn(), + realpathMock: vi.fn(), + copyFileMock: vi.fn(), + openMock: vi.fn(), + readFileMock: vi.fn(), + readdirMock: vi.fn(), + rmMock: vi.fn(), + unlinkMock: vi.fn() +})) vi.mock('electron', () => ({ ipcMain: { handle: handleMock } @@ -20,11 +37,15 @@ vi.mock('electron', () => ({ vi.mock('fs/promises', () => ({ lstat: lstatMock, mkdir: mkdirMock, + open: openMock, rename: vi.fn(), writeFile: vi.fn(), realpath: realpathMock, copyFile: copyFileMock, - readdir: readdirMock + readFile: readFileMock, + readdir: readdirMock, + rm: rmMock, + unlink: unlinkMock })) import { registerFilesystemMutationHandlers } from './filesystem-mutations' @@ -50,7 +71,14 @@ describe('fs:importExternalPaths', () => { const resolvedPath = path.resolve(filePath) lstatMock.mockImplementation(async (p: string) => { if (p === resolvedPath) { - return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } + return { + size: 12, + ino: 1, + dev: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } } throw enoent() }) @@ -62,6 +90,17 @@ describe('fs:importExternalPaths', () => { if (p === resolvedDir) { return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } } + const entry = entries.find((e) => path.join(resolvedDir, e.name) === p) + if (entry) { + return { + size: entry.isDir ? 0 : 12, + ino: entry.isDir ? 2 : 3, + dev: 1, + isFile: () => !entry.isDir, + isDirectory: () => entry.isDir, + isSymbolicLink: () => false + } + } throw enoent() }) readdirMock.mockImplementation(async () => { @@ -84,6 +123,35 @@ describe('fs:importExternalPaths', () => { }) } + function mockLocalCopyOpenSuccess(content = Buffer.from('file-content')): void { + openMock.mockImplementation(async (_p: string, flags: unknown) => { + if (flags === 'wx') { + const written: Buffer[] = [] + return { + createWriteStream: () => + new Writable({ + write(chunk, _encoding, callback) { + written.push(Buffer.from(chunk)) + callback() + } + }), + close: vi.fn().mockResolvedValue(undefined), + written + } + } + return { + stat: vi.fn().mockResolvedValue({ + size: content.byteLength, + ino: 1, + dev: 1, + isFile: () => true + }), + createReadStream: () => Readable.from([content]), + close: vi.fn().mockResolvedValue(undefined) + } + }) + } + beforeEach(() => { handlers.clear() handleMock.mockReset() @@ -91,7 +159,11 @@ describe('fs:importExternalPaths', () => { mkdirMock.mockReset() realpathMock.mockReset() copyFileMock.mockReset() + openMock.mockReset() + readFileMock.mockReset() readdirMock.mockReset() + rmMock.mockReset() + unlinkMock.mockReset() handleMock.mockImplementation((channel: string, handler: never) => { handlers.set(channel, handler) @@ -101,7 +173,11 @@ describe('fs:importExternalPaths', () => { lstatMock.mockRejectedValue(enoent()) mkdirMock.mockResolvedValue(undefined) copyFileMock.mockResolvedValue(undefined) + mockLocalCopyOpenSuccess() + readFileMock.mockResolvedValue(Buffer.from('file-content')) readdirMock.mockResolvedValue([]) + rmMock.mockResolvedValue(undefined) + unlinkMock.mockResolvedValue(undefined) registerFilesystemMutationHandlers(store as never) }) @@ -122,10 +198,44 @@ describe('fs:importExternalPaths', () => { renamed: false, destPath: path.join(destDir, 'logo.png') }) - expect(copyFileMock).toHaveBeenCalledWith( + expect(openMock).toHaveBeenCalledWith( path.resolve(sourcePath), - path.join(destDir, 'logo.png') + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) ) + expect(openMock).toHaveBeenCalledWith(path.join(destDir, 'logo.png'), 'wx') + expect(copyFileMock).not.toHaveBeenCalled() + }) + + it('fails local import instead of clobbering when the chosen destination appears late', async () => { + const sourcePath = '/tmp/dropped/logo.png' + mockSourceFile(sourcePath) + openMock.mockImplementation(async (_p: string, flags: unknown) => { + if (flags === 'wx') { + throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) + } + return { + stat: vi.fn().mockResolvedValue({ + size: 12, + ino: 1, + dev: 1, + isFile: () => true + }), + createReadStream: () => Readable.from([Buffer.from('file-content')]), + close: vi.fn().mockResolvedValue(undefined) + } + }) + + const result = (await handlers.get('fs:importExternalPaths')!(null, { + sourcePaths: [sourcePath], + destDir + })) as { results: { status: string; reason?: string }[] } + + expect(result.results[0]).toMatchObject({ status: 'failed', reason: 'EEXIST' }) + expect(openMock).toHaveBeenCalledWith( + path.resolve(sourcePath), + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) + ) + expect(openMock).toHaveBeenCalledWith(path.join(destDir, 'logo.png'), 'wx') }) it('imports multiple files in one batch', async () => { @@ -276,6 +386,44 @@ describe('fs:importExternalPaths', () => { expect(copyFileMock).not.toHaveBeenCalled() }) + it('fails and removes output if a local directory entry becomes a symlink after pre-scan', async () => { + const sourcePath = '/tmp/dropped/mixeddir' + const resolvedSource = path.resolve(sourcePath) + const childPath = path.join(resolvedSource, 'normal.txt') + lstatMock.mockImplementation(async (p: string) => { + if (p === resolvedSource) { + return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } + } + if (p === childPath) { + return { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => true } + } + throw enoent() + }) + readdirMock.mockResolvedValue([ + { + name: 'normal.txt', + isDirectory: () => false, + isSymbolicLink: () => false, + isFile: () => true + } + ]) + + const result = (await handlers.get('fs:importExternalPaths')!(null, { + sourcePaths: [sourcePath], + destDir + })) as { results: { status: string; reason?: string }[] } + + expect(result.results[0]).toMatchObject({ + status: 'failed', + reason: "Symlink not allowed in 'normal.txt'" + }) + expect(openMock).not.toHaveBeenCalledWith(childPath, expect.anything()) + expect(rmMock).toHaveBeenCalledWith(path.join(destDir, 'mixeddir'), { + recursive: true, + force: true + }) + }) + it('rejects unauthorized destinations', async () => { const sourcePath = '/tmp/dropped/file.txt' mockSourceFile(sourcePath) @@ -328,4 +476,118 @@ describe('fs:importExternalPaths', () => { reason: 'missing' }) }) + + it('stages external files for runtime upload without copying into the local worktree', async () => { + const sourcePath = '/tmp/dropped/logo.png' + const resolvedPath = path.resolve(sourcePath) + lstatMock.mockImplementation(async (p: string) => { + if (p === resolvedPath) { + return { + size: 4, + ino: 1, + dev: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } + } + throw enoent() + }) + const closeMock = vi.fn().mockResolvedValue(undefined) + const readFileHandleMock = vi.fn().mockResolvedValue(Buffer.from('png')) + openMock.mockResolvedValue({ + stat: vi.fn().mockResolvedValue({ + size: 4, + ino: 1, + dev: 1, + isFile: () => true + }), + readFile: readFileHandleMock, + close: closeMock + }) + + const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, { + sourcePaths: [sourcePath] + })) as { sources: unknown[] } + + expect(result.sources).toEqual([ + { + sourcePath, + status: 'staged', + name: 'logo.png', + kind: 'file', + entries: [{ relativePath: '', kind: 'file', contentBase64: 'cG5n' }] + } + ]) + expect(copyFileMock).not.toHaveBeenCalled() + expect(readFileHandleMock).toHaveBeenCalled() + expect(closeMock).toHaveBeenCalled() + }) + + it('fails runtime upload staging when a file changes between lstat and open', async () => { + const sourcePath = '/tmp/dropped/logo.png' + const resolvedPath = path.resolve(sourcePath) + lstatMock.mockImplementation(async (p: string) => { + if (p === resolvedPath) { + return { + size: 4, + ino: 1, + dev: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } + } + throw enoent() + }) + const readFileHandleMock = vi.fn().mockResolvedValue(Buffer.from('png')) + openMock.mockResolvedValue({ + stat: vi.fn().mockResolvedValue({ + size: 4, + ino: 2, + dev: 1, + isFile: () => true + }), + readFile: readFileHandleMock, + close: vi.fn().mockResolvedValue(undefined) + }) + + const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, { + sourcePaths: [sourcePath] + })) as { sources: { status: string; reason?: string }[] } + + expect(result.sources[0]).toMatchObject({ + status: 'failed', + reason: "File changed during upload staging: ''" + }) + expect(readFileHandleMock).not.toHaveBeenCalled() + }) + + it('fails runtime upload staging when a checked directory resolves outside the upload root', async () => { + const sourcePath = '/tmp/dropped/assets' + const resolvedPath = path.resolve(sourcePath) + lstatMock.mockImplementation(async (p: string) => { + if (p === resolvedPath) { + return { + isFile: () => false, + isDirectory: () => true, + isSymbolicLink: () => false + } + } + throw enoent() + }) + readdirMock.mockResolvedValue([]) + realpathMock + .mockResolvedValueOnce(resolvedPath) + .mockResolvedValueOnce(path.resolve('/private/assets')) + + const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, { + sourcePaths: [sourcePath] + })) as { sources: { status: string; reason?: string }[] } + + expect(result.sources[0]).toMatchObject({ + status: 'failed', + reason: "Path escaped upload root during staging: ''" + }) + }) }) diff --git a/src/main/ipc/filesystem-mutations.test.ts b/src/main/ipc/filesystem-mutations.test.ts index 3865fc1ef37..13c4a648acc 100644 --- a/src/main/ipc/filesystem-mutations.test.ts +++ b/src/main/ipc/filesystem-mutations.test.ts @@ -2,16 +2,16 @@ import path from 'path' import { beforeEach, describe, expect, it, vi } from 'vitest' const handlers = new Map Promise>() -const { handleMock, lstatMock, mkdirMock, renameMock, writeFileMock, realpathMock } = vi.hoisted( - () => ({ +const { handleMock, copyFileMock, lstatMock, mkdirMock, renameMock, writeFileMock, realpathMock } = + vi.hoisted(() => ({ handleMock: vi.fn(), + copyFileMock: vi.fn(), lstatMock: vi.fn(), mkdirMock: vi.fn(), renameMock: vi.fn(), writeFileMock: vi.fn(), realpathMock: vi.fn() - }) -) + })) vi.mock('electron', () => ({ ipcMain: { handle: handleMock } @@ -23,11 +23,15 @@ vi.mock('fs/promises', () => ({ rename: renameMock, writeFile: writeFileMock, realpath: realpathMock, - copyFile: vi.fn(), + copyFile: copyFileMock, readdir: vi.fn() })) import { registerFilesystemMutationHandlers } from './filesystem-mutations' +import { + registerSshFilesystemProvider, + unregisterSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' // Why: paths are resolved via path.resolve() in production code, so test // data must use resolved paths to avoid Unix-vs-Windows mismatches. @@ -58,6 +62,7 @@ describe('registerFilesystemMutationHandlers', () => { beforeEach(() => { handlers.clear() handleMock.mockReset() + copyFileMock.mockReset() lstatMock.mockReset() mkdirMock.mockReset() renameMock.mockReset() @@ -74,6 +79,7 @@ describe('registerFilesystemMutationHandlers', () => { mkdirMock.mockResolvedValue(undefined) writeFileMock.mockResolvedValue(undefined) renameMock.mockResolvedValue(undefined) + copyFileMock.mockResolvedValue(undefined) registerFilesystemMutationHandlers(store as never) }) @@ -210,6 +216,36 @@ describe('registerFilesystemMutationHandlers', () => { expect(renameMock).toHaveBeenCalledWith(oldPath, newPath) }) + // ── fs:copy ──────────────────────────────────────────────────── + + it('copies a file without overwriting an existing destination', async () => { + const sourcePath = path.resolve('/workspace/repo/source.ts') + const destinationPath = path.resolve('/workspace/repo/source copy.ts') + + await handlers.get('fs:copy')!(null, { sourcePath, destinationPath }) + + expect(mkdirMock).toHaveBeenCalledWith(path.resolve('/workspace/repo'), { recursive: true }) + expect(copyFileMock).toHaveBeenCalledWith(sourcePath, destinationPath, expect.any(Number)) + }) + + it('routes copy through the SSH filesystem provider when a connection is present', async () => { + const copy = vi.fn().mockResolvedValue(undefined) + registerSshFilesystemProvider('ssh-1', { copy } as never) + + try { + await handlers.get('fs:copy')!(null, { + sourcePath: '/home/me/repo/source.ts', + destinationPath: '/home/me/repo/source copy.ts', + connectionId: 'ssh-1' + }) + } finally { + unregisterSshFilesystemProvider('ssh-1') + } + + expect(copy).toHaveBeenCalledWith('/home/me/repo/source.ts', '/home/me/repo/source copy.ts') + expect(copyFileMock).not.toHaveBeenCalled() + }) + // ── Edge cases ───────────────────────────────────────────────── it('propagates non-ENOENT lstat errors in assertNotExists', async () => { diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index b9bcd91bedd..528baa1737c 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -1,6 +1,21 @@ +/* eslint-disable max-lines -- Why: filesystem mutation IPC handlers stay centralized so +authorization, SSH routing, and external import behavior remain audited together. */ import { ipcMain } from 'electron' -import { copyFile, lstat, mkdir, readdir, rename, writeFile } from 'fs/promises' -import { basename, dirname, join, resolve } from 'path' +import { constants } from 'fs' +import { + copyFile, + lstat, + mkdir, + open, + readdir, + realpath, + rename, + rm, + unlink, + writeFile +} from 'fs/promises' +import { basename, dirname, isAbsolute, join, relative, resolve } from 'path' +import { pipeline } from 'stream/promises' import type { Store } from '../persistence' import { authorizeExternalPath, resolveAuthorizedPath, isENOENT } from './filesystem-auth' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' @@ -117,14 +132,42 @@ export function registerFilesystemMutationHandlers(store: Store): void { } ) + ipcMain.handle( + 'fs:copy', + async ( + _event, + args: { sourcePath: string; destinationPath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshFilesystemProvider(args.connectionId) + if (!provider) { + throw new Error(`No filesystem provider for connection "${args.connectionId}"`) + } + return provider.copy(args.sourcePath, args.destinationPath) + } + const sourcePath = await resolveAuthorizedPath(args.sourcePath, store, { + preserveSymlink: true + }) + const destinationPath = await resolveAuthorizedPath(args.destinationPath, store, { + preserveSymlink: true + }) + await mkdir(dirname(destinationPath), { recursive: true }) + // Why: duplicate/copy callers deconflict before copying. COPYFILE_EXCL + // keeps a late race from silently overwriting an existing file. + await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL) + } + ) + ipcMain.handle( 'fs:importExternalPaths', async ( _event, - args: { sourcePaths: string[]; destDir: string; connectionId?: string } + args: { sourcePaths: string[]; destDir: string; connectionId?: string; ensureDir?: boolean } ): Promise<{ results: ImportItemResult[] }> => { if (args.connectionId) { - return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId) + return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId, { + ensureDir: args.ensureDir + }) } // Why: destDir must be authorized before any copy work begins. If the @@ -148,6 +191,20 @@ export function registerFilesystemMutationHandlers(store: Store): void { } ) + ipcMain.handle( + 'fs:stageExternalPathsForRuntimeUpload', + async ( + _event, + args: { sourcePaths: string[] } + ): Promise<{ sources: StagedExternalImportSource[] }> => { + const sources: StagedExternalImportSource[] = [] + for (const sourcePath of args.sourcePaths) { + sources.push(await stageOneSourceForRuntimeUpload(sourcePath)) + } + return { sources } + } + ) + // Why: terminal drag-and-drop resolver. Local worktrees pass paths through // unchanged (reference-in-place; preserves zero-latency drop). SSH worktrees // upload each path into `${worktreePath}/.orca/drops/` and return remote @@ -217,6 +274,32 @@ export type ImportItemResult = reason: string } +export type StagedExternalImportSource = + | { + sourcePath: string + status: 'staged' + name: string + kind: 'file' | 'directory' + entries: StagedExternalImportEntry[] + } + | { + sourcePath: string + status: 'skipped' + reason: ImportSkipReason + } + | { + sourcePath: string + status: 'failed' + reason: string + } + +export type StagedExternalImportEntry = + | { relativePath: string; kind: 'directory' } + | { relativePath: string; kind: 'file'; contentBase64: string } + +const REMOTE_IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024 +const REMOTE_IMPORT_MAX_TOTAL_BYTES = 100 * 1024 * 1024 + // ─── External Import Implementation ───────────────────────────────── /** @@ -288,8 +371,13 @@ async function importOneSource( const renamed = finalName !== originalName try { - await (isDir ? recursiveCopyDir(resolvedSource, destPath) : copyFile(resolvedSource, destPath)) + await (isDir + ? recursiveCopyDir(resolvedSource, destPath) + : copyLocalFileNoFollow(resolvedSource, destPath)) } catch (error) { + if (isDir) { + await rm(destPath, { recursive: true, force: true }).catch(() => {}) + } return { sourcePath, status: 'failed', @@ -306,6 +394,194 @@ async function importOneSource( } } +async function stageOneSourceForRuntimeUpload( + sourcePath: string +): Promise { + const resolvedSource = resolve(sourcePath) + + // Why: runtime uploads read client-local paths in the client main process; + // authorize before lstat/readFile just like local copy imports. + authorizeExternalPath(resolvedSource) + + let sourceStat: Awaited> + try { + sourceStat = await lstat(resolvedSource) + } catch (error) { + if (isENOENT(error)) { + return { sourcePath, status: 'skipped', reason: 'missing' } + } + if ( + error instanceof Error && + 'code' in error && + ((error as NodeJS.ErrnoException).code === 'EACCES' || + (error as NodeJS.ErrnoException).code === 'EPERM') + ) { + return { sourcePath, status: 'skipped', reason: 'permission-denied' } + } + return { + sourcePath, + status: 'failed', + reason: error instanceof Error ? error.message : String(error) + } + } + + if (sourceStat.isSymbolicLink()) { + return { sourcePath, status: 'skipped', reason: 'symlink' } + } + if (!sourceStat.isFile() && !sourceStat.isDirectory()) { + return { sourcePath, status: 'skipped', reason: 'unsupported' } + } + if (sourceStat.isDirectory() && (await preScanForSymlinks(resolvedSource))) { + return { sourcePath, status: 'skipped', reason: 'symlink' } + } + + try { + const entries = sourceStat.isDirectory() + ? await stageDirectoryEntries(resolvedSource) + : [await stageFileEntry(resolvedSource, '')] + return { + sourcePath, + status: 'staged', + name: basename(resolvedSource), + kind: sourceStat.isDirectory() ? 'directory' : 'file', + entries + } + } catch (error) { + return { + sourcePath, + status: 'failed', + reason: error instanceof Error ? error.message : String(error) + } + } +} + +async function stageDirectoryEntries(rootPath: string): Promise { + const entries: StagedExternalImportEntry[] = [{ relativePath: '', kind: 'directory' }] + let totalBytes = 0 + const rootRealPath = await realpath(rootPath) + + async function visit(dirPath: string): Promise { + const dirStat = await lstat(dirPath) + if (dirStat.isSymbolicLink()) { + throw new Error( + `Symlink not allowed in '${normalizeRelativeUploadPath(relative(rootPath, dirPath))}'` + ) + } + if (!dirStat.isDirectory()) { + throw new Error( + `Unsupported file type in '${normalizeRelativeUploadPath(relative(rootPath, dirPath))}'` + ) + } + await assertRealPathInsideRoot( + rootRealPath, + dirPath, + normalizeRelativeUploadPath(relative(rootPath, dirPath)) + ) + const dirEntries = await readdir(dirPath, { withFileTypes: true }) + for (const entry of dirEntries) { + const childPath = join(dirPath, entry.name) + const childRelativePath = normalizeRelativeUploadPath(relative(rootPath, childPath)) + if (entry.isDirectory()) { + const childStat = await lstat(childPath) + if (childStat.isSymbolicLink()) { + throw new Error(`Symlink not allowed in '${childRelativePath}'`) + } + if (!childStat.isDirectory()) { + throw new Error(`Unsupported file type in '${childRelativePath}'`) + } + entries.push({ relativePath: childRelativePath, kind: 'directory' }) + await visit(childPath) + continue + } + if (!entry.isFile()) { + throw new Error(`Unsupported file type in '${childRelativePath}'`) + } + const statResult = await lstat(childPath) + totalBytes += statResult.size + assertRemoteUploadBudget(childRelativePath, statResult.size, totalBytes) + entries.push(await stageFileEntry(childPath, childRelativePath, rootRealPath)) + } + } + + await visit(rootPath) + return entries +} + +async function stageFileEntry( + filePath: string, + relativePath: string, + rootRealPath?: string +): Promise { + const statResult = await lstat(filePath) + const displayPath = normalizeRelativeUploadPath(relativePath) + if (statResult.isSymbolicLink()) { + throw new Error(`Symlink not allowed in '${displayPath}'`) + } + if (!statResult.isFile()) { + throw new Error(`Unsupported file type in '${displayPath}'`) + } + if (rootRealPath) { + await assertRealPathInsideRoot(rootRealPath, filePath, displayPath) + } + assertRemoteUploadBudget(relativePath, statResult.size, statResult.size) + const fileHandle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + try { + const openedStat = await fileHandle.stat() + if (!openedStat.isFile()) { + throw new Error(`Unsupported file type in '${displayPath}'`) + } + if ( + openedStat.size !== statResult.size || + (statResult.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== statResult.ino) || + (statResult.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== statResult.dev) + ) { + throw new Error(`File changed during upload staging: '${displayPath}'`) + } + assertRemoteUploadBudget(relativePath, openedStat.size, openedStat.size) + const buffer = await fileHandle.readFile() + const afterReadStat = await fileHandle.stat() + if (afterReadStat.size !== openedStat.size) { + throw new Error(`File changed during upload staging: '${displayPath}'`) + } + return { + relativePath: displayPath, + kind: 'file', + contentBase64: buffer.toString('base64') + } + } finally { + await fileHandle.close() + } +} + +async function assertRealPathInsideRoot( + rootRealPath: string, + candidatePath: string, + displayPath: string +): Promise { + const candidateRealPath = await realpath(candidatePath) + const relativeToRoot = relative(rootRealPath, candidateRealPath) + if (relativeToRoot !== '' && (relativeToRoot.startsWith('..') || isAbsolute(relativeToRoot))) { + throw new Error(`Path escaped upload root during staging: '${displayPath}'`) + } +} + +function assertRemoteUploadBudget( + relativePath: string, + fileBytes: number, + totalBytes: number +): void { + if (fileBytes > REMOTE_IMPORT_MAX_FILE_BYTES) { + throw new Error(`'${relativePath}' is too large for remote import`) + } + if (totalBytes > REMOTE_IMPORT_MAX_TOTAL_BYTES) { + throw new Error('Remote import is too large') + } +} + +function normalizeRelativeUploadPath(path: string): string { + return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') +} + /** * Pre-scan a directory tree for symlinks. Returns true if any symlink * is found anywhere in the subtree. @@ -332,12 +608,72 @@ async function preScanForSymlinks(dirPath: string): Promise { * buffering entire files into memory. */ async function recursiveCopyDir(srcDir: string, destDir: string): Promise { - await mkdir(destDir, { recursive: true }) + await mkdir(destDir, { recursive: false }) const entries = await readdir(srcDir, { withFileTypes: true }) for (const entry of entries) { const srcPath = join(srcDir, entry.name) const dstPath = join(destDir, entry.name) - await (entry.isDirectory() ? recursiveCopyDir(srcPath, dstPath) : copyFile(srcPath, dstPath)) + const statResult = await lstat(srcPath) + if (statResult.isSymbolicLink()) { + throw new Error(`Symlink not allowed in '${entry.name}'`) + } + if (statResult.isDirectory()) { + await recursiveCopyDir(srcPath, dstPath) + continue + } + if (!statResult.isFile()) { + throw new Error(`Unsupported file type in '${entry.name}'`) + } + await copyLocalFileNoFollow(srcPath, dstPath, statResult) + } +} + +async function copyLocalFileNoFollow( + srcPath: string, + dstPath: string, + statResult?: Awaited> +): Promise { + const beforeOpenStat = statResult ?? (await lstat(srcPath)) + if (beforeOpenStat.isSymbolicLink()) { + throw new Error(`Symlink not allowed in '${basename(srcPath)}'`) + } + if (!beforeOpenStat.isFile()) { + throw new Error(`Unsupported file type in '${basename(srcPath)}'`) + } + + let destinationCreated = false + const sourceHandle = await open(srcPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + let destinationHandle: Awaited> | null = null + try { + const openedStat = await sourceHandle.stat() + if ( + !openedStat.isFile() || + (typeof beforeOpenStat.size === 'number' && openedStat.size !== beforeOpenStat.size) || + (typeof beforeOpenStat.ino === 'number' && + beforeOpenStat.ino !== 0 && + openedStat.ino !== 0 && + openedStat.ino !== beforeOpenStat.ino) || + (typeof beforeOpenStat.dev === 'number' && + beforeOpenStat.dev !== 0 && + openedStat.dev !== 0 && + openedStat.dev !== beforeOpenStat.dev) + ) { + throw new Error(`File changed during import: '${basename(srcPath)}'`) + } + // Why: copyFile(path, path) would follow a source symlink if the source is + // swapped after validation. Streaming from an O_NOFOLLOW handle keeps the + // authorized file identity pinned for the copy. + destinationHandle = await open(dstPath, 'wx') + destinationCreated = true + await pipeline(sourceHandle.createReadStream(), destinationHandle.createWriteStream()) + } catch (error) { + if (destinationCreated) { + await unlink(dstPath).catch(() => {}) + } + throw error + } finally { + await sourceHandle.close().catch(() => {}) + await destinationHandle?.close().catch(() => {}) } } diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index d1e3bafdebb..b6727bcf31f 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -28,16 +28,25 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ })) import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' +import { stat } from 'fs/promises' +import { subscribe as subscribeParcelWatcher } from '@parcel/watcher' type HandlerMap = Record Promise | unknown> describe('registerFilesystemWatcherHandlers', () => { const handlers: HandlerMap = {} + const originalPlatform = process.platform beforeEach(() => { vi.useRealTimers() handleMock.mockReset() getSshFilesystemProviderMock.mockReset() + vi.mocked(stat).mockReset() + vi.mocked(subscribeParcelWatcher).mockReset() + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) for (const key of Object.keys(handlers)) { delete handlers[key] } @@ -47,6 +56,28 @@ describe('registerFilesystemWatcherHandlers', () => { registerFilesystemWatcherHandlers() }) + it('pins Parcel to the Windows backend for local Windows watches', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: vi.fn() } as never) + + await handlers['fs:watchWorktree']( + { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } }, + { worktreePath: 'C:\\repo' } + ) + + expect(subscribeParcelWatcher).toHaveBeenCalledWith( + expect.any(String), + expect.any(Function), + expect.objectContaining({ backend: 'windows' }) + ) + + await closeAllWatchers() + }) + it('quietly skips SSH worktree watches while the filesystem provider is unavailable', async () => { vi.useFakeTimers() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 7ffbf3ae778..d6064b4696d 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -239,6 +239,14 @@ async function createWatcher(rootKey: string, rootPath: string): Promise { @@ -280,9 +288,7 @@ async function createWatcher(rootKey: string, rootPath: string): Promise ({ + handleMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { handle: handleMock } +})) + +vi.mock('qrcode', () => ({ + default: { + toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,qr') + } +})) + +import { registerMobileHandlers } from './mobile' + +describe('registerMobileHandlers', () => { + const handlers = new Map unknown>() + + beforeEach(() => { + handlers.clear() + handleMock.mockReset() + handleMock.mockImplementation((channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set(channel, handler) + }) + }) + + it('lists only paired mobile-scoped devices', () => { + const rpcServer = { + getDeviceRegistry: () => ({ + listDevices: () => [ + { + deviceId: 'mobile-1', + name: 'Phone', + scope: 'mobile', + pairedAt: 1, + lastSeenAt: 2 + }, + { + deviceId: 'runtime-1', + name: 'CLI', + scope: 'runtime', + pairedAt: 1, + lastSeenAt: 2 + }, + { + deviceId: 'pending-mobile', + name: 'Pending', + scope: 'mobile', + pairedAt: 1, + lastSeenAt: 0 + } + ] + }) + } + + registerMobileHandlers(rpcServer as never) + + expect(handlers.get('mobile:listDevices')?.()).toEqual({ + devices: [ + { + deviceId: 'mobile-1', + name: 'Phone', + pairedAt: 1, + lastSeenAt: 2 + } + ] + }) + }) +}) diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 9b1d8be3502..110f16d2699 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -2,7 +2,6 @@ import { ipcMain } from 'electron' import { networkInterfaces } from 'os' import QRCode from 'qrcode' import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' -import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' export type NetworkInterface = { name: string @@ -46,12 +45,6 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { ipcMain.handle( 'mobile:getPairingQR', async (_event, args?: { address?: string; rotate?: boolean }) => { - const rawEndpoint = rpcServer.getWebSocketEndpoint() - const registry = rpcServer.getDeviceRegistry() - if (!rawEndpoint || !registry) { - return { available: false as const } - } - // Why: allow the caller to specify which network interface address to // embed in the QR code. This supports overlay networks (Tailscale, // ZeroTier) where the default LAN IP isn't reachable from the phone. @@ -59,7 +52,6 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { if (!ip) { return { available: false as const } } - const endpoint = rawEndpoint.replace('0.0.0.0', ip) // Why: coalesce repeated QR regenerations onto a single never-scanned // pending token so the copy-button flow doesn't accumulate orphaned @@ -68,24 +60,17 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { // `rotate: true` (explicit "Regenerate" intent because the prior token // may have been exposed), we discard any pending token and mint a fresh // one so the new QR carries a different credential. - const name = `Mobile ${new Date().toLocaleDateString()}` - const device = args?.rotate - ? registry.rotatePendingDevice(name) - : registry.getOrCreatePendingDevice(name) - - const publicKeyB64 = rpcServer.getE2EEPublicKey() - if (!publicKeyB64) { + const offer = rpcServer.createPairingOffer({ + address: ip, + rotate: args?.rotate, + name: `Mobile ${new Date().toLocaleDateString()}`, + scope: 'mobile' + }) + if (!offer.available) { return { available: false as const } } - const url = encodePairingOffer({ - v: PAIRING_OFFER_VERSION, - endpoint, - deviceToken: device.token, - publicKeyB64 - }) - - const qrDataUrl = await QRCode.toDataURL(url, { + const qrDataUrl = await QRCode.toDataURL(offer.pairingUrl, { errorCorrectionLevel: 'M', margin: 2, width: 256 @@ -94,9 +79,9 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { return { available: true as const, qrDataUrl, - pairingUrl: url, - endpoint, - deviceId: device.deviceId + pairingUrl: offer.pairingUrl, + endpoint: offer.endpoint, + deviceId: offer.deviceId } } ) @@ -112,7 +97,7 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { return { devices: registry .listDevices() - .filter((d) => d.lastSeenAt > 0) + .filter((d) => d.scope === 'mobile' && d.lastSeenAt > 0) .map((d) => ({ deviceId: d.deviceId, name: d.name, @@ -127,7 +112,7 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { if (!registry) { return { revoked: false } } - return { revoked: registry.removeDevice(args.deviceId) } + return { revoked: rpcServer.revokeMobileDevice(args.deviceId) } }) ipcMain.handle('mobile:isWebSocketReady', () => { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 7c483712e69..2b304baea2b 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -931,6 +931,13 @@ export function registerPtyHandlers( return null } }, + hasChildProcesses: async (ptyId) => { + try { + return await getProviderForPty(ptyId).hasChildProcesses(ptyId) + } catch { + return false + } + }, clearBuffer: async (ptyId) => { // Why: desktop xterm owns local scrollback, while daemon/SSH providers // own their own retained buffers. Clear both surfaces so mobile @@ -1620,6 +1627,34 @@ export function registerPtyHandlers( ) } +export function registerHeadlessPtyRuntime( + runtime: OrcaRuntimeService, + getSelectedCodexHomePath?: () => string | null, + getSettings?: () => GlobalSettings, + prepareClaudeAuth?: () => Promise, + store?: Store +): void { + // Why: headless `orca serve` has no renderer window, but the runtime still + // needs the same PTY controller and provider listeners as desktop so remote + // clients can create, stream, inspect, and stop terminals. + const headlessWindow = { + isDestroyed: () => true, + webContents: { + send: () => {}, + on: () => {}, + removeListener: () => {} + } + } as unknown as BrowserWindow + registerPtyHandlers( + headlessWindow, + runtime, + getSelectedCodexHomePath, + getSettings, + prepareClaudeAuth, + store + ) +} + /** * Kill all PTY processes. Call on app quit. */ diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index eb6fdd43d6a..aab98e262f9 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -24,6 +24,7 @@ const { registerUIHandlersMock, registerFilesystemHandlersMock, registerRuntimeHandlersMock, + registerRuntimeEnvironmentHandlersMock, registerCodexAccountHandlersMock, registerAgentHookHandlersMock, registerAgentTrustHandlersMock, @@ -65,6 +66,7 @@ const { registerUIHandlersMock: vi.fn(), registerFilesystemHandlersMock: vi.fn(), registerRuntimeHandlersMock: vi.fn(), + registerRuntimeEnvironmentHandlersMock: vi.fn(), registerCodexAccountHandlersMock: vi.fn(), registerAgentHookHandlersMock: vi.fn(), registerAgentTrustHandlersMock: vi.fn(), @@ -194,6 +196,10 @@ vi.mock('./runtime', () => ({ registerRuntimeHandlers: registerRuntimeHandlersMock })) +vi.mock('./runtime-environments', () => ({ + registerRuntimeEnvironmentHandlers: registerRuntimeEnvironmentHandlersMock +})) + vi.mock('./codex-accounts', () => ({ registerCodexAccountHandlers: registerCodexAccountHandlersMock })) @@ -262,6 +268,7 @@ describe('registerCoreHandlers', () => { registerUIHandlersMock.mockReset() registerFilesystemHandlersMock.mockReset() registerRuntimeHandlersMock.mockReset() + registerRuntimeEnvironmentHandlersMock.mockReset() registerCodexAccountHandlersMock.mockReset() registerAgentHookHandlersMock.mockReset() registerAgentTrustHandlersMock.mockReset() @@ -328,6 +335,7 @@ describe('registerCoreHandlers', () => { expect(registerUIHandlersMock).toHaveBeenCalledWith(store) expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store) expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime) + expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalled() expect(registerCliHandlersMock).toHaveBeenCalled() expect(registerPreflightHandlersMock).toHaveBeenCalled() expect(registerShellHandlersMock).toHaveBeenCalled() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index e445a8e659f..74c08a40f69 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -18,6 +18,7 @@ import { registerStatsHandlers } from './stats' import { registerMemoryHandlers } from './memory' import { registerRateLimitHandlers } from './rate-limits' import { registerRuntimeHandlers } from './runtime' +import { registerRuntimeEnvironmentHandlers } from './runtime-environments' import { registerNotesHandlers } from './notes' import { registerNotificationHandlers } from './notifications' import { registerNotebookHandlers } from './notebook' @@ -120,6 +121,7 @@ export function registerCoreHandlers( registerFilesystemHandlers(store) registerFilesystemWatcherHandlers() registerRuntimeHandlers(runtime) + registerRuntimeEnvironmentHandlers() registerNotesHandlers(runtime) registerClipboardHandlers() registerUpdaterHandlers(store) diff --git a/src/main/ipc/runtime-environment-call-queue.ts b/src/main/ipc/runtime-environment-call-queue.ts new file mode 100644 index 00000000000..2a06416bb94 --- /dev/null +++ b/src/main/ipc/runtime-environment-call-queue.ts @@ -0,0 +1,92 @@ +const REMOTE_RUNTIME_CALL_CONCURRENCY = 8 +const REMOTE_RUNTIME_BACKGROUND_CALL_CONCURRENCY = 2 + +type QueuedRuntimeCall = { + background: boolean + run: () => Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} + +type RuntimeCallQueue = { + active: number + backgroundActive: number + foreground: QueuedRuntimeCall[] + background: QueuedRuntimeCall[] +} + +const runtimeCallQueues = new Map() + +function isBackgroundRuntimeMethod(method: string): boolean { + return ( + method === 'hostedReview.forBranch' || + method === 'github.listWorkItems' || + method === 'github.countWorkItems' || + method === 'git.status' || + method === 'git.conflictOperation' || + method === 'git.branchCompare' || + method === 'git.upstreamStatus' + ) +} + +function getRuntimeCallQueue(selector: string): RuntimeCallQueue { + let queue = runtimeCallQueues.get(selector) + if (!queue) { + queue = { active: 0, backgroundActive: 0, foreground: [], background: [] } + runtimeCallQueues.set(selector, queue) + } + return queue +} + +export function enqueueRuntimeCall( + selector: string, + method: string, + run: () => Promise +): Promise { + const queue = getRuntimeCallQueue(selector) + return new Promise((resolve, reject) => { + const call: QueuedRuntimeCall = { + background: isBackgroundRuntimeMethod(method), + run, + resolve, + reject + } + const targetQueue = call.background ? queue.background : queue.foreground + targetQueue.push(call as QueuedRuntimeCall) + pumpRuntimeCallQueue(selector, queue) + }) +} + +function pumpRuntimeCallQueue(selector: string, queue: RuntimeCallQueue): void { + while (queue.active < REMOTE_RUNTIME_CALL_CONCURRENCY) { + let call = queue.foreground.shift() + if (!call && queue.backgroundActive < REMOTE_RUNTIME_BACKGROUND_CALL_CONCURRENCY) { + call = queue.background.shift() + } + if (!call) { + break + } + + queue.active += 1 + if (call.background) { + queue.backgroundActive += 1 + } + // Why: remote WebSocket servers have a finite connection budget and each + // one-shot RPC currently opens its own encrypted socket. Background PR/task + // refreshes must not stampede the server and starve terminal/worktree calls. + void call + .run() + .then(call.resolve, call.reject) + .finally(() => { + queue.active = Math.max(0, queue.active - 1) + if (call.background) { + queue.backgroundActive = Math.max(0, queue.backgroundActive - 1) + } + if (queue.active === 0 && queue.foreground.length === 0 && queue.background.length === 0) { + runtimeCallQueues.delete(selector) + return + } + pumpRuntimeCallQueue(selector, queue) + }) + } +} diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts new file mode 100644 index 00000000000..97cc0bf21ed --- /dev/null +++ b/src/main/ipc/runtime-environment-request-connections.ts @@ -0,0 +1,46 @@ +import type { PairingOffer } from '../../shared/pairing' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { RemoteRuntimeRequestConnection } from '../../shared/remote-runtime-request-connection' + +type CachedRuntimeConnection = { + pairingKey: string + connection: RemoteRuntimeRequestConnection +} + +const requestConnections = new Map() + +export function sendRemoteRuntimeConnectionRequest( + environmentId: string, + pairing: PairingOffer, + method: string, + params: unknown, + timeoutMs: number +): Promise> { + const pairingKey = getPairingKey(pairing) + let cached = requestConnections.get(environmentId) + if (!cached || cached.pairingKey !== pairingKey) { + cached?.connection.close() + cached = { + pairingKey, + connection: new RemoteRuntimeRequestConnection(pairing) + } + requestConnections.set(environmentId, cached) + } + return cached.connection.request(method, params, timeoutMs) +} + +export function closeRemoteRuntimeRequestConnection(environmentId: string): void { + const cached = requestConnections.get(environmentId) + requestConnections.delete(environmentId) + cached?.connection.close() +} + +export function closeAllRemoteRuntimeRequestConnections(): void { + for (const environmentId of Array.from(requestConnections.keys())) { + closeRemoteRuntimeRequestConnection(environmentId) + } +} + +function getPairingKey(pairing: PairingOffer): string { + return [pairing.endpoint, pairing.deviceToken, pairing.publicKeyB64].join('\0') +} diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts new file mode 100644 index 00000000000..14d51953361 --- /dev/null +++ b/src/main/ipc/runtime-environments.test.ts @@ -0,0 +1,615 @@ +/* eslint-disable max-lines -- Why: this suite covers runtime environment + management, secret redaction, one-shot RPC, and streaming cleanup contracts. */ +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { encodePairingOffer } from '../../shared/pairing' +import * as environmentStore from '../../shared/runtime-environment-store' + +const { + handleMock, + onMock, + getPathMock, + sendRemoteRuntimeRequestMock, + subscribeRemoteRuntimeRequestMock, + sendRemoteRuntimeConnectionRequestMock, + closeRemoteRuntimeRequestConnectionMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + onMock: vi.fn(), + getPathMock: vi.fn(), + sendRemoteRuntimeRequestMock: vi.fn(), + subscribeRemoteRuntimeRequestMock: vi.fn(), + sendRemoteRuntimeConnectionRequestMock: vi.fn(), + closeRemoteRuntimeRequestConnectionMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: getPathMock }, + ipcMain: { handle: handleMock, on: onMock } +})) + +vi.mock('../../shared/remote-runtime-client', () => ({ + sendRemoteRuntimeRequest: sendRemoteRuntimeRequestMock, + subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock +})) + +vi.mock('./runtime-environment-request-connections', () => ({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock +})) + +import { registerRuntimeEnvironmentHandlers } from './runtime-environments' + +function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string { + return encodePairingOffer({ + v: 2, + endpoint, + deviceToken: 'device-token', + publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64') + }) +} + +function handler( + channel: string +): (_event: unknown, args: TArgs) => TResult | Promise { + const match = handleMock.mock.calls.find((call) => call[0] === channel) + expect(match).toBeTruthy() + return match![1] as (_event: unknown, args: TArgs) => TResult | Promise +} + +describe('registerRuntimeEnvironmentHandlers', () => { + let userDataPath: string + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-ipc-')) + getPathMock.mockReset() + getPathMock.mockReturnValue(userDataPath) + handleMock.mockReset() + onMock.mockReset() + sendRemoteRuntimeRequestMock.mockReset() + subscribeRemoteRuntimeRequestMock.mockReset() + sendRemoteRuntimeConnectionRequestMock.mockReset() + closeRemoteRuntimeRequestConnectionMock.mockReset() + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('registers desktop runtime environment management handlers', () => { + registerRuntimeEnvironmentHandlers() + + expect(handleMock.mock.calls.map((call) => call[0])).toEqual([ + 'runtimeEnvironments:list', + 'runtimeEnvironments:addFromPairingCode', + 'runtimeEnvironments:resolve', + 'runtimeEnvironments:remove', + 'runtimeEnvironments:getStatus', + 'runtimeEnvironments:call', + 'runtimeEnvironments:subscribe', + 'runtimeEnvironments:unsubscribe' + ]) + expect(onMock.mock.calls.map((call) => call[0])).toEqual([ + 'runtimeEnvironments:subscriptionBinary' + ]) + }) + + it('stores, resolves, lists, and removes environments under Electron userData', async () => { + registerRuntimeEnvironmentHandlers() + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + expect(JSON.stringify(added)).not.toContain('device-token') + expect(JSON.stringify(added)).not.toContain('publicKeyB64') + + const list = handler('runtimeEnvironments:list') + expect(await list(null, undefined)).toMatchObject([{ id: added.environment.id, name: 'desk' }]) + expect(JSON.stringify(await list(null, undefined))).not.toContain('device-token') + + const resolve = handler<{ selector: string }, { id: string; name: string }>( + 'runtimeEnvironments:resolve' + ) + expect(await resolve(null, { selector: 'desk' })).toMatchObject({ + id: added.environment.id, + name: 'desk' + }) + expect(JSON.stringify(await resolve(null, { selector: 'desk' }))).not.toContain('device-token') + + const remove = handler<{ selector: string }, { removed: { id: string; name: string } }>( + 'runtimeEnvironments:remove' + ) + const removed = await remove(null, { selector: added.environment.id }) + expect(removed).toMatchObject({ + removed: { id: added.environment.id, name: 'desk' } + }) + expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id) + expect(JSON.stringify(removed)).not.toContain('device-token') + expect(await list(null, undefined)).toEqual([]) + }) + + it('checks a saved remote runtime and records the runtime id on success', async () => { + registerRuntimeEnvironmentHandlers() + sendRemoteRuntimeRequestMock.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { runtimeId: 'runtime-remote', graphStatus: 'ready' }, + _meta: { runtimeId: 'runtime-remote' } + }) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const getStatus = handler< + { selector: string; timeoutMs?: number }, + { ok: true; result: { runtimeId: string } } + >('runtimeEnvironments:getStatus') + expect(await getStatus(null, { selector: 'desk', timeoutMs: 50 })).toMatchObject({ + ok: true, + result: { runtimeId: 'runtime-remote' } + }) + expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768', deviceToken: 'device-token' }), + 'status.get', + undefined, + 50 + ) + + const resolve = handler<{ selector: string }, { id: string; runtimeId: string | null }>( + 'runtimeEnvironments:resolve' + ) + expect(await resolve(null, { selector: added.environment.id })).toMatchObject({ + id: added.environment.id, + runtimeId: 'runtime-remote' + }) + }) + + it('proxies generic one-shot RPC calls to the saved remote runtime', async () => { + registerRuntimeEnvironmentHandlers() + sendRemoteRuntimeRequestMock.mockResolvedValue({ + id: 'rpc-2', + ok: true, + result: { repos: [{ id: 'repo-1' }] }, + _meta: { runtimeId: 'runtime-remote' } + }) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const call = handler< + { selector: string; method: string; params?: unknown; timeoutMs?: number }, + { ok: true; result: unknown } + >('runtimeEnvironments:call') + expect( + await call(null, { selector: 'desk', method: 'repo.list', timeoutMs: 75 }) + ).toMatchObject({ + ok: true, + result: { repos: [{ id: 'repo-1' }] } + }) + expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }), + 'repo.list', + undefined, + 75 + ) + expect(sendRemoteRuntimeConnectionRequestMock).not.toHaveBeenCalled() + }) + + it('uses the cached request connection for terminal hot path RPCs', async () => { + registerRuntimeEnvironmentHandlers() + sendRemoteRuntimeConnectionRequestMock.mockResolvedValue({ + id: 'rpc-terminal', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime-remote' } + }) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const call = handler< + { selector: string; method: string; params?: unknown; timeoutMs?: number }, + { ok: true; result: unknown } + >('runtimeEnvironments:call') + expect( + await call(null, { + selector: 'desk', + method: 'terminal.send', + params: { terminal: 't1', text: 'a' }, + timeoutMs: 75 + }) + ).toMatchObject({ + ok: true, + result: { send: { accepted: true } } + }) + expect(sendRemoteRuntimeConnectionRequestMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }), + 'terminal.send', + { terminal: 't1', text: 'a' }, + 75 + ) + expect(sendRemoteRuntimeRequestMock).not.toHaveBeenCalled() + }) + + it('limits background one-shot RPCs without blocking foreground runtime calls', async () => { + registerRuntimeEnvironmentHandlers() + const pendingBackground: ((value: unknown) => void)[] = [] + sendRemoteRuntimeRequestMock.mockImplementation(async () => { + return await new Promise((resolve) => pendingBackground.push(resolve)) + }) + sendRemoteRuntimeConnectionRequestMock.mockResolvedValue({ + id: 'terminal-send', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime-remote' } + }) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const call = handler< + { selector: string; method: string; params?: unknown; timeoutMs?: number }, + { ok: true; result: unknown } + >('runtimeEnvironments:call') + const bg1 = call(null, { selector: 'desk', method: 'hostedReview.forBranch' }) + const bg2 = call(null, { selector: 'desk', method: 'github.listWorkItems' }) + await vi.waitFor(() => expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledTimes(2)) + + const bg3 = call(null, { selector: 'desk', method: 'git.status' }) + const foreground = call(null, { + selector: 'desk', + method: 'terminal.send', + params: { terminal: 'term-1', text: 'a' } + }) + await vi.waitFor(() => + expect(sendRemoteRuntimeRequestMock.mock.calls.map((call) => call[1])).toEqual([ + 'hostedReview.forBranch', + 'github.listWorkItems' + ]) + ) + expect(sendRemoteRuntimeConnectionRequestMock).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + 'terminal.send', + { terminal: 'term-1', text: 'a' }, + 15_000 + ) + + await expect(foreground).resolves.toMatchObject({ + ok: true, + result: { send: { accepted: true } } + }) + expect(pendingBackground).toHaveLength(2) + + pendingBackground.shift()?.({ + id: 'background-1', + ok: true, + result: null, + _meta: { runtimeId: 'runtime-remote' } + }) + await vi.waitFor(() => expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledTimes(3)) + expect(sendRemoteRuntimeRequestMock.mock.calls.map((call) => call[1])).toEqual([ + 'hostedReview.forBranch', + 'github.listWorkItems', + 'git.status' + ]) + + pendingBackground.splice(0).forEach((resolve) => + resolve({ + id: 'background', + ok: true, + result: null, + _meta: { runtimeId: 'runtime-remote' } + }) + ) + await expect(bg1).resolves.toMatchObject({ ok: true }) + await expect(bg2).resolves.toMatchObject({ ok: true }) + await expect(bg3).resolves.toMatchObject({ ok: true }) + }) + + it('starts and stops streaming subscriptions for a saved remote runtime', async () => { + registerRuntimeEnvironmentHandlers() + const close = vi.fn() + const sendBinary = vi.fn() + const markUsedSpy = vi.spyOn(environmentStore, 'markEnvironmentUsed') + subscribeRemoteRuntimeRequestMock.mockImplementation( + async (_pairing, _method, _params, _timeoutMs, callbacks) => { + callbacks.onResponse({ + id: 'stream-1', + ok: true, + result: { type: 'subscribed' }, + _meta: { runtimeId: 'runtime-remote' } + }) + callbacks.onResponse({ + id: 'stream-1', + ok: true, + result: { type: 'data', chunk: 'hello' }, + _meta: { runtimeId: 'runtime-remote' } + }) + callbacks.onBinary(new Uint8Array([1, 2, 3])) + return { requestId: 'stream-1', close, sendBinary } + } + ) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const sent: unknown[] = [] + const destroyedListenerRemoved = vi.fn() + const subscribe = handler< + { + selector: string + method: string + params?: unknown + timeoutMs?: number + subscriptionId?: string + }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + const result = await subscribe( + { + sender: { + id: 1, + isDestroyed: () => false, + send: (_channel: string, payload: unknown) => sent.push(payload), + once: vi.fn(), + removeListener: destroyedListenerRemoved + } + }, + { + selector: 'desk', + method: 'terminal.subscribe', + params: { terminal: 't1' }, + timeoutMs: 25, + subscriptionId: 'preload-sub-1' + } + ) + + expect(result.requestId).toBe('stream-1') + expect(result.subscriptionId).toBe('preload-sub-1') + expect(subscribeRemoteRuntimeRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }), + 'terminal.subscribe', + { terminal: 't1' }, + 25, + expect.any(Object) + ) + expect(sent).toEqual([ + expect.objectContaining({ subscriptionId: result.subscriptionId, type: 'response' }), + expect.objectContaining({ subscriptionId: result.subscriptionId, type: 'response' }), + expect.objectContaining({ subscriptionId: result.subscriptionId, type: 'binary' }) + ]) + expect(markUsedSpy).toHaveBeenCalledTimes(1) + + const binaryListener = onMock.mock.calls.find( + (call) => call[0] === 'runtimeEnvironments:subscriptionBinary' + )?.[1] as (_event: unknown, args: unknown) => void + const bytes = new Uint8Array([9, 8, 7]) + binaryListener({ sender: { id: 1 } }, { subscriptionId: result.subscriptionId, bytes }) + expect(sendBinary).toHaveBeenCalledWith(bytes) + + const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>( + 'runtimeEnvironments:unsubscribe' + ) + expect( + await unsubscribe({ sender: { id: 1 } }, { subscriptionId: result.subscriptionId }) + ).toEqual({ + unsubscribed: true + }) + expect(close).toHaveBeenCalled() + expect(destroyedListenerRemoved).toHaveBeenCalledWith('destroyed', expect.any(Function)) + markUsedSpy.mockRestore() + }) + + it('rejects cross-window streaming subscription control', async () => { + registerRuntimeEnvironmentHandlers() + const close = vi.fn() + const sendBinary = vi.fn() + subscribeRemoteRuntimeRequestMock.mockResolvedValue({ + requestId: 'stream-1', + close, + sendBinary + }) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const subscribe = handler< + { + selector: string + method: string + params?: unknown + subscriptionId?: string + }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + const result = await subscribe( + { + sender: { + id: 1, + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn(), + removeListener: vi.fn() + } + }, + { + selector: 'desk', + method: 'terminal.subscribe', + params: { terminal: 't1' }, + subscriptionId: 'owned-sub' + } + ) + + const binaryListener = onMock.mock.calls.find( + (call) => call[0] === 'runtimeEnvironments:subscriptionBinary' + )?.[1] as (_event: unknown, args: unknown) => void + binaryListener( + { sender: { id: 2 } }, + { subscriptionId: result.subscriptionId, bytes: new Uint8Array([1]) } + ) + expect(sendBinary).not.toHaveBeenCalled() + + const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>( + 'runtimeEnvironments:unsubscribe' + ) + expect( + await unsubscribe({ sender: { id: 2 } }, { subscriptionId: result.subscriptionId }) + ).toEqual({ + unsubscribed: false + }) + expect(close).not.toHaveBeenCalled() + + expect( + await unsubscribe({ sender: { id: 1 } }, { subscriptionId: result.subscriptionId }) + ).toEqual({ + unsubscribed: true + }) + expect(close).toHaveBeenCalled() + }) + + it('closes a streaming subscription that resolves after the sender is destroyed', async () => { + registerRuntimeEnvironmentHandlers() + const close = vi.fn() + let resolveSubscribe: (value: { + requestId: string + close: () => void + sendBinary: (bytes: Uint8Array) => boolean + }) => void = () => {} + subscribeRemoteRuntimeRequestMock.mockImplementation( + () => + new Promise((resolve) => { + resolveSubscribe = resolve + }) + ) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) + + let destroyed = false + let destroyedHandler: unknown = null + const destroyedListenerRemoved = vi.fn() + const subscribe = handler< + { + selector: string + method: string + params?: unknown + subscriptionId?: string + }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + const resultPromise = subscribe( + { + sender: { + id: 1, + isDestroyed: () => destroyed, + send: vi.fn(), + once: vi.fn((_event: string, handler: () => void) => { + destroyedHandler = () => { + destroyed = true + handler() + } + }), + removeListener: destroyedListenerRemoved + } + }, + { + selector: 'desk', + method: 'terminal.subscribe', + params: { terminal: 't1' }, + subscriptionId: 'late-sub' + } + ) + + await vi.waitFor(() => { + expect(subscribeRemoteRuntimeRequestMock).toHaveBeenCalled() + }) + expect(destroyedHandler).toBeTypeOf('function') + ;(destroyedHandler as () => void)() + resolveSubscribe({ requestId: 'stream-late', close, sendBinary: vi.fn() }) + + await expect(resultPromise).resolves.toEqual({ + subscriptionId: 'late-sub', + requestId: 'stream-late' + }) + expect(close).toHaveBeenCalledTimes(1) + expect(destroyedListenerRemoved).toHaveBeenCalledWith('destroyed', expect.any(Function)) + + const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>( + 'runtimeEnvironments:unsubscribe' + ) + expect(await unsubscribe({ sender: { id: 1 } }, { subscriptionId: 'late-sub' })).toEqual({ + unsubscribed: false + }) + }) + + it('removes the destroyed listener when streaming subscription setup rejects', async () => { + registerRuntimeEnvironmentHandlers() + subscribeRemoteRuntimeRequestMock.mockRejectedValue(new Error('connect failed')) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const destroyedListenerRemoved = vi.fn() + const subscribe = handler< + { + selector: string + method: string + params?: unknown + subscriptionId?: string + }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + + await expect( + subscribe( + { + sender: { + id: 1, + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn(), + removeListener: destroyedListenerRemoved + } + }, + { + selector: 'desk', + method: 'terminal.subscribe', + params: { terminal: 't1' }, + subscriptionId: 'failed-sub' + } + ) + ).rejects.toThrow('connect failed') + + expect(destroyedListenerRemoved).toHaveBeenCalledWith('destroyed', expect.any(Function)) + }) +}) diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts new file mode 100644 index 00000000000..9b278ba63ba --- /dev/null +++ b/src/main/ipc/runtime-environments.ts @@ -0,0 +1,313 @@ +/* eslint-disable max-lines -- Why: runtime environment IPC is the security boundary for saved server calls and subscriptions; keeping ownership checks, lifecycle cleanup, and binary forwarding together makes the bridge auditable. */ +import { app, ipcMain } from 'electron' +import { randomUUID } from 'crypto' +import { + addEnvironmentFromPairingCode, + listEnvironments, + markEnvironmentUsed, + removeEnvironment, + resolveEnvironment, + resolveEnvironmentPairingOffer +} from '../../shared/runtime-environment-store' +import { + redactRuntimeEnvironment, + getPreferredPairingOffer, + type PublicKnownRuntimeEnvironment +} from '../../shared/runtime-environments' +import type { RuntimeStatus } from '../../shared/runtime-types' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { + sendRemoteRuntimeRequest, + subscribeRemoteRuntimeRequest, + type RemoteRuntimeSubscription +} from '../../shared/remote-runtime-client' +import { enqueueRuntimeCall } from './runtime-environment-call-queue' +import { + closeRemoteRuntimeRequestConnection, + sendRemoteRuntimeConnectionRequest +} from './runtime-environment-request-connections' + +const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000 +type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & { + ownerWebContentsId: number + removeDestroyedListener: () => void +} +const remoteRuntimeSubscriptions = new Map() + +function getUserDataPath(): string { + return app.getPath('userData') +} + +function shouldUseCachedRequestConnection(method: string): boolean { + return method === 'terminal.send' || method === 'terminal.updateViewport' +} + +export function registerRuntimeEnvironmentHandlers(): void { + ipcMain.handle('runtimeEnvironments:list', (): PublicKnownRuntimeEnvironment[] => + listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment) + ) + ipcMain.handle( + 'runtimeEnvironments:addFromPairingCode', + ( + _event, + args: { name: string; pairingCode: string } + ): { environment: PublicKnownRuntimeEnvironment } => ({ + environment: redactRuntimeEnvironment(addEnvironmentFromPairingCode(getUserDataPath(), args)) + }) + ) + ipcMain.handle( + 'runtimeEnvironments:resolve', + (_event, args: { selector: string }): PublicKnownRuntimeEnvironment => + redactRuntimeEnvironment(resolveEnvironment(getUserDataPath(), args.selector)) + ) + ipcMain.handle( + 'runtimeEnvironments:remove', + (_event, args: { selector: string }): { removed: PublicKnownRuntimeEnvironment } => { + const removed = removeEnvironment(getUserDataPath(), args.selector) + closeRemoteRuntimeRequestConnection(removed.id) + if (args.selector !== removed.id) { + closeRemoteRuntimeRequestConnection(args.selector) + } + return { removed: redactRuntimeEnvironment(removed) } + } + ) + ipcMain.handle( + 'runtimeEnvironments:getStatus', + async ( + _event, + args: { selector: string; timeoutMs?: number } + ): Promise> => { + const userDataPath = getUserDataPath() + const response = await sendRemoteRuntimeRequest( + resolveEnvironmentPairingOffer(userDataPath, args.selector), + 'status.get', + undefined, + args.timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS + ) + if (response.ok === true) { + markEnvironmentUsed(userDataPath, args.selector, { runtimeId: response._meta.runtimeId }) + } + return response + } + ) + ipcMain.handle( + 'runtimeEnvironments:call', + async ( + _event, + args: { selector: string; method: string; params?: unknown; timeoutMs?: number } + ): Promise> => { + return callRuntimeEnvironment(args.selector, args.method, args.params, args.timeoutMs) + } + ) + ipcMain.handle( + 'runtimeEnvironments:subscribe', + async ( + event, + args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + subscriptionId?: string + } + ): Promise<{ subscriptionId: string; requestId: string }> => { + const subscriptionId = + typeof args.subscriptionId === 'string' && args.subscriptionId.length > 0 + ? args.subscriptionId + : randomUUID() + if (remoteRuntimeSubscriptions.has(subscriptionId)) { + throw new Error('Runtime environment subscription id already exists') + } + const sender = event.sender + const ownerWebContentsId = sender.id + let senderDestroyed = sender.isDestroyed() + let subscription: RemoteRuntimeSubscription | null = null + let destroyedListenerAttached = false + const removeDestroyedListener = (): void => { + if (!destroyedListenerAttached) { + return + } + destroyedListenerAttached = false + sender.removeListener('destroyed', closeSubscription) + } + const closeSubscription = (): void => { + senderDestroyed = true + const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null + remoteRuntimeSubscriptions.delete(subscriptionId) + if (retained) { + retained.close() + return + } + removeDestroyedListener() + subscription?.close() + } + sender.once('destroyed', closeSubscription) + destroyedListenerAttached = true + try { + subscription = await subscribeRuntimeEnvironment( + args.selector, + args.method, + args.params, + args.timeoutMs, + { + onEvent: (payload) => { + if (!sender.isDestroyed()) { + sender.send('runtimeEnvironments:subscriptionEvent', { + subscriptionId, + ...payload + }) + } + }, + onClose: () => { + const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null + retained?.removeDestroyedListener() + remoteRuntimeSubscriptions.delete(subscriptionId) + } + } + ) + } catch (error) { + removeDestroyedListener() + throw error + } + if (senderDestroyed || sender.isDestroyed()) { + removeDestroyedListener() + subscription.close() + return { subscriptionId, requestId: subscription.requestId } + } + remoteRuntimeSubscriptions.set(subscriptionId, { + requestId: subscription.requestId, + ownerWebContentsId, + removeDestroyedListener, + sendBinary: (bytes) => subscription?.sendBinary(bytes) ?? false, + close: () => { + removeDestroyedListener() + subscription?.close() + } + }) + return { subscriptionId, requestId: subscription.requestId } + } + ) + ipcMain.handle( + 'runtimeEnvironments:unsubscribe', + (event, args: { subscriptionId: string }): { unsubscribed: boolean } => { + const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) + if (!subscription || subscription.ownerWebContentsId !== event.sender.id) { + return { unsubscribed: false } + } + remoteRuntimeSubscriptions.delete(args.subscriptionId) + subscription.close() + return { unsubscribed: true } + } + ) + ipcMain.on( + 'runtimeEnvironments:subscriptionBinary', + (event, args: { subscriptionId?: unknown; bytes?: unknown }) => { + if (typeof args.subscriptionId !== 'string') { + return + } + const bytes = toBinaryPayload(args.bytes) + if (!bytes) { + return + } + const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) + if (subscription?.ownerWebContentsId === event.sender.id) { + subscription.sendBinary(bytes) + } + } + ) +} + +function toBinaryPayload(value: unknown): Uint8Array | null { + if (value instanceof Uint8Array) { + return value + } + if (value instanceof ArrayBuffer) { + return new Uint8Array(value) + } + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + } + return null +} + +async function callRuntimeEnvironment( + selector: string, + method: string, + params: unknown, + timeoutMs?: number +): Promise> { + const userDataPath = getUserDataPath() + const environment = resolveEnvironment(userDataPath, selector) + return enqueueRuntimeCall(environment.id, method, async () => { + const currentEnvironment = resolveEnvironment(userDataPath, environment.id) + const pairing = getPreferredPairingOffer(currentEnvironment) + const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS + // Why: the cached request socket is only needed for terminal hot paths. + // Startup/control-plane RPCs use the proven one-shot path so repo hydration + // cannot be coupled to a stale terminal-control connection. + const response = shouldUseCachedRequestConnection(method) + ? await sendRemoteRuntimeConnectionRequest( + currentEnvironment.id, + pairing, + method, + params, + effectiveTimeoutMs + ) + : await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs) + if (response.ok === true) { + markEnvironmentUsed(userDataPath, currentEnvironment.id, { + runtimeId: response._meta.runtimeId + }) + } + return response + }) +} + +async function subscribeRuntimeEnvironment( + selector: string, + method: string, + params: unknown, + timeoutMs: number | undefined, + callbacks: { + onEvent: ( + payload: + | { type: 'response'; response: RuntimeRpcResponse } + | { type: 'binary'; bytes: Uint8Array } + | { type: 'error'; code: string; message: string } + | { type: 'close' } + ) => void + onClose: () => void + } +): Promise { + const userDataPath = getUserDataPath() + let markedUsed = false + const markUsedOnce = (runtimeId: string): void => { + if (markedUsed) { + return + } + markedUsed = true + markEnvironmentUsed(userDataPath, selector, { runtimeId }) + } + const subscription = await subscribeRemoteRuntimeRequest( + resolveEnvironmentPairingOffer(userDataPath, selector), + method, + params, + timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS, + { + onResponse: (response) => { + if (response.ok === true) { + markUsedOnce(response._meta.runtimeId) + } + callbacks.onEvent({ type: 'response', response }) + }, + onBinary: (bytes) => callbacks.onEvent({ type: 'binary', bytes }), + onError: (error) => + callbacks.onEvent({ type: 'error', code: error.code, message: error.message }), + onClose: () => { + callbacks.onEvent({ type: 'close' }) + callbacks.onClose() + } + } + ) + return subscription +} diff --git a/src/main/ipc/runtime.test.ts b/src/main/ipc/runtime.test.ts index 29c96559cd8..68a570e28cd 100644 --- a/src/main/ipc/runtime.test.ts +++ b/src/main/ipc/runtime.test.ts @@ -28,7 +28,8 @@ describe('registerRuntimeHandlers', () => { it('routes sync requests through the authoritative browser window id', () => { const runtime = { syncWindowGraph: vi.fn().mockReturnValue({ graphStatus: 'ready' }), - getStatus: vi.fn().mockReturnValue({ graphStatus: 'unavailable' }) + getStatus: vi.fn().mockReturnValue({ graphStatus: 'unavailable' }), + getRuntimeId: vi.fn().mockReturnValue('runtime-1') } registerRuntimeHandlers(runtime as never) @@ -46,4 +47,33 @@ describe('registerRuntimeHandlers', () => { expect(runtime.syncWindowGraph).toHaveBeenCalledWith(17, { tabs: [], leaves: [] }) expect(result).toEqual({ graphStatus: 'ready' }) }) + + it('routes generic local runtime RPC calls through the dispatcher', async () => { + const runtime = { + syncWindowGraph: vi.fn(), + getStatus: vi.fn().mockReturnValue({ + runtimeId: 'runtime-1', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0 + }), + getRuntimeId: vi.fn().mockReturnValue('runtime-1') + } + + registerRuntimeHandlers(runtime as never) + + const callRegistration = handleMock.mock.calls.find(([channel]) => channel === 'runtime:call') + expect(callRegistration).toBeTruthy() + + const handler = callRegistration![1] + const result = await handler({ sender: {} }, { method: 'status.get' }) + + expect(result).toMatchObject({ + ok: true, + result: { runtimeId: 'runtime-1', graphStatus: 'ready' }, + _meta: { runtimeId: 'runtime-1' } + }) + }) }) diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index 14a4de52ffb..d6c91e9e96e 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -1,10 +1,13 @@ import { BrowserWindow, ipcMain } from 'electron' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../shared/runtime-types' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { RpcDispatcher } from '../runtime/rpc/dispatcher' export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { ipcMain.removeHandler('runtime:syncWindowGraph') ipcMain.removeHandler('runtime:getStatus') + ipcMain.removeHandler('runtime:call') ipcMain.handle( 'runtime:syncWindowGraph', @@ -21,6 +24,21 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { return runtime.getStatus() }) + ipcMain.handle( + 'runtime:call', + async ( + _event, + args: { method: string; params?: unknown } + ): Promise> => { + return (await new RpcDispatcher({ runtime }).dispatch({ + id: 'desktop-ipc', + authToken: 'desktop-ipc', + method: args.method, + params: args.params + })) as RuntimeRpcResponse + } + ) + ipcMain.removeHandler('runtime:getTerminalFitOverrides') ipcMain.handle( 'runtime:getTerminalFitOverrides', diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 948297d004b..066c39d981e 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -108,7 +108,7 @@ async function ensureUniqueRemoteName(repoPath: string, preferred: string): Prom throw new Error(`Could not find an available remote name for ${preferred}.`) } -async function prepareWorktreePushTarget( +export async function prepareWorktreePushTarget( repoPath: string, target: GitPushTarget ): Promise { @@ -138,7 +138,7 @@ async function prepareWorktreePushTarget( } } -async function configureCreatedWorktreePushTarget( +export async function configureCreatedWorktreePushTarget( worktreePath: string, branchName: string, target: GitPushTarget diff --git a/src/main/powershell-osc133-bootstrap.test.ts b/src/main/powershell-osc133-bootstrap.test.ts index a7219b84500..10d278088a8 100644 --- a/src/main/powershell-osc133-bootstrap.test.ts +++ b/src/main/powershell-osc133-bootstrap.test.ts @@ -13,10 +13,13 @@ describe('PowerShell OSC 133 bootstrap', () => { expect(script).toContain('ORCA_PI_CODING_AGENT_DIR') expect(script).toContain('function Global:prompt') expect(script).toContain('function Global:PSConsoleHostReadLine') - expect(script).toContain('`e]133;D;$fakeExitCode`a') - expect(script).toContain('`e]133;A`a') - expect(script).toContain('`e]133;B`a') - expect(script).toContain('`e]133;C`a') + expect(script).toContain('Esc = [char]27') + expect(script).toContain('Bel = [char]7') + expect(script).toContain(')]133;D;$fakeExitCode$(') + expect(script).toContain(')]133;A$(') + expect(script).toContain(')]133;B$(') + expect(script).toContain(')]133;C$(') + expect(script).not.toContain('`e]133') expect(script).not.toContain('$PROFILE') expect(script).not.toContain('ExecutionPolicy') expect(script).not.toContain('NoProfile') diff --git a/src/main/powershell-osc133-bootstrap.ts b/src/main/powershell-osc133-bootstrap.ts index 1784020c468..8f87ed21d2d 100644 --- a/src/main/powershell-osc133-bootstrap.ts +++ b/src/main/powershell-osc133-bootstrap.ts @@ -28,6 +28,8 @@ $Global:__OrcaOsc133State = @{ OriginalReadLine = $function:PSConsoleHostReadLine HasSeenPrompt = $false HasPSReadLine = $null -ne (Get-Module -Name PSReadLine) + Esc = [char]27 + Bel = [char]7 } function Global:prompt { @@ -39,15 +41,15 @@ function Global:prompt { # Emit D from prompt, not readline state. Some profile setups bypass # PSConsoleHostReadLine; the consumer only needs completion. if ($Global:__OrcaOsc133State.HasSeenPrompt) { - $result += "\`e]133;D;$fakeExitCode\`a" + $result += "$($Global:__OrcaOsc133State.Esc)]133;D;$fakeExitCode$($Global:__OrcaOsc133State.Bel)" } $Global:__OrcaOsc133State.HasSeenPrompt = $true - $result += "\`e]133;A\`a" + $result += "$($Global:__OrcaOsc133State.Esc)]133;A$($Global:__OrcaOsc133State.Bel)" # Preserve the previous success/failure value for prompts that inspect it. if ($fakeExitCode -ne 0) { Write-Error "failure" -ea ignore } $result += $Global:__OrcaOsc133State.OriginalPrompt.Invoke() - $result += "\`e]133;B\`a" + $result += "$($Global:__OrcaOsc133State.Esc)]133;B$($Global:__OrcaOsc133State.Bel)" $result } @@ -55,7 +57,7 @@ if ($Global:__OrcaOsc133State.HasPSReadLine -and $null -ne $Global:__OrcaOsc133State.OriginalReadLine) { function Global:PSConsoleHostReadLine { $commandLine = $Global:__OrcaOsc133State.OriginalReadLine.Invoke() - [Console]::Write("\`e]133;C\`a") + [Console]::Write("$($Global:__OrcaOsc133State.Esc)]133;C$($Global:__OrcaOsc133State.Bel)") return $commandLine } } diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index dca8cf06bc9..8fbf9551c00 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: SSH filesystem provider coverage keeps relay fallback, +SFTP binary writes, watch fan-out, and provider lifecycle tests together so +transport parity regressions are visible in one suite. */ import { describe, expect, it, vi, beforeEach } from 'vitest' import { SshFilesystemProvider } from './ssh-filesystem-provider' @@ -90,6 +93,64 @@ describe('SshFilesystemProvider', () => { }) }) + describe('writeFileBase64', () => { + it('writes decoded bytes through SFTP', async () => { + const written: Buffer[] = [] + const writeStream = { + on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream), + end: vi.fn((buffer: Buffer) => { + written.push(buffer) + const closeHandler = writeStream.on.mock.calls.find(([event]) => event === 'close')?.[1] + closeHandler?.() + }), + destroy: vi.fn() + } + const sftp = { + createWriteStream: vi.fn(() => writeStream), + end: vi.fn() + } + provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never) + + await provider.writeFileBase64('/home/user/logo.png', 'cG5n') + + expect(sftp.createWriteStream).toHaveBeenCalledWith('/home/user/logo.png', { flags: 'wx' }) + expect(written).toEqual([Buffer.from('png')]) + expect(sftp.end).toHaveBeenCalled() + expect(mux.request).not.toHaveBeenCalledWith('fs.writeFile', expect.anything()) + }) + + it('can append decoded chunks through SFTP', async () => { + const writeStream = { + on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream), + end: vi.fn((_buffer: Buffer) => { + const closeHandler = writeStream.on.mock.calls.find(([event]) => event === 'close')?.[1] + closeHandler?.() + }), + destroy: vi.fn() + } + const sftp = { + createWriteStream: vi.fn(() => writeStream), + end: vi.fn() + } + provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never) + + await provider.writeFileBase64Chunk('/home/user/logo.png', 'cG5n', true) + + expect(sftp.createWriteStream).toHaveBeenCalledWith('/home/user/logo.png', { flags: 'a' }) + expect(sftp.end).toHaveBeenCalled() + }) + }) + + describe('createDirNoClobber', () => { + it('sends fs.createDirNoClobber request', async () => { + await provider.createDirNoClobber('/home/user/new-dir') + + expect(mux.request).toHaveBeenCalledWith('fs.createDirNoClobber', { + dirPath: '/home/user/new-dir' + }) + }) + }) + describe('stat', () => { it('sends fs.stat request', async () => { const statResult = { size: 1024, type: 'file', mtime: 1234567890 } @@ -196,6 +257,112 @@ describe('SshFilesystemProvider', () => { expect(callback).toHaveBeenCalledWith(events) }) + it('fans out same-root watch events and unwatches only after the last subscriber', async () => { + const first = vi.fn() + const second = vi.fn() + const unsubFirst = await provider.watch('/home/user/project', first) + const unsubSecond = await provider.watch('/home/user/project', second) + + expect(mux.request).toHaveBeenCalledTimes(1) + expect(mux.request).toHaveBeenCalledWith('fs.watch', { rootPath: '/home/user/project' }) + + const notifHandler = mux.onNotification.mock.calls[0][0] + const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] + notifHandler('fs.changed', { events }) + expect(first).toHaveBeenCalledWith(events) + expect(second).toHaveBeenCalledWith(events) + + unsubFirst() + expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) + notifHandler('fs.changed', { events }) + expect(second).toHaveBeenCalledTimes(2) + + unsubSecond() + expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) + }) + + it('shares an in-flight same-root watch setup across concurrent subscribers', async () => { + let resolveWatch: () => void = () => {} + mux.request.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveWatch = resolve + }) + ) + const first = vi.fn() + const second = vi.fn() + + const firstWatch = provider.watch('/home/user/project', first) + const secondWatch = provider.watch('/home/user/project', second) + + expect(mux.request).toHaveBeenCalledTimes(1) + resolveWatch() + const [unsubFirst, unsubSecond] = await Promise.all([firstWatch, secondWatch]) + + const notifHandler = mux.onNotification.mock.calls[0][0] + const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] + notifHandler('fs.changed', { events }) + expect(first).toHaveBeenCalledWith(events) + expect(second).toHaveBeenCalledWith(events) + + unsubFirst() + expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) + unsubSecond() + expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) + }) + + it('does not retain a watch listener when fs.watch setup fails', async () => { + mux.request.mockRejectedValueOnce(new Error('watch unavailable')) + const first = vi.fn() + await expect(provider.watch('/home/user/project', first)).rejects.toThrow('watch unavailable') + + const second = vi.fn() + await provider.watch('/home/user/project', second) + + const notifHandler = mux.onNotification.mock.calls[0][0] + const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] + notifHandler('fs.changed', { events }) + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledWith(events) + }) + + it('does not forward sibling paths with matching prefixes', async () => { + const callback = vi.fn() + await provider.watch('/home/user/project', callback) + + const notifHandler = mux.onNotification.mock.calls[0][0] + notifHandler('fs.changed', { + events: [ + { kind: 'update', absolutePath: '/home/user/project-old/file.ts' }, + { kind: 'update', absolutePath: '/home/user/project2/file.ts' } + ] + }) + + expect(callback).not.toHaveBeenCalled() + }) + + it('matches Windows and UNC watch roots case-insensitively', async () => { + const driveCallback = vi.fn() + const uncCallback = vi.fn() + await provider.watch('C:\\Repo', driveCallback) + await provider.watch('//Server/Share/Repo', uncCallback) + + const notifHandler = mux.onNotification.mock.calls[0][0] + notifHandler('fs.changed', { + events: [ + { kind: 'update', absolutePath: 'c:\\repo\\src\\file.ts' }, + { kind: 'update', absolutePath: '//server/share/repo/docs/readme.md' } + ] + }) + + expect(driveCallback).toHaveBeenCalledWith([ + { kind: 'update', absolutePath: 'c:\\repo\\src\\file.ts' } + ]) + expect(uncCallback).toHaveBeenCalledWith([ + { kind: 'update', absolutePath: '//server/share/repo/docs/readme.md' } + ]) + }) + it('sends fs.unwatch when last listener unsubscribes', async () => { const callback = vi.fn() const unsub = await provider.watch('/home/user/project', callback) diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index d0c545d6881..b33c0dbe08b 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -1,7 +1,16 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader' +import { uploadBuffer } from '../ssh/sftp-upload' import type { IFilesystemProvider, FileStat, FileReadResult } from './types' import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import type { SFTPWrapper } from 'ssh2' + +type SftpFactory = () => Promise +type WatchRegistration = { + callbacks: Set<(events: FsChangeEvent[]) => void> + setupPromise: Promise +} export class SshFilesystemProvider implements IFilesystemProvider { private connectionId: string @@ -9,7 +18,7 @@ export class SshFilesystemProvider implements IFilesystemProvider { // Why: each watch() call registers for a specific rootPath, but the relay // sends all fs.changed events on one notification channel. Keying by rootPath // prevents cross-pollination between different worktree watchers. - private watchListeners = new Map void>() + private watchListeners = new Map() // Why: store the unsubscribe handle so dispose() can detach from the // multiplexer. Without this, notification callbacks keep firing after // the provider is torn down on disconnect, routing events to stale state. @@ -19,17 +28,23 @@ export class SshFilesystemProvider implements IFilesystemProvider { // relays get diagnosed quickly without per-read log spam. private loggedStreamFallback = false - constructor(connectionId: string, mux: SshChannelMultiplexer) { + constructor( + connectionId: string, + mux: SshChannelMultiplexer, + private readonly createSftp?: SftpFactory + ) { this.connectionId = connectionId this.mux = mux this.unsubscribeNotifications = mux.onNotification((method, params) => { if (method === 'fs.changed') { const events = params.events as FsChangeEvent[] - for (const [rootPath, cb] of this.watchListeners) { - const matching = events.filter((e) => e.absolutePath.startsWith(rootPath)) + for (const [rootPath, registration] of this.watchListeners) { + const matching = events.filter((e) => isPathInsideOrEqual(rootPath, e.absolutePath)) if (matching.length > 0) { - cb(matching) + for (const cb of registration.callbacks) { + cb(matching) + } } } } @@ -78,6 +93,31 @@ export class SshFilesystemProvider implements IFilesystemProvider { await this.mux.request('fs.writeFile', { filePath, content }) } + async writeFileBase64(filePath: string, contentBase64: string): Promise { + await this.writeFileBase64Chunk(filePath, contentBase64, false) + } + + async writeFileBase64Chunk( + filePath: string, + contentBase64: string, + append: boolean + ): Promise { + if (!this.createSftp) { + throw new Error('remote_binary_upload_unavailable') + } + const sftp = await this.createSftp() + try { + // Why: relay fs.writeFile is text-only. SFTP writes the decoded bytes + // directly so runtime uploads do not corrupt images, PDFs, or archives. + await uploadBuffer(sftp, Buffer.from(contentBase64, 'base64'), filePath, { + append, + exclusive: !append + }) + } finally { + sftp.end() + } + } + async stat(filePath: string): Promise { return (await this.mux.request('fs.stat', { filePath })) as FileStat } @@ -94,6 +134,10 @@ export class SshFilesystemProvider implements IFilesystemProvider { await this.mux.request('fs.createDir', { dirPath }) } + async createDirNoClobber(dirPath: string): Promise { + await this.mux.request('fs.createDirNoClobber', { dirPath }) + } + async rename(oldPath: string, newPath: string): Promise { await this.mux.request('fs.rename', { oldPath, newPath }) } @@ -122,15 +166,41 @@ export class SshFilesystemProvider implements IFilesystemProvider { } async watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> { - this.watchListeners.set(rootPath, callback) - await this.mux.request('fs.watch', { rootPath }) + let registration = this.watchListeners.get(rootPath) + if (registration) { + registration.callbacks.add(callback) + await registration.setupPromise + return this.createWatchUnsubscribe(rootPath, registration, callback) + } + const callbacks = new Set<(events: FsChangeEvent[]) => void>([callback]) + const setupPromise = this.mux.request('fs.watch', { rootPath }).then( + () => undefined, + (error) => { + if (this.watchListeners.get(rootPath) === registration) { + this.watchListeners.delete(rootPath) + } + throw error + } + ) + registration = { callbacks, setupPromise } + this.watchListeners.set(rootPath, registration) + await setupPromise + + return this.createWatchUnsubscribe(rootPath, registration, callback) + } + + private createWatchUnsubscribe( + rootPath: string, + registration: WatchRegistration, + callback: (events: FsChangeEvent[]) => void + ): () => void { return () => { - this.watchListeners.delete(rootPath) - // Why: each watch() starts a @parcel/watcher on the relay for this specific - // rootPath. We must always notify the relay to stop it, not only when all - // watchers are gone — otherwise the remote watcher leaks inotify descriptors. - this.mux.notify('fs.unwatch', { rootPath }) + registration.callbacks.delete(callback) + if (registration.callbacks.size === 0 && this.watchListeners.get(rootPath) === registration) { + this.watchListeners.delete(rootPath) + this.mux.notify('fs.unwatch', { rootPath }) + } } } } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 024026115eb..998c22b3f73 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -118,10 +118,13 @@ export type IFilesystemProvider = { readDir(dirPath: string): Promise readFile(filePath: string): Promise writeFile(filePath: string, content: string): Promise + writeFileBase64(filePath: string, contentBase64: string): Promise + writeFileBase64Chunk(filePath: string, contentBase64: string, append: boolean): Promise stat(filePath: string): Promise deletePath(targetPath: string, recursive?: boolean): Promise createFile(filePath: string): Promise createDir(dirPath: string): Promise + createDirNoClobber(dirPath: string): Promise rename(oldPath: string, newPath: string): Promise copy(source: string, destination: string): Promise realpath(filePath: string): Promise diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 0c18d639340..e04fd6ca5b4 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -41,8 +41,11 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(opencodeRestoreIndex).toBeGreaterThan(outputEncodingIndex) expect(piRestoreIndex).toBeGreaterThan(outputEncodingIndex) expect(promptIndex).toBeGreaterThan(piRestoreIndex) - expect(command).toContain('`e]133;D;$fakeExitCode`a') - expect(command).toContain('`e]133;C`a') + expect(command).toContain('Esc = [char]27') + expect(command).toContain('Bel = [char]7') + expect(command).toContain(')]133;D;$fakeExitCode$(') + expect(command).toContain(')]133;C$(') + expect(command).not.toContain('`e]133') }) it('handles pwsh.exe (PowerShell Core) the same as Windows PowerShell', () => { diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index 5ccb91123ba..2469c4810df 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -1,6 +1,7 @@ import type { GitWorktreeInfo, Repo } from '../shared/types' import { listWorktrees } from './git/worktree' import { isFolderRepo } from '../shared/repo-kind' +import { getSshGitProvider } from './providers/ssh-git-dispatch' export function createFolderWorktree(repo: Repo): GitWorktreeInfo { return { @@ -19,5 +20,12 @@ export async function listRepoWorktrees(repo: Repo): Promise if (isFolderRepo(repo)) { return [createFolderWorktree(repo)] } + if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + // Why: runtime worktree resolution can run before SSH providers have + // reattached during startup. Return empty instead of falling back to + // local git against a server path. + return provider ? await provider.listWorktrees(repo.path) : [] + } return await listWorktrees(repo.path) } diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index c925bce2b9c..5a03d05a1c1 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -3,15 +3,19 @@ // compromising one device doesn't expose others. The registry is a simple // JSON file with hardened permissions matching the runtime metadata pattern. import { randomBytes, randomUUID } from 'crypto' -import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs' +import { existsSync, readFileSync } from 'fs' import { join } from 'path' +import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file' const DEVICE_REGISTRY_FILENAME = 'orca-devices.json' +export type DeviceScope = 'mobile' | 'runtime' + export type DeviceEntry = { deviceId: string name: string token: string + scope: DeviceScope pairedAt: number lastSeenAt: number } @@ -25,11 +29,12 @@ export class DeviceRegistry { this.load() } - addDevice(name: string): DeviceEntry { + addDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry { const entry: DeviceEntry = { deviceId: randomUUID(), name, token: randomBytes(24).toString('hex'), + scope, pairedAt: Date.now(), lastSeenAt: 0 } @@ -44,12 +49,12 @@ export class DeviceRegistry { // copy-button flow that encourages regeneration) leaves an orphaned token // forever. Returns an existing never-scanned entry if present; otherwise // mints a new one and drops any stale pending entries. - getOrCreatePendingDevice(name: string): DeviceEntry { - const existing = this.devices.find((d) => d.lastSeenAt === 0) + getOrCreatePendingDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry { + const existing = this.devices.find((d) => d.lastSeenAt === 0 && d.scope === scope) if (existing) { return existing } - return this.addDevice(name) + return this.addDevice(name, scope) } // Why: explicit rotation path for "Regenerate QR" — invalidates any @@ -58,9 +63,9 @@ export class DeviceRegistry { // this, getOrCreatePendingDevice keeps returning the same token forever // until a phone actually pairs, so users have no way to revoke a leaked // pre-pairing token. - rotatePendingDevice(name: string): DeviceEntry { - this.devices = this.devices.filter((d) => d.lastSeenAt !== 0) - return this.addDevice(name) + rotatePendingDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry { + this.devices = this.devices.filter((d) => d.lastSeenAt !== 0 || d.scope !== scope) + return this.addDevice(name, scope) } removeDevice(deviceId: string): boolean { @@ -73,6 +78,10 @@ export class DeviceRegistry { return false } + getDevice(deviceId: string): DeviceEntry | null { + return this.devices.find((d) => d.deviceId === deviceId) ?? null + } + listDevices(): readonly DeviceEntry[] { return this.devices } @@ -95,14 +104,20 @@ export class DeviceRegistry { return } try { - this.devices = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[] + hardenExistingSecureFile(this.registryPath) + const parsed = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[] + this.devices = parsed.map((device) => ({ + ...device, + // Why: older registries only existed for phone pairing. Treat missing + // scope as mobile so legacy device tokens do not gain new CLI powers. + scope: device.scope === 'runtime' ? 'runtime' : 'mobile' + })) } catch { this.devices = [] } } private save(): void { - writeFileSync(this.registryPath, JSON.stringify(this.devices, null, 2), { mode: 0o600 }) - chmodSync(this.registryPath, 0o600) + writeSecureJsonFile(this.registryPath, this.devices) } } diff --git a/src/main/runtime/e2ee-keypair.ts b/src/main/runtime/e2ee-keypair.ts index de0373ebafe..eee61363191 100644 --- a/src/main/runtime/e2ee-keypair.ts +++ b/src/main/runtime/e2ee-keypair.ts @@ -1,9 +1,10 @@ // Why: the E2EE keypair enables application-layer encryption between mobile // and desktop over plain ws://. The public key is embedded in the QR pairing // offer so the mobile client can derive a shared secret via ECDH. -import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs' +import { existsSync, readFileSync } from 'fs' import { join } from 'path' import nacl from 'tweetnacl' +import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file' const KEYPAIR_FILENAME = 'orca-e2ee-keypair.json' const KEYPAIR_VERSION = 1 @@ -25,6 +26,7 @@ export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair { if (existsSync(filePath)) { try { + hardenExistingSecureFile(filePath) const raw: KeypairFile = JSON.parse(readFileSync(filePath, 'utf-8')) if (raw.v === KEYPAIR_VERSION && raw.publicKeyB64 && raw.secretKeyB64) { const publicKey = Uint8Array.from(Buffer.from(raw.publicKeyB64, 'base64')) @@ -43,8 +45,7 @@ export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair { const secretKeyB64 = Buffer.from(keypair.secretKey).toString('base64') const data: KeypairFile = { v: KEYPAIR_VERSION, publicKeyB64, secretKeyB64 } - writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8') - chmodSync(filePath, 0o600) + writeSecureJsonFile(filePath, data) return { publicKey: keypair.publicKey, secretKey: keypair.secretKey, publicKeyB64 } } diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index d3a84349f2c..e25fbe22674 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -295,6 +295,27 @@ describe('mobile presence lock — multi-mobile semantics', () => { expect(driverEvents.slice(before).every((e) => e.driver.kind === 'mobile')).toBe(true) }) + it('updateDesktopViewport resizes the source PTY and records desktop geometry', async () => { + const { runtime, ptySizes, resizes } = createRuntime() + + expect(await runtime.updateDesktopViewport('pty-1', { cols: 132, rows: 44 })).toBe(true) + + expect(ptySizes.get('pty-1')).toEqual({ cols: 132, rows: 44 }) + expect(resizes.at(-1)).toEqual({ ptyId: 'pty-1', cols: 132, rows: 44 }) + expect(runtime.getLastRendererSize('pty-1')).toEqual({ cols: 132, rows: 44 }) + }) + + it('updateDesktopViewport does not resize while mobile is driving', async () => { + const { runtime, ptySizes, resizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 20 }) + resizes.length = 0 + + expect(await runtime.updateDesktopViewport('pty-1', { cols: 132, rows: 44 })).toBe(false) + + expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 20 }) + expect(resizes).toEqual([]) + }) + it('updateMobileViewport then disconnect restores PTY to original baseline', async () => { const { runtime, ptySizes } = createRuntime() await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 }) diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts new file mode 100644 index 00000000000..ea66e39da40 --- /dev/null +++ b/src/main/runtime/orca-runtime-browser.ts @@ -0,0 +1,1455 @@ +/* eslint-disable max-lines -- Why: this file is a command adapter for one external surface, Agent Browser automation. It stays separate from OrcaRuntimeService so runtime state does not grow further while browser routing remains easy to scan in one place. */ +import { randomUUID } from 'crypto' +import { ipcMain, type BrowserWindow } from 'electron' +import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' +import type { + BrowserBackResult, + BrowserCaptureStartResult, + BrowserCheckResult, + BrowserCaptureStopResult, + BrowserClearResult, + BrowserClickResult, + BrowserConsoleResult, + BrowserCookieDeleteResult, + BrowserCookieGetResult, + BrowserCookieSetResult, + BrowserDetectProfilesResult, + BrowserDragResult, + BrowserEvalResult, + BrowserFillResult, + BrowserFocusResult, + BrowserGeolocationResult, + BrowserGotoResult, + BrowserHoverResult, + BrowserInterceptDisableResult, + BrowserInterceptEnableResult, + BrowserKeypressResult, + BrowserNetworkLogResult, + BrowserPdfResult, + BrowserProfileClearDefaultCookiesResult, + BrowserProfileCreateResult, + BrowserProfileDeleteResult, + BrowserProfileImportFromBrowserResult, + BrowserProfileListResult, + BrowserReloadResult, + BrowserScreenshotResult, + BrowserScrollResult, + BrowserSelectAllResult, + BrowserSelectResult, + BrowserSnapshotResult, + BrowserTabCurrentResult, + BrowserTabListResult, + BrowserTabProfileCloneResult, + BrowserTabProfileShowResult, + BrowserTabSetProfileResult, + BrowserTabShowResult, + BrowserTabSwitchResult, + BrowserTypeResult, + BrowserUploadResult, + BrowserViewportResult, + BrowserWaitResult +} from '../../shared/runtime-types' +import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' +import { browserManager } from '../browser/browser-manager' +import { BrowserError } from '../browser/cdp-bridge' +import { browserSessionRegistry } from '../browser/browser-session-registry' +import { + detectInstalledBrowsers, + importCookiesFromBrowser, + selectBrowserProfile +} from '../browser/browser-cookie-import' +import { waitForTabRegistration } from '../ipc/browser' + +export type BrowserCommandTargetParams = { + worktree?: string + page?: string +} + +type ResolvedBrowserCommandTarget = { + worktreeId?: string + browserPageId?: string +} + +export type RuntimeBrowserCommandHost = { + getAgentBrowserBridge(): AgentBrowserBridge | null + resolveWorktreeSelector(selector: string): Promise<{ id: string }> + getAuthoritativeWindow(): BrowserWindow + getAvailableAuthoritativeWindow(): BrowserWindow | null +} + +export class RuntimeBrowserCommands { + constructor(private readonly host: RuntimeBrowserCommandHost) {} + + private requireAgentBrowserBridge(): AgentBrowserBridge { + const bridge = this.host.getAgentBrowserBridge() + if (!bridge) { + throw new BrowserError('browser_no_tab', 'No browser session is active') + } + return bridge + } + + // Why: the CLI sends worktree selectors (e.g. "path:/Users/...") but the + // bridge stores worktreeIds in "repoId::path" format (from the renderer's + // Zustand store). This helper resolves the selector to the store-compatible + // ID so the bridge can filter tabs correctly. + private async resolveBrowserWorktreeId(selector?: string): Promise { + if (!selector) { + // Why: after app restart, webviews only mount when the browser pane is visible. + // Without --worktree, we still need to activate the view so persisted tabs + // become operable via registerGuest. + const bridge = this.host.getAgentBrowserBridge() + if (bridge && bridge.getRegisteredTabs().size === 0) { + try { + const win = this.host.getAuthoritativeWindow() + win.webContents.send('browser:activateView', {}) + await new Promise((resolve) => setTimeout(resolve, 500)) + } catch { + // Window may not exist yet (e.g. during startup or in tests) + } + } + return undefined + } + + const worktreeId = (await this.host.resolveWorktreeSelector(selector)).id + // Why: explicit worktree selectors are user intent, so resolution errors + // must surface instead of silently widening browser routing scope. Only the + // activation step remains best-effort because missing windows during tests + // or startup should not erase the validated worktree target itself. + const bridge = this.host.getAgentBrowserBridge() + if (bridge && bridge.getRegisteredTabs(worktreeId).size === 0) { + try { + await this.ensureBrowserWorktreeActive(worktreeId) + } catch { + // Fall through with the validated worktree id so downstream routing + // still stays scoped to the caller's explicit selector. + } + } + return worktreeId + } + + private async resolveBrowserCommandTarget( + params: BrowserCommandTargetParams + ): Promise { + const browserPageId = + typeof params.page === 'string' && params.page.length > 0 ? params.page : undefined + if (!browserPageId) { + return { + worktreeId: await this.resolveBrowserWorktreeId(params.worktree) + } + } + + return { + // Why: explicit browserPageId is already a stable tab identity, so we do + // not auto-resolve cwd worktree scoping on top of it. Only honor an + // explicit --worktree when the caller asked for that extra validation. + worktreeId: params.worktree + ? await this.resolveBrowserWorktreeId(params.worktree) + : undefined, + browserPageId + } + } + + // Why: browser tabs only mount (and become operable) when their worktree is + // the active worktree in the renderer AND activeTabType is 'browser'. If either + // condition is false, the webview stays in display:none and Electron won't start + // its guest process — dom-ready never fires, registerGuest never runs, and CLI + // browser commands fail with "CDP connection refused". + private async ensureBrowserWorktreeActive(worktreeId: string): Promise { + const win = this.host.getAuthoritativeWindow() + const repoId = getRepoIdFromWorktreeId(worktreeId) + if (!repoId) { + return + } + win.webContents.send('ui:activateWorktree', { repoId, worktreeId }) + // Why: switching worktree alone sets activeView='terminal'. Browser webviews + // won't mount until activeTabType is 'browser'. Send a second IPC to flip it. + win.webContents.send('browser:activateView', { worktreeId }) + // Why: give the renderer time to mount the webview after switching worktrees. + // The webview needs to attach and fire dom-ready before registerGuest runs. + await new Promise((resolve) => setTimeout(resolve, 500)) + } + + // Why: agent-browser drives navigation via CDP, which bypasses Electron's + // webview event system. The renderer's did-navigate / page-title-updated + // listeners never fire, leaving the Zustand store (and thus the Orca UI's + // address bar and tab title) stale. Push updates from main → renderer after + // any navigation-causing command so the UI stays in sync. + private notifyRendererNavigation(browserPageId: string, url: string, title: string): void { + try { + const win = this.host.getAuthoritativeWindow() + win.webContents.send('browser:navigation-update', { browserPageId, url, title }) + } catch { + // Window may not exist during shutdown + } + } + + // Why: `tabSwitch` only flips the bridge's `activeWebContentsId` — it + // does not surface the browser pane in the renderer. Without --focus, the + // switch is invisible to the user. With --focus, we send a dedicated IPC + // so the renderer can update its per-worktree active-tab state. + // + // Why this IPC carries `worktreeId` instead of letting the renderer + // dispatch `setActiveWorktree`: multiple agents drive browsers in parallel + // worktrees. A global focus call from agent X would steal the user's + // screen from agent Y's worktree. The renderer-side handler + // (focusBrowserTabInWorktree) updates per-worktree state unconditionally + // and only flips globals when the user is already on the targeted + // worktree. Cross-worktree --focus calls pre-stage silently. + private notifyRendererBrowserPaneFocus( + worktreeId: string | undefined, + browserPageId: string + ): void { + try { + const win = this.host.getAuthoritativeWindow() + win.webContents.send('browser:pane-focus', { + worktreeId: worktreeId ?? null, + browserPageId + }) + } catch { + // Window may not exist during shutdown + } + } + + async browserSnapshot(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().snapshot(target.worktreeId, target.browserPageId) + } + + async browserClick( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const bridge = this.requireAgentBrowserBridge() + const result = await bridge.click(params.element, target.worktreeId, target.browserPageId) + // Why: clicks can trigger navigation (e.g. submitting a form, clicking a link). + // Read the target tab's live URL/title after the click and push to the + // renderer so the UI updates even when automation targeted a non-active page. + const page = bridge.getPageInfo(target.worktreeId, target.browserPageId) + if (page) { + this.notifyRendererNavigation(page.browserPageId, page.url, page.title) + } + return result + } + + async browserGoto( + params: { url: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const bridge = this.requireAgentBrowserBridge() + const result = await bridge.goto(params.url, target.worktreeId, target.browserPageId) + const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) + if (pageId) { + this.notifyRendererNavigation(pageId, result.url, result.title) + } + return result + } + + async browserFill( + params: { + element: string + value: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().fill( + params.element, + params.value, + target.worktreeId, + target.browserPageId + ) + } + + async browserType( + params: { input: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().type( + params.input, + target.worktreeId, + target.browserPageId + ) + } + + async browserSelect( + params: { + element: string + value: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().select( + params.element, + params.value, + target.worktreeId, + target.browserPageId + ) + } + + async browserScroll( + params: { direction: 'up' | 'down'; amount?: number } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().scroll( + params.direction, + params.amount, + target.worktreeId, + target.browserPageId + ) + } + + async browserBack(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const bridge = this.requireAgentBrowserBridge() + const result = await bridge.back(target.worktreeId, target.browserPageId) + const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) + if (pageId) { + this.notifyRendererNavigation(pageId, result.url, result.title) + } + return result + } + + async browserReload(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const bridge = this.requireAgentBrowserBridge() + const result = await bridge.reload(target.worktreeId, target.browserPageId) + const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) + if (pageId) { + this.notifyRendererNavigation(pageId, result.url, result.title) + } + return result + } + + async browserScreenshot( + params: { + format?: 'png' | 'jpeg' + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().screenshot( + params.format, + target.worktreeId, + target.browserPageId + ) + } + + async browserEval( + params: { expression: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().evaluate( + params.expression, + target.worktreeId, + target.browserPageId + ) + } + + async browserTabList(params: { worktree?: string }): Promise { + const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) + const result = this.requireAgentBrowserBridge().tabList(worktreeId) + return { + tabs: result.tabs.map((tab) => this.enrichBrowserTabInfo(tab)) + } + } + + async browserTabShow(params: { page: string; worktree?: string }): Promise { + const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) + return { tab: this.describeBrowserTab(params.page, worktreeId) } + } + + async browserTabCurrent(params: { worktree?: string }): Promise { + const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) + const browserPageId = this.requireAgentBrowserBridge().getActivePageId(worktreeId) + if (!browserPageId) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + return { tab: this.describeBrowserTab(browserPageId, worktreeId) } + } + + async browserTabSwitch( + params: { + index?: number + focus?: boolean + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const bridge = this.requireAgentBrowserBridge() + const result = await bridge.tabSwitch(params.index, target.worktreeId, target.browserPageId) + if (params.focus) { + // Why: prefer the explicit --worktree the caller passed; fall back to + // the bridge's owning-worktree map for the just-switched tab. The + // owning worktree is what the renderer needs to scope the focus to. + // The renderer NEVER yanks the user across worktrees on this signal + // (see focusBrowserTabInWorktree). + const worktreeId = + target.worktreeId ?? browserManager.getWorktreeIdForTab(result.browserPageId) ?? undefined + this.notifyRendererBrowserPaneFocus(worktreeId, result.browserPageId) + } + return result + } + + async browserHover( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().hover( + params.element, + target.worktreeId, + target.browserPageId + ) + } + + async browserDrag( + params: { + from: string + to: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().drag( + params.from, + params.to, + target.worktreeId, + target.browserPageId + ) + } + + async browserUpload( + params: { element: string; files: string[] } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().upload( + params.element, + params.files, + target.worktreeId, + target.browserPageId + ) + } + + async browserWait( + params: { + selector?: string + timeout?: number + text?: string + url?: string + load?: string + fn?: string + state?: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const { worktree: _, page: __, ...options } = params + return this.requireAgentBrowserBridge().wait(options, target.worktreeId, target.browserPageId) + } + + async browserCheck( + params: { element: string; checked: boolean } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().check( + params.element, + params.checked, + target.worktreeId, + target.browserPageId + ) + } + + async browserFocus( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().focus( + params.element, + target.worktreeId, + target.browserPageId + ) + } + + async browserClear( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().clear( + params.element, + target.worktreeId, + target.browserPageId + ) + } + + async browserSelectAll( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().selectAll( + params.element, + target.worktreeId, + target.browserPageId + ) + } + + async browserKeypress( + params: { key: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().keypress( + params.key, + target.worktreeId, + target.browserPageId + ) + } + + async browserPdf(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().pdf(target.worktreeId, target.browserPageId) + } + + async browserFullScreenshot( + params: { + format?: 'png' | 'jpeg' + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().fullPageScreenshot( + params.format, + target.worktreeId, + target.browserPageId + ) + } + + // ── Cookie management ── + + async browserCookieGet( + params: { url?: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().cookieGet( + params.url, + target.worktreeId, + target.browserPageId + ) + } + + async browserCookieSet( + params: { + name: string + value: string + domain?: string + path?: string + secure?: boolean + httpOnly?: boolean + sameSite?: string + expires?: number + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().cookieSet( + params, + target.worktreeId, + target.browserPageId + ) + } + + async browserCookieDelete( + params: { + name: string + domain?: string + url?: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().cookieDelete( + params.name, + params.domain, + params.url, + target.worktreeId, + target.browserPageId + ) + } + + // ── Viewport ── + + async browserSetViewport( + params: { + width: number + height: number + deviceScaleFactor?: number + mobile?: boolean + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setViewport( + params.width, + params.height, + params.deviceScaleFactor, + params.mobile, + target.worktreeId, + target.browserPageId + ) + } + + // ── Geolocation ── + + async browserSetGeolocation( + params: { + latitude: number + longitude: number + accuracy?: number + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setGeolocation( + params.latitude, + params.longitude, + params.accuracy, + target.worktreeId, + target.browserPageId + ) + } + + // ── Request interception ── + + async browserInterceptEnable( + params: { + patterns?: string[] + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().interceptEnable( + params.patterns, + target.worktreeId, + target.browserPageId + ) + } + + async browserInterceptDisable( + params: BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().interceptDisable( + target.worktreeId, + target.browserPageId + ) + } + + async browserInterceptList(params: BrowserCommandTargetParams): Promise<{ requests: unknown[] }> { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().interceptList(target.worktreeId, target.browserPageId) + } + + // ── Console/network capture ── + + async browserCaptureStart( + params: BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().captureStart(target.worktreeId, target.browserPageId) + } + + async browserCaptureStop(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().captureStop(target.worktreeId, target.browserPageId) + } + + async browserConsoleLog( + params: { limit?: number } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().consoleLog( + params.limit, + target.worktreeId, + target.browserPageId + ) + } + + async browserNetworkLog( + params: { limit?: number } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().networkLog( + params.limit, + target.worktreeId, + target.browserPageId + ) + } + + // ── Additional core commands ── + + async browserDblclick( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().dblclick( + params.element, + target.worktreeId, + target.browserPageId + ) + } + + async browserForward(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().forward(target.worktreeId, target.browserPageId) + } + + async browserScrollIntoView( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().scrollIntoView( + params.element, + target.worktreeId, + target.browserPageId + ) + } + + async browserGet( + params: { + what: string + selector?: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().get( + params.what, + params.selector, + target.worktreeId, + target.browserPageId + ) + } + + async browserIs( + params: { what: string; selector: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().is( + params.what, + params.selector, + target.worktreeId, + target.browserPageId + ) + } + + // ── Keyboard insert text ── + + async browserKeyboardInsertText( + params: { text: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().keyboardInsertText( + params.text, + target.worktreeId, + target.browserPageId + ) + } + + // ── Mouse commands ── + + async browserMouseMove( + params: { x: number; y: number } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().mouseMove( + params.x, + params.y, + target.worktreeId, + target.browserPageId + ) + } + + async browserMouseDown( + params: { button?: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().mouseDown( + params.button, + target.worktreeId, + target.browserPageId + ) + } + + async browserMouseUp(params: { button?: string } & BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().mouseUp( + params.button, + target.worktreeId, + target.browserPageId + ) + } + + async browserMouseWheel( + params: { + dy: number + dx?: number + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().mouseWheel( + params.dy, + params.dx, + target.worktreeId, + target.browserPageId + ) + } + + // ── Find (semantic locators) ── + + async browserFind( + params: { + locator: string + value: string + action: string + text?: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().find( + params.locator, + params.value, + params.action, + params.text, + target.worktreeId, + target.browserPageId + ) + } + + // ── Set commands ── + + async browserSetDevice(params: { name: string } & BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setDevice( + params.name, + target.worktreeId, + target.browserPageId + ) + } + + async browserSetOffline( + params: { state?: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setOffline( + params.state, + target.worktreeId, + target.browserPageId + ) + } + + async browserSetHeaders( + params: { headers: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setHeaders( + params.headers, + target.worktreeId, + target.browserPageId + ) + } + + async browserSetCredentials( + params: { + user: string + pass: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setCredentials( + params.user, + params.pass, + target.worktreeId, + target.browserPageId + ) + } + + async browserSetMedia( + params: { + colorScheme?: string + reducedMotion?: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setMedia( + params.colorScheme, + params.reducedMotion, + target.worktreeId, + target.browserPageId + ) + } + + // ── Clipboard commands ── + + async browserClipboardRead(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().clipboardRead(target.worktreeId, target.browserPageId) + } + + async browserClipboardWrite( + params: { text: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().clipboardWrite( + params.text, + target.worktreeId, + target.browserPageId + ) + } + + // ── Dialog commands ── + + async browserDialogAccept( + params: { text?: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().dialogAccept( + params.text, + target.worktreeId, + target.browserPageId + ) + } + + async browserDialogDismiss(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().dialogDismiss(target.worktreeId, target.browserPageId) + } + + // ── Storage commands ── + + async browserStorageLocalGet( + params: { key: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().storageLocalGet( + params.key, + target.worktreeId, + target.browserPageId + ) + } + + async browserStorageLocalSet( + params: { + key: string + value: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().storageLocalSet( + params.key, + params.value, + target.worktreeId, + target.browserPageId + ) + } + + async browserStorageLocalClear(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().storageLocalClear( + target.worktreeId, + target.browserPageId + ) + } + + async browserStorageSessionGet( + params: { key: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().storageSessionGet( + params.key, + target.worktreeId, + target.browserPageId + ) + } + + async browserStorageSessionSet( + params: { + key: string + value: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().storageSessionSet( + params.key, + params.value, + target.worktreeId, + target.browserPageId + ) + } + + async browserStorageSessionClear(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().storageSessionClear( + target.worktreeId, + target.browserPageId + ) + } + + // ── Download command ── + + async browserDownload( + params: { + selector: string + path: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().download( + params.selector, + params.path, + target.worktreeId, + target.browserPageId + ) + } + + // ── Highlight command ── + + async browserHighlight( + params: { selector: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().highlight( + params.selector, + target.worktreeId, + target.browserPageId + ) + } + + // ── New: exec passthrough + tab lifecycle ── + + async browserExec(params: { command: string } & BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().exec( + params.command, + target.worktreeId, + target.browserPageId + ) + } + + async browserTabCreate(params: { + url?: string + worktree?: string + profileId?: string + }): Promise<{ browserPageId: string }> { + const url = params.url ?? 'about:blank' + const worktreeId = params.worktree + ? (await this.host.resolveWorktreeSelector(params.worktree)).id + : undefined + if (!this.host.getAvailableAuthoritativeWindow()) { + throw new BrowserError( + 'browser_error', + 'Browser tab creation requires a desktop renderer; headless orca serve does not support browser panes yet.' + ) + } + const { browserPageId } = await this.createBrowserTabInRenderer( + url, + worktreeId, + params.profileId + ) + + // Why: the renderer creates the Zustand tab immediately, but the webview must + // mount and fire dom-ready before registerGuest runs. Waiting here ensures the + // tab is operable by subsequent CLI commands (snapshot, click, etc.). + // If registration doesn't complete within timeout, return the ID anyway — the + // tab exists in the UI but may not be ready for automation commands yet. + try { + await waitForTabRegistration(browserPageId) + } catch { + // Tab was created in the renderer but the webview hasn't finished mounting. + // Return success since the tab exists; subsequent commands will fail with a + // clear "tab not available" error if the webview never loads. + } + + // Why: newly created tabs should be auto-activated so subsequent commands + // (snapshot, click, goto) target the new tab without requiring an explicit + // tab switch. Without this, the bridge's active tab still points at the + // previously active tab and the new tab shows active: false in tab list. + const bridge = this.requireAgentBrowserBridge() + const wcId = bridge.getRegisteredTabs(worktreeId).get(browserPageId) + if (wcId != null) { + bridge.setActiveTab(wcId, worktreeId) + } + + // Why: the renderer sets webview.src=url on mount, but agent-browser connects + // via CDP after the webview loads about:blank. Without an explicit goto, the + // page stays blank from agent-browser's perspective. Navigate via the bridge + // so agent-browser's CDP session tracks the correct page state. + if (url && url !== 'about:blank') { + try { + const result = await bridge.goto(url, worktreeId, browserPageId) + this.notifyRendererNavigation(browserPageId, result.url, result.title) + } catch { + // Tab exists but navigation failed — caller can retry with explicit goto + } + } + + return { browserPageId } + } + + async browserTabSetProfile( + params: { + profileId: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const browserPageId = + target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId) + if (!browserPageId) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + // Why: 'default' is a synthetic id; fall back to the registry's default profile when not registered. + const profile = + browserSessionRegistry.getProfile(params.profileId) ?? + (params.profileId === 'default' ? browserSessionRegistry.getDefaultProfile() : null) + if (!profile) { + throw new BrowserError( + 'invalid_argument', + `Browser profile ${params.profileId} was not found` + ) + } + + // Why: short-circuit no-op switches so the renderer doesn't tear down and + // remount the webview when the tab is already on the requested profile. + const currentProfileId = browserManager.getSessionProfileIdForTab(browserPageId) ?? 'default' + if (currentProfileId === profile.id) { + return { + browserPageId, + profileId: profile.id, + profileLabel: profile.label + } + } + + const win = this.host.getAuthoritativeWindow() + const requestId = randomUUID() + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ipcMain.removeListener('browser:tabSetProfileReply', handler) + reject(new Error('Tab profile update timed out')) + }, 10_000) + + const handler = ( + _event: Electron.IpcMainEvent, + reply: { requestId: string; error?: string } + ): void => { + if (reply.requestId !== requestId) { + return + } + clearTimeout(timer) + ipcMain.removeListener('browser:tabSetProfileReply', handler) + if (reply.error) { + reject(new Error(reply.error)) + } else { + resolve() + } + } + ipcMain.on('browser:tabSetProfileReply', handler) + win.webContents.send('browser:requestTabSetProfile', { + requestId, + browserPageId, + profileId: profile.id + }) + }) + + // Why: the renderer destroys the old webview and remounts on the new + // partition. Wait for the re-register so a follow-up tab list + // --show-profile reads the updated sessionProfileId from BrowserManager + // instead of stale data, and so subsequent CLI ops (snapshot, click, etc.) + // hit a guest that's already attached. + try { + await waitForTabRegistration(browserPageId) + } catch { + // Best-effort: re-register won't fire if the worktree is hidden. The + // store already reflects the new profile; downstream commands retry + // once the pane re-mounts. + } + + return { + browserPageId, + profileId: profile.id, + profileLabel: profile.label + } + } + + async browserTabProfileShow(params: { + page: string + worktree?: string + }): Promise { + const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) + const tab = this.describeBrowserTab(params.page, worktreeId) + return { + browserPageId: tab.browserPageId, + worktreeId: tab.worktreeId ?? null, + profileId: tab.profileId ?? null, + profileLabel: tab.profileLabel ?? null + } + } + + async browserTabProfileClone( + params: { + profileId: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const sourceBrowserPageId = + target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId) + if (!sourceBrowserPageId) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + const sourceTab = this.describeBrowserTab(sourceBrowserPageId, target.worktreeId) + const profile = browserSessionRegistry.getProfile(params.profileId) + if (!profile) { + throw new BrowserError( + 'invalid_argument', + `Browser profile ${params.profileId} was not found` + ) + } + const created = await this.createBrowserTabInRenderer( + sourceTab.url, + sourceTab.worktreeId ?? target.worktreeId, + profile.id + ) + // Why: parity with browserTabCreate. Wait for the cloned tab's webview to + // register so the returned browserPageId is operable by the next CLI call. + try { + await waitForTabRegistration(created.browserPageId) + } catch { + // Best-effort: registration may not fire if the worktree is hidden. + } + return { + browserPageId: created.browserPageId, + sourceBrowserPageId, + profileId: profile.id, + profileLabel: profile.label + } + } + + async browserProfileList(): Promise { + return { profiles: browserSessionRegistry.listProfiles() } + } + + async browserProfileCreate(params: { + label: string + scope: 'isolated' | 'imported' + }): Promise { + return { + profile: browserSessionRegistry.createProfile(params.scope, params.label) + } + } + + async browserProfileDelete(params: { profileId: string }): Promise { + return { + deleted: await browserSessionRegistry.deleteProfile(params.profileId), + profileId: params.profileId + } + } + + async browserProfileDetectBrowsers(): Promise { + return { + // Why: clients only need display metadata for the picker; filesystem + // paths and keychain identifiers stay on the runtime server. + browsers: detectInstalledBrowsers().map((browser) => ({ + family: browser.family, + label: browser.label, + profiles: browser.profiles, + selectedProfile: browser.selectedProfile + })) + } + } + + async browserProfileImportFromBrowser(params: { + profileId: string + browserFamily: string + browserProfile?: string + }): Promise { + const profile = browserSessionRegistry.getProfile(params.profileId) + if (!profile) { + return { ok: false, reason: 'Session profile not found.' } + } + if ( + params.browserProfile && + (/[/\\]/.test(params.browserProfile) || params.browserProfile.includes('..')) + ) { + return { ok: false, reason: 'Invalid browser profile name.' } + } + + const browsers = detectInstalledBrowsers() + let browser = browsers.find((candidate) => candidate.family === params.browserFamily) + if (!browser) { + return { ok: false, reason: 'Browser not found on this system.' } + } + + if (params.browserProfile && params.browserProfile !== browser.selectedProfile) { + const reselected = selectBrowserProfile(browser, params.browserProfile) + if (!reselected) { + return { + ok: false, + reason: `No cookies database found for profile "${params.browserProfile}".` + } + } + browser = reselected + } + + const result = await importCookiesFromBrowser(browser, profile.partition) + if (!result.ok) { + return result + } + + const profileName = + browser.profiles.find((candidate) => candidate.directory === browser.selectedProfile)?.name ?? + browser.selectedProfile + browserSessionRegistry.updateProfileSource(params.profileId, { + browserFamily: browser.family, + profileName, + importedAt: Date.now() + }) + return { ...result, profileId: params.profileId } + } + + async browserProfileClearDefaultCookies(): Promise { + return { cleared: await browserSessionRegistry.clearDefaultSessionCookies() } + } + + async browserTabClose(params: { + index?: number + page?: string + worktree?: string + }): Promise<{ closed: boolean }> { + const bridge = this.requireAgentBrowserBridge() + const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) + + let tabId: string | null = null + if (typeof params.page === 'string' && params.page.length > 0) { + if (!bridge.getRegisteredTabs(worktreeId).has(params.page)) { + const scope = worktreeId ? ' in this worktree' : '' + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${params.page} was not found${scope}` + ) + } + tabId = params.page + } else if (params.index !== undefined) { + const tabs = bridge.getRegisteredTabs(worktreeId) + const entries = [...tabs.entries()] + if (params.index < 0 || params.index >= entries.length) { + throw new Error(`Tab index ${params.index} out of range (0-${entries.length - 1})`) + } + tabId = entries[params.index][0] + } else { + // Why: try the bridge first (registered tabs with webviews), then fall back + // to asking the renderer to close its active browser tab (handles cases where + // the webview hasn't mounted yet, e.g. tab was just created). + const tabs = bridge.getRegisteredTabs(worktreeId) + const entries = [...tabs.entries()] + const activeEntry = entries.find(([, wcId]) => wcId === bridge.getActiveWebContentsId()) + if (activeEntry) { + tabId = activeEntry[0] + } + } + + const win = this.host.getAuthoritativeWindow() + const requestId = randomUUID() + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ipcMain.removeListener('browser:tabCloseReply', handler) + reject(new Error('Tab close timed out')) + }, 10_000) + + const handler = ( + _event: Electron.IpcMainEvent, + reply: { requestId: string; error?: string } + ): void => { + if (reply.requestId !== requestId) { + return + } + clearTimeout(timer) + ipcMain.removeListener('browser:tabCloseReply', handler) + if (reply.error) { + reject(new Error(reply.error)) + } else { + resolve() + } + } + ipcMain.on('browser:tabCloseReply', handler) + // Why: when main cannot resolve a concrete tab id itself (for example if a + // browser workspace exists in the renderer before its guest mounts), the + // renderer still needs the intended worktree scope. Otherwise it falls + // back to the globally active browser tab and can close a tab in the + // wrong worktree. + win.webContents.send('browser:requestTabClose', { requestId, tabId, worktreeId }) + }) + + return { closed: true } + } + + private enrichBrowserTabInfo( + tab: BrowserTabListResult['tabs'][number] + ): BrowserTabListResult['tabs'][number] { + const rawProfileId = browserManager.getSessionProfileIdForTab(tab.browserPageId) + const profile = + browserSessionRegistry.getProfile(rawProfileId ?? 'default') ?? + browserSessionRegistry.getDefaultProfile() + return { + ...tab, + worktreeId: browserManager.getWorktreeIdForTab(tab.browserPageId) ?? null, + profileId: profile.id, + profileLabel: profile.label + } + } + + private describeBrowserTab( + browserPageId: string, + explicitWorktreeId?: string + ): BrowserTabListResult['tabs'][number] { + const worktreeId = explicitWorktreeId ?? browserManager.getWorktreeIdForTab(browserPageId) + const tab = this.requireAgentBrowserBridge() + .tabList(worktreeId) + .tabs.find((entry) => entry.browserPageId === browserPageId) + if (!tab) { + const scope = worktreeId ? ' in this worktree' : '' + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${browserPageId} was not found${scope}` + ) + } + return this.enrichBrowserTabInfo(tab) + } + + private async createBrowserTabInRenderer( + url: string, + worktreeId?: string, + profileId?: string + ): Promise<{ browserPageId: string }> { + const win = this.host.getAuthoritativeWindow() + const requestId = randomUUID() + + if (worktreeId) { + await this.ensureBrowserWorktreeActive(worktreeId) + } + + const browserPageId = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ipcMain.removeListener('browser:tabCreateReply', handler) + reject(new Error('Tab creation timed out')) + }, 10_000) + + const handler = ( + _event: Electron.IpcMainEvent, + reply: { requestId: string; browserPageId?: string; error?: string } + ): void => { + if (reply.requestId !== requestId) { + return + } + clearTimeout(timer) + ipcMain.removeListener('browser:tabCreateReply', handler) + if (reply.error) { + reject(new Error(reply.error)) + } else { + resolve(reply.browserPageId!) + } + } + ipcMain.on('browser:tabCreateReply', handler) + win.webContents.send('browser:requestTabCreate', { + requestId, + url, + worktreeId, + sessionProfileId: profileId + }) + }) + + return { browserPageId } + } +} diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts new file mode 100644 index 00000000000..e77943d1049 --- /dev/null +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as Fs from 'fs' +import type * as FsPromises from 'fs/promises' +import type * as FilesystemAuth from '../ipc/filesystem-auth' + +const { resolveAuthorizedPathMock, statMock, watchMock } = vi.hoisted(() => ({ + resolveAuthorizedPathMock: vi.fn(), + statMock: vi.fn(), + watchMock: vi.fn() +})) + +vi.mock('fs', async () => { + const actual = await vi.importActual('fs') + return { + ...actual, + watch: watchMock + } +}) + +vi.mock('fs/promises', async () => { + const actual = await vi.importActual('fs/promises') + return { + ...actual, + stat: statMock + } +}) + +vi.mock('../ipc/filesystem-auth', async () => { + const actual = await vi.importActual('../ipc/filesystem-auth') + return { + ...actual, + resolveAuthorizedPath: resolveAuthorizedPathMock + } +}) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: vi.fn() +})) + +import { RuntimeFileCommands } from './orca-runtime-files' + +describe('RuntimeFileCommands', () => { + const originalPlatform = process.platform + + beforeEach(() => { + vi.useFakeTimers() + resolveAuthorizedPathMock.mockReset() + statMock.mockReset() + watchMock.mockReset() + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + vi.useRealTimers() + }) + + it('uses a conservative Node watcher for Windows runtime file watches', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + const store = { getRepo: vi.fn(() => undefined) } + const close = vi.fn() + const on = vi.fn() + let listener: (() => void) | null = null + watchMock.mockImplementation((_rootPath, _options, callback) => { + listener = callback + return { close, on } + }) + resolveAuthorizedPathMock.mockResolvedValue('C:\\repo') + statMock.mockResolvedValue({ isDirectory: () => true }) + + const commands = new RuntimeFileCommands({ + getRuntimeId: () => 'runtime-1', + requireStore: () => store, + resolveWorktreeSelector: vi.fn(async () => ({ + id: 'wt-1', + repoId: 'repo-1', + path: 'C:\\repo' + })), + resolveRuntimeGitTarget: vi.fn(), + openFile: vi.fn() + } as never) + const onEvents = vi.fn() + + const unsubscribe = await commands.watchFileExplorer('id:wt-1', onEvents) + + expect(watchMock).toHaveBeenCalledWith('C:\\repo', { recursive: true }, expect.any(Function)) + const emit = listener as (() => void) | null + expect(emit).not.toBeNull() + + emit?.() + emit?.() + await vi.advanceTimersByTimeAsync(149) + expect(onEvents).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + expect(onEvents).toHaveBeenCalledTimes(1) + expect(onEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: 'C:\\repo' }]) + + unsubscribe() + expect(close).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts new file mode 100644 index 00000000000..ca326ae42ad --- /dev/null +++ b/src/main/runtime/orca-runtime-files.ts @@ -0,0 +1,918 @@ +/* eslint-disable max-lines -- Why: filesystem, editor-file, and search commands share the same local/SSH path authorization rules. Keeping that IO adapter together prevents separate command paths from drifting on safety checks. */ +import type { ChildProcess } from 'child_process' +import { watch as watchFs } from 'fs' +import { + constants, + copyFile, + lstat, + mkdir, + open, + readFile, + readdir, + rename, + rm, + stat, + writeFile +} from 'fs/promises' +import { basename, dirname, extname, join } from 'path' +import type { + DirEntry, + FsChangeEvent, + GitWorktreeInfo, + MarkdownDocument, + SearchOptions, + SearchResult, + Worktree +} from '../../shared/types' +import type { + RuntimeFileListResult, + RuntimeFileOpenResult, + RuntimeFilePreviewResult, + RuntimeFileReadResult +} from '../../shared/runtime-types' +import { wslAwareSpawn } from '../git/runner' +import { parseWslPath, toWindowsWslPath } from '../wsl' +import { isENOENT, resolveAuthorizedPath } from '../ipc/filesystem-auth' +import { listQuickOpenFiles } from '../ipc/filesystem-list-files' +import { searchWithGitGrep } from '../ipc/filesystem-search-git' +import { checkRgAvailable } from '../ipc/rg-availability' +import { + listMarkdownDocuments, + markdownDocumentsFromRelativePaths +} from '../ipc/markdown-documents' +import { + buildRgArgs, + createAccumulator, + DEFAULT_SEARCH_MAX_RESULTS, + finalize, + ingestRgJsonLine, + SEARCH_TIMEOUT_MS +} from '../../shared/text-search' +import type { Store } from '../persistence' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' + +const MOBILE_FILE_LIST_LIMIT = 5000 +const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024 +const RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES = 10 * 1024 * 1024 +const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150 +const MOBILE_BINARY_EXTENSIONS = new Set([ + '.avif', + '.bmp', + '.gif', + '.heic', + '.ico', + '.jpeg', + '.jpg', + '.mov', + '.mp3', + '.mp4', + '.pdf', + '.png', + '.webp', + '.zip' +]) +const RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.pdf': 'application/pdf' +} + +export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo } + +export type RuntimeFileCommandHost = { + getRuntimeId(): string + requireStore(): Store + resolveWorktreeSelector(selector: string): Promise + resolveRuntimeGitTarget( + selector: string + ): Promise<{ worktree: ResolvedRuntimeFileWorktree; connectionId?: string }> + openFile(worktreeId: string, filePath: string, relativePath: string): void +} + +export class RuntimeFileCommands { + private activeRuntimeTextSearches = new Map() + + constructor(private readonly host: RuntimeFileCommandHost) {} + + async listMobileFiles(worktreeSelector: string): Promise { + const store = this.host.requireStore() + const worktree = await this.host.resolveWorktreeSelector(worktreeSelector) + const repo = store.getRepo(worktree.repoId) + const connectionId = repo?.connectionId ?? undefined + const files = connectionId + ? await this.listRemoteMobileFiles(worktree.path, connectionId) + : await listQuickOpenFiles(worktree.path, store) + const entries = files + .filter((relativePath) => isSafeMobileRelativePath(relativePath)) + .sort((a, b) => a.localeCompare(b)) + .slice(0, MOBILE_FILE_LIST_LIMIT) + .map((relativePath) => ({ + relativePath, + basename: basenameFromRelativePath(relativePath), + kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const) + })) + + return { + worktree: worktree.id, + rootPath: worktree.path, + files: entries, + totalCount: files.length, + truncated: files.length > MOBILE_FILE_LIST_LIMIT + } + } + + async openMobileFile( + worktreeSelector: string, + relativePath: string + ): Promise { + const worktree = await this.host.resolveWorktreeSelector(worktreeSelector) + if (!isSafeMobileRelativePath(relativePath)) { + throw new Error('invalid_relative_path') + } + const kind = isMobileBinaryPath(relativePath) + ? 'binary' + : isMobileMarkdownPath(relativePath) + ? 'markdown' + : 'text' + if (kind === 'binary') { + return { worktree: worktree.id, relativePath, kind, opened: false } + } + const filePath = joinWorktreeRelativePath(worktree.path, relativePath) + this.host.openFile(worktree.id, filePath, relativePath) + return { worktree: worktree.id, relativePath, kind, opened: true } + } + + async readMobileFile( + worktreeSelector: string, + relativePath: string + ): Promise { + const store = this.host.requireStore() + const worktree = await this.host.resolveWorktreeSelector(worktreeSelector) + if (!isSafeMobileRelativePath(relativePath)) { + throw new Error('invalid_relative_path') + } + if (isMobileBinaryPath(relativePath)) { + throw new Error('binary_file') + } + + const repo = store.getRepo(worktree.repoId) + const filePath = joinWorktreeRelativePath(worktree.path, relativePath) + const content = repo?.connectionId + ? await this.readRemoteMobileFile(filePath, repo.connectionId) + : await readLocalMobileFile(filePath, store) + const truncated = truncateMobileFilePreview(content) + + return { + worktree: worktree.id, + relativePath, + content: truncated.content, + truncated: truncated.truncated, + byteLength: truncated.byteLength + } + } + + async readFileExplorerDir(worktreeSelector: string, relativePath: string): Promise { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + return provider.readDir(target.path) + } + + const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + const entries = await readdir(dirPath, { withFileTypes: true }) + const mapped = await Promise.all( + entries.map(async (entry) => { + const entryPath = join(dirPath, entry.name) + return { + name: entry.name, + isDirectory: await isRuntimeDirectoryEntry(entryPath), + isSymlink: entry.isSymbolicLink() + } + }) + ) + return mapped.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + } + + async watchFileExplorer( + worktreeSelector: string, + callback: (events: FsChangeEvent[]) => void + ): Promise<() => void> { + const target = await this.resolveFileExplorerPath(worktreeSelector, '') + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + return provider.watch(target.path, callback) + } + + const rootPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + const rootStats = await stat(rootPath) + if (!rootStats.isDirectory()) { + throw new Error('not_a_directory') + } + if (process.platform === 'win32') { + return watchWindowsRuntimeFileExplorer(rootPath, callback) + } + const watcher = await import('@parcel/watcher') + const subscription = await watcher.subscribe( + rootPath, + (err, events) => { + if (err) { + console.error('[runtime-files.watch] watcher error', { rootPath, err }) + callback([{ kind: 'overflow', absolutePath: rootPath }]) + return + } + void Promise.all( + events.map(async (event): Promise => { + let isDirectory = false + try { + isDirectory = (await stat(event.path)).isDirectory() + } catch { + isDirectory = false + } + return { + kind: event.type, + absolutePath: event.path, + isDirectory + } + }) + ).then(callback) + }, + { + ignore: [ + '.git', + 'node_modules', + 'dist', + 'build', + '.next', + '.cache', + '__pycache__', + 'target', + '.venv' + ] + } + ) + return () => { + void subscription.unsubscribe().catch((err: unknown) => { + console.error('[runtime-files.watch] unsubscribe error', { rootPath, err }) + }) + } + } + + async readFileExplorerPreview( + worktreeSelector: string, + relativePath: string + ): Promise { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + const fileStats = await provider.stat(target.path) + if (fileStats.size > RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES) { + throw new Error('file_too_large') + } + const result = await provider.readFile(target.path) + return result + } + + const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + const fileStats = await stat(filePath) + const mimeType = RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] + if (mimeType) { + if (fileStats.size > RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES) { + throw new Error('file_too_large') + } + const buffer = await readFile(filePath) + return { + content: buffer.toString('base64'), + isBinary: true, + isImage: true, + mimeType + } + } + + if (fileStats.size > MOBILE_FILE_READ_MAX_BYTES) { + throw new Error('file_too_large') + } + const buffer = await readFile(filePath) + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { content: buffer.toString('utf-8'), isBinary: false } + } + + async writeFileExplorerFile( + worktreeSelector: string, + relativePath: string, + content: string + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + await provider.writeFile(target.path, content) + return { ok: true } + } + + const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + try { + const fileStats = await lstat(filePath) + if (fileStats.isDirectory()) { + throw new Error('Cannot write to a directory') + } + } catch (error) { + if (!isENOENT(error)) { + throw error + } + } + await writeFile(filePath, content, 'utf-8') + return { ok: true } + } + + async writeFileExplorerFileBase64( + worktreeSelector: string, + relativePath: string, + contentBase64: string + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + const content = Buffer.from(contentBase64, 'base64') + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + await provider.writeFileBase64(target.path, contentBase64) + return { ok: true } + } + + const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, content, { flag: 'wx' }) + return { ok: true } + } + + async writeFileExplorerFileBase64Chunk( + worktreeSelector: string, + relativePath: string, + contentBase64: string, + append: boolean + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + const content = Buffer.from(contentBase64, 'base64') + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + await provider.writeFileBase64Chunk(target.path, contentBase64, append) + return { ok: true } + } + + const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, content, { flag: append ? 'a' : 'wx' }) + return { ok: true } + } + + async createFileExplorerFile( + worktreeSelector: string, + relativePath: string + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + await provider.createFile(target.path) + return { ok: true } + } + + const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + await mkdir(dirname(filePath), { recursive: true }) + try { + await writeFile(filePath, '', { encoding: 'utf-8', flag: 'wx' }) + } catch (error) { + rethrowRuntimeFileCreateError(error, filePath) + } + return { ok: true } + } + + async createFileExplorerDir( + worktreeSelector: string, + relativePath: string + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + await provider.createDir(target.path) + return { ok: true } + } + + const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + await assertRuntimePathDoesNotExist(dirPath) + await mkdir(dirPath, { recursive: false }) + return { ok: true } + } + + async createFileExplorerDirNoClobber( + worktreeSelector: string, + relativePath: string + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + await provider.createDirNoClobber(target.path) + return { ok: true } + } + + const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + await mkdir(dirPath, { recursive: false }) + return { ok: true } + } + + async commitFileExplorerUpload( + worktreeSelector: string, + tempRelativePath: string, + finalRelativePath: string + ): Promise<{ ok: true }> { + const tempTarget = await this.resolveFileExplorerPath(worktreeSelector, tempRelativePath) + const finalTarget = await this.resolveFileExplorerPath(worktreeSelector, finalRelativePath) + const provider = tempTarget.connectionId + ? getSshFilesystemProvider(tempTarget.connectionId) + : null + if (tempTarget.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + await provider.copy(tempTarget.path, finalTarget.path) + await provider.deletePath(tempTarget.path, false).catch(() => {}) + return { ok: true } + } + + const store = this.host.requireStore() + const tempPath = await resolveAuthorizedPath(tempTarget.path, store) + const finalPath = await resolveAuthorizedPath(finalTarget.path, store) + await mkdir(dirname(finalPath), { recursive: true }) + await copyFile(tempPath, finalPath, constants.COPYFILE_EXCL) + await rm(tempPath, { force: true }) + return { ok: true } + } + + async renameFileExplorerPath( + worktreeSelector: string, + oldRelativePath: string, + newRelativePath: string + ): Promise<{ ok: true }> { + const oldTarget = await this.resolveFileExplorerPath(worktreeSelector, oldRelativePath) + const newTarget = await this.resolveFileExplorerPath(worktreeSelector, newRelativePath) + const provider = oldTarget.connectionId + ? getSshFilesystemProvider(oldTarget.connectionId) + : null + if (oldTarget.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + await provider.rename(oldTarget.path, newTarget.path) + return { ok: true } + } + + const store = this.host.requireStore() + const oldPath = await resolveAuthorizedPath(oldTarget.path, store, { preserveSymlink: true }) + const newPath = await resolveAuthorizedPath(newTarget.path, store, { preserveSymlink: true }) + await assertRuntimePathDoesNotExist(newPath) + await rename(oldPath, newPath) + return { ok: true } + } + + async copyFileExplorerPath( + worktreeSelector: string, + sourceRelativePath: string, + destinationRelativePath: string + ): Promise<{ ok: true }> { + const sourceTarget = await this.resolveFileExplorerPath(worktreeSelector, sourceRelativePath) + const destinationTarget = await this.resolveFileExplorerPath( + worktreeSelector, + destinationRelativePath + ) + const provider = sourceTarget.connectionId + ? getSshFilesystemProvider(sourceTarget.connectionId) + : null + if (sourceTarget.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + await provider.copy(sourceTarget.path, destinationTarget.path) + return { ok: true } + } + + const store = this.host.requireStore() + const sourcePath = await resolveAuthorizedPath(sourceTarget.path, store, { + preserveSymlink: true + }) + const destinationPath = await resolveAuthorizedPath(destinationTarget.path, store, { + preserveSymlink: true + }) + await mkdir(dirname(destinationPath), { recursive: true }) + // Why: duplicate/copy operations are deconflicted by the caller. COPYFILE_EXCL + // preserves the same no-clobber invariant as the local shell copy IPC. + await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL) + return { ok: true } + } + + async deleteFileExplorerPath( + worktreeSelector: string, + relativePath: string, + recursive?: boolean + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + await provider.deletePath(target.path, recursive) + return { ok: true } + } + + const targetPath = await resolveAuthorizedPath(target.path, this.host.requireStore(), { + preserveSymlink: true + }) + // Why: a non-local runtime has no client OS Trash/Recycling Bin; server-side + // file mutations are permanent and the renderer confirms before calling this. + await rm(targetPath, { recursive: recursive === true, force: true }) + return { ok: true } + } + + async searchRuntimeFiles( + worktreeSelector: string, + options: Omit + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + const rootPath = target.worktree.path + const searchOptions = { ...options, rootPath } + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + return provider.search(searchOptions) + } + return this.searchLocalRuntimeFiles(rootPath, searchOptions) + } + + async listRuntimeFiles( + worktreeSelector: string, + options: { excludePaths?: string[] } = {} + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + return [] + } + return provider.listFiles(target.worktree.path, { excludePaths: options.excludePaths }) + } + return listQuickOpenFiles(target.worktree.path, this.host.requireStore(), options.excludePaths) + } + + async listRuntimeMarkdownDocuments(worktreeSelector: string): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + const relativePaths = await provider.listFiles(target.worktree.path) + return markdownDocumentsFromRelativePaths(target.worktree.path, relativePaths) + } + return listMarkdownDocuments(target.worktree.path) + } + + async statRuntimeFile( + worktreeSelector: string, + relativePath: string + ): Promise<{ size: number; isDirectory: boolean; mtime: number }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + const fileStat = await provider.stat(target.path) + return { + size: fileStat.size, + isDirectory: fileStat.type === 'directory', + mtime: fileStat.mtime + } + } + const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + const stats = await stat(filePath) + return { size: stats.size, isDirectory: stats.isDirectory(), mtime: stats.mtimeMs } + } + + private async searchLocalRuntimeFiles( + rootPath: string, + options: SearchOptions + ): Promise { + const authorizedRootPath = await resolveAuthorizedPath(rootPath, this.host.requireStore()) + const maxResults = Math.max( + 1, + Math.min(options.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) + ) + const rgAvailable = await checkRgAvailable(authorizedRootPath) + if (!rgAvailable) { + return searchWithGitGrep(authorizedRootPath, options, maxResults) + } + + return new Promise((resolvePromise) => { + const searchKey = `${this.host.getRuntimeId()}:${authorizedRootPath}` + const rgArgs = buildRgArgs(options.query, authorizedRootPath, options) + this.activeRuntimeTextSearches.get(searchKey)?.kill() + + const acc = createAccumulator() + let stdoutBuffer = '' + let resolved = false + let child: ChildProcess | null = null + const wslInfo = parseWslPath(authorizedRootPath) + const transformAbsPath = wslInfo + ? (p: string): string => toWindowsWslPath(p, wslInfo.distro) + : undefined + + const resolveOnce = (): void => { + if (resolved) { + return + } + resolved = true + if (this.activeRuntimeTextSearches.get(searchKey) === child) { + this.activeRuntimeTextSearches.delete(searchKey) + } + clearTimeout(killTimeout) + resolvePromise(finalize(acc)) + } + + const processLine = (line: string): void => { + const verdict = ingestRgJsonLine( + line, + authorizedRootPath, + acc, + maxResults, + transformAbsPath + ) + if (verdict === 'stop') { + child?.kill() + } + } + + const nextChild = wslAwareSpawn('rg', rgArgs, { + cwd: authorizedRootPath, + stdio: ['ignore', 'pipe', 'pipe'] + }) + child = nextChild + this.activeRuntimeTextSearches.set(searchKey, nextChild) + + nextChild.stdout!.setEncoding('utf-8') + nextChild.stdout!.on('data', (chunk: string) => { + stdoutBuffer += chunk + const lines = stdoutBuffer.split('\n') + stdoutBuffer = lines.pop() ?? '' + for (const line of lines) { + processLine(line) + } + }) + nextChild.stderr!.on('data', () => { + // Drain stderr so rg cannot block on a full pipe. + }) + nextChild.once('error', () => resolveOnce()) + nextChild.once('close', () => { + if (stdoutBuffer) { + processLine(stdoutBuffer) + } + resolveOnce() + }) + + const killTimeout = setTimeout(() => { + acc.truncated = true + child?.kill() + }, SEARCH_TIMEOUT_MS) + }) + } + + private async resolveFileExplorerPath( + worktreeSelector: string, + relativePath: string + ): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }> { + const store = this.host.requireStore() + const worktree = await this.host.resolveWorktreeSelector(worktreeSelector) + const normalizedRelativePath = normalizeRuntimeRelativePath(relativePath) + const repo = store.getRepo(worktree.repoId) + return { + worktree, + path: joinWorktreeRelativePath(worktree.path, normalizedRelativePath), + connectionId: repo?.connectionId ?? undefined + } + } + + private async listRemoteMobileFiles(rootPath: string, connectionId: string): Promise { + const provider = getSshFilesystemProvider(connectionId) + if (!provider) { + return [] + } + return provider.listFiles(rootPath) + } + + private async readRemoteMobileFile(filePath: string, connectionId: string): Promise { + const provider = getSshFilesystemProvider(connectionId) + if (!provider) { + throw new Error('remote_filesystem_unavailable') + } + const fileStat = await provider.stat(filePath) + // Why: the SSH filesystem API does not expose ranged reads here, so reject + // oversized remote previews instead of streaming a large file just to trim it. + if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) { + throw new Error('file_too_large') + } + const result = await provider.readFile(filePath) + if (result.isBinary) { + throw new Error('binary_file') + } + return result.content + } +} + +function watchWindowsRuntimeFileExplorer( + rootPath: string, + callback: (events: FsChangeEvent[]) => void +): () => void { + let disposed = false + let timer: ReturnType | null = null + + const emitOverflow = (): void => { + timer = null + if (disposed) { + return + } + callback([{ kind: 'overflow', absolutePath: rootPath }]) + } + + const scheduleOverflow = (): void => { + if (disposed) { + return + } + if (timer) { + clearTimeout(timer) + } + timer = setTimeout(emitOverflow, WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS) + } + + // Why: Parcel probes Watchman before the Windows backend and its native + // watcher can abort the headless server process. For remote Windows runtimes, + // a conservative overflow refresh is safer than a process-wide native crash. + const watcher = watchFs(rootPath, { recursive: true }, scheduleOverflow) + watcher.on('error', (err) => { + console.error('[runtime-files.watch] Windows watcher error', { rootPath, err }) + scheduleOverflow() + }) + + return () => { + disposed = true + if (timer) { + clearTimeout(timer) + timer = null + } + try { + watcher.close() + } catch (err) { + console.error('[runtime-files.watch] Windows watcher close error', { rootPath, err }) + } + } +} + +export function isSafeMobileRelativePath(relativePath: string): boolean { + if (!relativePath || relativePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(relativePath)) { + return false + } + const parts = relativePath.replace(/\\/g, '/').split('/') + return parts.every((part) => part !== '' && part !== '.' && part !== '..') +} + +function isMobileMarkdownPath(relativePath: string): boolean { + return /\.(md|mdx|markdown)$/i.test(relativePath) +} + +function isMobileBinaryPath(relativePath: string): boolean { + const basename = basenameFromRelativePath(relativePath) + const dotIndex = basename.lastIndexOf('.') + if (dotIndex <= 0) { + return false + } + return MOBILE_BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase()) +} + +function basenameFromRelativePath(relativePath: string): string { + const normalized = relativePath.replace(/\\/g, '/') + return normalized.slice(normalized.lastIndexOf('/') + 1) +} + +async function isRuntimeDirectoryEntry(entryPath: string): Promise { + try { + return (await stat(entryPath)).isDirectory() + } catch { + return false + } +} + +function isBinaryBuffer(buffer: Buffer): boolean { + const len = Math.min(buffer.length, 8192) + for (let i = 0; i < len; i += 1) { + if (buffer[i] === 0) { + return true + } + } + return false +} + +async function assertRuntimePathDoesNotExist(targetPath: string): Promise { + try { + await lstat(targetPath) + throw new Error( + `A file or folder named '${basename(targetPath)}' already exists in this location` + ) + } catch (error) { + if (!isENOENT(error)) { + throw error + } + } +} + +function rethrowRuntimeFileCreateError(error: unknown, targetPath: string): never { + const name = basename(targetPath) + if (error instanceof Error && 'code' in error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'EEXIST') { + throw new Error(`A file or folder named '${name}' already exists in this location`) + } + if (code === 'EACCES' || code === 'EPERM') { + throw new Error(`Permission denied: unable to create '${name}'`) + } + } + throw error +} + +async function readLocalMobileFile(filePath: string, store: Store): Promise { + const authorizedPath = await resolveAuthorizedPath(filePath, store) + const fileStat = await stat(authorizedPath) + // Why: mobile file previews are read-only convenience views; cap the read so + // opening a generated log or bundle cannot block the WebSocket like oversized scrollback. + const readLimit = Math.min(fileStat.size, MOBILE_FILE_READ_MAX_BYTES + 1) + const handle = await open(authorizedPath, 'r') + try { + const buffer = Buffer.alloc(readLimit) + const { bytesRead } = await handle.read(buffer, 0, readLimit, 0) + return buffer.subarray(0, bytesRead).toString('utf8') + } finally { + await handle.close() + } +} + +function truncateMobileFilePreview(content: string): { + content: string + truncated: boolean + byteLength: number +} { + const buffer = Buffer.from(content, 'utf8') + if (buffer.byteLength <= MOBILE_FILE_READ_MAX_BYTES) { + return { content, truncated: false, byteLength: buffer.byteLength } + } + return { + content: buffer.subarray(0, MOBILE_FILE_READ_MAX_BYTES).toString('utf8'), + truncated: true, + byteLength: buffer.byteLength + } +} diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts new file mode 100644 index 00000000000..9c405a04599 --- /dev/null +++ b/src/main/runtime/orca-runtime-git.ts @@ -0,0 +1,308 @@ +import type { + GitBranchCompareResult, + GitConflictOperation, + GitDiffResult, + GitPushTarget, + GitStatusResult, + GitUpstreamStatus, + GitWorktreeInfo, + Worktree +} from '../../shared/types' +import { getRemoteFileUrl } from '../git/repo' +import { + bulkStageFiles, + bulkUnstageFiles, + commitChanges, + detectConflictOperation, + discardChanges, + getBranchCompare, + getBranchDiff, + getDiff, + getStatus as getGitStatus, + stageFile, + unstageFile +} from '../git/status' +import { getUpstreamStatus } from '../git/upstream' +import { gitFetch, gitPull, gitPush } from '../git/remote' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { normalizeRuntimeRelativePath } from './runtime-relative-paths' + +export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo } + +export type RuntimeGitCommandHost = { + resolveRuntimeGitTarget( + selector: string + ): Promise<{ worktree: ResolvedRuntimeGitWorktree; connectionId?: string }> +} + +export class RuntimeGitCommands { + constructor(private readonly host: RuntimeGitCommandHost) {} + + async getRuntimeGitStatus(worktreeSelector: string): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.getStatus(target.worktree.path) + } + return getGitStatus(target.worktree.path) + } + + async getRuntimeGitConflictOperation(worktreeSelector: string): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.detectConflictOperation(target.worktree.path) + } + return detectConflictOperation(target.worktree.path) + } + + async getRuntimeGitDiff( + worktreeSelector: string, + filePath: string, + staged: boolean, + compareAgainstHead?: boolean + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const relativePath = normalizeRuntimeRelativePath(filePath) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.getDiff(target.worktree.path, relativePath, staged, compareAgainstHead) + } + return getDiff(target.worktree.path, relativePath, staged, compareAgainstHead) + } + + async getRuntimeGitBranchCompare( + worktreeSelector: string, + baseRef: string + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.getBranchCompare(target.worktree.path, baseRef) + } + return getBranchCompare(target.worktree.path, baseRef) + } + + async getRuntimeGitUpstreamStatus(worktreeSelector: string): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.getUpstreamStatus(target.worktree.path) + } + return getUpstreamStatus(target.worktree.path) + } + + async fetchRuntimeGit(worktreeSelector: string): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + await provider.fetchRemote(target.worktree.path) + return { ok: true } + } + await gitFetch(target.worktree.path) + return { ok: true } + } + + async pullRuntimeGit(worktreeSelector: string): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + await provider.pullBranch(target.worktree.path) + return { ok: true } + } + await gitPull(target.worktree.path) + return { ok: true } + } + + async pushRuntimeGit( + worktreeSelector: string, + publish?: boolean, + pushTarget?: GitPushTarget + ): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + await provider.pushBranch(target.worktree.path, publish === true, pushTarget) + return { ok: true } + } + await gitPush(target.worktree.path, publish === true, pushTarget) + return { ok: true } + } + + async getRuntimeGitBranchDiff( + worktreeSelector: string, + compare: { mergeBase: string; headOid: string }, + filePath: string, + oldPath?: string + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const relativePath = normalizeRuntimeRelativePath(filePath) + const oldRelativePath = oldPath ? normalizeRuntimeRelativePath(oldPath) : undefined + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + const results = await provider.getBranchDiff(target.worktree.path, compare.mergeBase, { + includePatch: true, + filePath: relativePath, + oldPath: oldRelativePath + }) + return ( + results[0] ?? { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } + ) + } + return getBranchDiff(target.worktree.path, { + mergeBase: compare.mergeBase, + headOid: compare.headOid, + filePath: relativePath, + oldPath: oldRelativePath + }) + } + + async commitRuntimeGit( + worktreeSelector: string, + message: string + ): Promise<{ success: boolean; error?: string }> { + if (message.trim().length === 0) { + throw new Error('Commit message is required') + } + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.commit(target.worktree.path, message) + } + return commitChanges(target.worktree.path, message) + } + + async stageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const relativePath = normalizeRuntimeRelativePath(filePath) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + await provider.stageFile(target.worktree.path, relativePath) + return { ok: true } + } + await stageFile(target.worktree.path, relativePath) + return { ok: true } + } + + async unstageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const relativePath = normalizeRuntimeRelativePath(filePath) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + await provider.unstageFile(target.worktree.path, relativePath) + return { ok: true } + } + await unstageFile(target.worktree.path, relativePath) + return { ok: true } + } + + async bulkStageRuntimeGitPaths( + worktreeSelector: string, + filePaths: string[] + ): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const relativePaths = filePaths.map((path) => normalizeRuntimeRelativePath(path)) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + await provider.bulkStageFiles(target.worktree.path, relativePaths) + return { ok: true } + } + await bulkStageFiles(target.worktree.path, relativePaths) + return { ok: true } + } + + async bulkUnstageRuntimeGitPaths( + worktreeSelector: string, + filePaths: string[] + ): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const relativePaths = filePaths.map((path) => normalizeRuntimeRelativePath(path)) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + await provider.bulkUnstageFiles(target.worktree.path, relativePaths) + return { ok: true } + } + await bulkUnstageFiles(target.worktree.path, relativePaths) + return { ok: true } + } + + async discardRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const relativePath = normalizeRuntimeRelativePath(filePath) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + await provider.discardChanges(target.worktree.path, relativePath) + return { ok: true } + } + await discardChanges(target.worktree.path, relativePath) + return { ok: true } + } + + async getRuntimeGitRemoteFileUrl( + worktreeSelector: string, + relativePath: string, + line: number + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const normalizedRelativePath = normalizeRuntimeRelativePath(relativePath) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.getRemoteFileUrl(target.worktree.path, normalizedRelativePath, line) + } + return getRemoteFileUrl(target.worktree.path, normalizedRelativePath, line) + } +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index cfba17c75c0..d1b3f1b2e54 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -5,11 +5,19 @@ import { addWorktree, listWorktrees, removeWorktree } from '../git/worktree' import { createSetupRunnerScript, getEffectiveHooks, + hasHooksFile, + parseOrcaYaml, runHook, shouldRunSetupForCreate } from '../hooks' +import { getDefaultBaseRef } from '../git/repo' import { OrchestrationDb } from './orchestration/db' import { OrcaRuntimeService } from './orca-runtime' +import { + registerSshFilesystemProvider, + unregisterSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' +import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' vi.mock('electron', () => ({ app: { @@ -62,7 +70,8 @@ vi.mock('../hooks', () => ({ .fn() .mockImplementation((_repo: never, decision: string) => decision === 'run'), getEffectiveSetupRunPolicy: vi.fn().mockReturnValue('auto'), - hasHooksFile: vi.fn().mockReturnValue(false) + hasHooksFile: vi.fn().mockReturnValue(false), + parseOrcaYaml: vi.fn().mockReturnValue(null) })) vi.mock('../ipc/worktree-logic', async (importOriginal) => { @@ -99,10 +108,14 @@ afterEach(() => { vi.mocked(removeWorktree).mockReset() vi.mocked(createSetupRunnerScript).mockReset() vi.mocked(getEffectiveHooks).mockReset() + vi.mocked(hasHooksFile).mockReset() + vi.mocked(parseOrcaYaml).mockReset() vi.mocked(runHook).mockReset() vi.mocked(shouldRunSetupForCreate).mockReset() vi.mocked(shouldRunSetupForCreate).mockImplementation((_repo, decision) => decision === 'run') vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(hasHooksFile).mockReturnValue(false) + vi.mocked(parseOrcaYaml).mockReturnValue(null) computeWorktreePathMock.mockReset() ensurePathWithinWorkspaceMock.mockReset() invalidateAuthorizedRootsCacheMock.mockReset() @@ -143,6 +156,20 @@ function createRuntime(): OrcaRuntimeService { return new OrcaRuntimeService(store) } +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + const store = { getRepo: (id: string) => store.getRepos().find((repo) => repo.id === id), getRepos: () => [ @@ -224,10 +251,15 @@ describe('OrcaRuntimeService', () => { expect(runtime.getRuntimeId()).toBeTruthy() }) - it('reports protocol version and minimum compatible mobile version on status', () => { + it('reports runtime protocol, capabilities, and mobile aliases on status', () => { const runtime = createRuntime() const status = runtime.getStatus() + expect(typeof status.runtimeProtocolVersion).toBe('number') + expect(typeof status.minCompatibleRuntimeClientVersion).toBe('number') + expect(status.runtimeProtocolVersion).toBe(status.protocolVersion) + expect(status.minCompatibleRuntimeClientVersion).toBe(status.minCompatibleMobileVersion) + expect(status.capabilities).toContain('terminal.binary-stream.v1') expect(typeof status.protocolVersion).toBe('number') expect(typeof status.minCompatibleMobileVersion).toBe('number') expect(status.protocolVersion).toBeGreaterThanOrEqual(1) @@ -354,12 +386,481 @@ describe('OrcaRuntimeService', () => { }) }) + it('routes SSH-backed forward-slash UNC file and git paths without collapsing the root', async () => { + vi.mocked(listWorktrees).mockClear() + vi.mocked(listWorktrees).mockRejectedValue(new Error('local git should not run for SSH repos')) + const remoteStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: '//Server/Share/Repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + ], + getRepo: () => ({ + id: TEST_REPO_ID, + path: '//Server/Share/Repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + }) + } + const fsProvider = { readDir: vi.fn().mockResolvedValue([]) } + const gitProvider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '//Server/Share/Repo', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: false + } + ]), + getStatus: vi.fn().mockResolvedValue({ + branch: 'feature/foo', + files: [], + ahead: 0, + behind: 0, + hasConflicts: false + }) + } + registerSshFilesystemProvider('ssh-1', fsProvider as never) + registerSshGitProvider('ssh-1', gitProvider as never) + const runtime = new OrcaRuntimeService(remoteStore as never) + + try { + await runtime.readFileExplorerDir('path://server/share/repo', 'src') + await runtime.getRuntimeGitStatus('path://server/share/repo') + await expect(runtime.showRepo('path://server/share/repo')).resolves.toMatchObject({ + path: '//Server/Share/Repo' + }) + } finally { + unregisterSshFilesystemProvider('ssh-1') + unregisterSshGitProvider('ssh-1') + } + + expect(listWorktrees).not.toHaveBeenCalled() + expect(gitProvider.listWorktrees).toHaveBeenCalledWith('//Server/Share/Repo') + expect(fsProvider.readDir).toHaveBeenCalledWith('\\\\Server\\Share\\Repo\\src') + expect(gitProvider.getStatus).toHaveBeenCalledWith('//Server/Share/Repo') + }) + it('does not interpret active as a runtime-global worktree selector', async () => { const runtime = new OrcaRuntimeService(store) await expect(runtime.showManagedWorktree('active')).rejects.toThrow('selector_not_found') }) + it('does not reuse stale in-flight worktree scans after creating a worktree', async () => { + const runtime = new OrcaRuntimeService(store) + const staleScan = deferred() + const createdWorktree = { + path: '/tmp/workspaces/cache-race', + head: 'def', + branch: 'cache-race', + isBare: false, + isMainWorktree: false + } + computeWorktreePathMock.mockReturnValue(createdWorktree.path) + ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path) + vi.mocked(listWorktrees) + .mockImplementationOnce(() => staleScan.promise) + .mockResolvedValueOnce([createdWorktree]) + .mockResolvedValueOnce([...MOCK_GIT_WORKTREES, createdWorktree]) + + const staleLookup = runtime.showManagedWorktree(TEST_WORKTREE_ID) + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'cache-race' + }) + const freshLookup = runtime.showManagedWorktree(result.worktree.id) + + staleScan.resolve(MOCK_GIT_WORKTREES) + + await expect(staleLookup).resolves.toMatchObject({ id: TEST_WORKTREE_ID }) + await expect(freshLookup).resolves.toMatchObject({ + id: result.worktree.id, + path: createdWorktree.path + }) + }) + + it('does not run local git when runtime worktree creation targets an SSH repo', async () => { + vi.mocked(listWorktrees).mockClear() + vi.mocked(addWorktree).mockClear() + const remoteStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + ] + } + const runtime = new OrcaRuntimeService(remoteStore as never) + + await expect( + runtime.createManagedWorktree({ repoSelector: TEST_REPO_ID, name: 'feature' }) + ).rejects.toThrow('SSH-backed worktree creation is not supported through runtime RPC yet') + + expect(addWorktree).not.toHaveBeenCalled() + expect(listWorktrees).not.toHaveBeenCalled() + }) + + it('removes SSH-backed runtime worktrees through the SSH git provider', async () => { + vi.mocked(listWorktrees).mockClear() + const remoteStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + ], + getRepo: () => ({ + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + }) + } + const gitProvider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: true + } + ]), + removeWorktree: vi.fn().mockResolvedValue(undefined) + } + registerSshGitProvider('ssh-1', gitProvider as never) + const runtime = new OrcaRuntimeService(remoteStore as never) + + try { + await runtime.removeManagedWorktree('path:/remote/repo', true) + } finally { + unregisterSshGitProvider('ssh-1') + } + + expect(gitProvider.removeWorktree).toHaveBeenCalledWith('/remote/repo', true) + expect(removeWorktree).not.toHaveBeenCalled() + expect(listWorktrees).not.toHaveBeenCalled() + }) + + it('reads SSH repo hooks through the SSH filesystem provider', async () => { + const remoteStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: 'C:/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + ] + } + const fsProvider = { + readFile: vi.fn().mockResolvedValue({ + content: 'scripts:\n setup: pnpm install\n', + isBinary: false + }) + } + vi.mocked(parseOrcaYaml).mockReturnValue({ scripts: { setup: 'pnpm install' } }) + registerSshFilesystemProvider('ssh-1', fsProvider as never) + const runtime = new OrcaRuntimeService(remoteStore as never) + + try { + await expect(runtime.getRepoHooks('id:repo-1')).resolves.toMatchObject({ + hasHooksFile: true, + hooks: { scripts: { setup: 'pnpm install' } }, + source: 'orca.yaml' + }) + } finally { + unregisterSshFilesystemProvider('ssh-1') + } + + expect(fsProvider.readFile).toHaveBeenCalledWith('C:\\remote\\repo\\.orca.yaml') + expect(hasHooksFile).not.toHaveBeenCalled() + expect(getEffectiveHooks).not.toHaveBeenCalled() + }) + + it('uses remote path joins for SSH hook checks and issue-command files', async () => { + const remoteStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: 'C:/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + ] + } + const fsProvider = { + readFile: vi.fn(async (filePath: string) => ({ + content: filePath.includes('.orca.yaml') + ? 'scripts:\n setup: pnpm install\n' + : filePath.endsWith('.gitignore') + ? 'node_modules\n' + : 'Fix it', + isBinary: false + })), + writeFile: vi.fn().mockResolvedValue(undefined), + createDir: vi.fn().mockResolvedValue(undefined), + deletePath: vi.fn().mockResolvedValue(undefined) + } + registerSshFilesystemProvider('ssh-1', fsProvider as never) + const runtime = new OrcaRuntimeService(remoteStore as never) + + try { + await expect(runtime.checkRepoHooks('id:repo-1')).resolves.toMatchObject({ + hasHooks: true, + mayNeedUpdate: false + }) + await expect(runtime.readRepoIssueCommand('id:repo-1')).resolves.toMatchObject({ + localContent: 'Fix it', + effectiveContent: 'Fix it', + localFilePath: 'C:\\remote\\repo\\.orca\\issue-command' + }) + await expect(runtime.writeRepoIssueCommand('id:repo-1', 'Ship it')).resolves.toEqual({ + ok: true + }) + } finally { + unregisterSshFilesystemProvider('ssh-1') + } + + expect(fsProvider.readFile).toHaveBeenCalledWith('C:\\remote\\repo\\.orca.yaml') + expect(fsProvider.readFile).toHaveBeenCalledWith('C:\\remote\\repo\\.orca\\issue-command') + expect(fsProvider.createDir).toHaveBeenCalledWith('C:\\remote\\repo\\.orca') + expect(fsProvider.writeFile).toHaveBeenCalledWith( + 'C:\\remote\\repo\\.orca\\issue-command', + 'Ship it\n' + ) + expect(fsProvider.writeFile).toHaveBeenCalledWith( + 'C:\\remote\\repo\\.gitignore', + 'node_modules\n.orca\n' + ) + }) + + it('resolves SSH issue commands from shared orca.yaml and deletes empty overrides', async () => { + const remoteStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + ] + } + vi.mocked(parseOrcaYaml).mockReturnValue({ + scripts: {}, + issueCommand: 'claude -p "Fix #{{issue}}"' + }) + const fsProvider = { + readFile: vi.fn(async (filePath: string) => { + if (filePath.endsWith('.orca/issue-command')) { + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + } + if (filePath.endsWith('orca.yaml')) { + return { content: 'issueCommand: claude -p "Fix #{{issue}}"', isBinary: false } + } + return { content: '', isBinary: false } + }), + writeFile: vi.fn().mockResolvedValue(undefined), + createDir: vi.fn().mockResolvedValue(undefined), + deletePath: vi.fn().mockResolvedValue(undefined) + } + registerSshFilesystemProvider('ssh-1', fsProvider as never) + const runtime = new OrcaRuntimeService(remoteStore as never) + + try { + await expect(runtime.readRepoIssueCommand('id:repo-1')).resolves.toMatchObject({ + localContent: null, + sharedContent: 'claude -p "Fix #{{issue}}"', + effectiveContent: 'claude -p "Fix #{{issue}}"', + localFilePath: '/remote/repo/.orca/issue-command', + source: 'shared' + }) + await expect(runtime.writeRepoIssueCommand('id:repo-1', ' ')).resolves.toEqual({ + ok: true + }) + } finally { + unregisterSshFilesystemProvider('ssh-1') + } + + expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml') + expect(fsProvider.deletePath).toHaveBeenCalledWith('/remote/repo/.orca/issue-command', false) + expect(fsProvider.writeFile).not.toHaveBeenCalledWith( + '/remote/repo/.orca/issue-command', + expect.anything() + ) + }) + + it('rejects host integration helpers for SSH repos instead of using remote paths locally', async () => { + const remoteStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + ] + } + const runtime = new OrcaRuntimeService(remoteStore as never) + + await expect(runtime.getRepoSlug('id:repo-1')).rejects.toThrow( + 'repo_slug_unsupported_for_ssh_repo' + ) + await expect(runtime.listRepoWorkItems('id:repo-1')).rejects.toThrow( + 'repo_work_items_unsupported_for_ssh_repo' + ) + }) + + it('treats SSH worktree drift as unknown without local git probes', async () => { + vi.mocked(listWorktrees).mockClear() + vi.mocked(getDefaultBaseRef).mockClear() + const remoteStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + ], + getWorktreeMeta: () => null + } + const gitProvider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: true + } + ]) + } + registerSshGitProvider('ssh-1', gitProvider as never) + const runtime = new OrcaRuntimeService(remoteStore as never) + + try { + await expect(runtime.probeWorktreeDrift('path:/remote/repo')).resolves.toBeNull() + } finally { + unregisterSshGitProvider('ssh-1') + } + + expect(gitProvider.listWorktrees).toHaveBeenCalledWith('/remote/repo') + expect(getDefaultBaseRef).not.toHaveBeenCalled() + expect(listWorktrees).not.toHaveBeenCalled() + }) + + it('deduplicates runtime repo paths with Windows/UNC comparison semantics', async () => { + const added: Record[] = [] + const uncStore = { + ...store, + getRepos: () => [ + { + id: 'repo-unc', + path: '//Server/Share/Repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + kind: 'folder' + }, + ...added + ], + addRepo: (repo: Record) => { + added.push(repo) + }, + getRepo: (id: string) => [...uncStore.getRepos()].find((repo) => repo.id === id) as never + } + const runtime = new OrcaRuntimeService(uncStore as never) + + const repo = await runtime.addRepo('//server/share/repo', 'folder') + + expect(repo).toMatchObject({ id: 'repo-unc', path: '//Server/Share/Repo' }) + expect(added).toHaveLength(0) + }) + + it('associates controller PTYs with mixed-case Windows and UNC cwd paths', async () => { + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: 'C:\\Repo', + head: 'abc', + branch: 'feature/windows', + isBare: false, + isMainWorktree: true + }, + { + path: '//Server/Share/Repo', + head: 'def', + branch: 'feature/unc', + isBare: false, + isMainWorktree: false + } + ]) + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { id: 'pty-windows', cwd: 'c:\\repo\\src', title: 'Windows shell' }, + { id: 'pty-unc', cwd: '//server/share/repo/src', title: 'UNC shell' } + ] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + const terminals = await runtime.listTerminals() + + expect(terminals.terminals).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + worktreeId: `${TEST_REPO_ID}::C:\\Repo`, + worktreePath: 'C:\\Repo' + }), + expect.objectContaining({ + worktreeId: `${TEST_REPO_ID}:://Server/Share/Repo`, + worktreePath: '//Server/Share/Repo' + }) + ]) + ) + }) + it('reads bounded terminal output and writes through the PTY controller', async () => { const writes: string[] = [] const runtime = new OrcaRuntimeService(store) @@ -1102,6 +1603,55 @@ describe('OrcaRuntimeService', () => { } }) + it('creates mobile session terminals in a headless runtime server', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-headless' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID, + preAllocatedHandle: expect.stringMatching(/^term_/) + }) + ) + expect(result.tab).toMatchObject({ + type: 'terminal', + status: 'ready', + terminal: expect.stringMatching(/^term_/), + isActive: true + }) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect(listed.tabs).toEqual([ + expect.objectContaining({ + id: result.tab.id, + status: 'ready', + terminal: result.tab.terminal + }) + ]) + }) + + it('reports browser tab creation as unsupported for headless runtime servers', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await expect( + runtime.browserTabCreate({ worktree: `id:${TEST_WORKTREE_ID}`, url: 'https://example.com' }) + ).rejects.toMatchObject({ + code: 'browser_error', + message: expect.stringContaining('headless orca serve') + }) + }) + it('keeps already-idle status after tui-idle wait for immediate message delivery', async () => { const runtime = new OrcaRuntimeService(store) const db = new OrchestrationDb(':memory:') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2b053f310dc..38fa678ec92 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1,4 +1,4 @@ -/* eslint-disable max-lines -- Why: the Orca runtime is the authoritative live control plane for the CLI, so handle validation, selector resolution, wait state, and summaries are kept together to avoid split-brain behavior. */ +/* eslint-disable max-lines -- Why: OrcaRuntimeService still owns the mutable live graph, PTY handles, waiters, mobile floor/layout state, and managed-worktree reconciliation. Stateless browser and file command adapters live beside it; the remaining split points need state-owner extraction before enforcing max-lines. */ /* eslint-disable unicorn/no-useless-spread -- Why: waiter sets and handle keys are cloned intentionally before mutation so resolution and rejection can safely remove entries while iterating. */ /* eslint-disable no-control-regex -- Why: terminal normalization must strip ANSI and OSC control sequences from PTY output before returning bounded text to agents. */ import { @@ -7,29 +7,40 @@ import { isShellProcess } from '../../shared/agent-detection' import type { AgentStatus } from '../../shared/agent-detection' -import { gitExecFileAsync } from '../git/runner' +import { gitExecFileAsync, wslAwareSpawn } from '../git/runner' import { isWslPath, parseWslPath, getWslHome } from '../wsl' import { createHash, randomUUID } from 'crypto' -import { join, posix, win32 } from 'path' -import { open, rm, stat } from 'fs/promises' +import { basename, isAbsolute, join } from 'path' +import { mkdir, readdir, rm, stat } from 'fs/promises' import { OrchestrationDb } from './orchestration/db' import { formatMessagesForInjection } from './orchestration/formatter' import type { CreateWorktreeResult, + GitPushTarget, + GitWorktreeInfo, GlobalSettings, Repo, StatsSummary, + Worktree, + WorktreeMeta, WorktreeBaseStatusEvent, WorktreeRemoteBranchConflictEvent, - WorktreeStartupLaunch + WorktreeStartupLaunch, + LinearIssueUpdate, + TuiAgent } from '../../shared/types' -import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id' +import { splitWorktreeId } from '../../shared/worktree-id' import { isFolderRepo } from '../../shared/repo-kind' import { buildSetupRunnerCommand } from '../../shared/setup-runner-command' import { FIRST_PANE_ID } from '../../shared/pane-key' import { - DESKTOP_PROTOCOL_VERSION, - MIN_COMPATIBLE_MOBILE_VERSION + isPathInsideOrEqual, + normalizeRuntimePathForComparison +} from '../../shared/cross-platform-path' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_CAPABILITIES, + RUNTIME_PROTOCOL_VERSION } from '../../shared/protocol-version' import type { RuntimeGraphStatus, @@ -57,87 +68,146 @@ import type { RuntimeMobileSessionCreateTerminalResult, RuntimeMobileSessionClientTab, RuntimeMobileSessionMarkdownTab, + RuntimeMobileSessionTerminalTab, RuntimeMobileSessionTabsRemovedResult, RuntimeMobileSessionTabsResult, RuntimeMobileSessionTabsSnapshot, - RuntimeFileListResult, - RuntimeFileOpenResult, - RuntimeFileReadResult, RuntimeSyncWindowGraph, - RuntimeWorktreeListResult, - BrowserSnapshotResult, - BrowserClickResult, - BrowserGotoResult, - BrowserFillResult, - BrowserTypeResult, - BrowserSelectResult, - BrowserScrollResult, - BrowserBackResult, - BrowserReloadResult, - BrowserProfileCreateResult, - BrowserProfileDeleteResult, - BrowserProfileListResult, - BrowserScreenshotResult, - BrowserEvalResult, - BrowserTabCurrentResult, - BrowserTabListResult, - BrowserTabProfileCloneResult, - BrowserTabProfileShowResult, - BrowserTabSetProfileResult, - BrowserTabShowResult, - BrowserTabSwitchResult, - BrowserHoverResult, - BrowserDragResult, - BrowserUploadResult, - BrowserWaitResult, - BrowserCheckResult, - BrowserFocusResult, - BrowserClearResult, - BrowserSelectAllResult, - BrowserKeypressResult, - BrowserPdfResult, - BrowserCookieGetResult, - BrowserCookieSetResult, - BrowserCookieDeleteResult, - BrowserViewportResult, - BrowserGeolocationResult, - BrowserInterceptEnableResult, - BrowserInterceptDisableResult, - BrowserCaptureStartResult, - BrowserCaptureStopResult, - BrowserConsoleResult, - BrowserNetworkLogResult + RuntimeWorktreeListResult } from '../../shared/runtime-types' +import { RuntimeBrowserCommands } from './orca-runtime-browser' +import { RuntimeFileCommands } from './orca-runtime-files' +import { RuntimeGitCommands } from './orca-runtime-git' +import { joinWorktreeRelativePath } from './runtime-relative-paths' import { app, BrowserWindow, ipcMain } from 'electron' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' -import { browserManager } from '../browser/browser-manager' -import { BrowserError } from '../browser/cdp-bridge' -import { browserSessionRegistry } from '../browser/browser-session-registry' -import { waitForTabRegistration } from '../ipc/browser' -import { getPRForBranch } from '../github/client' +import { + getPRForBranch, + getPullRequestPushTarget, + getRepoSlug, + getWorkItem, + listWorkItems, + countWorkItems, + getPRChecks, + getPRComments, + getIssue, + resolveReviewThread, + getWorkItemByOwnerRepo, + updatePRTitle, + mergePR, + createIssue, + updateIssue, + addIssueComment, + addPRReviewComment, + addPRReviewCommentReply, + listLabels, + listAssignableUsers +} from '../github/client' +import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details' +import { getRateLimit } from '../github/rate-limit' +import type { + GitHubIssueUpdate, + GitHubPRFile, + GitHubPRReviewCommentInput +} from '../../shared/types' +import type { HostedReviewInfo } from '../../shared/hosted-review' +import { getHostedReviewForBranch as getHostedReviewForBranchFromRepo } from '../source-control/hosted-review' +import { + connect as connectLinear, + disconnect as disconnectLinear, + getStatus as getLinearStatus, + testConnection as testLinearConnection +} from '../linear/client' +import { + addIssueComment as addLinearIssueComment, + createIssue as createLinearIssue, + getIssue as getLinearIssue, + getIssueComments as getLinearIssueComments, + listIssues as listLinearIssues, + searchIssues as searchLinearIssues, + updateIssue as updateLinearIssue, + type LinearListFilter +} from '../linear/issues' +import { + getTeamLabels as getLinearTeamLabels, + getTeamMembers as getLinearTeamMembers, + getTeamStates as getLinearTeamStates, + listTeams as listLinearTeams +} from '../linear/teams' +import { + clearProjectItemFieldValue, + getProjectViewTable, + listAccessibleProjects, + listProjectViews, + resolveProjectRef, + addIssueCommentBySlug, + deleteIssueCommentBySlug, + listAssignableUsersBySlug, + listIssueTypesBySlug, + listLabelsBySlug, + updateIssueCommentBySlug, + updateIssueBySlug, + updateIssueTypeBySlug, + updateProjectItemFieldValue, + updatePullRequestBySlug +} from '../github/project-view' +import type { + ClearProjectItemFieldArgs, + GetProjectViewTableArgs, + ListAssignableUsersBySlugArgs, + ListIssueTypesBySlugArgs, + ListLabelsBySlugArgs, + ListProjectViewsArgs, + ResolveProjectRefArgs, + AddIssueCommentBySlugArgs, + DeleteIssueCommentBySlugArgs, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdateProjectItemFieldArgs, + UpdatePullRequestBySlugArgs +} from '../../shared/github-project-types' import { getGitUsername, + getBaseRefDefault, getDefaultBaseRef, + getDefaultRemote, getBranchConflictKind, isGitRepo, getRepoName, searchBaseRefs, + getRemoteCount, + normalizeRefSearchQuery, + parseAndFilterSearchRefs, + parseRemoteCount, + resolveDefaultBaseRefViaExec, + buildSearchBaseRefsArgv, getRemoteDrift, getRecentDriftSubjects } from '../git/repo' -import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree' -import { listQuickOpenFiles } from '../ipc/filesystem-list-files' -import { resolveAuthorizedPath } from '../ipc/filesystem-auth' +import { listWorktrees, addWorktree, addSparseWorktree, removeWorktree } from '../git/worktree' +import { isENOENT } from '../ipc/filesystem-auth' import { createSetupRunnerScript, getEffectiveHooks, getEffectiveSetupRunPolicy, + hasUnrecognizedOrcaYamlKeys, hasHooksFile, + loadHooks, + parseOrcaYaml, + readIssueCommand, runHook, - shouldRunSetupForCreate + shouldRunSetupForCreate, + writeIssueCommand } from '../hooks' import { REPO_COLORS, getDefaultVoiceSettings } from '../../shared/constants' import { listRepoWorktrees } from '../repo-worktrees' +import { createWorktreeSymlinks } from '../ipc/worktree-symlinks' +import { + configureCreatedWorktreePushTarget, + prepareWorktreePushTarget +} from '../ipc/worktree-remote' +import { normalizeSparseDirectories } from '../ipc/sparse-checkout-directories' import type { Store } from '../persistence' import type { StatsCollector } from '../stats/collector' import { AgentDetector } from '../stats/agent-detector' @@ -156,8 +226,9 @@ import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { HeadlessEmulator } from '../daemon/headless-emulator' import { killAllProcessesForWorktree } from './worktree-teardown' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' -import type { IPtyProvider } from '../providers/types' +import type { IFilesystemProvider, IPtyProvider } from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' import type { ClaudeAccountService } from '../claude-accounts/service' import type { CodexAccountService } from '../codex-accounts/service' import type { RateLimitService } from '../rate-limits/service' @@ -170,6 +241,8 @@ import type { NoteDeleteArgs, NoteDeleteResult, NoteLinkArgs, + NoteLink, + NoteLinkKind, NoteListArgs, NoteListResult, NoteMutationResult, @@ -210,6 +283,8 @@ type RuntimeStore = { getRepo: Store['getRepo'] addRepo: Store['addRepo'] updateRepo: Store['updateRepo'] + removeRepo?: Store['removeRepo'] + reorderRepos?: Store['reorderRepos'] getAllWorktreeMeta: Store['getAllWorktreeMeta'] getWorktreeMeta: Store['getWorktreeMeta'] setWorktreeMeta: Store['setWorktreeMeta'] @@ -222,6 +297,7 @@ type RuntimeStore = { refreshLocalBaseRefOnWorktreeCreate: boolean branchPrefix: string branchPrefixCustom: string + experimentalWorktreeSymlinks?: boolean mobileAutoRestoreFitMs?: number | null voice?: VoiceSettings } @@ -290,6 +366,7 @@ type RuntimePtyController = { write(ptyId: string, data: string): boolean kill(ptyId: string): boolean getForegroundProcess(ptyId: string): Promise + hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean listProcesses?(): Promise<{ id: string; cwd: string; title: string }[]> @@ -304,25 +381,6 @@ type RuntimePtyController = { getSize?(ptyId: string): { cols: number; rows: number } | null } -const MOBILE_FILE_LIST_LIMIT = 5000 -const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024 -const MOBILE_BINARY_EXTENSIONS = new Set([ - '.avif', - '.bmp', - '.gif', - '.heic', - '.ico', - '.jpeg', - '.jpg', - '.mov', - '.mp3', - '.mp4', - '.pdf', - '.png', - '.webp', - '.zip' -]) - type RuntimeNotifier = { worktreesChanged(repoId: string): void worktreeBaseStatus?(event: WorktreeBaseStatusEvent): void @@ -403,38 +461,24 @@ type MessageWaiter = { timeout: NodeJS.Timeout | null } -type ResolvedWorktree = { - id: string - repoId: string - path: string - branch: string - linkedIssue: number | null - git: { - path: string - head: string - branch: string - isBare: boolean - isMainWorktree: boolean - } - displayName: string - comment: string +function omitUndefinedProperties>(value: T): Partial { + return Object.fromEntries( + Object.entries(value).filter(([, entry]) => entry !== undefined) + ) as Partial } -type BrowserCommandTargetParams = { - worktree?: string - page?: string -} - -type ResolvedBrowserCommandTarget = { - worktreeId?: string - browserPageId?: string -} +type ResolvedWorktree = Worktree & { git: GitWorktreeInfo } type ResolvedWorktreeCache = { expiresAt: number worktrees: ResolvedWorktree[] } +type ResolvedWorktreeInFlight = { + generation: number + promise: Promise +} + export type MobileNotificationEvent = { source: 'agent-task-complete' | 'terminal-bell' | 'test' title: string @@ -510,7 +554,8 @@ export class OrcaRuntimeService { private notifier: RuntimeNotifier | null = null private agentBrowserBridge: AgentBrowserBridge | null = null private resolvedWorktreeCache: ResolvedWorktreeCache | null = null - private resolvedWorktreeInFlight: Promise | null = null + private resolvedWorktreeInFlight: ResolvedWorktreeInFlight | null = null + private resolvedWorktreeGeneration = 0 private agentDetector: AgentDetector | null = null private _orchestrationDb: OrchestrationDb | null = null private messageWaitersByHandle = new Map>() @@ -525,6 +570,7 @@ export class OrcaRuntimeService { string, Set<(event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void> >() + private driverListeners = new Map void>>() private subscriptionCleanups = new Map void>() // Why: index of subscriptionIds by per-WebSocket connectionId so the // server can sweep all subscriptions for a closing socket without @@ -814,8 +860,11 @@ export class OrcaRuntimeService { authoritativeWindowId: this.authoritativeWindowId, liveTabCount: this.tabs.size, liveLeafCount: this.leaves.size, - protocolVersion: DESKTOP_PROTOCOL_VERSION, - minCompatibleMobileVersion: MIN_COMPATIBLE_MOBILE_VERSION + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: [...RUNTIME_CAPABILITIES], + protocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleMobileVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION } } @@ -989,7 +1038,12 @@ export class OrcaRuntimeService { throw new Error('tab_not_found') } if (tab.type === 'terminal') { - this.notifier?.closeTerminal(tab.parentTabId) + const pty = this.findPtyForMobileTerminalTab(tab) + if (pty) { + this.ptyController?.kill(pty.ptyId) + } else { + this.notifier?.closeTerminal(tab.parentTabId) + } } else { this.notifier?.closeSessionTab?.(tab.id, worktreeId) } @@ -1020,117 +1074,112 @@ export class OrcaRuntimeService { return await this.notifier.saveMobileMarkdownTab(worktreeId, tabId, baseVersion, content) } - async listMobileFiles(worktreeSelector: string): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') + private readonly fileCommands = new RuntimeFileCommands({ + getRuntimeId: () => this.runtimeId, + requireStore: () => this.requireStore(), + resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), + resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector), + openFile: (worktreeId, filePath, relativePath) => { + if (!this.notifier?.openFile) { + throw new Error('renderer_unavailable') + } + this.notifier.openFile(worktreeId, filePath, relativePath) } + }) + + listMobileFiles: RuntimeFileCommands['listMobileFiles'] = this.fileCommands.listMobileFiles.bind( + this.fileCommands + ) + openMobileFile: RuntimeFileCommands['openMobileFile'] = this.fileCommands.openMobileFile.bind( + this.fileCommands + ) + readMobileFile: RuntimeFileCommands['readMobileFile'] = this.fileCommands.readMobileFile.bind( + this.fileCommands + ) + readFileExplorerDir: RuntimeFileCommands['readFileExplorerDir'] = + this.fileCommands.readFileExplorerDir.bind(this.fileCommands) + watchFileExplorer: RuntimeFileCommands['watchFileExplorer'] = + this.fileCommands.watchFileExplorer.bind(this.fileCommands) + readFileExplorerPreview: RuntimeFileCommands['readFileExplorerPreview'] = + this.fileCommands.readFileExplorerPreview.bind(this.fileCommands) + writeFileExplorerFile: RuntimeFileCommands['writeFileExplorerFile'] = + this.fileCommands.writeFileExplorerFile.bind(this.fileCommands) + writeFileExplorerFileBase64: RuntimeFileCommands['writeFileExplorerFileBase64'] = + this.fileCommands.writeFileExplorerFileBase64.bind(this.fileCommands) + writeFileExplorerFileBase64Chunk: RuntimeFileCommands['writeFileExplorerFileBase64Chunk'] = + this.fileCommands.writeFileExplorerFileBase64Chunk.bind(this.fileCommands) + createFileExplorerFile: RuntimeFileCommands['createFileExplorerFile'] = + this.fileCommands.createFileExplorerFile.bind(this.fileCommands) + createFileExplorerDir: RuntimeFileCommands['createFileExplorerDir'] = + this.fileCommands.createFileExplorerDir.bind(this.fileCommands) + createFileExplorerDirNoClobber: RuntimeFileCommands['createFileExplorerDirNoClobber'] = + this.fileCommands.createFileExplorerDirNoClobber.bind(this.fileCommands) + commitFileExplorerUpload: RuntimeFileCommands['commitFileExplorerUpload'] = + this.fileCommands.commitFileExplorerUpload.bind(this.fileCommands) + renameFileExplorerPath: RuntimeFileCommands['renameFileExplorerPath'] = + this.fileCommands.renameFileExplorerPath.bind(this.fileCommands) + copyFileExplorerPath: RuntimeFileCommands['copyFileExplorerPath'] = + this.fileCommands.copyFileExplorerPath.bind(this.fileCommands) + deleteFileExplorerPath: RuntimeFileCommands['deleteFileExplorerPath'] = + this.fileCommands.deleteFileExplorerPath.bind(this.fileCommands) + searchRuntimeFiles: RuntimeFileCommands['searchRuntimeFiles'] = + this.fileCommands.searchRuntimeFiles.bind(this.fileCommands) + listRuntimeFiles: RuntimeFileCommands['listRuntimeFiles'] = + this.fileCommands.listRuntimeFiles.bind(this.fileCommands) + listRuntimeMarkdownDocuments: RuntimeFileCommands['listRuntimeMarkdownDocuments'] = + this.fileCommands.listRuntimeMarkdownDocuments.bind(this.fileCommands) + statRuntimeFile: RuntimeFileCommands['statRuntimeFile'] = this.fileCommands.statRuntimeFile.bind( + this.fileCommands + ) + + private readonly gitCommands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector) + }) + + getRuntimeGitStatus: RuntimeGitCommands['getRuntimeGitStatus'] = + this.gitCommands.getRuntimeGitStatus.bind(this.gitCommands) + getRuntimeGitConflictOperation: RuntimeGitCommands['getRuntimeGitConflictOperation'] = + this.gitCommands.getRuntimeGitConflictOperation.bind(this.gitCommands) + getRuntimeGitDiff: RuntimeGitCommands['getRuntimeGitDiff'] = + this.gitCommands.getRuntimeGitDiff.bind(this.gitCommands) + getRuntimeGitBranchCompare: RuntimeGitCommands['getRuntimeGitBranchCompare'] = + this.gitCommands.getRuntimeGitBranchCompare.bind(this.gitCommands) + getRuntimeGitUpstreamStatus: RuntimeGitCommands['getRuntimeGitUpstreamStatus'] = + this.gitCommands.getRuntimeGitUpstreamStatus.bind(this.gitCommands) + fetchRuntimeGit: RuntimeGitCommands['fetchRuntimeGit'] = this.gitCommands.fetchRuntimeGit.bind( + this.gitCommands + ) + pullRuntimeGit: RuntimeGitCommands['pullRuntimeGit'] = this.gitCommands.pullRuntimeGit.bind( + this.gitCommands + ) + pushRuntimeGit: RuntimeGitCommands['pushRuntimeGit'] = this.gitCommands.pushRuntimeGit.bind( + this.gitCommands + ) + getRuntimeGitBranchDiff: RuntimeGitCommands['getRuntimeGitBranchDiff'] = + this.gitCommands.getRuntimeGitBranchDiff.bind(this.gitCommands) + commitRuntimeGit: RuntimeGitCommands['commitRuntimeGit'] = this.gitCommands.commitRuntimeGit.bind( + this.gitCommands + ) + stageRuntimeGitPath: RuntimeGitCommands['stageRuntimeGitPath'] = + this.gitCommands.stageRuntimeGitPath.bind(this.gitCommands) + unstageRuntimeGitPath: RuntimeGitCommands['unstageRuntimeGitPath'] = + this.gitCommands.unstageRuntimeGitPath.bind(this.gitCommands) + bulkStageRuntimeGitPaths: RuntimeGitCommands['bulkStageRuntimeGitPaths'] = + this.gitCommands.bulkStageRuntimeGitPaths.bind(this.gitCommands) + bulkUnstageRuntimeGitPaths: RuntimeGitCommands['bulkUnstageRuntimeGitPaths'] = + this.gitCommands.bulkUnstageRuntimeGitPaths.bind(this.gitCommands) + discardRuntimeGitPath: RuntimeGitCommands['discardRuntimeGitPath'] = + this.gitCommands.discardRuntimeGitPath.bind(this.gitCommands) + getRuntimeGitRemoteFileUrl: RuntimeGitCommands['getRuntimeGitRemoteFileUrl'] = + this.gitCommands.getRuntimeGitRemoteFileUrl.bind(this.gitCommands) + + private async resolveRuntimeGitTarget( + worktreeSelector: string + ): Promise<{ worktree: ResolvedWorktree; connectionId?: string }> { + const store = this.requireStore() const worktree = await this.resolveWorktreeSelector(worktreeSelector) - const repo = this.store.getRepo(worktree.repoId) - const connectionId = repo?.connectionId ?? undefined - const files = connectionId - ? await this.listRemoteMobileFiles(worktree.path, connectionId) - : await listQuickOpenFiles(worktree.path, this.store as unknown as Store) - const entries = files - .filter((relativePath) => isSafeMobileRelativePath(relativePath)) - .sort((a, b) => a.localeCompare(b)) - .slice(0, MOBILE_FILE_LIST_LIMIT) - .map((relativePath) => ({ - relativePath, - basename: basenameFromRelativePath(relativePath), - kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const) - })) - - return { - worktree: worktree.id, - rootPath: worktree.path, - files: entries, - totalCount: files.length, - truncated: files.length > MOBILE_FILE_LIST_LIMIT - } - } - - async openMobileFile( - worktreeSelector: string, - relativePath: string - ): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') - } - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - if (!isSafeMobileRelativePath(relativePath)) { - throw new Error('invalid_relative_path') - } - const kind = isMobileBinaryPath(relativePath) - ? 'binary' - : isMobileMarkdownPath(relativePath) - ? 'markdown' - : 'text' - if (kind === 'binary') { - return { worktree: worktree.id, relativePath, kind, opened: false } - } - if (!this.notifier?.openFile) { - throw new Error('renderer_unavailable') - } - const filePath = joinWorktreeRelativePath(worktree.path, relativePath) - this.notifier.openFile(worktree.id, filePath, relativePath) - return { worktree: worktree.id, relativePath, kind, opened: true } - } - - async readMobileFile( - worktreeSelector: string, - relativePath: string - ): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') - } - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - if (!isSafeMobileRelativePath(relativePath)) { - throw new Error('invalid_relative_path') - } - if (isMobileBinaryPath(relativePath)) { - throw new Error('binary_file') - } - - const repo = this.store.getRepo(worktree.repoId) - const filePath = joinWorktreeRelativePath(worktree.path, relativePath) - const content = repo?.connectionId - ? await this.readRemoteMobileFile(filePath, repo.connectionId) - : await readLocalMobileFile(filePath, this.store as unknown as Store) - const truncated = truncateMobileFilePreview(content) - - return { - worktree: worktree.id, - relativePath, - content: truncated.content, - truncated: truncated.truncated, - byteLength: truncated.byteLength - } - } - - private async listRemoteMobileFiles(rootPath: string, connectionId: string): Promise { - const provider = getSshFilesystemProvider(connectionId) - if (!provider) { - return [] - } - return provider.listFiles(rootPath) - } - - private async readRemoteMobileFile(filePath: string, connectionId: string): Promise { - const provider = getSshFilesystemProvider(connectionId) - if (!provider) { - throw new Error('remote_filesystem_unavailable') - } - const fileStat = await provider.stat(filePath) - // Why: the SSH filesystem API does not expose ranged reads here, so reject - // oversized remote previews instead of streaming a large file just to trim it. - if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) { - throw new Error('file_too_large') - } - const result = await provider.readFile(filePath) - if (result.isBinary) { - throw new Error('binary_file') - } - return result.content + const repo = store.getRepo(worktree.repoId) + return { worktree, connectionId: repo?.connectionId ?? undefined } } onMobileSessionTabsChanged( @@ -1319,6 +1368,21 @@ export class OrcaRuntimeService { } } + subscribeToDriverChanges(ptyId: string, listener: (driver: DriverState) => void): () => void { + let listeners = this.driverListeners.get(ptyId) + if (!listeners) { + listeners = new Set() + this.driverListeners.set(ptyId, listeners) + } + listeners.add(listener) + return () => { + listeners.delete(listener) + if (listeners.size === 0) { + this.driverListeners.delete(ptyId) + } + } + } + private notifyFitOverrideListeners( ptyId: string, mode: 'mobile-fit' | 'desktop-fit', @@ -1619,9 +1683,7 @@ export class OrcaRuntimeService { // listener leaks in dataListeners and duplicates every PTY data event. const existing = this.subscriptionCleanups.get(subscriptionId) if (existing) { - existing() - // Why: existing() already evicts itself from the per-connection index - // via cleanupSubscription, so no extra bookkeeping is needed here. + this.cleanupSubscription(subscriptionId) } this.subscriptionCleanups.set(subscriptionId, cleanup) if (connectionId) { @@ -2318,6 +2380,12 @@ export class OrcaRuntimeService { this.currentDriver.set(ptyId, next) } this.notifier?.terminalDriverChanged(ptyId, next) + const listeners = this.driverListeners.get(ptyId) + if (listeners) { + for (const listener of listeners) { + listener(next) + } + } } // Why: invoked from mobile RPC method handlers (terminal.send / setDisplayMode / @@ -2398,6 +2466,36 @@ export class OrcaRuntimeService { return true } + // Why: remote desktop clients do not have the local `pty:resize` IPC path. + // Their measured xterm size still has to resize the source PTY so TUIs + // reflow to the visible client dimensions. + async updateDesktopViewport( + ptyId: string, + viewport: { cols: number; rows: number } + ): Promise { + if ( + this.isResizeSuppressed() || + this.getDriver(ptyId).kind === 'mobile' || + this.terminalFitOverrides.has(ptyId) + ) { + return false + } + const cols = Math.max(20, Math.min(240, Math.round(viewport.cols))) + const rows = Math.max(8, Math.min(120, Math.round(viewport.rows))) + let resized = false + try { + resized = this.ptyController?.resize?.(ptyId, cols, rows) ?? false + } catch { + return false + } + if (!resized) { + return false + } + this.resizeHeadlessTerminal(ptyId, cols, rows) + this.onExternalPtyResize(ptyId, cols, rows) + return true + } + // Why: invoked from `runtime:restoreTerminalFit` IPC (the desktop "Take // back" / "Restore" button). Forces the PTY back to desktop dims and // flips the driver to `desktop`, suppressing further mobile-driven dim @@ -3867,11 +3965,16 @@ export class OrcaRuntimeService { if (!this.store) { throw new Error('runtime_unavailable') } + if (!isAbsolute(path)) { + // Why: remote clients may run in a different cwd than the server. Require + // server-side repo paths to be explicit so `orca serve` cwd is irrelevant. + throw new Error('Repo path must be an absolute path') + } if (kind === 'git' && !isGitRepo(path)) { throw new Error(`Not a valid git repository: ${path}`) } - const existing = this.store.getRepos().find((repo) => repo.path === path) + const existing = this.store.getRepos().find((repo) => runtimePathsEqual(repo.path, path)) if (existing) { return existing } @@ -3890,6 +3993,182 @@ export class OrcaRuntimeService { return this.store.getRepo(repo.id) ?? repo } + async createRepo( + parentPath: string, + name: string, + kind: 'git' | 'folder' = 'git' + ): Promise<{ repo: Repo } | { error: string }> { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const trimmedName = name.trim() + const trimmedParentPath = parentPath.trim() + const repoKind: 'git' | 'folder' = kind === 'folder' ? 'folder' : 'git' + if (!trimmedName) { + return { error: 'Name cannot be empty' } + } + if (/[\\/]/.test(trimmedName) || trimmedName === '.' || trimmedName === '..') { + return { error: 'Name cannot contain slashes or be "." / ".."' } + } + if (!trimmedParentPath) { + return { error: 'Parent directory is required' } + } + if (!isAbsolute(trimmedParentPath)) { + return { error: 'Parent directory must be an absolute path' } + } + + const targetPath = join(trimmedParentPath, trimmedName) + const existing = this.store.getRepos().find((repo) => runtimePathsEqual(repo.path, targetPath)) + if (existing) { + return { repo: existing } + } + + let createdDir = false + try { + const existingStat = await stat(targetPath).catch((error: unknown) => { + if (isENOENT(error)) { + return null + } + throw error + }) + if (existingStat) { + if (!existingStat.isDirectory()) { + return { error: `"${trimmedName}" already exists at this location and is not a folder.` } + } + const entries = await readdir(targetPath) + if (entries.length > 0) { + return { error: `"${trimmedName}" already exists at this location and is not empty.` } + } + } else { + await mkdir(targetPath, { recursive: false }) + createdDir = true + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { error: `Failed to prepare directory: ${message}` } + } + + if (repoKind === 'git') { + let step: 'init' | 'commit' = 'init' + try { + await gitExecFileAsync(['init'], { cwd: targetPath }) + step = 'commit' + await gitExecFileAsync(['commit', '--allow-empty', '-m', 'Initial commit'], { + cwd: targetPath + }) + } catch (error) { + if (createdDir) { + await rm(targetPath, { recursive: true, force: true }).catch(() => {}) + } else if (step === 'commit') { + await rm(join(targetPath, '.git'), { recursive: true, force: true }).catch(() => {}) + } + const message = error instanceof Error ? error.message : String(error) + if ( + step === 'commit' && + /Please tell me who you are|user\.name|user\.email/i.test(message) + ) { + return { + error: + 'Git author identity is not configured. Run `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"`, then try again.' + } + } + const stepLabel = + step === 'init' + ? 'Failed to initialize git repository' + : 'Failed to create initial commit' + return { error: `${stepLabel}: ${message}` } + } + } + + const raceWinner = this.store + .getRepos() + .find((repo) => runtimePathsEqual(repo.path, targetPath)) + if (raceWinner) { + return { repo: raceWinner } + } + + const repo: Repo = { + id: randomUUID(), + path: targetPath, + displayName: trimmedName, + badgeColor: REPO_COLORS[this.store.getRepos().length % REPO_COLORS.length], + addedAt: Date.now(), + kind: repoKind + } + this.store.addRepo(repo) + invalidateAuthorizedRootsCache() + this.invalidateResolvedWorktreeCache() + this.notifier?.reposChanged() + return { repo: this.store.getRepo(repo.id) ?? repo } + } + + async cloneRepo(url: string, destination: string): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const trimmedUrl = url.trim() + const trimmedDestination = destination.trim() + const repoName = basename(trimmedUrl.replace(/\.git\/?$/, '')) + if (!repoName) { + throw new Error('Could not determine repository name from URL') + } + if (!trimmedDestination) { + throw new Error('Clone destination is required') + } + if (!isAbsolute(trimmedDestination)) { + throw new Error('Clone destination must be an absolute path') + } + await mkdir(trimmedDestination, { recursive: true }) + const clonePath = join(trimmedDestination, repoName) + await new Promise((resolve, reject) => { + const proc = wslAwareSpawn('git', ['clone', '--progress', '--', trimmedUrl, clonePath], { + cwd: trimmedDestination, + stdio: ['ignore', 'ignore', 'pipe'] + }) + let stderrTail = '' + proc.stderr?.on('data', (chunk: Buffer) => { + stderrTail = (stderrTail + chunk.toString()).slice(-4096) + }) + proc.on('error', (error) => reject(new Error(`Clone failed: ${error.message}`))) + proc.on('close', (code, signal) => { + if (signal === 'SIGTERM') { + reject(new Error('Clone aborted')) + } else if (code === 0) { + resolve() + } else { + const lastLine = stderrTail.trim().split('\n').pop() ?? 'unknown error' + reject(new Error(`Clone failed: ${lastLine}`)) + } + }) + }) + + const existing = this.store.getRepos().find((repo) => runtimePathsEqual(repo.path, clonePath)) + if (existing) { + if (isFolderRepo(existing)) { + const updated = this.store.updateRepo(existing.id, { kind: 'git' }) + if (updated) { + this.notifier?.reposChanged() + return updated + } + } + return existing + } + + const repo: Repo = { + id: randomUUID(), + path: clonePath, + displayName: getRepoName(clonePath), + badgeColor: REPO_COLORS[this.store.getRepos().length % REPO_COLORS.length], + addedAt: Date.now(), + kind: 'git' + } + this.store.addRepo(repo) + invalidateAuthorizedRootsCache() + this.invalidateResolvedWorktreeCache() + this.notifier?.reposChanged() + return this.store.getRepo(repo.id) ?? repo + } + async showRepo(repoSelector: string): Promise { return await this.resolveRepoSelector(repoSelector) } @@ -3911,6 +4190,75 @@ export class OrcaRuntimeService { return updated } + async updateRepo( + repoSelector: string, + updates: Partial< + Pick< + Repo, + | 'displayName' + | 'badgeColor' + | 'hookSettings' + | 'worktreeBaseRef' + | 'kind' + | 'symlinkPaths' + | 'issueSourcePreference' + > + > + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const repo = await this.resolveRepoSelector(repoSelector) + const updated = this.store.updateRepo(repo.id, omitUndefinedProperties(updates)) + if (!updated) { + throw new Error('repo_not_found') + } + this.invalidateResolvedWorktreeCache() + this.notifier?.reposChanged() + return updated + } + + async removeRepo(repoSelector: string): Promise<{ removed: true }> { + if (!this.store?.removeRepo) { + throw new Error('runtime_unavailable') + } + const repo = await this.resolveRepoSelector(repoSelector) + this.store.removeRepo(repo.id) + this.invalidateResolvedWorktreeCache() + invalidateAuthorizedRootsCache() + this.notifier?.reposChanged() + return { removed: true } + } + + async inspectTerminalProcess( + terminalSelector: string + ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> { + const leaf = this.resolveLeafForHandle(terminalSelector) + if (!leaf?.ptyId || !this.ptyController) { + return { foregroundProcess: null, hasChildProcesses: false } + } + const foregroundProcess = await this.ptyController.getForegroundProcess(leaf.ptyId) + const hasChildProcesses = + (await this.ptyController.hasChildProcesses?.(leaf.ptyId).catch(() => false)) ?? false + return { foregroundProcess, hasChildProcesses } + } + + reorderRepos(orderedIds: string[]): { status: 'applied' | 'rejected' } { + if (!this.store?.reorderRepos) { + throw new Error('runtime_unavailable') + } + // Why: remote clients can race repo add/remove on the server just like + // local drag-reorder can race another window. Let the store validate the + // full permutation and signal a resync-worthy rejection. + const applied = this.store.reorderRepos(orderedIds) + if (!applied) { + return { status: 'rejected' } + } + this.invalidateResolvedWorktreeCache() + this.notifier?.reposChanged() + return { status: 'applied' } + } + async searchRepoRefs( repoSelector: string, query: string, @@ -3926,15 +4274,462 @@ export class OrcaRuntimeService { truncated: false } } - const refs = await searchBaseRefs(repo.path, query, limit + 1) + const refs = repo.connectionId + ? await this.searchRemoteRepoRefs(repo, query, limit + 1) + : await searchBaseRefs(repo.path, query, limit + 1) return { refs: refs.slice(0, limit), truncated: refs.length > limit } } + async getRepoBaseRefDefault( + repoSelector: string + ): Promise<{ defaultBaseRef: string | null; remoteCount: number }> { + const repo = await this.resolveRepoSelector(repoSelector) + if (isFolderRepo(repo)) { + return { defaultBaseRef: null, remoteCount: 0 } + } + if (repo.connectionId) { + return this.getRemoteRepoBaseRefDefault(repo) + } + const [defaultBaseRef, remoteCount] = await Promise.all([ + getBaseRefDefault(repo.path), + getRemoteCount(repo.path) + ]) + return { defaultBaseRef, remoteCount } + } + + private async getRemoteRepoBaseRefDefault( + repo: Repo + ): Promise<{ defaultBaseRef: string | null; remoteCount: number }> { + const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : null + if (!provider) { + return { defaultBaseRef: null, remoteCount: 0 } + } + const [defaultBaseRef, remoteCount] = await Promise.all([ + resolveDefaultBaseRefViaExec(async (argv) => { + try { + return await provider.exec(argv, repo.path) + } catch (err) { + if (argv[0] === 'symbolic-ref') { + console.warn('[runtime:repo.baseRefDefault] SSH symbolic-ref failed', { + path: repo.path, + err + }) + } + throw err + } + }), + provider + .exec(['remote'], repo.path) + .then((result) => parseRemoteCount(result.stdout)) + .catch((err) => { + console.warn('[runtime:repo.baseRefDefault] SSH git remote count failed', { + path: repo.path, + err + }) + return 0 + }) + ]) + return { defaultBaseRef, remoteCount } + } + + private async searchRemoteRepoRefs(repo: Repo, query: string, limit: number): Promise { + const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : null + if (!provider) { + return [] + } + const normalizedQuery = normalizeRefSearchQuery(query) + if (!normalizedQuery) { + return [] + } + try { + const result = await provider.exec(buildSearchBaseRefsArgv(normalizedQuery), repo.path) + return parseAndFilterSearchRefs(result.stdout, limit) + } catch (err) { + console.warn('[runtime:repo.searchRefs] SSH for-each-ref failed', { + path: repo.path, + err + }) + return [] + } + } + + private assertHostIntegrationRepoIsLocal(repo: Repo, operation: string): void { + if (repo.connectionId) { + throw new Error(`${operation}_unsupported_for_ssh_repo`) + } + } + + async getRepoSlug(repoSelector: string): Promise<{ owner: string; repo: string } | null> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_slug') + return getRepoSlug(repo.path) + } + + async listRepoWorkItems( + repoSelector: string, + limit?: number, + query?: string, + before?: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_work_items') + return listWorkItems(repo.path, limit, query, before, repo.issueSourcePreference) + } + + async getRepoWorkItem( + repoSelector: string, + number: number, + type?: 'issue' | 'pr' + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_work_item') + return getWorkItem(repo.path, number, type) + } + + async getRepoWorkItemByOwnerRepo( + repoSelector: string, + ownerRepo: { owner: string; repo: string }, + number: number, + type: 'issue' | 'pr' + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_work_item') + return getWorkItemByOwnerRepo(repo.path, ownerRepo, number, type) + } + + async getRepoWorkItemDetails( + repoSelector: string, + number: number, + type?: 'issue' | 'pr' + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_work_item_details') + return getWorkItemDetails(repo.path, number, type) + } + + async countRepoWorkItems(repoSelector: string, query?: string): Promise { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_work_items') + return countWorkItems(repo.path, query, repo.issueSourcePreference) + } + + async listRepoLabels(repoSelector: string): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_labels') + return listLabels(repo.path, repo.issueSourcePreference) + } + + async listRepoAssignableUsers( + repoSelector: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_assignable_users') + return listAssignableUsers(repo.path, repo.issueSourcePreference) + } + + getGitHubRateLimit(options?: { + force?: boolean + }): Promise>> { + return getRateLimit(options) + } + + async getRepoPRForBranch( + repoSelector: string, + branch: string, + linkedPRNumber?: number | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr') + return getPRForBranch(repo.path, branch, linkedPRNumber ?? null) + } + + async getHostedReviewForBranch(args: { + repoSelector: string + branch: string + linkedGitHubPR?: number | null + linkedGitLabMR?: number | null + linkedBitbucketPR?: number | null + }): Promise { + const repo = await this.resolveRepoSelector(args.repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'hosted_review') + const review = await getHostedReviewForBranchFromRepo({ + repoPath: repo.path, + branch: args.branch, + linkedGitHubPR: args.linkedGitHubPR ?? null, + linkedGitLabMR: args.linkedGitLabMR ?? null, + linkedBitbucketPR: args.linkedBitbucketPR ?? null + }) + if (review?.provider === 'github' && this.stats && !this.stats.hasCountedPR(review.url)) { + this.stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: review.number, prUrl: review.url } + }) + } + return review + } + + async getRepoIssue( + repoSelector: string, + number: number + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_issue') + return getIssue(repo.path, number) + } + + async getRepoPRChecks( + repoSelector: string, + prNumber: number, + headSha?: string, + options?: { noCache?: boolean } + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_checks') + return getPRChecks(repo.path, prNumber, headSha, options) + } + + async getRepoPRComments( + repoSelector: string, + prNumber: number, + options?: { noCache?: boolean } + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_comments') + return getPRComments(repo.path, prNumber, options) + } + + async getRepoPRFileContents( + repoSelector: string, + args: { + prNumber: number + path: string + oldPath?: string + status: GitHubPRFile['status'] + headSha: string + baseSha: string + } + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_file_contents') + return getPRFileContents({ repoPath: repo.path, ...args }) + } + + async resolveRepoReviewThread( + repoSelector: string, + threadId: string, + resolve: boolean + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_review_thread') + return resolveReviewThread(repo.path, threadId, resolve) + } + + async updateRepoPRTitle( + repoSelector: string, + prNumber: number, + title: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_title') + return updatePRTitle(repo.path, prNumber, title) + } + + async mergeRepoPR( + repoSelector: string, + prNumber: number, + method?: 'merge' | 'squash' | 'rebase' + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_merge') + return mergePR(repo.path, prNumber, method) + } + + async createRepoIssue( + repoSelector: string, + title: string, + body: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_issue_create') + return createIssue(repo.path, title, body, repo.issueSourcePreference) + } + + async updateRepoIssue( + repoSelector: string, + number: number, + updates: GitHubIssueUpdate + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_issue_update') + return updateIssue(repo.path, number, updates) + } + + async addRepoIssueComment( + repoSelector: string, + number: number, + body: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_issue_comment') + return addIssueComment(repo.path, number, body) + } + + async addRepoPRReviewComment( + repoSelector: string, + args: Omit + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_review_comment') + return addPRReviewComment({ repoPath: repo.path, ...args }) + } + + async addRepoPRReviewCommentReply( + repoSelector: string, + args: { + prNumber: number + commentId: number + body: string + threadId?: string + path?: string + line?: number + } + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_review_comment_reply') + return addPRReviewCommentReply( + repo.path, + args.prNumber, + args.commentId, + args.body, + args.threadId, + args.path, + args.line + ) + } + + async listGitHubProjects(): Promise>> { + return listAccessibleProjects() + } + + async listGitHubLabelsBySlug( + args: ListLabelsBySlugArgs + ): Promise>> { + return listLabelsBySlug(args) + } + + async listGitHubAssignableUsersBySlug( + args: ListAssignableUsersBySlugArgs + ): Promise>> { + return listAssignableUsersBySlug(args) + } + + async listGitHubIssueTypesBySlug( + args: ListIssueTypesBySlugArgs + ): Promise>> { + return listIssueTypesBySlug(args) + } + + async resolveGitHubProjectRef( + args: ResolveProjectRefArgs + ): Promise>> { + return resolveProjectRef(args) + } + + async listGitHubProjectViews( + args: ListProjectViewsArgs + ): Promise>> { + return listProjectViews(args) + } + + async getGitHubProjectViewTable( + args: GetProjectViewTableArgs + ): Promise>> { + return getProjectViewTable(args) + } + + async updateGitHubProjectItemField( + args: UpdateProjectItemFieldArgs + ): Promise>> { + return updateProjectItemFieldValue(args) + } + + async clearGitHubProjectItemField( + args: ClearProjectItemFieldArgs + ): Promise>> { + return clearProjectItemFieldValue(args) + } + + async updateGitHubIssueBySlug( + args: UpdateIssueBySlugArgs + ): Promise>> { + return updateIssueBySlug(args) + } + + async updateGitHubPullRequestBySlug( + args: UpdatePullRequestBySlugArgs + ): Promise>> { + return updatePullRequestBySlug(args) + } + + async updateGitHubIssueTypeBySlug( + args: UpdateIssueTypeBySlugArgs + ): Promise>> { + return updateIssueTypeBySlug(args) + } + + async addGitHubIssueCommentBySlug( + args: AddIssueCommentBySlugArgs + ): Promise>> { + return addIssueCommentBySlug(args) + } + + async updateGitHubIssueCommentBySlug( + args: UpdateIssueCommentBySlugArgs + ): Promise>> { + return updateIssueCommentBySlug(args) + } + + async deleteGitHubIssueCommentBySlug( + args: DeleteIssueCommentBySlugArgs + ): Promise>> { + return deleteIssueCommentBySlug(args) + } + async getRepoHooks(repoSelector: string) { const repo = await this.resolveRepoSelector(repoSelector) + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { + hasHooksFile: false, + hooks: null, + setupRunPolicy: getEffectiveSetupRunPolicy(repo), + source: null + } + } + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, '.orca.yaml')) + const hooks = result.isBinary ? null : parseOrcaYaml(result.content) + return { + hasHooksFile: Boolean(hooks), + hooks, + setupRunPolicy: getEffectiveSetupRunPolicy(repo), + source: hooks ? 'orca.yaml' : null + } + } catch { + return { + hasHooksFile: false, + hooks: null, + setupRunPolicy: getEffectiveSetupRunPolicy(repo), + source: null + } + } + } const hasFile = hasHooksFile(repo.path) const hooks = getEffectiveHooks(repo) const setupRunPolicy = getEffectiveSetupRunPolicy(repo) @@ -3946,6 +4741,164 @@ export class OrcaRuntimeService { } } + async checkRepoHooks(repoSelector: string) { + const repo = await this.resolveRepoSelector(repoSelector) + if (isFolderRepo(repo)) { + return { hasHooks: false, hooks: null, mayNeedUpdate: false } + } + + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { hasHooks: false, hooks: null, mayNeedUpdate: false } + } + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, '.orca.yaml')) + if (result.isBinary) { + return { hasHooks: false, hooks: null, mayNeedUpdate: false } + } + const { parse } = await import('yaml') + const parsed = parse(result.content) + return { hasHooks: true, hooks: parsed, mayNeedUpdate: false } + } catch { + return { hasHooks: false, hooks: null, mayNeedUpdate: false } + } + } + + const has = hasHooksFile(repo.path) + const hooks = has ? loadHooks(repo.path) : null + return { + hasHooks: has, + hooks, + mayNeedUpdate: has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path) + } + } + + async readRepoIssueCommand(repoSelector: string) { + const repo = await this.resolveRepoSelector(repoSelector) + if (isFolderRepo(repo)) { + return { + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: '', + source: 'none' as const + } + } + + if (repo.connectionId) { + const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: issueCommandPath, + source: 'none' as const + } + } + const localContent = await this.readRemoteIssueCommandOverride(fsProvider, issueCommandPath) + const sharedContent = await this.readRemoteSharedIssueCommand(fsProvider, repo.path) + const effectiveContent = localContent ?? sharedContent + return { + localContent, + sharedContent, + effectiveContent, + localFilePath: issueCommandPath, + source: localContent + ? ('local' as const) + : sharedContent + ? ('shared' as const) + : ('none' as const) + } + } + + return readIssueCommand(repo.path) + } + + private async readRemoteIssueCommandOverride( + fsProvider: IFilesystemProvider, + issueCommandPath: string + ): Promise { + try { + const result = await fsProvider.readFile(issueCommandPath) + if (result.isBinary) { + return null + } + return result.content.trim() || null + } catch { + return null + } + } + + private async readRemoteSharedIssueCommand( + fsProvider: IFilesystemProvider, + repoPath: string + ): Promise { + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repoPath, 'orca.yaml')) + if (result.isBinary) { + return null + } + return parseOrcaYaml(result.content)?.issueCommand?.trim() || null + } catch { + return null + } + } + + async writeRepoIssueCommand(repoSelector: string, content: string): Promise<{ ok: true }> { + const repo = await this.resolveRepoSelector(repoSelector) + if (isFolderRepo(repo)) { + return { ok: true } + } + + if (repo.connectionId) { + const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { ok: true } + } + const trimmed = content.trim() + if (!trimmed) { + await fsProvider.deletePath(issueCommandPath, false).catch((error: unknown) => { + if (!isENOENT(error)) { + throw error + } + }) + return { ok: true } + } + await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca')) + await this.ensureRemoteOrcaDirIgnored(fsProvider, repo.path) + await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) + return { ok: true } + } + + writeIssueCommand(repo.path, content) + return { ok: true } + } + + private async ensureRemoteOrcaDirIgnored( + fsProvider: IFilesystemProvider, + repoPath: string + ): Promise { + const gitignorePath = joinWorktreeRelativePath(repoPath, '.gitignore') + try { + const result = await fsProvider.readFile(gitignorePath) + if (result.isBinary || /^\.orca\/?$/m.test(result.content)) { + return + } + const separator = result.content.endsWith('\n') ? '' : '\n' + await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`) + } catch { + try { + await fsProvider.writeFile(gitignorePath, '.orca\n') + } catch (error) { + console.warn('[runtime] Could not update remote .gitignore to exclude .orca', error) + } + } + } + async listNotes(args: { worktreeSelector: string; limit?: number }): Promise { const scope = await this.resolveNotesScope(args.worktreeSelector) return await this.getNotesStore().list(this.getNotesScope(scope.projectId), { @@ -3982,6 +4935,53 @@ export class OrcaRuntimeService { }) } + async saveNote(args: { + worktreeSelector: string + note: string + title?: string + bodyMarkdown: string + revision?: number + makeActive?: boolean + updatedBySessionId?: string | null + }): Promise { + const scope = await this.resolveNotesScope(args.worktreeSelector) + return await this.getNotesStore().save(this.getNotesScope(scope.projectId), { + projectId: scope.projectId, + worktreeId: scope.worktreeId, + note: args.note, + title: args.title, + bodyMarkdown: args.bodyMarkdown, + revision: args.revision, + makeActive: args.makeActive, + updatedBySessionId: args.updatedBySessionId + }) + } + + async renameNote(args: { + worktreeSelector: string + note: string + title: string + updatedBySessionId?: string | null + }): Promise { + const scope = await this.resolveNotesScope(args.worktreeSelector) + return await this.getNotesStore().rename(this.getNotesScope(scope.projectId), { + projectId: scope.projectId, + worktreeId: scope.worktreeId, + note: args.note, + title: args.title, + updatedBySessionId: args.updatedBySessionId + }) + } + + async deleteNote(args: { worktreeSelector: string; note: string }): Promise { + const scope = await this.resolveNotesScope(args.worktreeSelector) + return await this.getNotesStore().delete(this.getNotesScope(scope.projectId), { + projectId: scope.projectId, + worktreeId: scope.worktreeId, + note: args.note + }) + } + async appendNote(args: { worktreeSelector: string note: string @@ -4014,6 +5014,30 @@ export class OrcaRuntimeService { }) } + async linkNote(args: { + worktreeSelector: string + note: string + kind: NoteLinkKind + }): Promise { + const scope = await this.resolveNotesScope(args.worktreeSelector) + return await this.getNotesStore().setLink(this.getNotesScope(scope.projectId), { + projectId: scope.projectId, + worktreeId: scope.worktreeId, + note: args.note, + kind: args.kind + }) + } + + async resolveNotesPanelOpenStateForWorktree(args: { + worktreeSelector: string + }): Promise { + const scope = await this.resolveNotesScope(args.worktreeSelector) + return await this.getNotesStore().resolvePanelOpenState(this.getNotesScope(scope.projectId), { + projectId: scope.projectId, + worktreeId: scope.worktreeId + }) + } + async listProjectNotes(args: NoteListArgs): Promise { this.assertKnownNotesProject(args.projectId) return await this.getNotesStore().list(this.getNotesScope(args.projectId), args) @@ -4127,10 +5151,15 @@ export class OrcaRuntimeService { name: string baseBranch?: string linkedIssue?: number | null + linkedPR?: number | null comment?: string + displayName?: string + sparseCheckout?: { directories: string[]; presetId?: string } + pushTarget?: GitPushTarget runHooks?: boolean activate?: boolean setupDecision?: 'run' | 'skip' | 'inherit' + createdWithAgent?: TuiAgent startup?: WorktreeStartupLaunch }): Promise { if (!this.store) { @@ -4141,8 +5170,15 @@ export class OrcaRuntimeService { if (isFolderRepo(repo)) { throw new Error('Folder mode does not support creating worktrees.') } + if (repo.connectionId) { + // Why: SSH-backed worktree creation still relies on the desktop SSH + // flow, which can prime relay roots and enforce its remote constraints. + // Runtime RPC must not fall through to local git against server paths. + throw new Error('SSH-backed worktree creation is not supported through runtime RPC yet.') + } const settings = this.store.getSettings() const requestedName = args.name + const requestedDisplayName = args.displayName?.trim() || undefined const sanitizedName = sanitizeWorktreeName(args.name) const username = getGitUsername(repo.path) const branchName = computeBranchName(sanitizedName, settings, username) @@ -4200,13 +5236,47 @@ export class OrcaRuntimeService { // if future refactors change that contract. } - await addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate - ) + const sparseDirectories = args.sparseCheckout + ? normalizeSparseDirectories(args.sparseCheckout.directories) + : [] + if (args.sparseCheckout && sparseDirectories.length === 0) { + throw new Error('Sparse checkout requires at least one repo-relative directory.') + } + + let preparedPushTarget: GitPushTarget | undefined + if (args.pushTarget) { + // Why: fork-PR worktrees created through a remote runtime need the same + // upstream target setup as local desktop creates, or Push would publish + // to the wrong remote after the client/server split. + preparedPushTarget = await prepareWorktreePushTarget(repo.path, args.pushTarget) + } + + await (sparseDirectories.length > 0 + ? addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + ) + : addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + )) + + let configuredPushTarget: GitPushTarget | undefined + if (preparedPushTarget) { + configuredPushTarget = await configureCreatedWorktreePushTarget( + worktreePath, + branchName, + preparedPushTarget + ) + } + const gitWorktrees = await listWorktrees(repo.path) const created = gitWorktrees.find((gw) => areWorktreePathsEqual(gw.path, worktreePath)) if (!created) { @@ -4215,6 +5285,11 @@ export class OrcaRuntimeService { const worktreeId = `${repo.id}::${created.path}` const now = Date.now() + const displayNameMeta = requestedDisplayName + ? { displayName: requestedDisplayName } + : shouldSetDisplayName(requestedName, branchName, sanitizedName) + ? { displayName: requestedName } + : {} const meta = this.store.setWorktreeMeta(worktreeId, { lastActivityAt: now, // See createRemoteWorktree: createdAt grants the new worktree a grace @@ -4222,15 +5297,31 @@ export class OrcaRuntimeService { // push it down before the user has had a chance to notice it. Smart-sort // uses max(lastActivityAt, createdAt + CREATE_GRACE_MS). createdAt: now, - ...(shouldSetDisplayName(requestedName, branchName, sanitizedName) - ? { displayName: requestedName } - : {}), + ...displayNameMeta, baseRef: baseBranch, + ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), + ...(sparseDirectories.length > 0 + ? { + sparseDirectories, + sparseBaseRef: baseBranch, + sparsePresetId: args.sparseCheckout?.presetId + } + : {}), ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), + ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), + ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), ...(args.comment !== undefined ? { comment: args.comment } : {}) }) const worktree = mergeWorktree(repo.id, created, meta) + if ( + settings.experimentalWorktreeSymlinks && + repo.symlinkPaths && + repo.symlinkPaths.length > 0 + ) { + await createWorktreeSymlinks(repo.path, created.path, repo.symlinkPaths) + } + let setup: CreateWorktreeResult['setup'] let warning: string | undefined // Why: CLI-created worktrees do not have a renderer preview to mismatch @@ -4277,6 +5368,7 @@ export class OrcaRuntimeService { // are not recognized and all git operations fail with "Access denied: // unknown repository or worktree path". invalidateAuthorizedRootsCache() + this.notifier?.worktreesChanged(repo.id) const shouldActivate = args.activate === true || args.runHooks === true let didSpawnStartup = false @@ -4669,6 +5761,12 @@ export class OrcaRuntimeService { if (!repo) { return null } + if (repo.connectionId) { + // Why: the drift probe uses local git helpers. Until the SSH provider + // exposes equivalent remote refs/log plumbing, fail closed to "unknown" + // instead of probing a server path on the desktop filesystem. + return null + } const meta = this.store.getWorktreeMeta(wt.id) const base = meta?.baseRef || meta?.sparseBaseRef || repo.worktreeBaseRef || getDefaultBaseRef(repo.path) @@ -4694,25 +5792,12 @@ export class OrcaRuntimeService { return { base, behind: drift.behind, recentSubjects } } - async updateManagedWorktreeMeta( - worktreeSelector: string, - updates: { - displayName?: string - linkedIssue?: number | null - comment?: string - isPinned?: boolean - } - ) { + async updateManagedWorktreeMeta(worktreeSelector: string, updates: Partial) { if (!this.store) { throw new Error('runtime_unavailable') } const worktree = await this.resolveWorktreeSelector(worktreeSelector) - const meta = this.store.setWorktreeMeta(worktree.id, { - ...(updates.displayName !== undefined ? { displayName: updates.displayName } : {}), - ...(updates.linkedIssue !== undefined ? { linkedIssue: updates.linkedIssue } : {}), - ...(updates.comment !== undefined ? { comment: updates.comment } : {}), - ...(updates.isPinned !== undefined ? { isPinned: updates.isPinned } : {}) - }) + const meta = this.store.setWorktreeMeta(worktree.id, omitUndefinedProperties(updates)) // Why: unlike renderer-initiated optimistic updates, CLI callers need an // explicit push so the editor refreshes metadata changed outside the UI. this.invalidateResolvedWorktreeCache() @@ -4720,6 +5805,127 @@ export class OrcaRuntimeService { return mergeWorktree(worktree.repoId, worktree.git, meta) } + persistManagedWorktreeSortOrder(orderedIds: string[]): { updated: number } { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const now = Date.now() + let updated = 0 + for (let i = 0; i < orderedIds.length; i++) { + this.store.setWorktreeMeta(orderedIds[i], { sortOrder: now - i * 1000 }) + updated++ + } + this.invalidateResolvedWorktreeCache() + this.notifier?.reposChanged() + return { updated } + } + + async resolveManagedPrBase(args: { + repoId: string + prNumber: number + headRefName?: string + isCrossRepository?: boolean + }): Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }> { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const repo = this.store.getRepo(args.repoId) + if (!repo) { + return { error: 'Repo not found' } + } + if (repo.connectionId) { + return { error: 'PR start points are not supported for remote repos yet.' } + } + if (isFolderRepo(repo)) { + return { error: 'Folder mode does not support creating worktrees.' } + } + + let headRefName = args.headRefName?.trim() ?? '' + let isCrossRepository = args.isCrossRepository === true + let pushTarget: GitPushTarget | undefined + + if (!headRefName) { + const item = await getWorkItem(repo.path, args.prNumber, 'pr') + if (!item || item.type !== 'pr') { + return { error: `PR #${args.prNumber} not found.` } + } + headRefName = (item.branchName ?? '').trim() + if (!headRefName) { + return { error: `PR #${args.prNumber} has no head branch.` } + } + if (item.isCrossRepository === true) { + isCrossRepository = true + } + } + + if (isCrossRepository) { + try { + pushTarget = (await getPullRequestPushTarget(repo.path, args.prNumber)) ?? undefined + } catch (error) { + return { + error: + error instanceof Error + ? error.message + : `Could not resolve PR #${args.prNumber} head push target.` + } + } + if (!pushTarget) { + return { error: `Could not resolve PR #${args.prNumber} head push target.` } + } + } + + let remote: string + try { + remote = await getDefaultRemote(repo.path) + } catch (error) { + return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' } + } + + if (isCrossRepository) { + const pullRef = `refs/pull/${args.prNumber}/head` + try { + await gitExecFileAsync(['fetch', remote, pullRef], { cwd: repo.path }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { error: `Failed to fetch ${pullRef}: ${message.split('\n')[0]}` } + } + try { + const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', 'FETCH_HEAD'], { + cwd: repo.path + }) + const sha = stdout.trim() + if (!sha) { + return { error: `Empty SHA resolving fork PR #${args.prNumber} head.` } + } + return { baseBranch: sha, ...(pushTarget ? { pushTarget } : {}) } + } catch { + return { error: `Could not resolve fork PR #${args.prNumber} head after fetch.` } + } + } + + try { + await gitExecFileAsync( + ['fetch', remote, `+refs/heads/${headRefName}:refs/remotes/${remote}/${headRefName}`], + { cwd: repo.path } + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { error: `Failed to fetch ${remote}/${headRefName}: ${message.split('\n')[0]}` } + } + + const remoteRef = `${remote}/${headRefName}` + try { + await gitExecFileAsync(['rev-parse', '--verify', remoteRef], { cwd: repo.path }) + } catch { + return { error: `Remote ref ${remoteRef} does not exist after fetch.` } + } + + return { + baseBranch: remoteRef, + pushTarget: pushTarget ?? { remoteName: remote, branchName: headRefName } + } + } + async removeManagedWorktree( worktreeSelector: string, force = false, @@ -4736,6 +5942,19 @@ export class OrcaRuntimeService { if (isFolderRepo(repo)) { throw new Error('Folder mode does not support deleting worktrees.') } + if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${repo.connectionId}"`) + } + await provider.removeWorktree(worktree.path, force) + this.clearOptimisticReconcileToken(worktree.id) + this.store.removeWorktreeMeta(worktree.id) + this.invalidateResolvedWorktreeCache() + invalidateAuthorizedRootsCache() + this.notifier?.worktreesChanged(repo.id) + return {} + } // Why: kill every PTY belonging to this worktree BEFORE the git-level // removal. Some shells keep the worktree directory busy, and `git worktree @@ -4967,7 +6186,14 @@ export class OrcaRuntimeService { afterDesktopTabId = anchor.type === 'terminal' ? anchor.parentTabId : anchor.id } - const win = this.getAuthoritativeWindow() + const win = this.getAvailableAuthoritativeWindow() + if (!win) { + return await this.createHeadlessMobileSessionTerminal( + worktreeId, + opts.activate !== false, + opts.afterTabId + ) + } const requestId = randomUUID() const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => { const timer = setTimeout(() => { @@ -5004,6 +6230,64 @@ export class OrcaRuntimeService { return await this.waitForMobileTerminalSurface(worktreeId, reply.tabId) } + private async createHeadlessMobileSessionTerminal( + worktreeId: string, + activate: boolean, + afterTabId?: string + ): Promise { + const terminal = await this.createTerminal(`id:${worktreeId}`, { focus: false }) + const livePty = this.getLivePtyForHandle(terminal.handle) + if (!livePty) { + throw new Error('terminal_handle_stale') + } + const parentTabId = livePty.pty.tabId ?? `pty:${livePty.pty.ptyId}` + const leafId = `pane:${FIRST_PANE_ID}` + const tab: RuntimeMobileSessionTerminalTab = { + type: 'terminal', + id: `${parentTabId}::${leafId}`, + parentTabId, + leafId, + title: terminal.title ?? livePty.pty.title ?? 'Terminal', + isActive: activate + } + const existing = this.mobileSessionTabsByWorktree.get(worktreeId) + const tabs = (existing?.tabs ?? []) + .filter((candidate) => candidate.id !== tab.id) + .map((candidate) => ({ + ...candidate, + isActive: activate ? false : candidate.isActive + })) + const insertAfter = afterTabId ? tabs.findIndex((candidate) => candidate.id === afterTabId) : -1 + if (insertAfter >= 0) { + tabs.splice(insertAfter + 1, 0, tab) + } else { + tabs.push(tab) + } + const next: RuntimeMobileSessionTabsSnapshot = { + worktree: worktreeId, + publicationEpoch: `headless:${Date.now().toString(36)}`, + snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, + activeGroupId: existing?.activeGroupId ?? null, + activeTabId: activate ? tab.id : (existing?.activeTabId ?? null), + activeTabType: activate ? 'terminal' : (existing?.activeTabType ?? null), + tabs + } + this.mobileSessionTabsByWorktree.set(worktreeId, next) + const result = this.toMobileSessionTabsResult(next) + for (const listener of this.mobileSessionTabListeners) { + listener(result) + } + const created = result.tabs.find((candidate) => candidate.id === tab.id) + if (!created || created.type !== 'terminal') { + throw new Error('terminal_handle_stale') + } + return { + tab: created, + publicationEpoch: result.publicationEpoch, + snapshotVersion: result.snapshotVersion + } + } + private waitForMobileTerminalSurface( worktreeId: string, parentTabId: string, @@ -5392,7 +6676,9 @@ export class OrcaRuntimeService { if (selector.startsWith('id:')) { candidates = worktrees.filter((worktree) => worktree.id === selector.slice(3)) } else if (selector.startsWith('path:')) { - candidates = worktrees.filter((worktree) => worktree.path === selector.slice(5)) + candidates = worktrees.filter((worktree) => + runtimePathsEqual(worktree.path, selector.slice(5)) + ) } else if (selector.startsWith('branch:')) { const branchSelector = selector.slice(7) candidates = worktrees.filter((worktree) => @@ -5407,7 +6693,7 @@ export class OrcaRuntimeService { candidates = worktrees.filter( (worktree) => worktree.id === selector || - worktree.path === selector || + runtimePathsEqual(worktree.path, selector) || branchSelectorMatches(worktree.branch, selector) ) } @@ -5467,12 +6753,15 @@ export class OrcaRuntimeService { if (selector.startsWith('id:')) { candidates = repos.filter((repo) => repo.id === selector.slice(3)) } else if (selector.startsWith('path:')) { - candidates = repos.filter((repo) => repo.path === selector.slice(5)) + candidates = repos.filter((repo) => runtimePathsEqual(repo.path, selector.slice(5))) } else if (selector.startsWith('name:')) { candidates = repos.filter((repo) => repo.displayName === selector.slice(5)) } else { candidates = repos.filter( - (repo) => repo.id === selector || repo.path === selector || repo.displayName === selector + (repo) => + repo.id === selector || + runtimePathsEqual(repo.path, selector) || + repo.displayName === selector ) } @@ -5485,6 +6774,13 @@ export class OrcaRuntimeService { throw new Error('repo_not_found') } + private requireStore(): Store { + if (!this.store) { + throw new Error('runtime_unavailable') + } + return this.store as unknown as Store + } + private async listResolvedWorktrees(): Promise { if (!this.store) { return [] @@ -5493,19 +6789,23 @@ export class OrcaRuntimeService { if (this.resolvedWorktreeCache && this.resolvedWorktreeCache.expiresAt > now) { return this.resolvedWorktreeCache.worktrees } - if (this.resolvedWorktreeInFlight) { - return this.resolvedWorktreeInFlight + const generation = this.resolvedWorktreeGeneration + if (this.resolvedWorktreeInFlight?.generation === generation) { + return this.resolvedWorktreeInFlight.promise } - this.resolvedWorktreeInFlight = this.computeResolvedWorktrees() + const promise = this.computeResolvedWorktrees(generation) + this.resolvedWorktreeInFlight = { generation, promise } try { - return await this.resolvedWorktreeInFlight + return await promise } finally { - this.resolvedWorktreeInFlight = null + if (this.resolvedWorktreeInFlight?.promise === promise) { + this.resolvedWorktreeInFlight = null + } } } - private async computeResolvedWorktrees(): Promise { + private async computeResolvedWorktrees(generation: number): Promise { if (!this.store) { return [] } @@ -5524,11 +6824,7 @@ export class OrcaRuntimeService { const worktreeId = `${repo.id}::${gitWorktree.path}` const merged = mergeWorktree(repo.id, gitWorktree, metaById[worktreeId], repo.displayName) return { - id: merged.id, - repoId: repo.id, - path: merged.path, - branch: merged.branch, - linkedIssue: metaById[worktreeId]?.linkedIssue ?? null, + ...merged, git: { path: gitWorktree.path, head: gitWorktree.head, @@ -5546,9 +6842,11 @@ export class OrcaRuntimeService { // Why: terminal polling can be frequent, but git worktree state is still // allowed to change outside Orca. A short TTL avoids shelling out on every // read without pretending the cache is authoritative for long. - this.resolvedWorktreeCache = { - worktrees, - expiresAt: now + RESOLVED_WORKTREE_CACHE_TTL_MS + if (generation === this.resolvedWorktreeGeneration) { + this.resolvedWorktreeCache = { + worktrees, + expiresAt: now + RESOLVED_WORKTREE_CACHE_TTL_MS + } } return worktrees } @@ -5558,6 +6856,7 @@ export class OrcaRuntimeService { } private invalidateResolvedWorktreeCache(): void { + this.resolvedWorktreeGeneration += 1 this.resolvedWorktreeCache = null } @@ -5800,16 +7099,19 @@ export class OrcaRuntimeService { } const syncedTab = this.tabs.get(tab.parentTabId) const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null + const pty = leaf ? null : this.findPtyForMobileTerminalTab(tab) tabs.push({ type: 'terminal', id: tab.id, parentTabId: tab.parentTabId, leafId: tab.leafId, - title: leaf?.paneTitle ?? syncedTab?.title ?? tab.title, + title: leaf?.paneTitle ?? syncedTab?.title ?? pty?.title ?? tab.title, isActive: tab.isActive, ...(leaf ? { status: 'ready' as const, terminal: this.issueHandle(leaf) } - : { status: 'pending-handle' as const, terminal: null }) + : pty + ? { status: 'ready' as const, terminal: this.issuePtyHandle(pty) } + : { status: 'pending-handle' as const, terminal: null }) }) } const active = tabs.find((tab) => tab.isActive) ?? null @@ -5824,6 +7126,21 @@ export class OrcaRuntimeService { } } + private findPtyForMobileTerminalTab( + tab: RuntimeMobileSessionTerminalTab + ): RuntimePtyWorktreeRecord | null { + const paneKeys = new Set([`${tab.parentTabId}:${tab.leafId}`]) + if (tab.leafId === `pane:${FIRST_PANE_ID}`) { + paneKeys.add(`${tab.parentTabId}:${FIRST_PANE_ID}`) + } + for (const pty of this.ptysById.values()) { + if (pty.tabId === tab.parentTabId && pty.paneKey && paneKeys.has(pty.paneKey)) { + return pty + } + } + return null + } + // Why: group address resolution (Section 4.5) needs to query per-handle agent // status without throwing on stale handles, so this returns null on any error. getAgentStatusForHandle(handle: string): string | null { @@ -6418,1318 +7735,355 @@ export class OrcaRuntimeService { return `${tabId}::${leafId}` } + // ── Linear integration ── + + linearConnect(apiKey: string): ReturnType { + return connectLinear(apiKey) + } + + linearDisconnect(): { ok: true } { + disconnectLinear() + return { ok: true } + } + + linearStatus(): ReturnType { + return getLinearStatus() + } + + linearTestConnection(): ReturnType { + return testLinearConnection() + } + + linearSearchIssues(query: string, limit = 20): ReturnType { + return searchLinearIssues(query, Math.min(Math.max(1, limit), 50)) + } + + linearListIssues(filter?: LinearListFilter, limit = 20): ReturnType { + return listLinearIssues(filter, Math.min(Math.max(1, limit), 50)) + } + + linearCreateIssue( + teamId: string, + title: string, + description?: string + ): ReturnType { + return createLinearIssue(teamId, title, description) + } + + linearGetIssue(id: string): ReturnType { + return getLinearIssue(id) + } + + linearUpdateIssue(id: string, updates: LinearIssueUpdate): ReturnType { + return updateLinearIssue(id, updates) + } + + linearAddIssueComment(issueId: string, body: string): ReturnType { + return addLinearIssueComment(issueId, body) + } + + linearIssueComments(issueId: string): ReturnType { + return getLinearIssueComments(issueId) + } + + linearListTeams(): ReturnType { + return listLinearTeams() + } + + linearTeamStates(teamId: string): ReturnType { + return getLinearTeamStates(teamId) + } + + linearTeamLabels(teamId: string): ReturnType { + return getLinearTeamLabels(teamId) + } + + linearTeamMembers(teamId: string): ReturnType { + return getLinearTeamMembers(teamId) + } + // ── Browser automation ── - private requireAgentBrowserBridge(): AgentBrowserBridge { - if (!this.agentBrowserBridge) { - throw new BrowserError('browser_no_tab', 'No browser session is active') - } - return this.agentBrowserBridge - } - - // Why: the CLI sends worktree selectors (e.g. "path:/Users/...") but the - // bridge stores worktreeIds in "repoId::path" format (from the renderer's - // Zustand store). This helper resolves the selector to the store-compatible - // ID so the bridge can filter tabs correctly. - private async resolveBrowserWorktreeId(selector?: string): Promise { - if (!selector) { - // Why: after app restart, webviews only mount when the browser pane is visible. - // Without --worktree, we still need to activate the view so persisted tabs - // become operable via registerGuest. - const bridge = this.agentBrowserBridge - if (bridge && bridge.getRegisteredTabs().size === 0) { - try { - const win = this.getAuthoritativeWindow() - win.webContents.send('browser:activateView', {}) - await new Promise((resolve) => setTimeout(resolve, 500)) - } catch { - // Window may not exist yet (e.g. during startup or in tests) - } - } - return undefined - } - - const worktreeId = (await this.resolveWorktreeSelector(selector)).id - // Why: explicit worktree selectors are user intent, so resolution errors - // must surface instead of silently widening browser routing scope. Only the - // activation step remains best-effort because missing windows during tests - // or startup should not erase the validated worktree target itself. - const bridge = this.agentBrowserBridge - if (bridge && bridge.getRegisteredTabs(worktreeId).size === 0) { - try { - await this.ensureBrowserWorktreeActive(worktreeId) - } catch { - // Fall through with the validated worktree id so downstream routing - // still stays scoped to the caller's explicit selector. - } - } - return worktreeId - } - - private async resolveBrowserCommandTarget( - params: BrowserCommandTargetParams - ): Promise { - const browserPageId = - typeof params.page === 'string' && params.page.length > 0 ? params.page : undefined - if (!browserPageId) { - return { - worktreeId: await this.resolveBrowserWorktreeId(params.worktree) - } - } - - return { - // Why: explicit browserPageId is already a stable tab identity, so we do - // not auto-resolve cwd worktree scoping on top of it. Only honor an - // explicit --worktree when the caller asked for that extra validation. - worktreeId: params.worktree - ? await this.resolveBrowserWorktreeId(params.worktree) - : undefined, - browserPageId - } - } - - // Why: browser tabs only mount (and become operable) when their worktree is - // the active worktree in the renderer AND activeTabType is 'browser'. If either - // condition is false, the webview stays in display:none and Electron won't start - // its guest process — dom-ready never fires, registerGuest never runs, and CLI - // browser commands fail with "CDP connection refused". - private async ensureBrowserWorktreeActive(worktreeId: string): Promise { - const win = this.getAuthoritativeWindow() - const repoId = getRepoIdFromWorktreeId(worktreeId) - if (!repoId) { - return - } - win.webContents.send('ui:activateWorktree', { repoId, worktreeId }) - // Why: switching worktree alone sets activeView='terminal'. Browser webviews - // won't mount until activeTabType is 'browser'. Send a second IPC to flip it. - win.webContents.send('browser:activateView', { worktreeId }) - // Why: give the renderer time to mount the webview after switching worktrees. - // The webview needs to attach and fire dom-ready before registerGuest runs. - await new Promise((resolve) => setTimeout(resolve, 500)) - } - - // Why: agent-browser drives navigation via CDP, which bypasses Electron's - // webview event system. The renderer's did-navigate / page-title-updated - // listeners never fire, leaving the Zustand store (and thus the Orca UI's - // address bar and tab title) stale. Push updates from main → renderer after - // any navigation-causing command so the UI stays in sync. - private notifyRendererNavigation(browserPageId: string, url: string, title: string): void { - try { - const win = this.getAuthoritativeWindow() - win.webContents.send('browser:navigation-update', { browserPageId, url, title }) - } catch { - // Window may not exist during shutdown - } - } - - // Why: `tabSwitch` only flips the bridge's `activeWebContentsId` — it - // does not surface the browser pane in the renderer. Without --focus, the - // switch is invisible to the user. With --focus, we send a dedicated IPC - // so the renderer can update its per-worktree active-tab state. - // - // Why this IPC carries `worktreeId` instead of letting the renderer - // dispatch `setActiveWorktree`: multiple agents drive browsers in parallel - // worktrees. A global focus call from agent X would steal the user's - // screen from agent Y's worktree. The renderer-side handler - // (focusBrowserTabInWorktree) updates per-worktree state unconditionally - // and only flips globals when the user is already on the targeted - // worktree. Cross-worktree --focus calls pre-stage silently. - private notifyRendererBrowserPaneFocus( - worktreeId: string | undefined, - browserPageId: string - ): void { - try { - const win = this.getAuthoritativeWindow() - win.webContents.send('browser:pane-focus', { - worktreeId: worktreeId ?? null, - browserPageId - }) - } catch { - // Window may not exist during shutdown - } - } - - async browserSnapshot(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().snapshot(target.worktreeId, target.browserPageId) - } - - async browserClick( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.click(params.element, target.worktreeId, target.browserPageId) - // Why: clicks can trigger navigation (e.g. submitting a form, clicking a link). - // Read the target tab's live URL/title after the click and push to the - // renderer so the UI updates even when automation targeted a non-active page. - const page = bridge.getPageInfo(target.worktreeId, target.browserPageId) - if (page) { - this.notifyRendererNavigation(page.browserPageId, page.url, page.title) - } - return result - } - - async browserGoto( - params: { url: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.goto(params.url, target.worktreeId, target.browserPageId) - const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) - if (pageId) { - this.notifyRendererNavigation(pageId, result.url, result.title) - } - return result - } - - async browserFill( - params: { - element: string - value: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().fill( - params.element, - params.value, - target.worktreeId, - target.browserPageId - ) - } - - async browserType( - params: { input: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().type( - params.input, - target.worktreeId, - target.browserPageId - ) - } - - async browserSelect( - params: { - element: string - value: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().select( - params.element, - params.value, - target.worktreeId, - target.browserPageId - ) - } - - async browserScroll( - params: { direction: 'up' | 'down'; amount?: number } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().scroll( - params.direction, - params.amount, - target.worktreeId, - target.browserPageId - ) - } - - async browserBack(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.back(target.worktreeId, target.browserPageId) - const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) - if (pageId) { - this.notifyRendererNavigation(pageId, result.url, result.title) - } - return result - } - - async browserReload(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.reload(target.worktreeId, target.browserPageId) - const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) - if (pageId) { - this.notifyRendererNavigation(pageId, result.url, result.title) - } - return result - } - - async browserScreenshot( - params: { - format?: 'png' | 'jpeg' - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().screenshot( - params.format, - target.worktreeId, - target.browserPageId - ) - } - - async browserEval( - params: { expression: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().evaluate( - params.expression, - target.worktreeId, - target.browserPageId - ) - } - - async browserTabList(params: { worktree?: string }): Promise { - const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) - const result = this.requireAgentBrowserBridge().tabList(worktreeId) - return { - tabs: result.tabs.map((tab) => this.enrichBrowserTabInfo(tab)) - } - } - - async browserTabShow(params: { page: string; worktree?: string }): Promise { - const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) - return { tab: this.describeBrowserTab(params.page, worktreeId) } - } - - async browserTabCurrent(params: { worktree?: string }): Promise { - const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) - const browserPageId = this.requireAgentBrowserBridge().getActivePageId(worktreeId) - if (!browserPageId) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - return { tab: this.describeBrowserTab(browserPageId, worktreeId) } - } - - async browserTabSwitch( - params: { - index?: number - focus?: boolean - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.tabSwitch(params.index, target.worktreeId, target.browserPageId) - if (params.focus) { - // Why: prefer the explicit --worktree the caller passed; fall back to - // the bridge's owning-worktree map for the just-switched tab. The - // owning worktree is what the renderer needs to scope the focus to. - // The renderer NEVER yanks the user across worktrees on this signal - // (see focusBrowserTabInWorktree). - const worktreeId = - target.worktreeId ?? browserManager.getWorktreeIdForTab(result.browserPageId) ?? undefined - this.notifyRendererBrowserPaneFocus(worktreeId, result.browserPageId) - } - return result - } - - async browserHover( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().hover( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserDrag( - params: { - from: string - to: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().drag( - params.from, - params.to, - target.worktreeId, - target.browserPageId - ) - } - - async browserUpload( - params: { element: string; files: string[] } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().upload( - params.element, - params.files, - target.worktreeId, - target.browserPageId - ) - } - - async browserWait( - params: { - selector?: string - timeout?: number - text?: string - url?: string - load?: string - fn?: string - state?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const { worktree: _, page: __, ...options } = params - return this.requireAgentBrowserBridge().wait(options, target.worktreeId, target.browserPageId) - } - - async browserCheck( - params: { element: string; checked: boolean } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().check( - params.element, - params.checked, - target.worktreeId, - target.browserPageId - ) - } - - async browserFocus( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().focus( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserClear( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().clear( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserSelectAll( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().selectAll( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserKeypress( - params: { key: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().keypress( - params.key, - target.worktreeId, - target.browserPageId - ) - } - - async browserPdf(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().pdf(target.worktreeId, target.browserPageId) - } - - async browserFullScreenshot( - params: { - format?: 'png' | 'jpeg' - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().fullPageScreenshot( - params.format, - target.worktreeId, - target.browserPageId - ) - } - - // ── Cookie management ── - - async browserCookieGet( - params: { url?: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().cookieGet( - params.url, - target.worktreeId, - target.browserPageId - ) - } - - async browserCookieSet( - params: { - name: string - value: string - domain?: string - path?: string - secure?: boolean - httpOnly?: boolean - sameSite?: string - expires?: number - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().cookieSet( - params, - target.worktreeId, - target.browserPageId - ) - } - - async browserCookieDelete( - params: { - name: string - domain?: string - url?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().cookieDelete( - params.name, - params.domain, - params.url, - target.worktreeId, - target.browserPageId - ) - } - - // ── Viewport ── - - async browserSetViewport( - params: { - width: number - height: number - deviceScaleFactor?: number - mobile?: boolean - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setViewport( - params.width, - params.height, - params.deviceScaleFactor, - params.mobile, - target.worktreeId, - target.browserPageId - ) - } - - // ── Geolocation ── - - async browserSetGeolocation( - params: { - latitude: number - longitude: number - accuracy?: number - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setGeolocation( - params.latitude, - params.longitude, - params.accuracy, - target.worktreeId, - target.browserPageId - ) - } - - // ── Request interception ── - - async browserInterceptEnable( - params: { - patterns?: string[] - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().interceptEnable( - params.patterns, - target.worktreeId, - target.browserPageId - ) - } - - async browserInterceptDisable( - params: BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().interceptDisable( - target.worktreeId, - target.browserPageId - ) - } - - async browserInterceptList(params: BrowserCommandTargetParams): Promise<{ requests: unknown[] }> { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().interceptList(target.worktreeId, target.browserPageId) - } - - // ── Console/network capture ── - - async browserCaptureStart( - params: BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().captureStart(target.worktreeId, target.browserPageId) - } - - async browserCaptureStop(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().captureStop(target.worktreeId, target.browserPageId) - } - - async browserConsoleLog( - params: { limit?: number } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().consoleLog( - params.limit, - target.worktreeId, - target.browserPageId - ) - } - - async browserNetworkLog( - params: { limit?: number } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().networkLog( - params.limit, - target.worktreeId, - target.browserPageId - ) - } - - // ── Additional core commands ── - - async browserDblclick( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().dblclick( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserForward(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().forward(target.worktreeId, target.browserPageId) - } - - async browserScrollIntoView( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().scrollIntoView( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserGet( - params: { - what: string - selector?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().get( - params.what, - params.selector, - target.worktreeId, - target.browserPageId - ) - } - - async browserIs( - params: { what: string; selector: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().is( - params.what, - params.selector, - target.worktreeId, - target.browserPageId - ) - } - - // ── Keyboard insert text ── - - async browserKeyboardInsertText( - params: { text: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().keyboardInsertText( - params.text, - target.worktreeId, - target.browserPageId - ) - } - - // ── Mouse commands ── - - async browserMouseMove( - params: { x: number; y: number } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseMove( - params.x, - params.y, - target.worktreeId, - target.browserPageId - ) - } - - async browserMouseDown( - params: { button?: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseDown( - params.button, - target.worktreeId, - target.browserPageId - ) - } - - async browserMouseUp(params: { button?: string } & BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseUp( - params.button, - target.worktreeId, - target.browserPageId - ) - } - - async browserMouseWheel( - params: { - dy: number - dx?: number - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseWheel( - params.dy, - params.dx, - target.worktreeId, - target.browserPageId - ) - } - - // ── Find (semantic locators) ── - - async browserFind( - params: { - locator: string - value: string - action: string - text?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().find( - params.locator, - params.value, - params.action, - params.text, - target.worktreeId, - target.browserPageId - ) - } - - // ── Set commands ── - - async browserSetDevice(params: { name: string } & BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setDevice( - params.name, - target.worktreeId, - target.browserPageId - ) - } - - async browserSetOffline( - params: { state?: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setOffline( - params.state, - target.worktreeId, - target.browserPageId - ) - } - - async browserSetHeaders( - params: { headers: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setHeaders( - params.headers, - target.worktreeId, - target.browserPageId - ) - } - - async browserSetCredentials( - params: { - user: string - pass: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setCredentials( - params.user, - params.pass, - target.worktreeId, - target.browserPageId - ) - } - - async browserSetMedia( - params: { - colorScheme?: string - reducedMotion?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setMedia( - params.colorScheme, - params.reducedMotion, - target.worktreeId, - target.browserPageId - ) - } - - // ── Clipboard commands ── - - async browserClipboardRead(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().clipboardRead(target.worktreeId, target.browserPageId) - } - - async browserClipboardWrite( - params: { text: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().clipboardWrite( - params.text, - target.worktreeId, - target.browserPageId - ) - } - - // ── Dialog commands ── - - async browserDialogAccept( - params: { text?: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().dialogAccept( - params.text, - target.worktreeId, - target.browserPageId - ) - } - - async browserDialogDismiss(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().dialogDismiss(target.worktreeId, target.browserPageId) - } - - // ── Storage commands ── - - async browserStorageLocalGet( - params: { key: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageLocalGet( - params.key, - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageLocalSet( - params: { - key: string - value: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageLocalSet( - params.key, - params.value, - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageLocalClear(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageLocalClear( - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageSessionGet( - params: { key: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageSessionGet( - params.key, - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageSessionSet( - params: { - key: string - value: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageSessionSet( - params.key, - params.value, - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageSessionClear(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageSessionClear( - target.worktreeId, - target.browserPageId - ) - } - - // ── Download command ── - - async browserDownload( - params: { - selector: string - path: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().download( - params.selector, - params.path, - target.worktreeId, - target.browserPageId - ) - } - - // ── Highlight command ── - - async browserHighlight( - params: { selector: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().highlight( - params.selector, - target.worktreeId, - target.browserPageId - ) - } - - // ── New: exec passthrough + tab lifecycle ── - - async browserExec(params: { command: string } & BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().exec( - params.command, - target.worktreeId, - target.browserPageId - ) - } - - async browserTabCreate(params: { - url?: string - worktree?: string - profileId?: string - }): Promise<{ browserPageId: string }> { - const url = params.url ?? 'about:blank' - const worktreeId = params.worktree - ? (await this.resolveWorktreeSelector(params.worktree)).id - : undefined - const { browserPageId } = await this.createBrowserTabInRenderer( - url, - worktreeId, - params.profileId - ) - - // Why: the renderer creates the Zustand tab immediately, but the webview must - // mount and fire dom-ready before registerGuest runs. Waiting here ensures the - // tab is operable by subsequent CLI commands (snapshot, click, etc.). - // If registration doesn't complete within timeout, return the ID anyway — the - // tab exists in the UI but may not be ready for automation commands yet. - try { - await waitForTabRegistration(browserPageId) - } catch { - // Tab was created in the renderer but the webview hasn't finished mounting. - // Return success since the tab exists; subsequent commands will fail with a - // clear "tab not available" error if the webview never loads. - } - - // Why: newly created tabs should be auto-activated so subsequent commands - // (snapshot, click, goto) target the new tab without requiring an explicit - // tab switch. Without this, the bridge's active tab still points at the - // previously active tab and the new tab shows active: false in tab list. - const bridge = this.requireAgentBrowserBridge() - const wcId = bridge.getRegisteredTabs(worktreeId).get(browserPageId) - if (wcId != null) { - bridge.setActiveTab(wcId, worktreeId) - } - - // Why: the renderer sets webview.src=url on mount, but agent-browser connects - // via CDP after the webview loads about:blank. Without an explicit goto, the - // page stays blank from agent-browser's perspective. Navigate via the bridge - // so agent-browser's CDP session tracks the correct page state. - if (url && url !== 'about:blank') { - try { - const result = await bridge.goto(url, worktreeId, browserPageId) - this.notifyRendererNavigation(browserPageId, result.url, result.title) - } catch { - // Tab exists but navigation failed — caller can retry with explicit goto - } - } - - return { browserPageId } - } - - async browserTabSetProfile( - params: { - profileId: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const browserPageId = - target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId) - if (!browserPageId) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - // Why: 'default' is a synthetic id; fall back to the registry's default profile when not registered. - const profile = - browserSessionRegistry.getProfile(params.profileId) ?? - (params.profileId === 'default' ? browserSessionRegistry.getDefaultProfile() : null) - if (!profile) { - throw new BrowserError( - 'invalid_argument', - `Browser profile ${params.profileId} was not found` - ) - } - - // Why: short-circuit no-op switches so the renderer doesn't tear down and - // remount the webview when the tab is already on the requested profile. - const currentProfileId = browserManager.getSessionProfileIdForTab(browserPageId) ?? 'default' - if (currentProfileId === profile.id) { - return { - browserPageId, - profileId: profile.id, - profileLabel: profile.label - } - } - - const win = this.getAuthoritativeWindow() - const requestId = randomUUID() - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - ipcMain.removeListener('browser:tabSetProfileReply', handler) - reject(new Error('Tab profile update timed out')) - }, 10_000) - - const handler = ( - _event: Electron.IpcMainEvent, - reply: { requestId: string; error?: string } - ): void => { - if (reply.requestId !== requestId) { - return - } - clearTimeout(timer) - ipcMain.removeListener('browser:tabSetProfileReply', handler) - if (reply.error) { - reject(new Error(reply.error)) - } else { - resolve() - } - } - ipcMain.on('browser:tabSetProfileReply', handler) - win.webContents.send('browser:requestTabSetProfile', { - requestId, - browserPageId, - profileId: profile.id - }) - }) - - // Why: the renderer destroys the old webview and remounts on the new - // partition. Wait for the re-register so a follow-up tab list - // --show-profile reads the updated sessionProfileId from BrowserManager - // instead of stale data, and so subsequent CLI ops (snapshot, click, etc.) - // hit a guest that's already attached. - try { - await waitForTabRegistration(browserPageId) - } catch { - // Best-effort: re-register won't fire if the worktree is hidden. The - // store already reflects the new profile; downstream commands retry - // once the pane re-mounts. - } - - return { - browserPageId, - profileId: profile.id, - profileLabel: profile.label - } - } - - async browserTabProfileShow(params: { - page: string - worktree?: string - }): Promise { - const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) - const tab = this.describeBrowserTab(params.page, worktreeId) - return { - browserPageId: tab.browserPageId, - worktreeId: tab.worktreeId ?? null, - profileId: tab.profileId ?? null, - profileLabel: tab.profileLabel ?? null - } - } - - async browserTabProfileClone( - params: { - profileId: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const sourceBrowserPageId = - target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId) - if (!sourceBrowserPageId) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - const sourceTab = this.describeBrowserTab(sourceBrowserPageId, target.worktreeId) - const profile = browserSessionRegistry.getProfile(params.profileId) - if (!profile) { - throw new BrowserError( - 'invalid_argument', - `Browser profile ${params.profileId} was not found` - ) - } - const created = await this.createBrowserTabInRenderer( - sourceTab.url, - sourceTab.worktreeId ?? target.worktreeId, - profile.id - ) - // Why: parity with browserTabCreate. Wait for the cloned tab's webview to - // register so the returned browserPageId is operable by the next CLI call. - try { - await waitForTabRegistration(created.browserPageId) - } catch { - // Best-effort: registration may not fire if the worktree is hidden. - } - return { - browserPageId: created.browserPageId, - sourceBrowserPageId, - profileId: profile.id, - profileLabel: profile.label - } - } - - async browserProfileList(): Promise { - return { profiles: browserSessionRegistry.listProfiles() } - } - - async browserProfileCreate(params: { - label: string - scope: 'isolated' | 'imported' - }): Promise { - return { - profile: browserSessionRegistry.createProfile(params.scope, params.label) - } - } - - async browserProfileDelete(params: { profileId: string }): Promise { - return { - deleted: await browserSessionRegistry.deleteProfile(params.profileId), - profileId: params.profileId - } - } - - async browserTabClose(params: { - index?: number - page?: string - worktree?: string - }): Promise<{ closed: boolean }> { - const bridge = this.requireAgentBrowserBridge() - const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) - - let tabId: string | null = null - if (typeof params.page === 'string' && params.page.length > 0) { - if (!bridge.getRegisteredTabs(worktreeId).has(params.page)) { - const scope = worktreeId ? ' in this worktree' : '' - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${params.page} was not found${scope}` - ) - } - tabId = params.page - } else if (params.index !== undefined) { - const tabs = bridge.getRegisteredTabs(worktreeId) - const entries = [...tabs.entries()] - if (params.index < 0 || params.index >= entries.length) { - throw new Error(`Tab index ${params.index} out of range (0-${entries.length - 1})`) - } - tabId = entries[params.index][0] - } else { - // Why: try the bridge first (registered tabs with webviews), then fall back - // to asking the renderer to close its active browser tab (handles cases where - // the webview hasn't mounted yet, e.g. tab was just created). - const tabs = bridge.getRegisteredTabs(worktreeId) - const entries = [...tabs.entries()] - const activeEntry = entries.find(([, wcId]) => wcId === bridge.getActiveWebContentsId()) - if (activeEntry) { - tabId = activeEntry[0] - } - } - - const win = this.getAuthoritativeWindow() - const requestId = randomUUID() - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - ipcMain.removeListener('browser:tabCloseReply', handler) - reject(new Error('Tab close timed out')) - }, 10_000) - - const handler = ( - _event: Electron.IpcMainEvent, - reply: { requestId: string; error?: string } - ): void => { - if (reply.requestId !== requestId) { - return - } - clearTimeout(timer) - ipcMain.removeListener('browser:tabCloseReply', handler) - if (reply.error) { - reject(new Error(reply.error)) - } else { - resolve() - } - } - ipcMain.on('browser:tabCloseReply', handler) - // Why: when main cannot resolve a concrete tab id itself (for example if a - // browser workspace exists in the renderer before its guest mounts), the - // renderer still needs the intended worktree scope. Otherwise it falls - // back to the globally active browser tab and can close a tab in the - // wrong worktree. - win.webContents.send('browser:requestTabClose', { requestId, tabId, worktreeId }) - }) - - return { closed: true } - } - - private enrichBrowserTabInfo( - tab: BrowserTabListResult['tabs'][number] - ): BrowserTabListResult['tabs'][number] { - const rawProfileId = browserManager.getSessionProfileIdForTab(tab.browserPageId) - const profile = - browserSessionRegistry.getProfile(rawProfileId ?? 'default') ?? - browserSessionRegistry.getDefaultProfile() - return { - ...tab, - worktreeId: browserManager.getWorktreeIdForTab(tab.browserPageId) ?? null, - profileId: profile.id, - profileLabel: profile.label - } - } - - private describeBrowserTab( - browserPageId: string, - explicitWorktreeId?: string - ): BrowserTabListResult['tabs'][number] { - const worktreeId = explicitWorktreeId ?? browserManager.getWorktreeIdForTab(browserPageId) - const tab = this.requireAgentBrowserBridge() - .tabList(worktreeId) - .tabs.find((entry) => entry.browserPageId === browserPageId) - if (!tab) { - const scope = worktreeId ? ' in this worktree' : '' - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${browserPageId} was not found${scope}` - ) - } - return this.enrichBrowserTabInfo(tab) - } - - private async createBrowserTabInRenderer( - url: string, - worktreeId?: string, - profileId?: string - ): Promise<{ browserPageId: string }> { - const win = this.getAuthoritativeWindow() - const requestId = randomUUID() - - if (worktreeId) { - await this.ensureBrowserWorktreeActive(worktreeId) - } - - const browserPageId = await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - ipcMain.removeListener('browser:tabCreateReply', handler) - reject(new Error('Tab creation timed out')) - }, 10_000) - - const handler = ( - _event: Electron.IpcMainEvent, - reply: { requestId: string; browserPageId?: string; error?: string } - ): void => { - if (reply.requestId !== requestId) { - return - } - clearTimeout(timer) - ipcMain.removeListener('browser:tabCreateReply', handler) - if (reply.error) { - reject(new Error(reply.error)) - } else { - resolve(reply.browserPageId!) - } - } - ipcMain.on('browser:tabCreateReply', handler) - win.webContents.send('browser:requestTabCreate', { - requestId, - url, - worktreeId, - sessionProfileId: profileId - }) - }) - - return { browserPageId } - } + private readonly browserCommands = new RuntimeBrowserCommands({ + getAgentBrowserBridge: () => this.agentBrowserBridge, + resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), + getAuthoritativeWindow: () => this.getAuthoritativeWindow(), + getAvailableAuthoritativeWindow: () => this.getAvailableAuthoritativeWindow() + }) + + browserSnapshot: RuntimeBrowserCommands['browserSnapshot'] = + this.browserCommands.browserSnapshot.bind(this.browserCommands) + + browserClick: RuntimeBrowserCommands['browserClick'] = this.browserCommands.browserClick.bind( + this.browserCommands + ) + + browserGoto: RuntimeBrowserCommands['browserGoto'] = this.browserCommands.browserGoto.bind( + this.browserCommands + ) + + browserFill: RuntimeBrowserCommands['browserFill'] = this.browserCommands.browserFill.bind( + this.browserCommands + ) + + browserType: RuntimeBrowserCommands['browserType'] = this.browserCommands.browserType.bind( + this.browserCommands + ) + + browserSelect: RuntimeBrowserCommands['browserSelect'] = this.browserCommands.browserSelect.bind( + this.browserCommands + ) + + browserScroll: RuntimeBrowserCommands['browserScroll'] = this.browserCommands.browserScroll.bind( + this.browserCommands + ) + + browserBack: RuntimeBrowserCommands['browserBack'] = this.browserCommands.browserBack.bind( + this.browserCommands + ) + + browserReload: RuntimeBrowserCommands['browserReload'] = this.browserCommands.browserReload.bind( + this.browserCommands + ) + + browserScreenshot: RuntimeBrowserCommands['browserScreenshot'] = + this.browserCommands.browserScreenshot.bind(this.browserCommands) + + browserEval: RuntimeBrowserCommands['browserEval'] = this.browserCommands.browserEval.bind( + this.browserCommands + ) + + browserTabList: RuntimeBrowserCommands['browserTabList'] = + this.browserCommands.browserTabList.bind(this.browserCommands) + + browserTabShow: RuntimeBrowserCommands['browserTabShow'] = + this.browserCommands.browserTabShow.bind(this.browserCommands) + + browserTabCurrent: RuntimeBrowserCommands['browserTabCurrent'] = + this.browserCommands.browserTabCurrent.bind(this.browserCommands) + + browserTabSwitch: RuntimeBrowserCommands['browserTabSwitch'] = + this.browserCommands.browserTabSwitch.bind(this.browserCommands) + + browserHover: RuntimeBrowserCommands['browserHover'] = this.browserCommands.browserHover.bind( + this.browserCommands + ) + + browserDrag: RuntimeBrowserCommands['browserDrag'] = this.browserCommands.browserDrag.bind( + this.browserCommands + ) + + browserUpload: RuntimeBrowserCommands['browserUpload'] = this.browserCommands.browserUpload.bind( + this.browserCommands + ) + + browserWait: RuntimeBrowserCommands['browserWait'] = this.browserCommands.browserWait.bind( + this.browserCommands + ) + + browserCheck: RuntimeBrowserCommands['browserCheck'] = this.browserCommands.browserCheck.bind( + this.browserCommands + ) + + browserFocus: RuntimeBrowserCommands['browserFocus'] = this.browserCommands.browserFocus.bind( + this.browserCommands + ) + + browserClear: RuntimeBrowserCommands['browserClear'] = this.browserCommands.browserClear.bind( + this.browserCommands + ) + + browserSelectAll: RuntimeBrowserCommands['browserSelectAll'] = + this.browserCommands.browserSelectAll.bind(this.browserCommands) + + browserKeypress: RuntimeBrowserCommands['browserKeypress'] = + this.browserCommands.browserKeypress.bind(this.browserCommands) + + browserPdf: RuntimeBrowserCommands['browserPdf'] = this.browserCommands.browserPdf.bind( + this.browserCommands + ) + + browserFullScreenshot: RuntimeBrowserCommands['browserFullScreenshot'] = + this.browserCommands.browserFullScreenshot.bind(this.browserCommands) + + browserCookieGet: RuntimeBrowserCommands['browserCookieGet'] = + this.browserCommands.browserCookieGet.bind(this.browserCommands) + + browserCookieSet: RuntimeBrowserCommands['browserCookieSet'] = + this.browserCommands.browserCookieSet.bind(this.browserCommands) + + browserCookieDelete: RuntimeBrowserCommands['browserCookieDelete'] = + this.browserCommands.browserCookieDelete.bind(this.browserCommands) + + browserSetViewport: RuntimeBrowserCommands['browserSetViewport'] = + this.browserCommands.browserSetViewport.bind(this.browserCommands) + + browserSetGeolocation: RuntimeBrowserCommands['browserSetGeolocation'] = + this.browserCommands.browserSetGeolocation.bind(this.browserCommands) + + browserInterceptEnable: RuntimeBrowserCommands['browserInterceptEnable'] = + this.browserCommands.browserInterceptEnable.bind(this.browserCommands) + + browserInterceptDisable: RuntimeBrowserCommands['browserInterceptDisable'] = + this.browserCommands.browserInterceptDisable.bind(this.browserCommands) + + browserInterceptList: RuntimeBrowserCommands['browserInterceptList'] = + this.browserCommands.browserInterceptList.bind(this.browserCommands) + + browserCaptureStart: RuntimeBrowserCommands['browserCaptureStart'] = + this.browserCommands.browserCaptureStart.bind(this.browserCommands) + + browserCaptureStop: RuntimeBrowserCommands['browserCaptureStop'] = + this.browserCommands.browserCaptureStop.bind(this.browserCommands) + + browserConsoleLog: RuntimeBrowserCommands['browserConsoleLog'] = + this.browserCommands.browserConsoleLog.bind(this.browserCommands) + + browserNetworkLog: RuntimeBrowserCommands['browserNetworkLog'] = + this.browserCommands.browserNetworkLog.bind(this.browserCommands) + + browserDblclick: RuntimeBrowserCommands['browserDblclick'] = + this.browserCommands.browserDblclick.bind(this.browserCommands) + + browserForward: RuntimeBrowserCommands['browserForward'] = + this.browserCommands.browserForward.bind(this.browserCommands) + + browserScrollIntoView: RuntimeBrowserCommands['browserScrollIntoView'] = + this.browserCommands.browserScrollIntoView.bind(this.browserCommands) + + browserGet: RuntimeBrowserCommands['browserGet'] = this.browserCommands.browserGet.bind( + this.browserCommands + ) + + browserIs: RuntimeBrowserCommands['browserIs'] = this.browserCommands.browserIs.bind( + this.browserCommands + ) + + browserKeyboardInsertText: RuntimeBrowserCommands['browserKeyboardInsertText'] = + this.browserCommands.browserKeyboardInsertText.bind(this.browserCommands) + + browserMouseMove: RuntimeBrowserCommands['browserMouseMove'] = + this.browserCommands.browserMouseMove.bind(this.browserCommands) + + browserMouseDown: RuntimeBrowserCommands['browserMouseDown'] = + this.browserCommands.browserMouseDown.bind(this.browserCommands) + + browserMouseUp: RuntimeBrowserCommands['browserMouseUp'] = + this.browserCommands.browserMouseUp.bind(this.browserCommands) + + browserMouseWheel: RuntimeBrowserCommands['browserMouseWheel'] = + this.browserCommands.browserMouseWheel.bind(this.browserCommands) + + browserFind: RuntimeBrowserCommands['browserFind'] = this.browserCommands.browserFind.bind( + this.browserCommands + ) + + browserSetDevice: RuntimeBrowserCommands['browserSetDevice'] = + this.browserCommands.browserSetDevice.bind(this.browserCommands) + + browserSetOffline: RuntimeBrowserCommands['browserSetOffline'] = + this.browserCommands.browserSetOffline.bind(this.browserCommands) + + browserSetHeaders: RuntimeBrowserCommands['browserSetHeaders'] = + this.browserCommands.browserSetHeaders.bind(this.browserCommands) + + browserSetCredentials: RuntimeBrowserCommands['browserSetCredentials'] = + this.browserCommands.browserSetCredentials.bind(this.browserCommands) + + browserSetMedia: RuntimeBrowserCommands['browserSetMedia'] = + this.browserCommands.browserSetMedia.bind(this.browserCommands) + + browserClipboardRead: RuntimeBrowserCommands['browserClipboardRead'] = + this.browserCommands.browserClipboardRead.bind(this.browserCommands) + + browserClipboardWrite: RuntimeBrowserCommands['browserClipboardWrite'] = + this.browserCommands.browserClipboardWrite.bind(this.browserCommands) + + browserDialogAccept: RuntimeBrowserCommands['browserDialogAccept'] = + this.browserCommands.browserDialogAccept.bind(this.browserCommands) + + browserDialogDismiss: RuntimeBrowserCommands['browserDialogDismiss'] = + this.browserCommands.browserDialogDismiss.bind(this.browserCommands) + + browserStorageLocalGet: RuntimeBrowserCommands['browserStorageLocalGet'] = + this.browserCommands.browserStorageLocalGet.bind(this.browserCommands) + + browserStorageLocalSet: RuntimeBrowserCommands['browserStorageLocalSet'] = + this.browserCommands.browserStorageLocalSet.bind(this.browserCommands) + + browserStorageLocalClear: RuntimeBrowserCommands['browserStorageLocalClear'] = + this.browserCommands.browserStorageLocalClear.bind(this.browserCommands) + + browserStorageSessionGet: RuntimeBrowserCommands['browserStorageSessionGet'] = + this.browserCommands.browserStorageSessionGet.bind(this.browserCommands) + + browserStorageSessionSet: RuntimeBrowserCommands['browserStorageSessionSet'] = + this.browserCommands.browserStorageSessionSet.bind(this.browserCommands) + + browserStorageSessionClear: RuntimeBrowserCommands['browserStorageSessionClear'] = + this.browserCommands.browserStorageSessionClear.bind(this.browserCommands) + + browserDownload: RuntimeBrowserCommands['browserDownload'] = + this.browserCommands.browserDownload.bind(this.browserCommands) + + browserHighlight: RuntimeBrowserCommands['browserHighlight'] = + this.browserCommands.browserHighlight.bind(this.browserCommands) + + browserExec: RuntimeBrowserCommands['browserExec'] = this.browserCommands.browserExec.bind( + this.browserCommands + ) + + browserTabCreate: RuntimeBrowserCommands['browserTabCreate'] = + this.browserCommands.browserTabCreate.bind(this.browserCommands) + + browserTabSetProfile: RuntimeBrowserCommands['browserTabSetProfile'] = + this.browserCommands.browserTabSetProfile.bind(this.browserCommands) + + browserTabProfileShow: RuntimeBrowserCommands['browserTabProfileShow'] = + this.browserCommands.browserTabProfileShow.bind(this.browserCommands) + + browserTabProfileClone: RuntimeBrowserCommands['browserTabProfileClone'] = + this.browserCommands.browserTabProfileClone.bind(this.browserCommands) + + browserProfileList: RuntimeBrowserCommands['browserProfileList'] = + this.browserCommands.browserProfileList.bind(this.browserCommands) + + browserProfileCreate: RuntimeBrowserCommands['browserProfileCreate'] = + this.browserCommands.browserProfileCreate.bind(this.browserCommands) + + browserProfileDelete: RuntimeBrowserCommands['browserProfileDelete'] = + this.browserCommands.browserProfileDelete.bind(this.browserCommands) + + browserProfileDetectBrowsers: RuntimeBrowserCommands['browserProfileDetectBrowsers'] = + this.browserCommands.browserProfileDetectBrowsers.bind(this.browserCommands) + + browserProfileImportFromBrowser: RuntimeBrowserCommands['browserProfileImportFromBrowser'] = + this.browserCommands.browserProfileImportFromBrowser.bind(this.browserCommands) + + browserProfileClearDefaultCookies: RuntimeBrowserCommands['browserProfileClearDefaultCookies'] = + this.browserCommands.browserProfileClearDefaultCookies.bind(this.browserCommands) + + browserTabClose: RuntimeBrowserCommands['browserTabClose'] = + this.browserCommands.browserTabClose.bind(this.browserCommands) private getAuthoritativeWindow(): BrowserWindow { - if (this.authoritativeWindowId === null) { - throw new Error('No renderer window available') - } - const win = BrowserWindow.fromId(this.authoritativeWindowId) + const win = this.getAvailableAuthoritativeWindow() if (!win || win.isDestroyed()) { throw new Error('No renderer window available') } return win } + + private getAvailableAuthoritativeWindow(): BrowserWindow | null { + if (this.authoritativeWindowId === null) { + return null + } + if (!BrowserWindow?.fromId) { + return null + } + const win = BrowserWindow.fromId(this.authoritativeWindowId) + return win && !win.isDestroyed() ? win : null + } } const MAX_TAIL_LINES = 120 @@ -7922,6 +8276,10 @@ function branchSelectorMatches(branch: string, selector: string): boolean { return normalizeBranchRef(branch) === normalizeBranchRef(selector) } +function runtimePathsEqual(left: string, right: string): boolean { + return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right) +} + function normalizeBranchRef(branch: string): string { return branch.startsWith('refs/heads/') ? branch.slice('refs/heads/'.length) : branch } @@ -7956,23 +8314,11 @@ function findResolvedWorktreeIdForPath( return null } const matches = resolvedWorktrees - .filter( - (worktree) => - areWorktreePathsEqual(worktree.path, cwd) || isPathInsideWorktree(cwd, worktree.path) - ) + .filter((worktree) => isPathInsideOrEqual(worktree.path, cwd)) .sort((left, right) => right.path.length - left.path.length) return matches[0]?.id ?? null } -function isPathInsideWorktree(candidatePath: string, worktreePath: string): boolean { - if (candidatePath === worktreePath) { - return true - } - const normalizedCandidate = candidatePath.replace(/\\/g, '/').replace(/\/+$/, '') - const normalizedWorktree = worktreePath.replace(/\\/g, '/').replace(/\/+$/, '') - return normalizedCandidate.startsWith(`${normalizedWorktree}/`) -} - function getLeafWorktreeStatus( leaf: RuntimeLeafRecord, tabTitle: string | null @@ -8033,72 +8379,6 @@ function maxTimestamp(left: number | null, right: number | null): number | null return Math.max(left, right) } -function isSafeMobileRelativePath(relativePath: string): boolean { - if (!relativePath || relativePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(relativePath)) { - return false - } - const parts = relativePath.replace(/\\/g, '/').split('/') - return parts.every((part) => part !== '' && part !== '.' && part !== '..') -} - -function isMobileMarkdownPath(relativePath: string): boolean { - return /\.(md|mdx|markdown)$/i.test(relativePath) -} - -function isMobileBinaryPath(relativePath: string): boolean { - const basename = basenameFromRelativePath(relativePath) - const dotIndex = basename.lastIndexOf('.') - if (dotIndex <= 0) { - return false - } - return MOBILE_BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase()) -} - -function basenameFromRelativePath(relativePath: string): string { - const normalized = relativePath.replace(/\\/g, '/') - return normalized.slice(normalized.lastIndexOf('/') + 1) -} - -function joinWorktreeRelativePath(rootPath: string, relativePath: string): string { - const normalizedRelativePath = relativePath.replace(/\\/g, '/') - if (/^[a-zA-Z]:[\\/]/.test(rootPath) || rootPath.startsWith('\\\\')) { - return win32.join(rootPath, ...normalizedRelativePath.split('/')) - } - return posix.join(rootPath, ...normalizedRelativePath.split('/')) -} - -async function readLocalMobileFile(filePath: string, store: Store): Promise { - const authorizedPath = await resolveAuthorizedPath(filePath, store) - const fileStat = await stat(authorizedPath) - // Why: mobile file previews are read-only convenience views; cap the read so - // opening a generated log or bundle cannot block the WebSocket like oversized scrollback. - const readLimit = Math.min(fileStat.size, MOBILE_FILE_READ_MAX_BYTES + 1) - const handle = await open(authorizedPath, 'r') - try { - const buffer = Buffer.alloc(readLimit) - const { bytesRead } = await handle.read(buffer, 0, readLimit, 0) - return buffer.subarray(0, bytesRead).toString('utf8') - } finally { - await handle.close() - } -} - -function truncateMobileFilePreview(content: string): { - content: string - truncated: boolean - byteLength: number -} { - const buffer = Buffer.from(content, 'utf8') - if (buffer.byteLength <= MOBILE_FILE_READ_MAX_BYTES) { - return { content, truncated: false, byteLength: buffer.byteLength } - } - return { - content: buffer.subarray(0, MOBILE_FILE_READ_MAX_BYTES).toString('utf8'), - truncated: true, - byteLength: buffer.byteLength - } -} - function compareWorktreePs( left: RuntimeWorktreePsSummary, right: RuntimeWorktreePsSummary diff --git a/src/cli/orchestration.subprocess.test.ts b/src/main/runtime/orchestration-cli-subprocess.test.ts similarity index 93% rename from src/cli/orchestration.subprocess.test.ts rename to src/main/runtime/orchestration-cli-subprocess.test.ts index c8648ceafe6..054328a9653 100644 --- a/src/cli/orchestration.subprocess.test.ts +++ b/src/main/runtime/orchestration-cli-subprocess.test.ts @@ -20,9 +20,9 @@ import { existsSync, mkdtempSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { describe, expect, it } from 'vitest' -import { OrcaRuntimeService } from '../main/runtime/orca-runtime' -import { OrchestrationDb } from '../main/runtime/orchestration/db' -import { OrcaRuntimeRpcServer } from '../main/runtime/runtime-rpc' +import { OrcaRuntimeService } from './orca-runtime' +import { OrchestrationDb } from './orchestration/db' +import { OrcaRuntimeRpcServer } from './runtime-rpc' // Why: Vitest runs tests with `process.cwd()` pinned to the repo root, so // join against it to locate the compiled CLI regardless of where this test @@ -108,12 +108,12 @@ describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => { expect(heartbeatLines[0]).toHaveProperty('elapsedMs') expect(heartbeatLines[0]).toHaveProperty('deadlineMs', waitTimeoutMs) - // Why: first heartbeat must arrive within one interval + scheduler - // slack (300ms is generous); if the stream were fully buffered we'd - // see everything only after exit. + // Why: under full-suite load the child process startup may take longer + // than one heartbeat interval. The invariant that matters is that at + // least one heartbeat is observed before the terminal stdout payload. const firstHeartbeatChunk = stderrChunks.find((c) => c.data.includes('_heartbeat')) expect(firstHeartbeatChunk).toBeDefined() - expect(firstHeartbeatChunk!.at).toBeLessThan(heartbeatMs + 300) + expect(firstHeartbeatChunk!.at).toBeLessThan(stdoutChunks[0]?.at ?? Number.POSITIVE_INFINITY) // Why: line-flushing proof — the *first* heartbeat chunk must arrive // strictly before the exit chunk; i.e. we got at least two separate diff --git a/src/main/runtime/remote-runtime-request-connection.integration.test.ts b/src/main/runtime/remote-runtime-request-connection.integration.test.ts new file mode 100644 index 00000000000..540ad425b58 --- /dev/null +++ b/src/main/runtime/remote-runtime-request-connection.integration.test.ts @@ -0,0 +1,67 @@ +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, expect, it } from 'vitest' +import { getDefaultRepoHookSettings } from '../../shared/constants' +import type { Repo } from '../../shared/types' +import { parsePairingCode } from '../../shared/pairing' +import { RemoteRuntimeRequestConnection } from '../../shared/remote-runtime-request-connection' +import type { OrcaRuntimeService } from './orca-runtime' +import { OrcaRuntimeRpcServer } from './runtime-rpc' + +describe('remote runtime request connection integration', () => { + it('fetches repos through the real E2EE WebSocket runtime', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-request-')) + const repoPath = join(userDataPath, 'repo') + const repos: Repo[] = [ + { + id: 'repo-1', + path: repoPath, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + hookSettings: getDefaultRepoHookSettings(), + worktreeBaseRef: 'main', + kind: 'git' + } + ] + const runtime = { + getRuntimeId: () => 'runtime-test', + getStartedAt: () => 1, + cleanupSubscriptionsForConnection: () => {}, + cancelMobileDictationForConnection: () => {}, + onClientDisconnected: () => {}, + listRepos: () => repos + } as unknown as OrcaRuntimeService + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + const offer = server.createPairingOffer({ name: 'integration', scope: 'runtime' }) + if (!offer.available) { + throw new Error('pairing unavailable') + } + const pairing = parsePairingCode(offer.pairingUrl) + if (!pairing) { + throw new Error('invalid pairing') + } + const connection = new RemoteRuntimeRequestConnection(pairing) + try { + await expect(connection.request('repo.list', undefined, 1000)).resolves.toMatchObject({ + ok: true, + result: { repos } + }) + } finally { + connection.close() + } + } finally { + await server.stop() + rmSync(userDataPath, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index bdbc6e5b63b..4e51248c0b4 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -3,6 +3,7 @@ // CLI-facing contract greppable and lets the dispatcher verify every payload // against the same shape the handler consumed during development. import { ZodError, type ZodType } from 'zod' +import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol' import type { OrcaRuntimeService } from '../orca-runtime' export type RpcEnvelopeMeta = { @@ -59,6 +60,13 @@ export type RpcContext = { // responses after the binary terminal cutover. Undefined on Unix/socket // transports and non-E2EE WebSocket paths. sendBinary?: (bytes: Uint8Array) => void + // Why: binary terminal input/resize frames arrive outside JSON-RPC after a + // stream is established. The WebSocket transport owns the connection-scoped + // stream table; handlers register only the stream IDs they created. + registerBinaryStreamHandler?: ( + streamId: number, + handler: (frame: TerminalStreamFrame) => void + ) => () => void } export type RpcHandler = (params: TParams, ctx: RpcContext) => Promise | unknown diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index edd247cd92e..aa693b5b1b1 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -13,6 +13,7 @@ import { type RpcRequest, type RpcResponse } from './core' +import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol' import { errorResponse, mapBrowserError, mapRuntimeError, successResponse } from './errors' import { ALL_RPC_METHODS } from './methods' import type { OrcaRuntimeService } from '../orca-runtime' @@ -79,8 +80,13 @@ export class RpcDispatcher { reply: (response: string) => void, options?: { connectionId?: string + signal?: AbortSignal clientId?: string sendBinary?: (bytes: Uint8Array) => void + registerBinaryStreamHandler?: ( + streamId: number, + handler: (frame: TerminalStreamFrame) => void + ) => () => void } ): Promise { const meta = this.meta() @@ -104,9 +110,11 @@ export class RpcDispatcher { try { const result = await method.handler(parsedParams.value, { runtime: this.runtime, + signal: options?.signal, connectionId: options?.connectionId, clientId: options?.clientId, - sendBinary: options?.sendBinary + sendBinary: options?.sendBinary, + registerBinaryStreamHandler: options?.registerBinaryStreamHandler }) reply(JSON.stringify(successResponse(request.id, meta, result))) } catch (error) { @@ -126,9 +134,11 @@ export class RpcDispatcher { parsedParams.value, { runtime: this.runtime, + signal: options?.signal, connectionId: options?.connectionId, clientId: options?.clientId, - sendBinary: options?.sendBinary + sendBinary: options?.sendBinary, + registerBinaryStreamHandler: options?.registerBinaryStreamHandler }, emit ) diff --git a/src/main/runtime/rpc/e2ee-channel.test.ts b/src/main/runtime/rpc/e2ee-channel.test.ts index 69b242c4ca8..a1daec13669 100644 --- a/src/main/runtime/rpc/e2ee-channel.test.ts +++ b/src/main/runtime/rpc/e2ee-channel.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import type { WebSocket } from 'ws' import { E2EEChannel, type E2EEChannelOptions } from './e2ee-channel' -import { generateKeyPair, deriveSharedKey, encrypt, decrypt } from './e2ee-crypto' +import { generateKeyPair, deriveSharedKey, encrypt, decrypt, encryptBytes } from './e2ee-crypto' function publicKeyToBase64(key: Uint8Array): string { return Buffer.from(key).toString('base64') @@ -182,6 +182,20 @@ describe('E2EEChannel', () => { expect(replyPlain).toBe('{"id":"rpc-1","ok":true}') }) + it('decrypts and forwards binary messages after authentication', () => { + const ctx = setup() + const sharedKey = doHandshake(ctx) + const received: Uint8Array[] = [] + + ctx.channel.onBinaryMessage((bytes) => { + received.push(bytes) + }) + + ctx.channel.handleRawMessage(encryptBytes(new Uint8Array([1, 2, 3]), sharedKey)) + + expect([...received[0]!]).toEqual([1, 2, 3]) + }) + it('silently drops messages with wrong key', () => { const ctx = setup() doHandshake(ctx) diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts index a105b882a0a..279da548310 100644 --- a/src/main/runtime/rpc/e2ee-channel.ts +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -46,6 +46,7 @@ export class E2EEChannel { encryptedBinaryReply: (response: Uint8Array) => void ) => void) | null = null + private binaryMessageHandler: ((plaintext: Uint8Array) => void) | null = null deviceToken: string | null = null @@ -71,6 +72,10 @@ export class E2EEChannel { this.messageHandler = handler } + onBinaryMessage(handler: (plaintext: Uint8Array) => void): void { + this.binaryMessageHandler = handler + } + handleRawMessage(raw: string | Uint8Array): void { if (this.state === 'awaiting_hello') { if (typeof raw !== 'string') { @@ -89,7 +94,14 @@ export class E2EEChannel { const plaintextBytes = decryptBytes(raw, this.sharedKey) if (plaintextBytes === null) { this.trackDecryptFailure() + return } + this.consecutiveFailures = 0 + if (this.state !== 'ready') { + this.onError(4001, 'Invalid binary message before authentication') + return + } + this.binaryMessageHandler?.(plaintextBytes) return } @@ -211,5 +223,6 @@ export class E2EEChannel { } this.sharedKey = null this.messageHandler = null + this.binaryMessageHandler = null } } diff --git a/src/main/runtime/rpc/e2ee-crypto.ts b/src/main/runtime/rpc/e2ee-crypto.ts index 40818c424b5..f0f4f460d83 100644 --- a/src/main/runtime/rpc/e2ee-crypto.ts +++ b/src/main/runtime/rpc/e2ee-crypto.ts @@ -1,53 +1,8 @@ -// Why: shared E2EE primitives for the desktop side. Wraps tweetnacl to provide -// encrypt/decrypt with the NaCl box format: [24-byte nonce][ciphertext]. JSON -// RPC uses base64 text frames; terminal streams use the raw byte bundle. -import nacl from 'tweetnacl' - -export function generateKeyPair(): nacl.BoxKeyPair { - return nacl.box.keyPair() -} - -export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array { - return nacl.box.before(peerPublicKey, ourSecretKey) -} - -export function encrypt(plaintext: string, sharedKey: Uint8Array): string { - const messageBytes = new TextEncoder().encode(plaintext) - return Buffer.from(encryptBytes(messageBytes, sharedKey)).toString('base64') -} - -export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null { - const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64')) - const plaintext = decryptBytes(bundle, sharedKey) - return plaintext ? new TextDecoder().decode(plaintext) : null -} - -export function encryptBytes( - plaintext: Uint8Array, - sharedKey: Uint8Array -): Uint8Array { - const nonce = nacl.randomBytes(nacl.box.nonceLength) - const ciphertext = nacl.box.after(plaintext, nonce, sharedKey) - - const bundle = new Uint8Array(nonce.length + ciphertext.length) - bundle.set(nonce) - bundle.set(ciphertext, nonce.length) - - return bundle -} - -export function decryptBytes(bundle: Uint8Array, sharedKey: Uint8Array): Uint8Array | null { - if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) { - return null - } - - const nonce = bundle.slice(0, nacl.box.nonceLength) - const ciphertext = bundle.slice(nacl.box.nonceLength) - const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) - - if (!plaintext) { - return null - } - - return plaintext -} +export { + decrypt, + decryptBytes, + deriveSharedKey, + encrypt, + encryptBytes, + generateKeyPair +} from '../../../shared/e2ee-crypto' diff --git a/src/main/runtime/rpc/methods/browser-core.ts b/src/main/runtime/rpc/methods/browser-core.ts index b141c0b14a3..3736aaf5e74 100644 --- a/src/main/runtime/rpc/methods/browser-core.ts +++ b/src/main/runtime/rpc/methods/browser-core.ts @@ -18,6 +18,7 @@ import { LimitParam, ProfileCreate, ProfileDelete, + ProfileImportFromBrowser, Screenshot, Scroll, Select, @@ -151,6 +152,21 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [ params: ProfileDelete, handler: async (params, { runtime }) => runtime.browserProfileDelete(params) }), + defineMethod({ + name: 'browser.profileDetectBrowsers', + params: null, + handler: async (_params, { runtime }) => runtime.browserProfileDetectBrowsers() + }), + defineMethod({ + name: 'browser.profileImportFromBrowser', + params: ProfileImportFromBrowser, + handler: async (params, { runtime }) => runtime.browserProfileImportFromBrowser(params) + }), + defineMethod({ + name: 'browser.profileClearDefaultCookies', + params: null, + handler: async (_params, { runtime }) => runtime.browserProfileClearDefaultCookies() + }), defineMethod({ name: 'browser.hover', params: Element, diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts index 0a216c1d9e0..1009429d625 100644 --- a/src/main/runtime/rpc/methods/browser-schemas.ts +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -143,6 +143,12 @@ export const ProfileDelete = z.object({ profileId: requiredString('Missing required --profile') }) +export const ProfileImportFromBrowser = z.object({ + profileId: requiredString('Missing required --profile'), + browserFamily: requiredString('Missing required --browser-family'), + browserProfile: OptionalString +}) + export const Drag = BrowserTarget.extend({ from: requiredString('Missing required --from and --to element refs'), to: requiredString('Missing required --from and --to element refs') diff --git a/src/main/runtime/rpc/methods/browser.test.ts b/src/main/runtime/rpc/methods/browser.test.ts new file mode 100644 index 00000000000..0312593e6b8 --- /dev/null +++ b/src/main/runtime/rpc/methods/browser.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { BROWSER_CORE_METHODS } from './browser-core' +import { BROWSER_EXTRA_METHODS } from './browser-extras' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('browser RPC methods', () => { + it('routes core browser automation commands to the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + browserSnapshot: vi.fn().mockResolvedValue({ elements: [] }), + browserGoto: vi.fn().mockResolvedValue({ url: 'https://example.com' }), + browserProfileDetectBrowsers: vi.fn().mockResolvedValue({ browsers: [] }), + browserProfileImportFromBrowser: vi.fn().mockResolvedValue({ ok: false, reason: 'empty' }), + browserTabCreate: vi.fn().mockResolvedValue({ browserPageId: 'page-1' }), + browserTabSwitch: vi.fn().mockResolvedValue({ browserPageId: 'page-1' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_CORE_METHODS }) + + await dispatcher.dispatch(makeRequest('browser.snapshot', { worktree: 'id:wt-1' })) + await dispatcher.dispatch( + makeRequest('browser.goto', { + worktree: 'id:wt-1', + page: 'page-1', + url: 'https://example.com' + }) + ) + await dispatcher.dispatch( + makeRequest('browser.tabCreate', { + worktree: 'id:wt-1', + url: 'https://example.com', + profileId: 'profile-1' + }) + ) + await dispatcher.dispatch( + makeRequest('browser.tabSwitch', { + worktree: 'id:wt-1', + index: 0, + focus: true + }) + ) + await dispatcher.dispatch(makeRequest('browser.profileDetectBrowsers')) + await dispatcher.dispatch( + makeRequest('browser.profileImportFromBrowser', { + profileId: 'profile-1', + browserFamily: 'chrome', + browserProfile: 'Default' + }) + ) + + expect(runtime.browserSnapshot).toHaveBeenCalledWith({ worktree: 'id:wt-1' }) + expect(runtime.browserGoto).toHaveBeenCalledWith({ + worktree: 'id:wt-1', + page: 'page-1', + url: 'https://example.com' + }) + expect(runtime.browserTabCreate).toHaveBeenCalledWith({ + worktree: 'id:wt-1', + url: 'https://example.com', + profileId: 'profile-1' + }) + expect(runtime.browserTabSwitch).toHaveBeenCalledWith({ + worktree: 'id:wt-1', + index: 0, + focus: true + }) + expect(runtime.browserProfileDetectBrowsers).toHaveBeenCalled() + expect(runtime.browserProfileImportFromBrowser).toHaveBeenCalledWith({ + profileId: 'profile-1', + browserFamily: 'chrome', + browserProfile: 'Default' + }) + }) + + it('routes browser session and environment controls to the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + browserCookieGet: vi.fn().mockResolvedValue({ cookies: [] }), + browserSetViewport: vi.fn().mockResolvedValue({ ok: true }), + browserMouseWheel: vi.fn().mockResolvedValue({ ok: true }), + browserStorageLocalSet: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_EXTRA_METHODS }) + + await dispatcher.dispatch( + makeRequest('browser.cookie.get', { + worktree: 'id:wt-1', + page: 'page-1', + url: 'https://example.com' + }) + ) + await dispatcher.dispatch( + makeRequest('browser.viewport', { + worktree: 'id:wt-1', + page: 'page-1', + width: 1024, + height: 768 + }) + ) + await dispatcher.dispatch( + makeRequest('browser.mouseWheel', { + worktree: 'id:wt-1', + page: 'page-1', + dy: 240 + }) + ) + await dispatcher.dispatch( + makeRequest('browser.storage.local.set', { + worktree: 'id:wt-1', + page: 'page-1', + key: 'orca', + value: 'enabled' + }) + ) + + expect(runtime.browserCookieGet).toHaveBeenCalledWith({ + worktree: 'id:wt-1', + page: 'page-1', + url: 'https://example.com' + }) + expect(runtime.browserSetViewport).toHaveBeenCalledWith({ + worktree: 'id:wt-1', + page: 'page-1', + width: 1024, + height: 768 + }) + expect(runtime.browserMouseWheel).toHaveBeenCalledWith({ + worktree: 'id:wt-1', + page: 'page-1', + dy: 240 + }) + expect(runtime.browserStorageLocalSet).toHaveBeenCalledWith({ + worktree: 'id:wt-1', + page: 'page-1', + key: 'orca', + value: 'enabled' + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/file-watch-event-batcher.ts b/src/main/runtime/rpc/methods/file-watch-event-batcher.ts new file mode 100644 index 00000000000..146ed251b0b --- /dev/null +++ b/src/main/runtime/rpc/methods/file-watch-event-batcher.ts @@ -0,0 +1,65 @@ +import type { FsChangeEvent } from '../../../../shared/types' + +const FILE_WATCH_FLUSH_MS = 150 +const FILE_WATCH_MAX_WAIT_MS = 500 + +export function createFileWatchEventBatcher( + worktree: string, + emit: (result: unknown) => void +): { + push: (events: FsChangeEvent[]) => void + flush: () => void + dispose: () => void +} { + let events: FsChangeEvent[] = [] + let timer: ReturnType | null = null + let firstEventAt = 0 + + const clearTimer = (): void => { + if (!timer) { + return + } + clearTimeout(timer) + timer = null + } + + const flush = (): void => { + clearTimer() + const nextEvents = events.splice(0) + firstEventAt = 0 + if (nextEvents.length === 0) { + return + } + emit({ type: 'changed', worktree, events: nextEvents }) + } + + return { + push(nextEvents: FsChangeEvent[]): void { + if (nextEvents.length === 0) { + return + } + events.push(...nextEvents) + const now = Date.now() + if (firstEventAt === 0) { + firstEventAt = now + } + if (now - firstEventAt >= FILE_WATCH_MAX_WAIT_MS) { + flush() + return + } + clearTimer() + // Why: remote file-watch events cross the runtime WebSocket before the + // renderer refreshes the tree. Match local watcher batching here. + timer = setTimeout(flush, FILE_WATCH_FLUSH_MS) + if (typeof timer.unref === 'function') { + timer.unref() + } + }, + flush, + dispose(): void { + clearTimer() + events = [] + firstEventAt = 0 + } + } +} diff --git a/src/main/runtime/rpc/methods/files.test.ts b/src/main/runtime/rpc/methods/files.test.ts index c05f252668e..708121e967b 100644 --- a/src/main/runtime/rpc/methods/files.test.ts +++ b/src/main/runtime/rpc/methods/files.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: file RPC routing coverage stays together so +the dispatcher contract for read, write, mutation, and watch methods is easy to audit. */ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' @@ -54,6 +56,117 @@ describe('file RPC methods', () => { }) }) + it('streams file watch changes until the subscription is cleaned up', async () => { + vi.useFakeTimers() + try { + type WatchCallback = ( + events: { kind: 'update'; absolutePath: string; isDirectory?: boolean }[] + ) => void + const watchFileExplorer = vi.fn(async (_worktree: string, _callback: WatchCallback) => { + return vi.fn() + }) + const cleanups = new Map void>() + const runtime = { + getRuntimeId: () => 'test-runtime', + watchFileExplorer, + registerSubscriptionCleanup: vi.fn().mockImplementation((id, cleanup) => { + cleanups.set(id, cleanup) + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + const replies: unknown[] = [] + + const dispatch = dispatcher.dispatchStreaming( + makeRequest('files.watch', { worktree: 'id:wt-1' }), + (response) => replies.push(JSON.parse(response)) + ) + + await vi.waitFor(() => { + expect(replies).toHaveLength(1) + }) + expect(runtime.watchFileExplorer).toHaveBeenCalledWith('id:wt-1', expect.any(Function)) + expect(replies[0]).toMatchObject({ + ok: true, + streaming: true, + result: { type: 'ready', subscriptionId: expect.stringContaining('files-watch-') } + }) + + const emitWatchChange = watchFileExplorer.mock.calls[0]?.[1] + expect(emitWatchChange).toBeDefined() + emitWatchChange?.([{ kind: 'update', absolutePath: '/repo/readme.md', isDirectory: false }]) + emitWatchChange?.([ + { kind: 'update', absolutePath: '/repo/package.json', isDirectory: false } + ]) + expect(replies).toHaveLength(1) + + await vi.runOnlyPendingTimersAsync() + + expect(replies[1]).toMatchObject({ + ok: true, + streaming: true, + result: { + type: 'changed', + worktree: 'id:wt-1', + events: [ + { kind: 'update', absolutePath: '/repo/readme.md', isDirectory: false }, + { kind: 'update', absolutePath: '/repo/package.json', isDirectory: false } + ] + } + }) + + const ready = replies[0] as { result: { subscriptionId: string } } + cleanups.get(ready.result.subscriptionId)?.() + await dispatch + + expect(replies[2]).toMatchObject({ + ok: true, + streaming: true, + result: { type: 'end' } + }) + } finally { + vi.useRealTimers() + } + }) + + it('tears down a file watch that resolves after the connection already closed', async () => { + type WatchCallback = ( + events: { kind: 'update'; absolutePath: string; isDirectory?: boolean }[] + ) => void + const unwatch = vi.fn() + let resolveWatch: (value: () => void) => void = () => {} + const watchFileExplorer = vi.fn((_worktree: string, _callback: WatchCallback) => { + return new Promise<() => void>((resolve) => { + resolveWatch = resolve + }) + }) + const runtime = { + getRuntimeId: () => 'test-runtime', + watchFileExplorer, + registerSubscriptionCleanup: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + const abortController = new AbortController() + const replies: unknown[] = [] + + const dispatch = dispatcher.dispatchStreaming( + makeRequest('files.watch', { worktree: 'id:wt-1' }), + (response) => replies.push(JSON.parse(response)), + { connectionId: 'conn-1', signal: abortController.signal } + ) + await vi.waitFor(() => { + expect(watchFileExplorer).toHaveBeenCalled() + }) + abortController.abort() + await dispatch + + resolveWatch(unwatch) + await vi.waitFor(() => { + expect(unwatch).toHaveBeenCalled() + }) + expect(runtime.registerSubscriptionCleanup).not.toHaveBeenCalled() + expect(replies).toEqual([]) + }) + it('reads a relative file path for a selected worktree', async () => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -77,4 +190,285 @@ describe('file RPC methods', () => { result: { content: 'export {}\\n', truncated: false } }) }) + + it('reads a preview file for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + readFileExplorerPreview: vi.fn().mockResolvedValue({ + content: 'base64', + isBinary: true, + isImage: true, + mimeType: 'image/png' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.readPreview', { worktree: 'id:wt-1', relativePath: 'img/logo.png' }) + ) + + expect(runtime.readFileExplorerPreview).toHaveBeenCalledWith('id:wt-1', 'img/logo.png') + expect(response).toMatchObject({ + ok: true, + result: { content: 'base64', isBinary: true, mimeType: 'image/png' } + }) + }) + + it('reads a file explorer directory for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + readFileExplorerDir: vi.fn().mockResolvedValue([{ name: 'src', isDirectory: true }]) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.readDir', { worktree: 'id:wt-1', relativePath: '' }) + ) + + expect(runtime.readFileExplorerDir).toHaveBeenCalledWith('id:wt-1', '') + expect(response).toMatchObject({ + ok: true, + result: [{ name: 'src', isDirectory: true }] + }) + }) + + it('writes file explorer content for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + writeFileExplorerFile: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.write', { + worktree: 'id:wt-1', + relativePath: 'src/index.ts', + content: 'export {}' + }) + ) + + expect(runtime.writeFileExplorerFile).toHaveBeenCalledWith( + 'id:wt-1', + 'src/index.ts', + 'export {}' + ) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('writes base64 file explorer content for runtime uploads', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + writeFileExplorerFileBase64: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.writeBase64', { + worktree: 'id:wt-1', + relativePath: 'assets/logo.png', + contentBase64: 'cG5n' + }) + ) + + expect(runtime.writeFileExplorerFileBase64).toHaveBeenCalledWith( + 'id:wt-1', + 'assets/logo.png', + 'cG5n' + ) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('writes base64 file explorer content chunks for large runtime uploads', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + writeFileExplorerFileBase64Chunk: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.writeBase64Chunk', { + worktree: 'id:wt-1', + relativePath: 'assets/video.mov', + contentBase64: 'AAAA', + append: true + }) + ) + + expect(runtime.writeFileExplorerFileBase64Chunk).toHaveBeenCalledWith( + 'id:wt-1', + 'assets/video.mov', + 'AAAA', + true + ) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('commits staged runtime uploads without clobbering the final destination', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + commitFileExplorerUpload: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.commitUpload', { + worktree: 'id:wt-1', + tempRelativePath: 'assets/.logo.png.orca-upload-a', + finalRelativePath: 'assets/logo.png' + }) + ) + + expect(runtime.commitFileExplorerUpload).toHaveBeenCalledWith( + 'id:wt-1', + 'assets/.logo.png.orca-upload-a', + 'assets/logo.png' + ) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('renames file explorer paths for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + renameFileExplorerPath: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.rename', { + worktree: 'id:wt-1', + oldRelativePath: 'old.ts', + newRelativePath: 'new.ts' + }) + ) + + expect(runtime.renameFileExplorerPath).toHaveBeenCalledWith('id:wt-1', 'old.ts', 'new.ts') + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('copies file explorer paths for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + copyFileExplorerPath: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.copy', { + worktree: 'id:wt-1', + sourceRelativePath: 'old.ts', + destinationRelativePath: 'old copy.ts' + }) + ) + + expect(runtime.copyFileExplorerPath).toHaveBeenCalledWith('id:wt-1', 'old.ts', 'old copy.ts') + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('deletes file explorer paths for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + deleteFileExplorerPath: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.delete', { + worktree: 'id:wt-1', + relativePath: 'src', + recursive: true + }) + ) + + expect(runtime.deleteFileExplorerPath).toHaveBeenCalledWith('id:wt-1', 'src', true) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('searches files for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + searchRuntimeFiles: vi.fn().mockResolvedValue({ + files: [], + totalMatches: 0, + truncated: false + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.search', { + worktree: 'id:wt-1', + query: 'needle', + caseSensitive: true, + maxResults: 50 + }) + ) + + expect(runtime.searchRuntimeFiles).toHaveBeenCalledWith('id:wt-1', { + query: 'needle', + caseSensitive: true, + wholeWord: undefined, + useRegex: undefined, + includePattern: undefined, + excludePattern: undefined, + maxResults: 50 + }) + expect(response).toMatchObject({ ok: true, result: { files: [], totalMatches: 0 } }) + }) + + it('lists all quick-open files for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listRuntimeFiles: vi.fn().mockResolvedValue(['src/index.ts']) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.listAll', { + worktree: 'id:wt-1', + excludePaths: ['/repo/other-worktree'] + }) + ) + + expect(runtime.listRuntimeFiles).toHaveBeenCalledWith('id:wt-1', { + excludePaths: ['/repo/other-worktree'] + }) + expect(response).toMatchObject({ ok: true, result: ['src/index.ts'] }) + }) + + it('lists markdown documents for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listRuntimeMarkdownDocuments: vi.fn().mockResolvedValue([ + { + filePath: '/repo/readme.md', + relativePath: 'readme.md', + basename: 'readme.md', + name: 'readme' + } + ]) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.listMarkdownDocuments', { worktree: 'id:wt-1' }) + ) + + expect(runtime.listRuntimeMarkdownDocuments).toHaveBeenCalledWith('id:wt-1') + expect(response).toMatchObject({ ok: true, result: [{ relativePath: 'readme.md' }] }) + }) + + it('stats a relative path for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + statRuntimeFile: vi.fn().mockResolvedValue({ size: 12, isDirectory: false, mtime: 1 }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.stat', { worktree: 'id:wt-1', relativePath: 'readme.md' }) + ) + + expect(runtime.statRuntimeFile).toHaveBeenCalledWith('id:wt-1', 'readme.md') + expect(response).toMatchObject({ ok: true, result: { isDirectory: false } }) + }) }) diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index ceb8c00b13f..c97d211fec5 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -1,5 +1,8 @@ import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { createFileWatchEventBatcher } from './file-watch-event-batcher' + +let filesWatchSubscriptionSeq = 0 const WorktreeSelector = z.object({ worktree: z @@ -15,7 +18,93 @@ const FileOpen = WorktreeSelector.extend({ .pipe(z.string().min(1, 'Missing relative path')) }) -export const FILE_METHODS: RpcMethod[] = [ +const FileTreePath = WorktreeSelector.extend({ + relativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string()) +}) + +const FileWrite = FileOpen.extend({ + content: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string()) +}) + +const FileWriteBase64 = FileOpen.extend({ + contentBase64: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string()) +}) + +const FileWriteBase64Chunk = FileWriteBase64.extend({ + append: z.boolean().optional() +}) + +const FileRename = WorktreeSelector.extend({ + oldRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing source path')), + newRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing destination path')) +}) + +const FileCopy = WorktreeSelector.extend({ + sourceRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing source path')), + destinationRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing destination path')) +}) + +const FileCommitUpload = WorktreeSelector.extend({ + tempRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing temporary path')), + finalRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing final path')) +}) + +const FileDelete = FileOpen.extend({ + recursive: z.boolean().optional() +}) + +const FileSearch = WorktreeSelector.extend({ + query: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing search query')), + caseSensitive: z.boolean().optional(), + wholeWord: z.boolean().optional(), + useRegex: z.boolean().optional(), + includePattern: z.string().optional(), + excludePattern: z.string().optional(), + maxResults: z.number().int().positive().optional() +}) + +const FileListAll = WorktreeSelector.extend({ + excludePaths: z.array(z.string()).optional() +}) + +const FileUnwatch = z.object({ + subscriptionId: z + .unknown() + .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) + .pipe(z.string().min(1, 'Missing subscriptionId')) +}) + +export const FILE_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'files.list', params: WorktreeSelector, @@ -32,5 +121,198 @@ export const FILE_METHODS: RpcMethod[] = [ params: FileOpen, handler: async (params, { runtime }) => runtime.readMobileFile(params.worktree, params.relativePath) + }), + defineMethod({ + name: 'files.readPreview', + params: FileOpen, + handler: async (params, { runtime }) => + runtime.readFileExplorerPreview(params.worktree, params.relativePath) + }), + defineMethod({ + name: 'files.readDir', + params: FileTreePath, + handler: async (params, { runtime }) => + runtime.readFileExplorerDir(params.worktree, params.relativePath) + }), + defineMethod({ + name: 'files.write', + params: FileWrite, + handler: async (params, { runtime }) => + runtime.writeFileExplorerFile(params.worktree, params.relativePath, params.content) + }), + defineMethod({ + name: 'files.writeBase64', + params: FileWriteBase64, + handler: async (params, { runtime }) => + runtime.writeFileExplorerFileBase64( + params.worktree, + params.relativePath, + params.contentBase64 + ) + }), + defineMethod({ + name: 'files.writeBase64Chunk', + params: FileWriteBase64Chunk, + handler: async (params, { runtime }) => + runtime.writeFileExplorerFileBase64Chunk( + params.worktree, + params.relativePath, + params.contentBase64, + params.append === true + ) + }), + defineMethod({ + name: 'files.createFile', + params: FileOpen, + handler: async (params, { runtime }) => + runtime.createFileExplorerFile(params.worktree, params.relativePath) + }), + defineMethod({ + name: 'files.createDir', + params: FileOpen, + handler: async (params, { runtime }) => + runtime.createFileExplorerDir(params.worktree, params.relativePath) + }), + defineMethod({ + name: 'files.createDirNoClobber', + params: FileOpen, + handler: async (params, { runtime }) => + runtime.createFileExplorerDirNoClobber(params.worktree, params.relativePath) + }), + defineMethod({ + name: 'files.commitUpload', + params: FileCommitUpload, + handler: async (params, { runtime }) => + runtime.commitFileExplorerUpload( + params.worktree, + params.tempRelativePath, + params.finalRelativePath + ) + }), + defineMethod({ + name: 'files.rename', + params: FileRename, + handler: async (params, { runtime }) => + runtime.renameFileExplorerPath( + params.worktree, + params.oldRelativePath, + params.newRelativePath + ) + }), + defineMethod({ + name: 'files.copy', + params: FileCopy, + handler: async (params, { runtime }) => + runtime.copyFileExplorerPath( + params.worktree, + params.sourceRelativePath, + params.destinationRelativePath + ) + }), + defineMethod({ + name: 'files.delete', + params: FileDelete, + handler: async (params, { runtime }) => + runtime.deleteFileExplorerPath(params.worktree, params.relativePath, params.recursive) + }), + defineMethod({ + name: 'files.search', + params: FileSearch, + handler: async (params, { runtime }) => + runtime.searchRuntimeFiles(params.worktree, { + query: params.query, + caseSensitive: params.caseSensitive, + wholeWord: params.wholeWord, + useRegex: params.useRegex, + includePattern: params.includePattern, + excludePattern: params.excludePattern, + maxResults: params.maxResults + }) + }), + defineMethod({ + name: 'files.listAll', + params: FileListAll, + handler: async (params, { runtime }) => + runtime.listRuntimeFiles(params.worktree, { excludePaths: params.excludePaths }) + }), + defineMethod({ + name: 'files.listMarkdownDocuments', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.listRuntimeMarkdownDocuments(params.worktree) + }), + defineMethod({ + name: 'files.stat', + params: FileTreePath, + handler: async (params, { runtime }) => + runtime.statRuntimeFile(params.worktree, params.relativePath) + }), + defineStreamingMethod({ + name: 'files.watch', + params: WorktreeSelector, + handler: async (params, { runtime, connectionId, signal }, emit) => { + const seq = ++filesWatchSubscriptionSeq + const subscriptionId = `files-watch-${connectionId ?? 'inproc'}-${seq}` + if (signal?.aborted) { + return + } + await new Promise((resolve, reject) => { + let settled = false + let unwatch: (() => void) | null = null + const eventBatcher = createFileWatchEventBatcher(params.worktree, emit) + const finish = (): void => { + if (settled) { + return + } + settled = true + signal?.removeEventListener('abort', handleAbort) + resolve() + } + const cleanup = (): void => { + eventBatcher.flush() + eventBatcher.dispose() + unwatch?.() + emit({ type: 'end' }) + finish() + } + const handleAbort = (): void => { + if (unwatch) { + cleanup() + } else { + finish() + } + } + signal?.addEventListener('abort', handleAbort, { once: true }) + void runtime + .watchFileExplorer(params.worktree, (events) => { + eventBatcher.push(events) + }) + .then((nextUnwatch) => { + if (signal?.aborted || settled) { + // Why: the connection can close while watch setup is still + // resolving. Tear down the late watcher immediately instead of + // registering cleanup on a connection that was already reaped. + nextUnwatch() + return + } + unwatch = nextUnwatch + runtime.registerSubscriptionCleanup(subscriptionId, cleanup, connectionId) + emit({ type: 'ready', subscriptionId }) + }) + .catch((error) => { + if (!settled) { + signal?.removeEventListener('abort', handleAbort) + reject(error) + } + }) + }) + } + }), + defineMethod({ + name: 'files.unwatch', + params: FileUnwatch, + handler: async (params, { runtime }) => { + runtime.cleanupSubscription(params.subscriptionId) + return { unsubscribed: true } + } }) ] diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts new file mode 100644 index 00000000000..0bd8a810710 --- /dev/null +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { GIT_METHODS } from './git' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('git RPC methods', () => { + it('returns status for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitStatus: vi.fn().mockResolvedValue({ + entries: [], + conflictOperation: 'unknown', + branch: 'main', + head: 'abc' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('git.status', { worktree: 'id:wt-1' })) + + expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1') + expect(response).toMatchObject({ + ok: true, + result: { entries: [], branch: 'main' } + }) + }) + + it('returns a worktree file diff', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitDiff: vi.fn().mockResolvedValue({ + kind: 'text', + originalContent: '', + modifiedContent: 'hello', + originalIsBinary: false, + modifiedIsBinary: false + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('git.diff', { + worktree: 'id:wt-1', + filePath: 'src/index.ts', + staged: false, + compareAgainstHead: true + }) + ) + + expect(runtime.getRuntimeGitDiff).toHaveBeenCalledWith('id:wt-1', 'src/index.ts', false, true) + expect(response).toMatchObject({ + ok: true, + result: { kind: 'text', modifiedContent: 'hello' } + }) + }) + + it('routes common mutations to the runtime', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + stageRuntimeGitPath: vi.fn().mockResolvedValue({ ok: true }), + bulkUnstageRuntimeGitPaths: vi.fn().mockResolvedValue({ ok: true }), + discardRuntimeGitPath: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + await dispatcher.dispatch( + makeRequest('git.stage', { worktree: 'id:wt-1', filePath: 'src/a.ts' }) + ) + await dispatcher.dispatch( + makeRequest('git.bulkUnstage', { worktree: 'id:wt-1', filePaths: ['src/a.ts', 'b.ts'] }) + ) + await dispatcher.dispatch( + makeRequest('git.discard', { worktree: 'id:wt-1', filePath: 'src/a.ts' }) + ) + + expect(runtime.stageRuntimeGitPath).toHaveBeenCalledWith('id:wt-1', 'src/a.ts') + expect(runtime.bulkUnstageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['src/a.ts', 'b.ts']) + expect(runtime.discardRuntimeGitPath).toHaveBeenCalledWith('id:wt-1', 'src/a.ts') + }) + + it('routes remote operations to the runtime', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + commitRuntimeGit: vi.fn().mockResolvedValue({ success: true }), + pushRuntimeGit: vi.fn().mockResolvedValue({ ok: true }), + getRuntimeGitRemoteFileUrl: vi.fn().mockResolvedValue('https://example.com/file#L3') + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + await dispatcher.dispatch( + makeRequest('git.commit', { worktree: 'id:wt-1', message: 'feat: test' }) + ) + await dispatcher.dispatch( + makeRequest('git.push', { + worktree: 'id:wt-1', + publish: true, + pushTarget: { remote: 'origin' } + }) + ) + const response = await dispatcher.dispatch( + makeRequest('git.remoteFileUrl', { + worktree: 'id:wt-1', + relativePath: 'src/a.ts', + line: 3 + }) + ) + + expect(runtime.commitRuntimeGit).toHaveBeenCalledWith('id:wt-1', 'feat: test') + expect(runtime.pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, { remote: 'origin' }) + expect(response).toMatchObject({ ok: true, result: 'https://example.com/file#L3' }) + }) + + it('rejects branch diff revisions that are not full object ids', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitBranchDiff: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('git.branchDiff', { + worktree: 'id:wt-1', + filePath: 'src/a.ts', + compare: { + headOid: '--output=/tmp/orca-test', + mergeBase: 'a'.repeat(40) + } + }) + ) + + expect(response.ok).toBe(false) + expect(runtime.getRuntimeGitBranchDiff).not.toHaveBeenCalled() + }) + + it('rejects branch compare refs that look like git options', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitBranchCompare: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('git.branchCompare', { + worktree: 'id:wt-1', + baseRef: '--output=/tmp/orca-test' + }) + ) + + expect(response.ok).toBe(false) + expect(runtime.getRuntimeGitBranchCompare).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts new file mode 100644 index 00000000000..3d4e6274046 --- /dev/null +++ b/src/main/runtime/rpc/methods/git.ts @@ -0,0 +1,175 @@ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' + +const WorktreeSelector = z.object({ + worktree: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing worktree selector')) +}) + +const GitFilePath = WorktreeSelector.extend({ + filePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing file path')) +}) + +const GitDiff = GitFilePath.extend({ + staged: z.boolean(), + compareAgainstHead: z.boolean().optional() +}) + +const GitBranchCompare = WorktreeSelector.extend({ + baseRef: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe( + z + .string() + .min(1, 'Missing base ref') + .refine((value) => !value.startsWith('-'), 'Base ref must not start with -') + ) +}) + +const FullGitObjectId = z + .string() + .regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id') + +const GitBranchDiff = GitFilePath.extend({ + compare: z.object({ + baseRef: z.string().optional(), + baseOid: FullGitObjectId.optional(), + headOid: FullGitObjectId, + mergeBase: FullGitObjectId + }), + oldPath: z.string().optional() +}) + +const GitCommit = WorktreeSelector.extend({ + message: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing commit message')) +}) + +const GitBulkPaths = WorktreeSelector.extend({ + filePaths: z.array(z.string()) +}) + +const GitPush = WorktreeSelector.extend({ + publish: z.boolean().optional(), + pushTarget: z.unknown().optional() +}) + +const GitRemoteFileUrl = WorktreeSelector.extend({ + relativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing relative path')), + line: z.number().int().min(1) +}) + +export const GIT_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'git.status', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.getRuntimeGitStatus(params.worktree) + }), + defineMethod({ + name: 'git.conflictOperation', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.getRuntimeGitConflictOperation(params.worktree) + }), + defineMethod({ + name: 'git.diff', + params: GitDiff, + handler: async (params, { runtime }) => + runtime.getRuntimeGitDiff( + params.worktree, + params.filePath, + params.staged, + params.compareAgainstHead + ) + }), + defineMethod({ + name: 'git.branchCompare', + params: GitBranchCompare, + handler: async (params, { runtime }) => + runtime.getRuntimeGitBranchCompare(params.worktree, params.baseRef) + }), + defineMethod({ + name: 'git.upstreamStatus', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.getRuntimeGitUpstreamStatus(params.worktree) + }), + defineMethod({ + name: 'git.fetch', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.fetchRuntimeGit(params.worktree) + }), + defineMethod({ + name: 'git.pull', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.pullRuntimeGit(params.worktree) + }), + defineMethod({ + name: 'git.push', + params: GitPush, + handler: async (params, { runtime }) => + runtime.pushRuntimeGit(params.worktree, params.publish, params.pushTarget as never) + }), + defineMethod({ + name: 'git.branchDiff', + params: GitBranchDiff, + handler: async (params, { runtime }) => + runtime.getRuntimeGitBranchDiff( + params.worktree, + params.compare, + params.filePath, + params.oldPath + ) + }), + defineMethod({ + name: 'git.commit', + params: GitCommit, + handler: async (params, { runtime }) => + runtime.commitRuntimeGit(params.worktree, params.message) + }), + defineMethod({ + name: 'git.stage', + params: GitFilePath, + handler: async (params, { runtime }) => + runtime.stageRuntimeGitPath(params.worktree, params.filePath) + }), + defineMethod({ + name: 'git.bulkStage', + params: GitBulkPaths, + handler: async (params, { runtime }) => + runtime.bulkStageRuntimeGitPaths(params.worktree, params.filePaths) + }), + defineMethod({ + name: 'git.unstage', + params: GitFilePath, + handler: async (params, { runtime }) => + runtime.unstageRuntimeGitPath(params.worktree, params.filePath) + }), + defineMethod({ + name: 'git.bulkUnstage', + params: GitBulkPaths, + handler: async (params, { runtime }) => + runtime.bulkUnstageRuntimeGitPaths(params.worktree, params.filePaths) + }), + defineMethod({ + name: 'git.discard', + params: GitFilePath, + handler: async (params, { runtime }) => + runtime.discardRuntimeGitPath(params.worktree, params.filePath) + }), + defineMethod({ + name: 'git.remoteFileUrl', + params: GitRemoteFileUrl, + handler: async (params, { runtime }) => + runtime.getRuntimeGitRemoteFileUrl(params.worktree, params.relativePath, params.line) + }) +] diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts new file mode 100644 index 00000000000..bcd32235050 --- /dev/null +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -0,0 +1,519 @@ +/* eslint-disable max-lines -- Why: runtime GitHub RPC methods share one dispatcher suite so repo-scoped and Project-scoped contract coverage cannot drift. */ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { GITHUB_METHODS } from './github' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('github RPC methods', () => { + it('resolves the repo slug on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRepoSlug: vi.fn().mockResolvedValue({ owner: 'acme', repo: 'orca' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('github.repoSlug', { repo: 'repo-1' })) + + expect(runtime.getRepoSlug).toHaveBeenCalledWith('repo-1') + expect(response).toMatchObject({ + ok: true, + result: { owner: 'acme', repo: 'orca' } + }) + }) + + it('fetches GitHub rate limits on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getGitHubRateLimit: vi.fn().mockResolvedValue({ ok: true, snapshot: { core: {} } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('github.rateLimit', { force: true })) + + expect(runtime.getGitHubRateLimit).toHaveBeenCalledWith({ force: true }) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('lists work items on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listRepoWorkItems: vi.fn().mockResolvedValue({ items: [] }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.listWorkItems', { repo: 'repo-1', limit: 10, query: 'is:pr' }) + ) + + expect(runtime.listRepoWorkItems).toHaveBeenCalledWith('repo-1', 10, 'is:pr', undefined) + expect(response).toMatchObject({ ok: true, result: { items: [] } }) + }) + + it('looks up a single work item on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRepoWorkItem: vi.fn().mockResolvedValue({ number: 12, type: 'pr' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.workItem', { repo: 'repo-1', number: 12, type: 'pr' }) + ) + + expect(runtime.getRepoWorkItem).toHaveBeenCalledWith('repo-1', 12, 'pr') + expect(response).toMatchObject({ ok: true, result: { number: 12, type: 'pr' } }) + }) + + it('looks up a single work item by explicit owner/repo on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRepoWorkItemByOwnerRepo: vi.fn().mockResolvedValue({ number: 12, type: 'pr' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.workItemByOwnerRepo', { + repo: 'repo-1', + owner: 'acme', + ownerRepo: 'orca', + number: 12, + type: 'pr' + }) + ) + + expect(runtime.getRepoWorkItemByOwnerRepo).toHaveBeenCalledWith( + 'repo-1', + { owner: 'acme', repo: 'orca' }, + 12, + 'pr' + ) + expect(response).toMatchObject({ ok: true, result: { number: 12, type: 'pr' } }) + }) + + it('fetches work item details on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRepoWorkItemDetails: vi.fn().mockResolvedValue({ body: 'Details' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.workItemDetails', { repo: 'repo-1', number: 12, type: 'issue' }) + ) + + expect(runtime.getRepoWorkItemDetails).toHaveBeenCalledWith('repo-1', 12, 'issue') + expect(response).toMatchObject({ ok: true, result: { body: 'Details' } }) + }) + + it('counts work items on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + countRepoWorkItems: vi.fn().mockResolvedValue(3) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.countWorkItems', { repo: 'repo-1', query: 'is:open' }) + ) + + expect(runtime.countRepoWorkItems).toHaveBeenCalledWith('repo-1', 'is:open') + expect(response).toMatchObject({ ok: true, result: 3 }) + }) + + it('lists repo issue metadata on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listRepoLabels: vi.fn().mockResolvedValue(['bug']), + listRepoAssignableUsers: vi.fn().mockResolvedValue([{ login: 'octo' }]) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const labels = await dispatcher.dispatch(makeRequest('github.listLabels', { repo: 'repo-1' })) + const users = await dispatcher.dispatch( + makeRequest('github.listAssignableUsers', { repo: 'repo-1' }) + ) + + expect(runtime.listRepoLabels).toHaveBeenCalledWith('repo-1') + expect(runtime.listRepoAssignableUsers).toHaveBeenCalledWith('repo-1') + expect(labels).toMatchObject({ ok: true, result: ['bug'] }) + expect(users).toMatchObject({ ok: true, result: [{ login: 'octo' }] }) + }) + + it('fetches PR checks on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRepoPRChecks: vi.fn().mockResolvedValue([]) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.prChecks', { + repo: 'repo-1', + prNumber: 7, + headSha: 'abc123', + noCache: true + }) + ) + + expect(runtime.getRepoPRChecks).toHaveBeenCalledWith('repo-1', 7, 'abc123', { + noCache: true + }) + expect(response).toMatchObject({ ok: true, result: [] }) + }) + + it('fetches PR file contents on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRepoPRFileContents: vi.fn().mockResolvedValue({ + original: '', + modified: 'new', + originalIsBinary: false, + modifiedIsBinary: false + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.prFileContents', { + repo: 'repo-1', + prNumber: 7, + path: 'src/app.ts', + status: 'modified', + headSha: 'head', + baseSha: 'base' + }) + ) + + expect(runtime.getRepoPRFileContents).toHaveBeenCalledWith('repo-1', { + prNumber: 7, + path: 'src/app.ts', + oldPath: undefined, + status: 'modified', + headSha: 'head', + baseSha: 'base' + }) + expect(response).toMatchObject({ ok: true, result: { modified: 'new' } }) + }) + + it('resolves review threads on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveRepoReviewThread: vi.fn().mockResolvedValue(true) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.resolveReviewThread', { + repo: 'repo-1', + threadId: 'PRRT_1', + resolve: true + }) + ) + + expect(runtime.resolveRepoReviewThread).toHaveBeenCalledWith('repo-1', 'PRRT_1', true) + expect(response).toMatchObject({ ok: true, result: true }) + }) + + it('updates PR titles on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateRepoPRTitle: vi.fn().mockResolvedValue(true) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.updatePRTitle', { + repo: 'repo-1', + prNumber: 7, + title: 'New title' + }) + ) + + expect(runtime.updateRepoPRTitle).toHaveBeenCalledWith('repo-1', 7, 'New title') + expect(response).toMatchObject({ ok: true, result: true }) + }) + + it('merges PRs on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + mergeRepoPR: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.mergePR', { + repo: 'repo-1', + prNumber: 7, + method: 'squash' + }) + ) + + expect(runtime.mergeRepoPR).toHaveBeenCalledWith('repo-1', 7, 'squash') + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('creates issues on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createRepoIssue: vi.fn().mockResolvedValue({ ok: true, number: 3, url: 'https://gh/3' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.createIssue', { + repo: 'repo-1', + title: 'Bug', + body: 'Body' + }) + ) + + expect(runtime.createRepoIssue).toHaveBeenCalledWith('repo-1', 'Bug', 'Body') + expect(response).toMatchObject({ ok: true, result: { ok: true, number: 3 } }) + }) + + it('updates issues on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateRepoIssue: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.updateIssue', { + repo: 'repo-1', + number: 3, + updates: { state: 'closed', addLabels: ['bug'] } + }) + ) + + expect(runtime.updateRepoIssue).toHaveBeenCalledWith('repo-1', 3, { + state: 'closed', + addLabels: ['bug'] + }) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('adds issue comments on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + addRepoIssueComment: vi.fn().mockResolvedValue({ ok: true, comment: { id: 1 } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.addIssueComment', { + repo: 'repo-1', + number: 3, + body: 'Looks good', + type: 'pr' + }) + ) + + expect(runtime.addRepoIssueComment).toHaveBeenCalledWith('repo-1', 3, 'Looks good') + expect(response).toMatchObject({ ok: true, result: { ok: true, comment: { id: 1 } } }) + }) + + it('adds PR review comments on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + addRepoPRReviewComment: vi.fn().mockResolvedValue({ ok: true, comment: { id: 2 } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.addPRReviewComment', { + repo: 'repo-1', + prNumber: 7, + commitId: 'head', + path: 'src/app.ts', + line: 12, + startLine: 10, + body: 'Please tweak' + }) + ) + + expect(runtime.addRepoPRReviewComment).toHaveBeenCalledWith('repo-1', { + prNumber: 7, + commitId: 'head', + path: 'src/app.ts', + line: 12, + startLine: 10, + body: 'Please tweak' + }) + expect(response).toMatchObject({ ok: true, result: { ok: true, comment: { id: 2 } } }) + }) + + it('adds PR review comment replies on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + addRepoPRReviewCommentReply: vi.fn().mockResolvedValue({ ok: true, comment: { id: 4 } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.addPRReviewCommentReply', { + repo: 'repo-1', + prNumber: 7, + commentId: 2, + body: 'Done', + threadId: 'PRRT_1', + path: 'src/app.ts', + line: 12 + }) + ) + + expect(runtime.addRepoPRReviewCommentReply).toHaveBeenCalledWith('repo-1', { + prNumber: 7, + commentId: 2, + body: 'Done', + threadId: 'PRRT_1', + path: 'src/app.ts', + line: 12 + }) + expect(response).toMatchObject({ ok: true, result: { ok: true, comment: { id: 4 } } }) + }) + + it('fetches GitHub project views on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listGitHubProjectViews: vi.fn().mockResolvedValue({ ok: true, views: [] }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.project.listViews', { + owner: 'acme', + ownerType: 'organization', + projectNumber: 1 + }) + ) + + expect(runtime.listGitHubProjectViews).toHaveBeenCalledWith({ + owner: 'acme', + ownerType: 'organization', + projectNumber: 1 + }) + expect(response).toMatchObject({ ok: true, result: { ok: true, views: [] } }) + }) + + it('lists slug-addressed issue metadata on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listGitHubLabelsBySlug: vi.fn().mockResolvedValue({ ok: true, labels: ['bug'] }), + listGitHubAssignableUsersBySlug: vi + .fn() + .mockResolvedValue({ ok: true, users: [{ login: 'octo' }] }), + listGitHubIssueTypesBySlug: vi.fn().mockResolvedValue({ ok: true, types: [{ id: 'it-1' }] }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const labels = await dispatcher.dispatch( + makeRequest('github.project.listLabelsBySlug', { owner: 'acme', repo: 'orca' }) + ) + const users = await dispatcher.dispatch( + makeRequest('github.project.listAssignableUsersBySlug', { + owner: 'acme', + repo: 'orca', + seedLogins: ['octo'] + }) + ) + const issueTypes = await dispatcher.dispatch( + makeRequest('github.project.listIssueTypesBySlug', { owner: 'acme', repo: 'orca' }) + ) + + expect(runtime.listGitHubLabelsBySlug).toHaveBeenCalledWith({ owner: 'acme', repo: 'orca' }) + expect(runtime.listGitHubAssignableUsersBySlug).toHaveBeenCalledWith({ + owner: 'acme', + repo: 'orca', + seedLogins: ['octo'] + }) + expect(runtime.listGitHubIssueTypesBySlug).toHaveBeenCalledWith({ owner: 'acme', repo: 'orca' }) + expect(labels).toMatchObject({ ok: true, result: { ok: true, labels: ['bug'] } }) + expect(users).toMatchObject({ ok: true, result: { ok: true, users: [{ login: 'octo' }] } }) + expect(issueTypes).toMatchObject({ ok: true, result: { ok: true, types: [{ id: 'it-1' }] } }) + }) + + it('fetches GitHub project tables on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getGitHubProjectViewTable: vi.fn().mockResolvedValue({ ok: true, data: { rows: [] } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.project.viewTable', { + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1', + queryOverride: 'is:open' + }) + ) + + expect(runtime.getGitHubProjectViewTable).toHaveBeenCalledWith({ + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1', + viewNumber: undefined, + viewName: undefined, + queryOverride: 'is:open' + }) + expect(response).toMatchObject({ ok: true, result: { ok: true, data: { rows: [] } } }) + }) + + it('updates GitHub project item fields on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateGitHubProjectItemField: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.project.updateItemField', { + projectId: 'project-1', + itemId: 'item-1', + fieldId: 'field-1', + value: { kind: 'text', text: 'Now' } + }) + ) + + expect(runtime.updateGitHubProjectItemField).toHaveBeenCalledWith({ + projectId: 'project-1', + itemId: 'item-1', + fieldId: 'field-1', + value: { kind: 'text', text: 'Now' } + }) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('updates GitHub project issue types on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateGitHubIssueTypeBySlug: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.project.updateIssueTypeBySlug', { + owner: 'acme', + repo: 'orca', + number: 9, + issueTypeId: null + }) + ) + + expect(runtime.updateGitHubIssueTypeBySlug).toHaveBeenCalledWith({ + owner: 'acme', + repo: 'orca', + number: 9, + issueTypeId: null + }) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) +}) diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts new file mode 100644 index 00000000000..72b32e6cdd8 --- /dev/null +++ b/src/main/runtime/rpc/methods/github.ts @@ -0,0 +1,442 @@ +/* eslint-disable max-lines -- Why: GitHub runtime RPC keeps related repo, project, and mutation schemas beside their handlers so the method contract stays reviewable in one place. */ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' +import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' + +const RepoSelector = z.object({ + repo: requiredString('Missing repo selector') +}) + +const WorkItemsList = RepoSelector.extend({ + limit: OptionalFiniteNumber, + query: OptionalString, + before: OptionalString +}) + +const WorkItem = RepoSelector.extend({ + number: z.number().int().positive(), + type: z.enum(['issue', 'pr']).optional() +}) + +const WorkItemByOwnerRepo = RepoSelector.extend({ + owner: requiredString('Missing owner'), + ownerRepo: requiredString('Missing repo'), + number: z.number().int().positive(), + type: z.enum(['issue', 'pr']) +}) + +const WorkItemDetails = WorkItem + +const WorkItemsCount = RepoSelector.extend({ + query: OptionalString +}) + +const RateLimit = z.object({ + force: z.boolean().optional() +}) + +const SlugRepo = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo') +}) + +const SlugAssignableUsers = SlugRepo.extend({ + seedLogins: z.array(z.string()).optional() +}) + +const PrForBranch = RepoSelector.extend({ + branch: requiredString('Missing branch'), + linkedPRNumber: z.number().int().positive().nullable().optional() +}) + +const Issue = RepoSelector.extend({ + number: z.number().int().positive() +}) + +const PullRequest = RepoSelector.extend({ + prNumber: z.number().int().positive(), + noCache: z.boolean().optional() +}) + +const PullRequestChecks = PullRequest.extend({ + headSha: OptionalString +}) + +const PullRequestFileContents = RepoSelector.extend({ + prNumber: z.number().int().positive(), + path: requiredString('Missing file path'), + oldPath: OptionalString, + status: z.enum(['added', 'removed', 'modified', 'renamed', 'copied', 'changed', 'unchanged']), + headSha: requiredString('Missing head SHA'), + baseSha: requiredString('Missing base SHA') +}) + +const ReviewThread = RepoSelector.extend({ + threadId: requiredString('Missing thread ID'), + resolve: z.boolean() +}) + +const UpdatePrTitle = RepoSelector.extend({ + prNumber: z.number().int().positive(), + title: requiredString('Missing title') +}) + +const MergePr = RepoSelector.extend({ + prNumber: z.number().int().positive(), + method: z.enum(['merge', 'squash', 'rebase']).optional() +}) + +const CreateIssue = RepoSelector.extend({ + title: requiredString('Missing title'), + body: z.string() +}) + +const IssueUpdate = z.object({ + state: z.enum(['open', 'closed']).optional(), + title: OptionalString, + body: OptionalString, + addLabels: z.array(z.string()).optional(), + removeLabels: z.array(z.string()).optional(), + addAssignees: z.array(z.string()).optional(), + removeAssignees: z.array(z.string()).optional() +}) + +const UpdateIssue = RepoSelector.extend({ + number: z.number().int().positive(), + updates: IssueUpdate +}) + +const IssueComment = RepoSelector.extend({ + number: z.number().int().positive(), + body: requiredString('Comment body required'), + type: z.enum(['issue', 'pr']).optional() +}) + +const PRReviewComment = RepoSelector.extend({ + prNumber: z.number().int().positive(), + commitId: requiredString('Missing PR head SHA'), + path: requiredString('File path required'), + line: z.number().int().positive(), + startLine: z.number().int().positive().optional(), + body: requiredString('Comment body required') +}) + +const PRReviewCommentReply = RepoSelector.extend({ + prNumber: z.number().int().positive(), + commentId: z.number().int().positive(), + body: requiredString('Comment body required'), + threadId: OptionalString, + path: OptionalString, + line: z.number().int().positive().optional() +}) + +const ProjectOwnerType = z.enum(['organization', 'user']) + +const ProjectViewTable = z.object({ + owner: requiredString('Missing owner'), + ownerType: ProjectOwnerType, + projectNumber: z.number().int().positive(), + viewId: OptionalString, + viewNumber: z.number().int().positive().optional(), + viewName: OptionalString, + queryOverride: OptionalString +}) + +const ProjectRef = z.object({ + input: requiredString('Missing project reference') +}) + +const ProjectViews = z.object({ + owner: requiredString('Missing owner'), + ownerType: ProjectOwnerType, + projectNumber: z.number().int().positive() +}) + +const ProjectItemField = z.object({ + projectId: requiredString('Missing project ID'), + itemId: requiredString('Missing item ID'), + fieldId: requiredString('Missing field ID'), + value: z.any() +}) + +const ClearProjectItemField = z.object({ + projectId: requiredString('Missing project ID'), + itemId: requiredString('Missing item ID'), + fieldId: requiredString('Missing field ID') +}) + +const SlugIssueUpdate = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + number: z.number().int().positive(), + updates: IssueUpdate +}) + +const SlugPullRequestUpdate = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + number: z.number().int().positive(), + updates: z.object({ + title: OptionalString, + body: OptionalString + }) +}) + +const SlugIssueTypeUpdate = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + number: z.number().int().positive(), + issueTypeId: z.string().nullable() +}) + +const SlugIssueComment = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + number: z.number().int().positive(), + body: requiredString('Comment body required') +}) + +const SlugIssueCommentEdit = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + commentId: z.number().int().positive(), + body: requiredString('Comment body required') +}) + +const SlugIssueCommentDelete = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + commentId: z.number().int().positive() +}) + +export const GITHUB_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'github.repoSlug', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.getRepoSlug(params.repo) + }), + defineMethod({ + name: 'github.rateLimit', + params: RateLimit, + handler: async (params, { runtime }) => runtime.getGitHubRateLimit(params) + }), + defineMethod({ + name: 'github.listWorkItems', + params: WorkItemsList, + handler: async (params, { runtime }) => + runtime.listRepoWorkItems(params.repo, params.limit, params.query, params.before) + }), + defineMethod({ + name: 'github.countWorkItems', + params: WorkItemsCount, + handler: async (params, { runtime }) => runtime.countRepoWorkItems(params.repo, params.query) + }), + defineMethod({ + name: 'github.listLabels', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.listRepoLabels(params.repo) + }), + defineMethod({ + name: 'github.listAssignableUsers', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.listRepoAssignableUsers(params.repo) + }), + defineMethod({ + name: 'github.workItem', + params: WorkItem, + handler: async (params, { runtime }) => + runtime.getRepoWorkItem(params.repo, params.number, params.type) + }), + defineMethod({ + name: 'github.workItemByOwnerRepo', + params: WorkItemByOwnerRepo, + handler: async (params, { runtime }) => + runtime.getRepoWorkItemByOwnerRepo( + params.repo, + { owner: params.owner, repo: params.ownerRepo }, + params.number, + params.type + ) + }), + defineMethod({ + name: 'github.workItemDetails', + params: WorkItemDetails, + handler: async (params, { runtime }) => + runtime.getRepoWorkItemDetails(params.repo, params.number, params.type) + }), + defineMethod({ + name: 'github.prForBranch', + params: PrForBranch, + handler: async (params, { runtime }) => + runtime.getRepoPRForBranch(params.repo, params.branch, params.linkedPRNumber) + }), + defineMethod({ + name: 'github.issue', + params: Issue, + handler: async (params, { runtime }) => runtime.getRepoIssue(params.repo, params.number) + }), + defineMethod({ + name: 'github.prChecks', + params: PullRequestChecks, + handler: async (params, { runtime }) => + runtime.getRepoPRChecks(params.repo, params.prNumber, params.headSha, { + noCache: params.noCache + }) + }), + defineMethod({ + name: 'github.prComments', + params: PullRequest, + handler: async (params, { runtime }) => + runtime.getRepoPRComments(params.repo, params.prNumber, { noCache: params.noCache }) + }), + defineMethod({ + name: 'github.prFileContents', + params: PullRequestFileContents, + handler: async (params, { runtime }) => + runtime.getRepoPRFileContents(params.repo, { + prNumber: params.prNumber, + path: params.path, + oldPath: params.oldPath, + status: params.status, + headSha: params.headSha, + baseSha: params.baseSha + }) + }), + defineMethod({ + name: 'github.resolveReviewThread', + params: ReviewThread, + handler: async (params, { runtime }) => + runtime.resolveRepoReviewThread(params.repo, params.threadId, params.resolve) + }), + defineMethod({ + name: 'github.updatePRTitle', + params: UpdatePrTitle, + handler: async (params, { runtime }) => + runtime.updateRepoPRTitle(params.repo, params.prNumber, params.title) + }), + defineMethod({ + name: 'github.mergePR', + params: MergePr, + handler: async (params, { runtime }) => + runtime.mergeRepoPR(params.repo, params.prNumber, params.method) + }), + defineMethod({ + name: 'github.createIssue', + params: CreateIssue, + handler: async (params, { runtime }) => + runtime.createRepoIssue(params.repo, params.title, params.body) + }), + defineMethod({ + name: 'github.updateIssue', + params: UpdateIssue, + handler: async (params, { runtime }) => + runtime.updateRepoIssue(params.repo, params.number, params.updates) + }), + defineMethod({ + name: 'github.addIssueComment', + params: IssueComment, + handler: async (params, { runtime }) => + runtime.addRepoIssueComment(params.repo, params.number, params.body) + }), + defineMethod({ + name: 'github.addPRReviewComment', + params: PRReviewComment, + handler: async (params, { runtime }) => + runtime.addRepoPRReviewComment(params.repo, { + prNumber: params.prNumber, + commitId: params.commitId, + path: params.path, + line: params.line, + startLine: params.startLine, + body: params.body + }) + }), + defineMethod({ + name: 'github.addPRReviewCommentReply', + params: PRReviewCommentReply, + handler: async (params, { runtime }) => + runtime.addRepoPRReviewCommentReply(params.repo, { + prNumber: params.prNumber, + commentId: params.commentId, + body: params.body, + threadId: params.threadId, + path: params.path, + line: params.line + }) + }), + defineMethod({ + name: 'github.project.listAccessible', + params: z.object({}), + handler: async (_params, { runtime }) => runtime.listGitHubProjects() + }), + defineMethod({ + name: 'github.project.listLabelsBySlug', + params: SlugRepo, + handler: async (params, { runtime }) => runtime.listGitHubLabelsBySlug(params) + }), + defineMethod({ + name: 'github.project.listAssignableUsersBySlug', + params: SlugAssignableUsers, + handler: async (params, { runtime }) => runtime.listGitHubAssignableUsersBySlug(params) + }), + defineMethod({ + name: 'github.project.listIssueTypesBySlug', + params: SlugRepo, + handler: async (params, { runtime }) => runtime.listGitHubIssueTypesBySlug(params) + }), + defineMethod({ + name: 'github.project.resolveRef', + params: ProjectRef, + handler: async (params, { runtime }) => runtime.resolveGitHubProjectRef(params) + }), + defineMethod({ + name: 'github.project.listViews', + params: ProjectViews, + handler: async (params, { runtime }) => runtime.listGitHubProjectViews(params) + }), + defineMethod({ + name: 'github.project.viewTable', + params: ProjectViewTable, + handler: async (params, { runtime }) => runtime.getGitHubProjectViewTable(params) + }), + defineMethod({ + name: 'github.project.updateItemField', + params: ProjectItemField, + handler: async (params, { runtime }) => runtime.updateGitHubProjectItemField(params) + }), + defineMethod({ + name: 'github.project.clearItemField', + params: ClearProjectItemField, + handler: async (params, { runtime }) => runtime.clearGitHubProjectItemField(params) + }), + defineMethod({ + name: 'github.project.updateIssueBySlug', + params: SlugIssueUpdate, + handler: async (params, { runtime }) => runtime.updateGitHubIssueBySlug(params) + }), + defineMethod({ + name: 'github.project.updatePullRequestBySlug', + params: SlugPullRequestUpdate, + handler: async (params, { runtime }) => runtime.updateGitHubPullRequestBySlug(params) + }), + defineMethod({ + name: 'github.project.updateIssueTypeBySlug', + params: SlugIssueTypeUpdate, + handler: async (params, { runtime }) => runtime.updateGitHubIssueTypeBySlug(params) + }), + defineMethod({ + name: 'github.project.addIssueCommentBySlug', + params: SlugIssueComment, + handler: async (params, { runtime }) => runtime.addGitHubIssueCommentBySlug(params) + }), + defineMethod({ + name: 'github.project.updateIssueCommentBySlug', + params: SlugIssueCommentEdit, + handler: async (params, { runtime }) => runtime.updateGitHubIssueCommentBySlug(params) + }), + defineMethod({ + name: 'github.project.deleteIssueCommentBySlug', + params: SlugIssueCommentDelete, + handler: async (params, { runtime }) => runtime.deleteGitHubIssueCommentBySlug(params) + }) +] diff --git a/src/main/runtime/rpc/methods/hosted-review.test.ts b/src/main/runtime/rpc/methods/hosted-review.test.ts new file mode 100644 index 00000000000..df3ecc0f544 --- /dev/null +++ b/src/main/runtime/rpc/methods/hosted-review.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { HOSTED_REVIEW_METHODS } from './hosted-review' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('hosted review RPC methods', () => { + it('fetches branch review status on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getHostedReviewForBranch: vi.fn().mockResolvedValue({ + provider: 'github', + number: 12, + title: 'Feature', + state: 'open', + url: 'https://github.com/acme/orca/pull/12', + status: 'success', + updatedAt: '2026-05-10T00:00:00.000Z', + mergeable: 'MERGEABLE' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: HOSTED_REVIEW_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('hostedReview.forBranch', { + repo: 'C:\\repo', + branch: 'feature/windows', + linkedGitHubPR: 12 + }) + ) + + expect(runtime.getHostedReviewForBranch).toHaveBeenCalledWith({ + repoSelector: 'C:\\repo', + branch: 'feature/windows', + linkedGitHubPR: 12, + linkedGitLabMR: null, + linkedBitbucketPR: null + }) + expect(response).toMatchObject({ + ok: true, + result: { provider: 'github', number: 12 } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/hosted-review.ts b/src/main/runtime/rpc/methods/hosted-review.ts new file mode 100644 index 00000000000..952f00f6be4 --- /dev/null +++ b/src/main/runtime/rpc/methods/hosted-review.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' +import { requiredString } from '../schemas' + +const HostedReviewForBranch = z.object({ + repo: requiredString('Missing repo selector'), + branch: requiredString('Missing branch'), + linkedGitHubPR: z.number().int().positive().nullable().optional(), + linkedGitLabMR: z.number().int().positive().nullable().optional(), + linkedBitbucketPR: z.number().int().positive().nullable().optional() +}) + +export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'hostedReview.forBranch', + params: HostedReviewForBranch, + handler: async (params, { runtime }) => + runtime.getHostedReviewForBranch({ + repoSelector: params.repo, + branch: params.branch, + linkedGitHubPR: params.linkedGitHubPR ?? null, + linkedGitLabMR: params.linkedGitLabMR ?? null, + linkedBitbucketPR: params.linkedBitbucketPR ?? null + }) + }) +] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 69a4caccdf2..9cbc55d3435 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -12,6 +12,10 @@ import { ACCOUNT_METHODS } from './accounts' import { COMPUTER_METHODS } from './computer' import { SESSION_TAB_METHODS } from './session-tabs' import { FILE_METHODS } from './files' +import { GIT_METHODS } from './git' +import { GITHUB_METHODS } from './github' +import { HOSTED_REVIEW_METHODS } from './hosted-review' +import { LINEAR_METHODS } from './linear' import { NOTE_METHODS } from './notes' import { SPEECH_METHODS } from './speech' @@ -32,6 +36,10 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...COMPUTER_METHODS, ...SESSION_TAB_METHODS, ...FILE_METHODS, + ...GIT_METHODS, + ...GITHUB_METHODS, + ...HOSTED_REVIEW_METHODS, + ...LINEAR_METHODS, ...NOTE_METHODS, ...SPEECH_METHODS ] diff --git a/src/main/runtime/rpc/methods/linear.test.ts b/src/main/runtime/rpc/methods/linear.test.ts new file mode 100644 index 00000000000..ec95e5525b2 --- /dev/null +++ b/src/main/runtime/rpc/methods/linear.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { LINEAR_METHODS } from './linear' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('linear RPC methods', () => { + it('routes Linear account methods to the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + linearStatus: vi.fn().mockResolvedValue({ connected: true, viewer: null }), + linearTestConnection: vi.fn().mockResolvedValue({ ok: true, viewer: { displayName: 'Ada' } }), + linearConnect: vi.fn().mockResolvedValue({ ok: true, viewer: { displayName: 'Ada' } }), + linearDisconnect: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_METHODS }) + + await dispatcher.dispatch(makeRequest('linear.status')) + await dispatcher.dispatch(makeRequest('linear.testConnection')) + await dispatcher.dispatch(makeRequest('linear.connect', { apiKey: 'lin_api_key' })) + await dispatcher.dispatch(makeRequest('linear.disconnect')) + + expect(runtime.linearStatus).toHaveBeenCalled() + expect(runtime.linearTestConnection).toHaveBeenCalled() + expect(runtime.linearConnect).toHaveBeenCalledWith('lin_api_key') + expect(runtime.linearDisconnect).toHaveBeenCalled() + }) + + it('routes Linear issue queries and mutations to the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + linearSearchIssues: vi.fn().mockResolvedValue([{ id: 'issue-1' }]), + linearListIssues: vi.fn().mockResolvedValue([{ id: 'issue-2' }]), + linearGetIssue: vi.fn().mockResolvedValue({ id: 'issue-3' }), + linearCreateIssue: vi.fn().mockResolvedValue({ ok: true, id: 'issue-4' }), + linearUpdateIssue: vi.fn().mockResolvedValue({ ok: true }), + linearAddIssueComment: vi.fn().mockResolvedValue({ ok: true, id: 'comment-1' }), + linearIssueComments: vi.fn().mockResolvedValue([{ id: 'comment-2' }]) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_METHODS }) + + await dispatcher.dispatch(makeRequest('linear.searchIssues', { query: 'bug', limit: 30 })) + await dispatcher.dispatch(makeRequest('linear.listIssues', { filter: 'assigned', limit: 20 })) + await dispatcher.dispatch(makeRequest('linear.getIssue', { id: 'issue-3' })) + await dispatcher.dispatch( + makeRequest('linear.createIssue', { + teamId: 'team-1', + title: 'Fix bug', + description: 'Details' + }) + ) + await dispatcher.dispatch( + makeRequest('linear.updateIssue', { + id: 'issue-3', + updates: { stateId: 'state-1', assigneeId: null, priority: 2, labelIds: ['label-1'] } + }) + ) + await dispatcher.dispatch( + makeRequest('linear.addIssueComment', { issueId: 'issue-3', body: 'Looks good' }) + ) + await dispatcher.dispatch(makeRequest('linear.issueComments', { issueId: 'issue-3' })) + + expect(runtime.linearSearchIssues).toHaveBeenCalledWith('bug', 30) + expect(runtime.linearListIssues).toHaveBeenCalledWith('assigned', 20) + expect(runtime.linearGetIssue).toHaveBeenCalledWith('issue-3') + expect(runtime.linearCreateIssue).toHaveBeenCalledWith('team-1', 'Fix bug', 'Details') + expect(runtime.linearUpdateIssue).toHaveBeenCalledWith('issue-3', { + stateId: 'state-1', + assigneeId: null, + priority: 2, + labelIds: ['label-1'] + }) + expect(runtime.linearAddIssueComment).toHaveBeenCalledWith('issue-3', 'Looks good') + expect(runtime.linearIssueComments).toHaveBeenCalledWith('issue-3') + }) + + it('routes Linear metadata requests to the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + linearListTeams: vi.fn().mockResolvedValue([{ id: 'team-1' }]), + linearTeamStates: vi.fn().mockResolvedValue([{ id: 'state-1' }]), + linearTeamLabels: vi.fn().mockResolvedValue([{ id: 'label-1' }]), + linearTeamMembers: vi.fn().mockResolvedValue([{ id: 'member-1' }]) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_METHODS }) + + await dispatcher.dispatch(makeRequest('linear.listTeams')) + await dispatcher.dispatch(makeRequest('linear.teamStates', { teamId: 'team-1' })) + await dispatcher.dispatch(makeRequest('linear.teamLabels', { teamId: 'team-1' })) + await dispatcher.dispatch(makeRequest('linear.teamMembers', { teamId: 'team-1' })) + + expect(runtime.linearListTeams).toHaveBeenCalled() + expect(runtime.linearTeamStates).toHaveBeenCalledWith('team-1') + expect(runtime.linearTeamLabels).toHaveBeenCalledWith('team-1') + expect(runtime.linearTeamMembers).toHaveBeenCalledWith('team-1') + }) +}) diff --git a/src/main/runtime/rpc/methods/linear.ts b/src/main/runtime/rpc/methods/linear.ts new file mode 100644 index 00000000000..29bdcf488f8 --- /dev/null +++ b/src/main/runtime/rpc/methods/linear.ts @@ -0,0 +1,136 @@ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' +import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' + +const VALID_FILTERS = ['assigned', 'created', 'all', 'completed'] as const + +const Connect = z.object({ + apiKey: requiredString('Invalid API key') +}) + +const SearchIssues = z.object({ + query: requiredString('Missing query'), + limit: OptionalFiniteNumber +}) + +const ListIssues = z + .object({ + filter: z.enum(VALID_FILTERS).optional(), + limit: OptionalFiniteNumber + }) + .optional() + +const CreateIssue = z.object({ + teamId: requiredString('Team ID is required'), + title: requiredString('Title is required'), + description: OptionalString +}) + +const IssueId = z.object({ + id: requiredString('Issue ID is required') +}) + +const IssueComment = z.object({ + issueId: requiredString('Issue ID is required'), + body: requiredString('Comment body is required') +}) + +const TeamId = z.object({ + teamId: requiredString('Team ID is required') +}) + +const IssueUpdate = z.object({ + id: requiredString('Issue ID is required'), + updates: z.object({ + stateId: OptionalString, + title: OptionalString, + assigneeId: z.union([z.string(), z.null()]).optional(), + priority: z.number().int().min(0).max(4).optional(), + labelIds: z.array(z.string()).optional() + }) +}) + +export const LINEAR_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'linear.connect', + params: Connect, + handler: async (params, { runtime }) => runtime.linearConnect(params.apiKey.trim()) + }), + defineMethod({ + name: 'linear.disconnect', + params: null, + handler: async (_params, { runtime }) => runtime.linearDisconnect() + }), + defineMethod({ + name: 'linear.status', + params: null, + handler: async (_params, { runtime }) => runtime.linearStatus() + }), + defineMethod({ + name: 'linear.testConnection', + params: null, + handler: async (_params, { runtime }) => runtime.linearTestConnection() + }), + defineMethod({ + name: 'linear.searchIssues', + params: SearchIssues, + handler: async (params, { runtime }) => runtime.linearSearchIssues(params.query, params.limit) + }), + defineMethod({ + name: 'linear.listIssues', + params: ListIssues, + handler: async (params, { runtime }) => runtime.linearListIssues(params?.filter, params?.limit) + }), + defineMethod({ + name: 'linear.createIssue', + params: CreateIssue, + handler: async (params, { runtime }) => + runtime.linearCreateIssue( + params.teamId.trim(), + params.title.trim(), + params.description?.trim() || undefined + ) + }), + defineMethod({ + name: 'linear.getIssue', + params: IssueId, + handler: async (params, { runtime }) => runtime.linearGetIssue(params.id.trim()) + }), + defineMethod({ + name: 'linear.updateIssue', + params: IssueUpdate, + handler: async (params, { runtime }) => + runtime.linearUpdateIssue(params.id.trim(), params.updates) + }), + defineMethod({ + name: 'linear.addIssueComment', + params: IssueComment, + handler: async (params, { runtime }) => + runtime.linearAddIssueComment(params.issueId.trim(), params.body.trim()) + }), + defineMethod({ + name: 'linear.issueComments', + params: z.object({ issueId: requiredString('Issue ID is required') }), + handler: async (params, { runtime }) => runtime.linearIssueComments(params.issueId.trim()) + }), + defineMethod({ + name: 'linear.listTeams', + params: null, + handler: async (_params, { runtime }) => runtime.linearListTeams() + }), + defineMethod({ + name: 'linear.teamStates', + params: TeamId, + handler: async (params, { runtime }) => runtime.linearTeamStates(params.teamId.trim()) + }), + defineMethod({ + name: 'linear.teamLabels', + params: TeamId, + handler: async (params, { runtime }) => runtime.linearTeamLabels(params.teamId.trim()) + }), + defineMethod({ + name: 'linear.teamMembers', + params: TeamId, + handler: async (params, { runtime }) => runtime.linearTeamMembers(params.teamId.trim()) + }) +] diff --git a/src/main/runtime/rpc/methods/notes.test.ts b/src/main/runtime/rpc/methods/notes.test.ts new file mode 100644 index 00000000000..35c56129f5e --- /dev/null +++ b/src/main/runtime/rpc/methods/notes.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { NOTE_METHODS } from './notes' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('notes RPC methods', () => { + it('routes note reads through the selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listNotes: vi.fn().mockResolvedValue({ notes: [], totalCount: 0, truncated: false }), + showNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: null }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: NOTE_METHODS }) + + await dispatcher.dispatch(makeRequest('note.list', { worktree: 'id:wt-1', limit: 50 })) + await dispatcher.dispatch(makeRequest('note.show', { worktree: 'id:wt-1', note: 'note-1' })) + + expect(runtime.listNotes).toHaveBeenCalledWith({ worktreeSelector: 'id:wt-1', limit: 50 }) + expect(runtime.showNote).toHaveBeenCalledWith({ + worktreeSelector: 'id:wt-1', + note: 'note-1' + }) + }) + + it('routes note mutations through the selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createNote: vi.fn().mockResolvedValue({ note: { id: 'created' }, linkKind: 'active' }), + saveNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: 'active' }), + renameNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: null }), + deleteNote: vi.fn().mockResolvedValue({ noteId: 'note-1', projectId: 'repo-1' }), + appendNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: null }), + linkNote: vi.fn().mockResolvedValue({ + noteId: 'note-1', + projectId: 'repo-1', + worktreeId: 'wt-1', + kind: 'active', + createdAt: 'now' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: NOTE_METHODS }) + + await dispatcher.dispatch( + makeRequest('note.create', { + worktree: 'id:wt-1', + title: 'Plan', + bodyMarkdown: 'body', + makeActive: true + }) + ) + await dispatcher.dispatch( + makeRequest('note.save', { + worktree: 'id:wt-1', + note: 'note-1', + title: 'Plan v2', + bodyMarkdown: 'updated', + revision: 2, + makeActive: true + }) + ) + await dispatcher.dispatch( + makeRequest('note.rename', { worktree: 'id:wt-1', note: 'note-1', title: 'Renamed' }) + ) + await dispatcher.dispatch(makeRequest('note.delete', { worktree: 'id:wt-1', note: 'note-1' })) + await dispatcher.dispatch( + makeRequest('note.append', { + worktree: 'id:wt-1', + note: 'note-1', + bodyMarkdown: 'more', + makeActive: true + }) + ) + await dispatcher.dispatch( + makeRequest('note.link', { worktree: 'id:wt-1', note: 'note-1', kind: 'active' }) + ) + + expect(runtime.createNote).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeSelector: 'id:wt-1', + title: 'Plan', + bodyMarkdown: 'body', + makeActive: true + }) + ) + expect(runtime.saveNote).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeSelector: 'id:wt-1', + note: 'note-1', + title: 'Plan v2', + bodyMarkdown: 'updated', + revision: 2, + makeActive: true + }) + ) + expect(runtime.renameNote).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeSelector: 'id:wt-1', + note: 'note-1', + title: 'Renamed' + }) + ) + expect(runtime.deleteNote).toHaveBeenCalledWith({ + worktreeSelector: 'id:wt-1', + note: 'note-1' + }) + expect(runtime.appendNote).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeSelector: 'id:wt-1', + note: 'note-1', + bodyMarkdown: 'more', + makeActive: true + }) + ) + expect(runtime.linkNote).toHaveBeenCalledWith({ + worktreeSelector: 'id:wt-1', + note: 'note-1', + kind: 'active' + }) + }) + + it('routes panel state and search through the selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + searchNotes: vi.fn().mockResolvedValue({ notes: [], totalCount: 0, truncated: false }), + resolveNotesPanelOpenStateForWorktree: vi.fn().mockResolvedValue({ state: 'emptyDraft' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: NOTE_METHODS }) + + await dispatcher.dispatch( + makeRequest('note.search', { worktree: 'id:wt-1', query: 'todo', limit: 20 }) + ) + await dispatcher.dispatch(makeRequest('note.panelState', { worktree: 'id:wt-1' })) + + expect(runtime.searchNotes).toHaveBeenCalledWith({ + worktreeSelector: 'id:wt-1', + query: 'todo', + limit: 20 + }) + expect(runtime.resolveNotesPanelOpenStateForWorktree).toHaveBeenCalledWith({ + worktreeSelector: 'id:wt-1' + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/notes.ts b/src/main/runtime/rpc/methods/notes.ts index a4013f76671..fc0ea173871 100644 --- a/src/main/runtime/rpc/methods/notes.ts +++ b/src/main/runtime/rpc/methods/notes.ts @@ -17,13 +17,34 @@ const NoteShowParams = NoteScopedParams.extend({ const NoteCreateParams = NoteScopedParams.extend({ title: requiredString('Missing note title'), bodyMarkdown: OptionalString, - makeActive: z.boolean().optional() + makeActive: z.boolean().optional(), + createdBySessionId: z.string().nullable().optional() +}) + +const NoteSaveParams = NoteScopedParams.extend({ + note: requiredString('Missing note selector'), + title: OptionalString, + bodyMarkdown: requiredString('Missing note body'), + revision: OptionalFiniteNumber, + makeActive: z.boolean().optional(), + updatedBySessionId: z.string().nullable().optional() +}) + +const NoteRenameParams = NoteScopedParams.extend({ + note: requiredString('Missing note selector'), + title: requiredString('Missing note title'), + updatedBySessionId: z.string().nullable().optional() +}) + +const NoteDeleteParams = NoteScopedParams.extend({ + note: requiredString('Missing note selector') }) const NoteAppendParams = NoteScopedParams.extend({ note: requiredString('Missing note selector'), bodyMarkdown: requiredString('Missing note body'), - makeActive: z.boolean().optional() + makeActive: z.boolean().optional(), + updatedBySessionId: z.string().nullable().optional() }) const NoteSearchParams = NoteScopedParams.extend({ @@ -31,6 +52,11 @@ const NoteSearchParams = NoteScopedParams.extend({ limit: OptionalFiniteNumber }) +const NoteLinkParams = NoteScopedParams.extend({ + note: requiredString('Missing note selector'), + kind: z.enum(['active', 'referenced']) +}) + export const NOTE_METHODS: readonly RpcAnyMethod[] = [ defineMethod({ name: 'note.list', @@ -58,7 +84,42 @@ export const NOTE_METHODS: readonly RpcAnyMethod[] = [ worktreeSelector: params.worktree, title: params.title, bodyMarkdown: params.bodyMarkdown, - makeActive: params.makeActive + makeActive: params.makeActive, + createdBySessionId: params.createdBySessionId + }) + }), + defineMethod({ + name: 'note.save', + params: NoteSaveParams, + handler: async (params, { runtime }) => + await runtime.saveNote({ + worktreeSelector: params.worktree, + note: params.note, + title: params.title, + bodyMarkdown: params.bodyMarkdown, + revision: params.revision, + makeActive: params.makeActive, + updatedBySessionId: params.updatedBySessionId + }) + }), + defineMethod({ + name: 'note.rename', + params: NoteRenameParams, + handler: async (params, { runtime }) => + await runtime.renameNote({ + worktreeSelector: params.worktree, + note: params.note, + title: params.title, + updatedBySessionId: params.updatedBySessionId + }) + }), + defineMethod({ + name: 'note.delete', + params: NoteDeleteParams, + handler: async (params, { runtime }) => + await runtime.deleteNote({ + worktreeSelector: params.worktree, + note: params.note }) }), defineMethod({ @@ -69,7 +130,8 @@ export const NOTE_METHODS: readonly RpcAnyMethod[] = [ worktreeSelector: params.worktree, note: params.note, bodyMarkdown: params.bodyMarkdown, - makeActive: params.makeActive + makeActive: params.makeActive, + updatedBySessionId: params.updatedBySessionId }) }), defineMethod({ @@ -81,5 +143,23 @@ export const NOTE_METHODS: readonly RpcAnyMethod[] = [ query: params.query, limit: params.limit }) + }), + defineMethod({ + name: 'note.link', + params: NoteLinkParams, + handler: async (params, { runtime }) => + await runtime.linkNote({ + worktreeSelector: params.worktree, + note: params.note, + kind: params.kind + }) + }), + defineMethod({ + name: 'note.panelState', + params: NoteScopedParams, + handler: async (params, { runtime }) => + await runtime.resolveNotesPanelOpenStateForWorktree({ + worktreeSelector: params.worktree + }) }) ] diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 3ae1d213586..93856e98adb 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -349,6 +349,42 @@ describe('orchestration RPC methods', () => { // Must not have marked read expect(db.getUnreadMessages('b')).toHaveLength(1) }) + + it('does not mark messages read when a waiting check is aborted', async () => { + setup() + const abortController = new AbortController() + ctx = { runtime, signal: abortController.signal } + vi.spyOn(runtime, 'waitForMessage').mockImplementation(async () => { + db.insertMessage({ from: 'a', to: 'b', subject: 'arrived during close' }) + abortController.abort() + }) + + const result = (await call('orchestration.check', { + terminal: 'b', + wait: true, + timeoutMs: 100 + })) as { messages: unknown[]; count: number } + + expect(result).toEqual({ messages: [], count: 0 }) + expect(db.getUnreadMessages('b')).toHaveLength(1) + }) + + it('does not mark existing messages read when the check starts aborted', async () => { + setup() + const abortController = new AbortController() + abortController.abort() + ctx = { runtime, signal: abortController.signal } + db.insertMessage({ from: 'a', to: 'b', subject: 'already unread' }) + + const result = (await call('orchestration.check', { + terminal: 'b', + wait: true, + timeoutMs: 100 + })) as { messages: unknown[]; count: number } + + expect(result).toEqual({ messages: [], count: 0 }) + expect(db.getUnreadMessages('b')).toHaveLength(1) + }) }) describe('orchestration.reply', () => { diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index e1a2d68b943..0fb5a6f8680 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -241,6 +241,9 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ return { messages, count: messages.length } } + if (signal?.aborted) { + return { messages: [], count: 0 } + } const result = readAndReturn() if (result.count > 0 || !params.wait) { return result @@ -257,6 +260,9 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ timeoutMs: params.timeoutMs ?? undefined, signal }) + if (signal?.aborted) { + return { messages: [], count: 0 } + } return readAndReturn() } }), diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts new file mode 100644 index 00000000000..da7b9212215 --- /dev/null +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { REPO_METHODS } from './repo' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('repo RPC methods', () => { + it('creates a repo on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createRepo: vi.fn().mockResolvedValue({ + repo: { id: 'repo-1', path: '/srv/projects/new-app', kind: 'git' } + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('repo.create', { + parentPath: '/srv/projects', + name: 'new-app', + kind: 'git' + }) + ) + + expect(runtime.createRepo).toHaveBeenCalledWith('/srv/projects', 'new-app', 'git') + expect(response).toMatchObject({ + ok: true, + result: { repo: { id: 'repo-1', path: '/srv/projects/new-app' } } + }) + }) + + it('clones a repo on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + cloneRepo: vi.fn().mockResolvedValue({ + id: 'repo-1', + path: '/srv/projects/orca', + kind: 'git' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('repo.clone', { + url: 'https://github.com/example/orca.git', + destination: '/srv/projects' + }) + ) + + expect(runtime.cloneRepo).toHaveBeenCalledWith( + 'https://github.com/example/orca.git', + '/srv/projects' + ) + expect(response).toMatchObject({ + ok: true, + result: { repo: { id: 'repo-1', path: '/srv/projects/orca' } } + }) + }) + + it('routes repository hook operations to the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + checkRepoHooks: vi.fn().mockResolvedValue({ + hasHooks: true, + hooks: { scripts: { setup: 'pnpm install' } }, + mayNeedUpdate: false + }), + readRepoIssueCommand: vi.fn().mockResolvedValue({ + localContent: null, + sharedContent: 'Fix {{artifact_url}}', + effectiveContent: 'Fix {{artifact_url}}', + localFilePath: '/srv/repo/.orca/issue-command', + source: 'shared' + }), + writeRepoIssueCommand: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + await dispatcher.dispatch(makeRequest('repo.hooksCheck', { repo: 'repo-1' })) + await dispatcher.dispatch(makeRequest('repo.issueCommandRead', { repo: 'repo-1' })) + await dispatcher.dispatch( + makeRequest('repo.issueCommandWrite', { + repo: 'repo-1', + content: 'Fix it' + }) + ) + + expect(runtime.checkRepoHooks).toHaveBeenCalledWith('repo-1') + expect(runtime.readRepoIssueCommand).toHaveBeenCalledWith('repo-1') + expect(runtime.writeRepoIssueCommand).toHaveBeenCalledWith('repo-1', 'Fix it') + }) +}) diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 3af1133f836..9b466a2ce6a 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -1,13 +1,25 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' -import { OptionalFiniteNumber, requiredString } from '../schemas' +import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' const RepoSelector = z.object({ repo: requiredString('Missing repo selector') }) const RepoPath = z.object({ - path: requiredString('Missing repo path') + path: requiredString('Missing repo path'), + kind: z.enum(['git', 'folder']).optional() +}) + +const RepoCreate = z.object({ + parentPath: requiredString('Missing parent path'), + name: requiredString('Missing repo name'), + kind: z.enum(['git', 'folder']).optional() +}) + +const RepoClone = z.object({ + url: requiredString('Missing clone URL'), + destination: requiredString('Missing clone destination') }) const RepoSetBaseRef = z.object({ @@ -15,6 +27,18 @@ const RepoSetBaseRef = z.object({ ref: requiredString('Missing base ref') }) +const RepoUpdate = RepoSelector.extend({ + updates: z.object({ + displayName: OptionalString, + badgeColor: OptionalString, + hookSettings: z.unknown().optional(), + worktreeBaseRef: OptionalString, + kind: z.enum(['git', 'folder']).optional(), + symlinkPaths: z.array(z.string()).optional(), + issueSourcePreference: z.enum(['auto', 'github', 'linear']).optional() + }) +}) + const RepoSearchRefs = z.object({ repo: requiredString('Missing repo selector'), query: z @@ -24,6 +48,14 @@ const RepoSearchRefs = z.object({ limit: OptionalFiniteNumber }) +const RepoReorder = z.object({ + orderedIds: z.array(z.string()) +}) + +const RepoIssueCommandWrite = RepoSelector.extend({ + content: z.string() +}) + export const REPO_METHODS: RpcMethod[] = [ defineMethod({ name: 'repo.list', @@ -33,13 +65,48 @@ export const REPO_METHODS: RpcMethod[] = [ defineMethod({ name: 'repo.add', params: RepoPath, - handler: async (params, { runtime }) => ({ repo: await runtime.addRepo(params.path) }) + handler: async (params, { runtime }) => ({ + repo: await runtime.addRepo(params.path, params.kind) + }) + }), + defineMethod({ + name: 'repo.create', + params: RepoCreate, + handler: async (params, { runtime }) => + runtime.createRepo(params.parentPath, params.name, params.kind) + }), + defineMethod({ + name: 'repo.clone', + params: RepoClone, + handler: async (params, { runtime }) => ({ + repo: await runtime.cloneRepo(params.url, params.destination) + }) }), defineMethod({ name: 'repo.show', params: RepoSelector, handler: async (params, { runtime }) => ({ repo: await runtime.showRepo(params.repo) }) }), + defineMethod({ + name: 'repo.update', + params: RepoUpdate, + handler: async (params, { runtime }) => ({ + repo: await runtime.updateRepo( + params.repo, + params.updates as Parameters[1] + ) + }) + }), + defineMethod({ + name: 'repo.rm', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.removeRepo(params.repo) + }), + defineMethod({ + name: 'repo.reorder', + params: RepoReorder, + handler: async (params, { runtime }) => runtime.reorderRepos(params.orderedIds) + }), defineMethod({ name: 'repo.setBaseRef', params: RepoSetBaseRef, @@ -47,6 +114,11 @@ export const REPO_METHODS: RpcMethod[] = [ repo: await runtime.setRepoBaseRef(params.repo, params.ref) }) }), + defineMethod({ + name: 'repo.baseRefDefault', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.getRepoBaseRefDefault(params.repo) + }), defineMethod({ name: 'repo.searchRefs', params: RepoSearchRefs, @@ -57,5 +129,21 @@ export const REPO_METHODS: RpcMethod[] = [ name: 'repo.hooks', params: RepoSelector, handler: async (params, { runtime }) => runtime.getRepoHooks(params.repo) + }), + defineMethod({ + name: 'repo.hooksCheck', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.checkRepoHooks(params.repo) + }), + defineMethod({ + name: 'repo.issueCommandRead', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.readRepoIssueCommand(params.repo) + }), + defineMethod({ + name: 'repo.issueCommandWrite', + params: RepoIssueCommandWrite, + handler: async (params, { runtime }) => + runtime.writeRepoIssueCommand(params.repo, params.content) }) ] diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index fdf9350b99a..1fcbc4fae41 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -2,12 +2,15 @@ import { z } from 'zod' import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' -import type { OrcaRuntimeService } from '../../orca-runtime' +import type { DriverState, OrcaRuntimeService } from '../../orca-runtime' import { TerminalStreamOpcode, + decodeTerminalStreamJson, + decodeTerminalStreamText, encodeTerminalStreamFrame, encodeTerminalStreamJson, - encodeTerminalStreamText + encodeTerminalStreamText, + type TerminalStreamFrame } from '../../../../shared/terminal-stream-protocol' // Why: when a mobile client subscribes the server resizes the PTY to phone @@ -19,6 +22,9 @@ import { const MOBILE_SUBSCRIBE_SCROLLBACK_ROWS = 1000 const MOBILE_SNAPSHOT_BYTE_BUDGET = 512 * 1024 const TERMINAL_STREAM_CHUNK_BYTES = 48 * 1024 +const TERMINAL_OUTPUT_FLUSH_MS = 5 +const TERMINAL_OUTPUT_BATCH_MAX_CHARS = 64 * 1024 +const TERMINAL_MULTIPLEX_PENDING_MAX_CHARS = 256 * 1024 let nextTerminalStreamId = 1 type SnapshotFrameOptions = { @@ -41,6 +47,126 @@ type SerializedSnapshot = { truncatedByByteBudget: boolean } | null +type TerminalViewportClient = { + id: string + type?: 'mobile' | 'desktop' +} + +type TerminalMultiplexStream = { + streamId: number + terminal: string + ptyId: string + client: TerminalViewportClient | undefined + isMobile: boolean + buffering: boolean + pendingOutput: string[] + pendingOutputChars: number + outputBatcher: ReturnType + unsubscribeData: () => void + unsubscribeResize: () => void + unsubscribeFit: () => void + unsubscribeDriver: () => void + unregisterBinaryHandler: () => void +} + +function createTerminalOutputBatcher(onFlush: (data: string) => void): { + push: (data: string) => void + flush: () => void + dispose: () => void +} { + let chunks: string[] = [] + let chars = 0 + let timer: ReturnType | null = null + + const clearTimer = (): void => { + if (!timer) { + return + } + clearTimeout(timer) + timer = null + } + + const flush = (): void => { + clearTimer() + if (chunks.length === 0) { + return + } + const data = chunks.length === 1 ? chunks[0]! : chunks.join('') + chunks = [] + chars = 0 + onFlush(data) + } + + return { + push(data: string): void { + if (!data) { + return + } + chunks.push(data) + chars += data.length + if (chars >= TERMINAL_OUTPUT_BATCH_MAX_CHARS) { + flush() + return + } + if (!timer) { + // Why: Paseo coalesces terminal stream output before crossing the + // network. Desktop runtime subscribers need the same burst boundary. + timer = setTimeout(flush, TERMINAL_OUTPUT_FLUSH_MS) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + }, + flush, + dispose(): void { + clearTimer() + chunks = [] + chars = 0 + } + } +} + +function isTerminalInputLockedForClient( + runtime: OrcaRuntimeService, + ptyId: string, + client: TerminalViewportClient | undefined +): boolean { + if (client?.type === 'mobile') { + return false + } + // Why: pre-refactor mobile builds did not send client metadata. Desktop + // callers we control now identify as desktop, so keep legacy mobile input + // working without opening the new desktop path. + if (!client) { + return false + } + return runtime.getDriver(ptyId).kind === 'mobile' +} + +function resolveMobileFloorClientId( + driver: DriverState | null, + client: TerminalViewportClient | undefined +): string | null { + if (client?.type === 'mobile') { + return client.id + } + if (!client && driver?.kind === 'mobile') { + return driver.clientId + } + return null +} + +function appendPendingMultiplexOutput(stream: TerminalMultiplexStream, data: string): void { + stream.pendingOutput.push(data) + stream.pendingOutputChars += data.length + while ( + stream.pendingOutputChars > TERMINAL_MULTIPLEX_PENDING_MAX_CHARS && + stream.pendingOutput.length > 0 + ) { + stream.pendingOutputChars -= stream.pendingOutput.shift()?.length ?? 0 + } +} + function sendSnapshotFrames( sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array) => void, options: SnapshotFrameOptions @@ -99,6 +225,20 @@ async function serializeBudgetedMobileSnapshot( return null } +async function updateViewportForClient( + runtime: OrcaRuntimeService, + ptyId: string, + client: TerminalViewportClient, + viewport: { cols: number; rows: number }, + defaultType: 'mobile' | 'desktop' +): Promise { + const type = client.type ?? defaultType + if (type === 'mobile') { + return runtime.updateMobileViewport(ptyId, client.id, viewport) + } + return runtime.updateDesktopViewport(ptyId, viewport) +} + const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) @@ -161,6 +301,11 @@ const TerminalSend = TerminalHandle.extend({ .optional() }) +const TerminalViewport = z.object({ + cols: z.number().int().min(1).max(1000), + rows: z.number().int().min(1).max(500) +}) + const TerminalWait = TerminalHandle.extend({ for: z.custom<'exit' | 'tui-idle'>((value) => value === 'exit' || value === 'tui-idle', { message: 'Invalid --for value. Supported: exit, tui-idle' @@ -171,6 +316,7 @@ const TerminalWait = TerminalHandle.extend({ const TerminalCreateParams = z.object({ worktree: OptionalString, command: OptionalString, + env: z.record(z.string(), z.string()).optional(), title: OptionalString, focus: z.unknown().optional() }) @@ -210,12 +356,7 @@ const TerminalSubscribe = TerminalHandle.extend({ type: z.enum(['mobile', 'desktop']).default('desktop') }) .optional(), - viewport: z - .object({ - cols: z.number().int().min(20).max(240), - rows: z.number().int().min(8).max(120) - }) - .optional(), + viewport: TerminalViewport.optional(), capabilities: z .object({ terminalBinaryStream: z.literal(1).optional() @@ -223,6 +364,19 @@ const TerminalSubscribe = TerminalHandle.extend({ .optional() }) +const TerminalMultiplex = z.object({}) + +const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ + streamId: z.number().int().min(1), + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop') + }) + .optional(), + viewport: TerminalViewport.optional() +}) + const TerminalSetDisplayMode = TerminalHandle.extend({ // Why: 'phone' was previously a "stay at phone dims after unsubscribe" // mode that the toggle UI never produced and nothing in product @@ -317,6 +471,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ terminal: await runtime.readTerminal(params.terminal, { cursor: params.cursor }) }) }), + defineMethod({ + name: 'terminal.inspectProcess', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + process: await runtime.inspectTerminalProcess(params.terminal) + }) + }), defineMethod({ name: 'terminal.rename', params: TerminalRename, @@ -335,6 +496,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ name: 'terminal.send', params: TerminalSend, handler: async (params, { runtime }) => { + const leaf = runtime.resolveLeafForHandle(params.terminal) + const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null + if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } const result = await runtime.sendTerminal(params.terminal, { text: params.text, enter: params.enter === true, @@ -343,12 +515,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // Why: deliberate mobile input is a take-floor action. Drives the // `* → mobile{clientId}` driver transition so the desktop banner // remounts (if previously reclaimed) and active phone-fit dims follow - // the most recent actor. Only mobile-typed callers take the floor. - if (params.client && params.client.type === 'mobile') { - const leaf = runtime.resolveLeafForHandle(params.terminal) - if (leaf?.ptyId) { - await runtime.mobileTookFloor(leaf.ptyId, params.client.id) - } + // the most recent actor. Clientless sends are old mobile builds, so use + // the current mobile driver as their compatibility identity. + const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) + if (leaf?.ptyId && mobileFloorClientId) { + await runtime.mobileTookFloor(leaf.ptyId, mobileFloorClientId) } return { send: result } } @@ -369,6 +540,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ handler: async (params, { runtime }) => ({ terminal: await runtime.createTerminal(params.worktree, { command: params.command, + env: params.env, title: params.title, focus: params.focus === true }) @@ -452,6 +624,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ return { mode: params.mode, seq: runtime.getLayout(leaf.ptyId)?.seq } } }), + defineMethod({ + name: 'terminal.restoreFit', + params: TerminalHandle, + handler: async (params, { runtime }) => { + const leaf = runtime.resolveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + return { restored: await runtime.reclaimTerminalForDesktop(leaf.ptyId) } + } + }), defineMethod({ name: 'terminal.getDisplayMode', params: TerminalHandle, @@ -470,24 +653,336 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (!leaf?.ptyId) { throw new Error('no_connected_pty') } - const updated = await runtime.updateMobileViewport( + const updated = await updateViewportForClient( + runtime, leaf.ptyId, - params.client.id, - params.viewport + params.client, + params.viewport, + 'mobile' ) return { updated, seq: runtime.getLayout(leaf.ptyId)?.seq } } }), + // Why: desktop remote sessions can have dozens of panes. One streaming RPC + // owns the binary socket and routes terminal slots by streamId, mirroring + // Paseo's slot-based terminal data plane while keeping legacy subscribe as + // the compatibility fallback. + defineStreamingMethod({ + name: 'terminal.multiplex', + params: TerminalMultiplex, + handler: async ( + _params, + { runtime, connectionId, sendBinary, registerBinaryStreamHandler }, + emit + ) => { + if (!sendBinary || !registerBinaryStreamHandler || !connectionId) { + throw new Error('binary_terminal_stream_required') + } + + let closed = false + let cursor = 0 + const streams = new Map() + let resolveMultiplex = (): void => {} + const multiplexClosed = new Promise((resolve) => { + resolveMultiplex = resolve + }) + const sendFrame = ( + streamId: number, + opcode: TerminalStreamOpcode, + payload: Uint8Array = new Uint8Array() + ): void => { + if (closed) { + return + } + sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: cursor++, payload })) + } + const sendStreamError = (streamId: number, message: string): void => { + sendFrame(streamId, TerminalStreamOpcode.Error, encodeTerminalStreamText(message)) + emit({ type: 'error', streamId, message }) + } + const detachStream = (streamId: number, emitEnd: boolean): void => { + const stream = streams.get(streamId) + if (!stream) { + return + } + stream.outputBatcher.flush() + stream.outputBatcher.dispose() + stream.unsubscribeData() + stream.unsubscribeResize() + stream.unsubscribeFit() + stream.unsubscribeDriver() + stream.unregisterBinaryHandler() + streams.delete(streamId) + if (stream.isMobile && stream.client?.id) { + runtime.handleMobileUnsubscribe(stream.ptyId, stream.client.id) + } + if (emitEnd) { + emit({ type: 'end', streamId }) + } + } + const closeMultiplex = (): void => { + if (closed) { + return + } + closed = true + for (const streamId of Array.from(streams.keys())) { + detachStream(streamId, false) + } + unregisterControlHandler() + resolveMultiplex() + } + const handleSlotFrame = ( + stream: TerminalMultiplexStream, + frame: TerminalStreamFrame + ): void => { + if (closed || streams.get(stream.streamId) !== stream) { + return + } + if (frame.opcode === TerminalStreamOpcode.Unsubscribe) { + detachStream(stream.streamId, false) + return + } + if (frame.opcode === TerminalStreamOpcode.Input) { + const text = decodeTerminalStreamText(frame.payload) + if (!text) { + return + } + if (isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { + return + } + void runtime + .sendTerminal(stream.terminal, { text, enter: false, interrupt: false }) + .then(async () => { + if (stream.isMobile && stream.client?.id) { + await runtime.mobileTookFloor(stream.ptyId, stream.client.id) + } + }) + .catch(() => {}) + return + } + if (frame.opcode === TerminalStreamOpcode.Resize && stream.client) { + const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( + frame.payload + ) + if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { + return + } + void updateViewportForClient( + runtime, + stream.ptyId, + stream.client, + { cols: viewport.cols, rows: viewport.rows }, + stream.isMobile ? 'mobile' : 'desktop' + ).catch(() => {}) + } + } + const handleSubscribeFrame = async (payload: Uint8Array): Promise => { + const raw = decodeTerminalStreamJson(payload) + const parsed = TerminalMultiplexSubscribeFrame.safeParse(raw) + if (!parsed.success) { + return + } + const request = parsed.data + detachStream(request.streamId, false) + + let leaf = runtime.resolveLeafForHandle(request.terminal) + const isMobile = request.client?.type === 'mobile' + if (!leaf?.ptyId && isMobile) { + try { + const ptyId = await runtime.waitForLeafPtyId(request.terminal) + leaf = { ptyId } + } catch { + // Fall through to the explicit no_connected_pty error below. + } + } + if (!leaf?.ptyId) { + sendStreamError(request.streamId, 'no_connected_pty') + emit({ type: 'end', streamId: request.streamId }) + return + } + + const ptyId = leaf.ptyId + const stream: TerminalMultiplexStream = { + streamId: request.streamId, + terminal: request.terminal, + ptyId, + client: request.client, + isMobile, + buffering: true, + pendingOutput: [], + pendingOutputChars: 0, + outputBatcher: createTerminalOutputBatcher((data) => { + sendFrame(request.streamId, TerminalStreamOpcode.Output, encodeTerminalStreamText(data)) + }), + unsubscribeData: () => {}, + unsubscribeResize: () => {}, + unsubscribeFit: () => {}, + unsubscribeDriver: () => {}, + unregisterBinaryHandler: () => {} + } + streams.set(request.streamId, stream) + stream.unregisterBinaryHandler = registerBinaryStreamHandler(request.streamId, (frame) => + handleSlotFrame(stream, frame) + ) + + try { + stream.unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => { + if (closed || streams.get(request.streamId) !== stream) { + return + } + if (stream.buffering) { + appendPendingMultiplexOutput(stream, data) + return + } + stream.outputBatcher.push(data) + }) + + if (isMobile && request.client?.id) { + await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport) + } else if (request.viewport && request.client) { + await updateViewportForClient( + runtime, + ptyId, + request.client, + request.viewport, + 'desktop' + ) + } + if (closed || streams.get(request.streamId) !== stream) { + return + } + + if (!isMobile) { + stream.unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + emit({ + type: 'fit-override-changed', + streamId: request.streamId, + mode: event.mode, + cols: event.cols, + rows: event.rows + }) + }) + stream.unsubscribeDriver = runtime.subscribeToDriverChanges(ptyId, (driver) => { + emit({ + type: 'driver-changed', + streamId: request.streamId, + driver + }) + }) + } + + const read = await runtime.readTerminal(request.terminal) + const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (closed || streams.get(request.streamId) !== stream) { + return + } + const size = runtime.getTerminalSize(ptyId) + const displayMode = runtime.getMobileDisplayMode(ptyId) + const seq = runtime.getLayout(ptyId)?.seq + if (!isMobile) { + const fitOverride = runtime.getTerminalFitOverride(ptyId) + emit({ + type: 'fit-override-changed', + streamId: request.streamId, + mode: fitOverride?.mode ?? 'desktop-fit', + cols: fitOverride?.cols ?? size?.cols ?? 0, + rows: fitOverride?.rows ?? size?.rows ?? 0 + }) + emit({ + type: 'driver-changed', + streamId: request.streamId, + driver: runtime.getDriver(ptyId) + }) + } + emit({ + type: 'subscribed', + streamId: request.streamId, + terminal: request.terminal, + cols: serialized?.cols ?? size?.cols, + rows: serialized?.rows ?? size?.rows, + displayMode, + seq, + truncated: read.truncated + }) + sendSnapshotFrames((opcode, payload) => sendFrame(request.streamId, opcode, payload), { + kind: 'scrollback', + cols: serialized?.cols ?? size?.cols ?? 80, + rows: serialized?.rows ?? size?.rows ?? 24, + displayMode, + seq, + truncated: read.truncated, + truncatedByByteBudget: serialized?.truncatedByByteBudget, + data: serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '') + }) + stream.buffering = false + for (const data of stream.pendingOutput.splice(0)) { + stream.outputBatcher.push(data) + } + stream.pendingOutputChars = 0 + stream.outputBatcher.flush() + + stream.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { + stream.outputBatcher.flush() + sendFrame( + request.streamId, + TerminalStreamOpcode.Resized, + encodeTerminalStreamJson({ + cols: event.cols, + rows: event.rows, + displayMode: event.displayMode, + reason: event.reason, + seq: event.seq + }) + ) + }) + void runtime + .waitForTerminal(request.terminal, { condition: 'exit' }) + .then(() => { + if (streams.get(request.streamId) === stream) { + detachStream(request.streamId, true) + } + }) + .catch(() => { + if (streams.get(request.streamId) === stream) { + detachStream(request.streamId, true) + } + }) + } catch (error) { + detachStream(request.streamId, false) + sendStreamError(request.streamId, error instanceof Error ? error.message : String(error)) + emit({ type: 'end', streamId: request.streamId }) + } + } + const unregisterControlHandler = registerBinaryStreamHandler(0, (frame) => { + if (frame.opcode !== TerminalStreamOpcode.Subscribe) { + return + } + void handleSubscribeFrame(frame.payload) + }) + + runtime.registerSubscriptionCleanup( + `terminal-multiplex:${connectionId}`, + closeMultiplex, + connectionId + ) + emit({ type: 'ready' }) + await multiplexClosed + } + }), // Why: terminal.subscribe streams live terminal output over WebSocket. // It sends initial scrollback, then live data chunks as they arrive. // Mobile clients pass client+viewport params for server-side auto-fit. defineStreamingMethod({ name: 'terminal.subscribe', params: TerminalSubscribe, - handler: async (params, { runtime, connectionId, sendBinary }, emit) => { + handler: async ( + params, + { runtime, connectionId, sendBinary, registerBinaryStreamHandler }, + emit + ) => { let leaf = runtime.resolveLeafForHandle(params.terminal) const isMobile = params.client?.type === 'mobile' - const useBinaryStream = isMobile && params.capabilities?.terminalBinaryStream === 1 + const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary) // Why: the left pane's PTY spawns asynchronously after the tab is created. // Mobile clients that subscribe before the PTY is ready would get a bare @@ -515,7 +1010,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const ptyId = leaf.ptyId const clientId = params.client?.id - if (!isMobile) { + if (!useBinaryStream) { const read = await runtime.readTerminal(params.terminal) const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false) const size = runtime.getTerminalSize(ptyId) @@ -532,11 +1027,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ seq }) + // Why: desktop can have both a hidden automation watcher and a visible + // pane subscribed to the same terminal. Key by client when provided so + // one stream cannot evict the other. + const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal await new Promise((resolve) => { + const outputBatcher = createTerminalOutputBatcher((chunk) => { + emit({ type: 'data', chunk }) + }) const unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => { - emit({ type: 'data', chunk: data }) + outputBatcher.push(data) }) const unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + outputBatcher.flush() emit({ type: 'fit-override-changed', mode: event.mode, @@ -545,8 +1048,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }) }) runtime.registerSubscriptionCleanup( - params.terminal, + subscriptionId, () => { + outputBatcher.flush() + outputBatcher.dispose() unsubscribeData() unsubscribeFit() emit({ type: 'end' }) @@ -554,6 +1059,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }, connectionId ) + void runtime + .waitForTerminal(params.terminal, { condition: 'exit' }) + .then(() => runtime.cleanupSubscription(subscriptionId)) + .catch(() => runtime.cleanupSubscription(subscriptionId)) }) return } @@ -563,23 +1072,30 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let closed = false let buffering = true const pendingOutput: string[] = [] + let pendingOutputChars = 0 let unsubscribeData = (): void => {} let unsubscribeResize = (): void => {} let unsubscribeFit = (): void => {} + let unregisterBinaryHandler = (): void => {} + let outputBatcher: ReturnType | null = null let resolveStream = (): void => {} const streamClosed = new Promise((resolve) => { resolveStream = resolve }) // Why: register cleanup before any mobile-fit or snapshot await. A phone - // can disconnect mid-subscribe; cleanup must still remove mobile presence. + // can disconnect mid-subscribe; cleanup must still remove mobile + // presence. Client-scoped ids also allow parallel desktop subscribers. const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal runtime.registerSubscriptionCleanup( subscriptionId, () => { + outputBatcher?.flush() + outputBatcher?.dispose() closed = true unsubscribeData() unsubscribeResize() unsubscribeFit() + unregisterBinaryHandler() if (isMobile && clientId) { runtime.handleMobileUnsubscribe(ptyId, clientId) } @@ -588,6 +1104,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }, connectionId ) + void runtime + .waitForTerminal(params.terminal, { condition: 'exit' }) + .then(() => runtime.cleanupSubscription(subscriptionId)) + .catch(() => runtime.cleanupSubscription(subscriptionId)) const sendFrame = ( opcode: TerminalStreamOpcode, payload: Uint8Array = new Uint8Array() @@ -597,6 +1117,52 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: cursor++, payload })) } + outputBatcher = createTerminalOutputBatcher((data) => { + sendFrame(TerminalStreamOpcode.Output, encodeTerminalStreamText(data)) + }) + unregisterBinaryHandler = + registerBinaryStreamHandler?.(streamId, (frame) => { + if (closed) { + return + } + if (frame.opcode === TerminalStreamOpcode.Input) { + const text = decodeTerminalStreamText(frame.payload) + if (!text) { + return + } + if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + return + } + void runtime + .sendTerminal(params.terminal, { text, enter: false, interrupt: false }) + .then(async () => { + if (isMobile && clientId) { + await runtime.mobileTookFloor(ptyId, clientId) + } + }) + .catch(() => {}) + return + } + if (frame.opcode === TerminalStreamOpcode.Resize && params.client) { + const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( + frame.payload + ) + if ( + !viewport || + typeof viewport.cols !== 'number' || + typeof viewport.rows !== 'number' + ) { + return + } + void updateViewportForClient( + runtime, + ptyId, + params.client, + { cols: viewport.cols, rows: viewport.rows }, + 'desktop' + ).catch(() => {}) + } + }) ?? (() => {}) // Server-side auto-fit: resize PTY to phone dims before serializing scrollback try { if (isMobile && clientId) { @@ -612,20 +1178,20 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } if (buffering) { pendingOutput.push(data) + pendingOutputChars += data.length + while ( + pendingOutputChars > TERMINAL_MULTIPLEX_PENDING_MAX_CHARS && + pendingOutput.length > 0 + ) { + pendingOutputChars -= pendingOutput.shift()?.length ?? 0 + } return } - sendBinary!( - encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Output, - streamId, - seq: cursor++, - payload: encodeTerminalStreamText(data) - }) - ) + outputBatcher?.push(data) }) const read = await runtime.readTerminal(params.terminal) - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, true) + const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) if (closed) { return } @@ -667,20 +1233,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }) buffering = false for (const item of pendingOutput.splice(0)) { - sendBinary!( - encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Output, - streamId, - seq: cursor++, - payload: encodeTerminalStreamText(item) - }) - ) + outputBatcher.push(item) } + pendingOutputChars = 0 + outputBatcher.flush() unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { // Why: true PTY geometry changes should be followed by the TUI's // redraw output, not a full scrollback replay. The client resizes // xterm geometry and consumes subsequent live output on this stream. + outputBatcher?.flush() sendFrame( TerminalStreamOpcode.Resized, encodeTerminalStreamJson({ diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts new file mode 100644 index 00000000000..e0da750b5c4 --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { WORKTREE_METHODS } from './worktree' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('worktree RPC methods', () => { + it('routes create options to the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'feature', + baseBranch: 'origin/main', + setupDecision: 'skip', + displayName: 'Feature title', + linkedIssue: 123, + linkedPR: 456, + sparseCheckout: { directories: ['src'], presetId: 'preset-1' }, + pushTarget: { remoteName: 'fork', branchName: 'feature' } + }) + ) + + expect(runtime.createManagedWorktree).toHaveBeenCalledWith({ + repoSelector: 'repo-1', + name: 'feature', + baseBranch: 'origin/main', + linkedIssue: 123, + linkedPR: 456, + comment: undefined, + displayName: 'Feature title', + sparseCheckout: { directories: ['src'], presetId: 'preset-1' }, + pushTarget: { remoteName: 'fork', branchName: 'feature' }, + runHooks: false, + activate: false, + setupDecision: 'skip', + startup: undefined + }) + }) + + it('persists smart sort order on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + persistManagedWorktreeSortOrder: vi.fn().mockReturnValue({ updated: 2 }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.persistSortOrder', { orderedIds: ['wt-1', 'wt-2'] }) + ) + + expect(runtime.persistManagedWorktreeSortOrder).toHaveBeenCalledWith(['wt-1', 'wt-2']) + expect(response).toMatchObject({ ok: true, result: { updated: 2 } }) + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index e3358fa731b..0dd7c5384ba 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -3,9 +3,11 @@ import { defineMethod, type RpcMethod } from '../core' import { OptionalBoolean, OptionalFiniteNumber, + OptionalPlainString, OptionalString, TriStateLinkedIssue } from '../schemas' +import { isTuiAgent } from '../../../../shared/tui-agent-config' const WorktreeListParams = z.object({ repo: OptionalString, @@ -16,6 +18,10 @@ const WorktreePsParams = z.object({ limit: OptionalFiniteNumber }) +const WorktreeSortOrder = z.object({ + orderedIds: z.array(z.string()) +}) + const WorktreeSelector = z.object({ worktree: z .unknown() @@ -31,7 +37,22 @@ const WorktreeCreate = z.object({ name: OptionalString, baseBranch: OptionalString, linkedIssue: TriStateLinkedIssue, + linkedPR: TriStateLinkedIssue, comment: OptionalString, + displayName: OptionalString, + sparseCheckout: z + .object({ + directories: z.array(z.string()), + presetId: OptionalString + }) + .optional(), + pushTarget: z + .object({ + remoteName: z.string(), + branchName: z.string(), + remoteUrl: OptionalString + }) + .optional(), runHooks: OptionalBoolean, activate: OptionalBoolean, setupDecision: z @@ -43,14 +64,39 @@ const WorktreeCreate = z.object({ .optional(), // Why: mobile clients pass a startup command (e.g. 'claude') so the first // terminal pane launches the selected agent instead of an idle shell. - startupCommand: OptionalString + startupCommand: OptionalString, + createdWithAgent: z + .unknown() + .transform((value) => (isTuiAgent(value) ? value : undefined)) + .optional() }) const WorktreeSet = WorktreeSelector.extend({ displayName: OptionalString, + // Why: empty comments are meaningful metadata updates, so use the plain + // string parser instead of OptionalString's empty-as-undefined behavior. + comment: OptionalPlainString, linkedIssue: TriStateLinkedIssue, - comment: OptionalString, - isPinned: OptionalBoolean + linkedPR: TriStateLinkedIssue, + linkedLinearIssue: z.union([z.string(), z.null()]).optional(), + isArchived: OptionalBoolean, + isUnread: OptionalBoolean, + isPinned: OptionalBoolean, + sortOrder: OptionalFiniteNumber, + lastActivityAt: OptionalFiniteNumber, + createdAt: OptionalFiniteNumber, + sparseDirectories: z.array(z.string()).optional(), + sparseBaseRef: OptionalString, + sparsePresetId: OptionalString, + baseRef: OptionalString, + pushTarget: z + .object({ + remoteName: z.string(), + branchName: z.string(), + remoteUrl: OptionalString + }) + .optional(), + diffComments: z.array(z.unknown()).optional() }) const WorktreeRemove = WorktreeSelector.extend({ @@ -58,6 +104,19 @@ const WorktreeRemove = WorktreeSelector.extend({ runHooks: OptionalBoolean }) +const WorktreeResolvePrBase = z.object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')), + prNumber: z + .unknown() + .transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)) + .pipe(z.number().int().positive('Missing PR number')), + headRefName: OptionalString, + isCrossRepository: OptionalBoolean +}) + export const WORKTREE_METHODS: RpcMethod[] = [ defineMethod({ name: 'worktree.ps', @@ -95,10 +154,15 @@ export const WORKTREE_METHODS: RpcMethod[] = [ name: params.name ?? '', baseBranch: params.baseBranch, linkedIssue: params.linkedIssue, + linkedPR: params.linkedPR, comment: params.comment, + displayName: params.displayName, + sparseCheckout: params.sparseCheckout, + pushTarget: params.pushTarget, runHooks: params.runHooks === true, activate: params.activate === true, setupDecision: params.setupDecision, + createdWithAgent: params.createdWithAgent, startup: params.startupCommand ? { command: params.startupCommand } : undefined }) }), @@ -109,11 +173,41 @@ export const WORKTREE_METHODS: RpcMethod[] = [ worktree: await runtime.updateManagedWorktreeMeta(params.worktree, { displayName: params.displayName, linkedIssue: params.linkedIssue, + linkedPR: params.linkedPR, + linkedLinearIssue: params.linkedLinearIssue, comment: params.comment, - isPinned: params.isPinned - }) + isArchived: params.isArchived, + isUnread: params.isUnread, + isPinned: params.isPinned, + sortOrder: params.sortOrder, + lastActivityAt: params.lastActivityAt, + createdAt: params.createdAt, + sparseDirectories: params.sparseDirectories, + sparseBaseRef: params.sparseBaseRef, + sparsePresetId: params.sparsePresetId, + baseRef: params.baseRef, + pushTarget: params.pushTarget, + diffComments: params.diffComments + } as Parameters[1]) }) }), + defineMethod({ + name: 'worktree.persistSortOrder', + params: WorktreeSortOrder, + handler: async (params, { runtime }) => + runtime.persistManagedWorktreeSortOrder(params.orderedIds) + }), + defineMethod({ + name: 'worktree.resolvePrBase', + params: WorktreeResolvePrBase, + handler: async (params, { runtime }) => + runtime.resolveManagedPrBase({ + repoId: params.repo, + prNumber: params.prNumber, + headRefName: params.headRefName, + isCrossRepository: params.isCrossRepository + }) + }), defineMethod({ name: 'worktree.rm', params: WorktreeRemove, diff --git a/src/main/runtime/rpc/streaming.test.ts b/src/main/runtime/rpc/streaming.test.ts index 7afa5ae7553..abc9ab20372 100644 --- a/src/main/runtime/rpc/streaming.test.ts +++ b/src/main/runtime/rpc/streaming.test.ts @@ -3,6 +3,8 @@ import { z } from 'zod' import { RpcDispatcher } from './dispatcher' import { defineMethod, defineStreamingMethod, type RpcRequest } from './core' import type { OrcaRuntimeService } from '../orca-runtime' +import { TERMINAL_METHODS } from './methods/terminal' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { @@ -263,4 +265,57 @@ describe('RpcDispatcher streaming', () => { error: { code: 'runtime_error' } }) }) + + it('ends terminal.subscribe when the backing terminal exits', async () => { + const messages: string[] = [] + let resolveExit!: () => void + const cleanups = new Map void>() + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn( + () => + new Promise((resolve) => { + resolveExit = () => + resolve({ + handle: 'terminal-1', + condition: 'exit', + satisfied: true, + status: 'exited', + exitCode: 0 + }) + }) + ) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' } + }), + (msg) => messages.push(msg) + ) + + await vi.waitFor(() => expect(cleanups.has('terminal-1:desktop-1')).toBe(true)) + resolveExit() + await dispatchPromise + + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'end')).toBe(true) + expect(runtime.cleanupSubscription).toHaveBeenCalledWith('terminal-1:desktop-1') + }) }) diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts new file mode 100644 index 00000000000..1f066cca372 --- /dev/null +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -0,0 +1,395 @@ +/* oxlint-disable max-lines -- Why: multiplex transport tests share a live dispatcher harness; splitting it would duplicate stream setup and weaken race coverage. */ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' +import { TERMINAL_METHODS } from './methods/terminal' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + decodeTerminalStreamText, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' + +function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + ...overrides + } as OrcaRuntimeService +} + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('terminal multiplex RPC', () => { + it('multiplexes terminal streams and routes desktop resize to the source PTY', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListenerRef: { current?: (data: string) => void } = {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'snapshot', + cols: 120, + rows: 40 + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn((_: string, listener: (data: string) => void) => { + dataListenerRef.current = listener + return vi.fn() + }), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-1', + sendBinary: (bytes) => binaryFrames.push(bytes), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + expect(handlers.has(0)).toBe(true) + + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 5, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 300, rows: 150 } + }) + }) + )! + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + expect(messages.map((msg) => JSON.parse(msg).result)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'fit-override-changed', + streamId: 5, + mode: 'desktop-fit' + }), + expect.objectContaining({ + type: 'driver-changed', + streamId: 5, + driver: { kind: 'idle' } + }) + ]) + ) + expect(runtime.updateDesktopViewport).toHaveBeenCalledWith('pty-1', { + cols: 300, + rows: 150 + }) + expect(handlers.has(5)).toBe(true) + + dataListenerRef.current?.('a') + dataListenerRef.current?.('b') + await vi.runOnlyPendingTimersAsync() + + const outputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + expect(outputFrames).toHaveLength(1) + expect(outputFrames[0]?.streamId).toBe(5) + expect(outputFrames[0] ? decodeTerminalStreamText(outputFrames[0].payload) : '').toBe('ab') + + handlers.get(5)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 5, + seq: 2, + payload: encodeTerminalStreamText('ls\r') + }) + )! + ) + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text: 'ls\r', + enter: false, + interrupt: false + }) + ) + + handlers.get(5)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Resize, + streamId: 5, + seq: 3, + payload: encodeTerminalStreamJson({ cols: 100, rows: 30 }) + }) + )! + ) + await vi.waitFor(() => + expect(runtime.updateDesktopViewport).toHaveBeenLastCalledWith('pty-1', { + cols: 100, + rows: 30 + }) + ) + + const snapshotStartFrame = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart) + expect( + snapshotStartFrame && decodeTerminalStreamJson(snapshotStartFrame.payload) + ).toMatchObject({ + cols: 120, + rows: 40 + }) + + runtime.cleanupSubscription('terminal-multiplex:conn-1') + await dispatchPromise + } finally { + vi.useRealTimers() + } + }) + + it('drops desktop multiplex input while a mobile client owns the terminal floor', async () => { + const messages: string[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue({ + mode: 'mobile-fit', + cols: 49, + rows: 20 + }), + getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'phone-1' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-locked', + sendBinary: vi.fn(), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 7, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 } + }) + }) + )! + ) + await vi.waitFor(() => expect(handlers.has(7)).toBe(true)) + await vi.waitFor(() => + expect(messages.map((msg) => JSON.parse(msg).result)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'fit-override-changed', + streamId: 7, + mode: 'mobile-fit', + cols: 49, + rows: 20 + }), + expect.objectContaining({ + type: 'driver-changed', + streamId: 7, + driver: { kind: 'mobile', clientId: 'phone-1' } + }) + ]) + ) + ) + + handlers.get(7)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 7, + seq: 2, + payload: encodeTerminalStreamText('typed while locked') + }) + )! + ) + + expect(runtime.sendTerminal).not.toHaveBeenCalled() + cleanups.get('terminal-multiplex:conn-locked')?.() + await dispatchPromise + }) + + it('bounds live output queued while a multiplex snapshot is loading', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListenerRef: { current?: (data: string) => void } = {} + let resolveSnapshot: (value: { data: string; cols: number; rows: number }) => void = () => {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn( + () => + new Promise<{ data: string; cols: number; rows: number }>((resolve) => { + resolveSnapshot = resolve + }) + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn((_: string, listener: (data: string) => void) => { + dataListenerRef.current = listener + return vi.fn() + }), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-buffered', + sendBinary: (bytes) => binaryFrames.push(bytes), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 9, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 } + }) + }) + )! + ) + await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) + + for (let index = 0; index < 400; index += 1) { + dataListenerRef.current?.(`${String(index).padStart(3, '0')}${'x'.repeat(1021)}`) + } + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled()) + resolveSnapshot({ data: '', cols: 120, rows: 40 }) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + await vi.runOnlyPendingTimersAsync() + + const output = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(output.length).toBeLessThanOrEqual(256 * 1024) + expect(output).not.toContain('000') + expect(output).toContain('399') + + cleanups.get('terminal-multiplex:conn-buffered')?.() + await dispatchPromise + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts new file mode 100644 index 00000000000..11e2b7476c8 --- /dev/null +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' +import { TERMINAL_METHODS } from './methods/terminal' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText, + encodeTerminalStreamFrame, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' + +function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + ...overrides + } as OrcaRuntimeService +} + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('terminal output batching', () => { + it('coalesces desktop terminal output bursts before emitting stream data', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const cleanups = new Map void>() + const dataListenerRef: { current?: (data: string) => void } = {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn((_: string, listener: (data: string) => void) => { + dataListenerRef.current = listener + return vi.fn() + }), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' } + }), + (msg) => messages.push(msg) + ) + + await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) + const emitData = dataListenerRef.current + if (!emitData) { + throw new Error('missing terminal data listener') + } + emitData('a') + emitData('b') + + expect(messages.map((msg) => JSON.parse(msg).result?.type)).not.toContain('data') + + await vi.runOnlyPendingTimersAsync() + + const dataMessages = messages + .map((msg) => JSON.parse(msg)) + .filter((message) => message.result?.type === 'data') + expect(dataMessages).toHaveLength(1) + expect(dataMessages[0]).toMatchObject({ result: { type: 'data', chunk: 'ab' } }) + + runtime.cleanupSubscription('terminal-1:desktop-1') + await dispatchPromise + } finally { + vi.useRealTimers() + } + }) + + it('streams desktop terminal output as coalesced binary frames when requested', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + const dataListenerRef: { current?: (data: string) => void } = {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'snapshot', + cols: 80, + rows: 24 + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn((_: string, listener: (data: string) => void) => { + dataListenerRef.current = listener + return vi.fn() + }), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateMobileViewport: vi.fn().mockResolvedValue(false) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + capabilities: { terminalBinaryStream: 1 } + }), + (msg) => messages.push(msg), + { + connectionId: 'conn-1', + sendBinary: (bytes) => binaryFrames.push(bytes) + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + const subscribed = messages + .map((msg) => JSON.parse(msg)) + .find((msg) => msg.result?.type === 'subscribed') + expect(subscribed?.result).toMatchObject({ type: 'subscribed', streamId: expect.any(Number) }) + await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) + + const emitData = dataListenerRef.current + if (!emitData) { + throw new Error('missing terminal data listener') + } + emitData('a') + emitData('b') + + expect(messages.map((msg) => JSON.parse(msg).result?.type)).not.toContain('data') + + await vi.runOnlyPendingTimersAsync() + + const outputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + expect(outputFrames).toHaveLength(1) + expect(outputFrames[0] ? decodeTerminalStreamText(outputFrames[0].payload) : '').toBe('ab') + + runtime.cleanupSubscription('terminal-1:desktop-1') + await dispatchPromise + } finally { + vi.useRealTimers() + } + }) + + it('routes binary terminal input frames back to the subscribed PTY', async () => { + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateMobileViewport: vi.fn().mockResolvedValue(false) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const messages: string[] = [] + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + capabilities: { terminalBinaryStream: 1 } + }), + (msg) => messages.push(msg), + { + connectionId: 'conn-1', + sendBinary: vi.fn(), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => { + expect(messages.some((msg) => JSON.parse(msg).result?.streamId)).toBe(true) + }) + const streamId = JSON.parse(messages.find((msg) => JSON.parse(msg).result?.streamId)!).result + .streamId as number + handlers.get(streamId)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId, + seq: 1, + payload: encodeTerminalStreamText('ls\r') + }) + )! + ) + + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text: 'ls\r', + enter: false, + interrupt: false + }) + ) + + runtime.cleanupSubscription('terminal-1:desktop-1') + await dispatchPromise + }) +}) diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts new file mode 100644 index 00000000000..37e4c503135 --- /dev/null +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' +import { TERMINAL_METHODS } from './methods/terminal' + +function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + ...overrides + } as OrcaRuntimeService +} + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('terminal send RPC', () => { + it('drops desktop input while a mobile client owns the terminal floor', async () => { + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), + sendTerminal: vi.fn(), + mobileTookFloor: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: 'x', + client: { id: 'desktop-1', type: 'desktop' } + }) + ) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(response.result).toEqual({ + send: { + handle: 'terminal-1', + accepted: false, + bytesWritten: 0 + } + }) + expect(runtime.sendTerminal).not.toHaveBeenCalled() + expect(runtime.mobileTookFloor).not.toHaveBeenCalled() + }) + + it('accepts legacy clientless mobile input when the current driver is mobile', async () => { + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), + sendTerminal: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + accepted: true, + bytesWritten: 1 + }), + mobileTookFloor: vi.fn().mockResolvedValue(undefined) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: 'x' + }) + ) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(response.result).toMatchObject({ send: { accepted: true, bytesWritten: 1 } }) + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text: 'x', + enter: false, + interrupt: false + }) + expect(runtime.mobileTookFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') + }) + + it('routes terminal restore fit through the runtime driver state machine', async () => { + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + reclaimTerminalForDesktop: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.restoreFit', { + terminal: 'terminal-1' + }) + ) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(response.result).toEqual({ restored: true }) + expect(runtime.reclaimTerminalForDesktop).toHaveBeenCalledWith('pty-1') + }) +}) diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts new file mode 100644 index 00000000000..8c1a0b77c5d --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' +import { TERMINAL_METHODS } from './methods/terminal' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' + +function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + ...overrides + } as OrcaRuntimeService +} + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('terminal subscribe buffering', () => { + it('bounds legacy binary output queued while the initial snapshot is serializing', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + const dataListenerRef: { current?: (data: string) => void } = {} + let resolveSnapshot: (value: { data: string; cols: number; rows: number }) => void = () => {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn( + () => + new Promise<{ data: string; cols: number; rows: number }>((resolve) => { + resolveSnapshot = resolve + }) + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn((_: string, listener: (data: string) => void) => { + dataListenerRef.current = listener + return vi.fn() + }), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateMobileViewport: vi.fn().mockResolvedValue(false) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + capabilities: { terminalBinaryStream: 1 } + }), + (msg) => messages.push(msg), + { + connectionId: 'conn-buffered', + sendBinary: (bytes) => binaryFrames.push(bytes) + } + ) + + await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) + for (let index = 0; index < 400; index += 1) { + dataListenerRef.current?.(`${String(index).padStart(3, '0')}${'x'.repeat(1021)}`) + } + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled()) + resolveSnapshot({ data: '', cols: 120, rows: 40 }) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + await vi.runOnlyPendingTimersAsync() + + const output = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(output.length).toBeLessThanOrEqual(256 * 1024) + expect(output).not.toContain('000') + expect(output).toContain('399') + + runtime.cleanupSubscription('terminal-1:desktop-1') + await dispatchPromise + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index 29db2b3b88b..2f3bcf6bdb6 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -23,7 +23,8 @@ describe('WebSocketTransport', () => { }) async function createTransport( - handler?: (msg: string, reply: (response: string) => void) => void + handler?: (msg: string, reply: (response: string) => void) => void, + options: { preAuthTimeoutMs?: number } = {} ) { const tls = makeTls() const transport = new WebSocketTransport({ @@ -32,7 +33,8 @@ describe('WebSocketTransport', () => { // Port 0 lets the OS reserve an available port atomically. port: 0, tlsCert: tls.cert, - tlsKey: tls.key + tlsKey: tls.key, + preAuthTimeoutMs: options.preAuthTimeoutMs }) if (handler) { transport.onMessage(handler) @@ -41,7 +43,8 @@ describe('WebSocketTransport', () => { return { transport, tls } } - function connectWs(port: number): Promise { + function connectWs(target: number | WebSocketTransport): Promise { + const port = typeof target === 'number' ? target : target.resolvedPort return new Promise((resolve, reject) => { const ws = new WebSocket(`wss://127.0.0.1:${port}`, { rejectUnauthorized: false @@ -75,7 +78,7 @@ describe('WebSocketTransport', () => { await transport.start() - const ws = await connectWs(transport.resolvedPort) + const ws = await connectWs(transport) const response = await sendAndReceive( ws, JSON.stringify({ id: 'req-1', method: 'test', deviceToken: 'tok' }) @@ -99,9 +102,9 @@ describe('WebSocketTransport', () => { await transport.start() const clients = await Promise.all([ - connectWs(transport.resolvedPort), - connectWs(transport.resolvedPort), - connectWs(transport.resolvedPort) + connectWs(transport), + connectWs(transport), + connectWs(transport) ]) const responses = await Promise.all( @@ -125,7 +128,7 @@ describe('WebSocketTransport', () => { await transport.start() - const ws = await connectWs(transport.resolvedPort) + const ws = await connectWs(transport) const r1 = sendAndReceive(ws, JSON.stringify({ id: 'a', method: 'first' })) const resp1 = JSON.parse(await r1) @@ -148,7 +151,7 @@ describe('WebSocketTransport', () => { await transport.start() - const ws = await connectWs(transport.resolvedPort) + const ws = await connectWs(transport) const messages: string[] = [] await new Promise((resolve) => { @@ -173,7 +176,7 @@ describe('WebSocketTransport', () => { await transport.start() - const ws = await connectWs(transport.resolvedPort) + const ws = await connectWs(transport) // Why: ws maxPayload is 1MB — sending >1MB should trigger close. const oversized = 'x'.repeat(1024 * 1024 + 100) @@ -193,7 +196,7 @@ describe('WebSocketTransport', () => { await transport.start() - const ws = await connectWs(transport.resolvedPort) + const ws = await connectWs(transport) ws.send(JSON.stringify({ id: 'req-1', method: 'test' })) // Why: wait for the handler to capture the reply function. @@ -215,6 +218,87 @@ describe('WebSocketTransport', () => { expect(() => capturedReply!(JSON.stringify({ id: 'req-1', ok: true }))).not.toThrow() }) + it('runs connection cleanup for sockets that close before auth', async () => { + const { transport } = await createTransport() + const calls: { clientId: string | null; hasOtherConnections: boolean }[] = [] + transport.onConnectionClose((clientId, _ws, hasOtherConnections) => { + calls.push({ clientId, hasOtherConnections }) + }) + + await transport.start() + + const ws = await connectWs(transport) + ws.close() + + const start = Date.now() + while (calls.length === 0 && Date.now() - start < 2_000) { + await new Promise((resolve) => setTimeout(resolve, 20)) + } + + expect(calls).toEqual([{ clientId: null, hasOtherConnections: false }]) + }) + + it('terminates every active connection for a revoked client id', async () => { + const { transport } = await createTransport() + const closedClientIds: (string | null)[] = [] + transport.onConnectionClose((clientId) => { + closedClientIds.push(clientId) + }) + + await transport.start() + + const clients = await Promise.all([connectWs(transport), connectWs(transport)]) + const wss = (transport as unknown as { wss: { clients: Set } }).wss + for (const client of wss.clients) { + transport.setClientId(client, 'device-token') + } + + expect(transport.terminateClientConnections('device-token')).toBe(2) + + await Promise.all( + clients.map( + (client) => + new Promise((resolve) => { + if (client.readyState === client.CLOSED) { + resolve() + return + } + client.once('close', () => resolve()) + }) + ) + ) + + const start = Date.now() + while (closedClientIds.length < 2 && Date.now() - start < 2_000) { + await new Promise((resolve) => setTimeout(resolve, 20)) + } + + expect(closedClientIds).toEqual(['device-token', 'device-token']) + }) + + it('reaps silent pre-auth sockets so they cannot hold the connection cap', async () => { + const { transport } = await createTransport(undefined, { preAuthTimeoutMs: 50 }) + await transport.start() + + const clients = await Promise.all(Array.from({ length: 32 }, () => connectWs(transport))) + await Promise.all( + clients.map( + (client) => + new Promise((resolve) => { + if (client.readyState === client.CLOSED) { + resolve() + return + } + client.once('close', () => resolve()) + }) + ) + ) + + const liveClient = await connectWs(transport) + expect(liveClient.readyState).toBe(liveClient.OPEN) + liveClient.close() + }) + it('is idempotent on double start', async () => { const { transport } = await createTransport() @@ -282,7 +366,7 @@ describe('WebSocketTransport', () => { // start so we can stamp every accepted ws with a token. await transport.start() - const ws = await connectWs(transport.resolvedPort) + const ws = await connectWs(transport) // Why: pausing the underlying TCP socket halts both read (ping in) // and write (pong out) at the kernel level, so the `ws` library's // auto-pong can't actually be flushed back. From the server's diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index b6f34c71113..028c3f379ce 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -10,6 +10,7 @@ import type { RpcTransport } from './transport' const MAX_WS_MESSAGE_BYTES = 1024 * 1024 const MAX_WS_CONNECTIONS = 32 +const PRE_AUTH_TIMEOUT_MS = 10_000 type WebSocketMessagePayload = string | Uint8Array type WebSocketMessageHandler = { bivarianceHack( @@ -38,6 +39,8 @@ export type WebSocketTransportOptions = { tlsKey?: string // Why: test-only override. Production uses HEARTBEAT_INTERVAL_MS. heartbeatIntervalMs?: number + // Why: test-only override. Production uses PRE_AUTH_TIMEOUT_MS. + preAuthTimeoutMs?: number } export class WebSocketTransport implements RpcTransport { @@ -46,6 +49,7 @@ export class WebSocketTransport implements RpcTransport { private readonly tlsCert: string | undefined private readonly tlsKey: string | undefined private readonly heartbeatIntervalMs: number + private readonly preAuthTimeoutMs: number private httpServer: HttpsServer | HttpServer | null = null private wss: WebSocketServer | null = null private heartbeatTimer: ReturnType | null = null @@ -55,18 +59,27 @@ export class WebSocketTransport implements RpcTransport { private wsAlive = new WeakSet() private messageHandler: WebSocketMessageHandler | null = null private connectionCloseHandler: - | ((clientId: string, ws: WebSocket, hasOtherConnections: boolean) => void) + | ((clientId: string | null, ws: WebSocket, hasOtherConnections: boolean) => void) | null = null // Why: maps each WebSocket to the clientId (deviceToken) that authenticated it, // so ws.on('close') can notify the runtime which mobile client disconnected. private wsClientIds = new Map() + private preAuthTimers = new WeakMap>() - constructor({ host, port, tlsCert, tlsKey, heartbeatIntervalMs }: WebSocketTransportOptions) { + constructor({ + host, + port, + tlsCert, + tlsKey, + heartbeatIntervalMs, + preAuthTimeoutMs + }: WebSocketTransportOptions) { this.host = host this.port = port this.tlsCert = tlsCert this.tlsKey = tlsKey this.heartbeatIntervalMs = heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS + this.preAuthTimeoutMs = preAuthTimeoutMs ?? PRE_AUTH_TIMEOUT_MS } onMessage(handler: WebSocketMessageHandler): void { @@ -80,13 +93,26 @@ export class WebSocketTransport implements RpcTransport { // so client-scoped teardown (mobile-fit overrides, etc.) only fires on the // last disconnect. onConnectionClose( - handler: (clientId: string, ws: WebSocket, hasOtherConnections: boolean) => void + handler: (clientId: string | null, ws: WebSocket, hasOtherConnections: boolean) => void ): void { this.connectionCloseHandler = handler } setClientId(ws: WebSocket, clientId: string): void { this.wsClientIds.set(ws, clientId) + this.clearPreAuthTimer(ws) + } + + terminateClientConnections(clientId: string): number { + const sockets = Array.from(this.wsClientIds.entries()) + .filter(([, candidateClientId]) => candidateClientId === clientId) + .map(([ws]) => ws) + for (const ws of sockets) { + // Why: revocation is a security boundary; terminate skips the close + // handshake so a revoked mobile stream stops immediately. + ws.terminate() + } + return sockets.length } // Why: when port 0 is passed the OS assigns a random available port. The @@ -235,6 +261,33 @@ export class WebSocketTransport implements RpcTransport { // connection via the RPC `id` field. The transport delegates all auth // and dispatch logic to the message handler set by OrcaRuntimeRpcServer. private handleConnection(ws: WebSocket): void { + let finalized = false + const finalizeConnection = (): void => { + if (finalized) { + return + } + finalized = true + this.clearPreAuthTimer(ws) + const clientId = this.wsClientIds.get(ws) ?? null + this.wsClientIds.delete(ws) + const hasOtherConnections = + clientId !== null && Array.from(this.wsClientIds.values()).includes(clientId) + this.connectionCloseHandler?.(clientId, ws, hasOtherConnections) + } + + const preAuthTimer = setTimeout(() => { + if (!this.wsClientIds.has(ws)) { + // Why: a silent client that only auto-pongs can otherwise occupy one + // of the finite mobile WebSocket slots forever without ever starting + // the E2EE handshake. + ws.terminate() + } + }, this.preAuthTimeoutMs) + if (typeof preAuthTimer.unref === 'function') { + preAuthTimer.unref() + } + this.preAuthTimers.set(ws, preAuthTimer) + // Why: seed alive=true so the first heartbeat tick after connect doesn't // treat a fresh socket as dead. Subsequent pongs (or any inbound traffic) // re-arm it. @@ -272,24 +325,23 @@ export class WebSocketTransport implements RpcTransport { // Why: mobile clients disconnect when the phone locks, loses wifi, or // backgrounds the app. The runtime must clean up connection-scoped state // (e.g., mobile-fit overrides) to prevent orphaned phone-fit on desktop. - ws.on('close', () => { - const clientId = this.wsClientIds.get(ws) - this.wsClientIds.delete(ws) - if (clientId) { - // Why: a paired device may have multiple concurrent sockets open - // (e.g. one per app screen). Per-client teardown must only fire when - // the last socket for this token closes — otherwise closing the - // accounts-screen socket would clobber the host-screen socket's - // state and strand it in a non-functional state until re-paired. - const hasOtherConnections = Array.from(this.wsClientIds.values()).includes(clientId) - this.connectionCloseHandler?.(clientId, ws, hasOtherConnections) - } - }) + ws.on('close', finalizeConnection) ws.on('error', () => { + // Why: close is not guaranteed after every ws error path; finalize here + // too so pre-auth E2EE channels and connection ids cannot leak. + finalizeConnection() ws.close() }) } + + private clearPreAuthTimer(ws: WebSocket): void { + const timer = this.preAuthTimers.get(ws) + if (timer) { + clearTimeout(timer) + this.preAuthTimers.delete(ws) + } + } } function isEAddressInUse(error: unknown): boolean { diff --git a/src/main/runtime/runtime-metadata.test.ts b/src/main/runtime/runtime-metadata.test.ts index 4e93e909dd6..da369b01d40 100644 --- a/src/main/runtime/runtime-metadata.test.ts +++ b/src/main/runtime/runtime-metadata.test.ts @@ -1,8 +1,16 @@ -import { mkdtempSync, statSync } from 'fs' +import { chmodSync, mkdtempSync, readdirSync, statSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { afterEach, describe, expect, it } from 'vitest' import { getRuntimeMetadataPath } from '../../shared/runtime-bootstrap' +import { encodePairingOffer } from '../../shared/pairing' +import { + addEnvironmentFromPairingCode, + getEnvironmentStorePath, + listEnvironments +} from '../../shared/runtime-environment-store' +import { DeviceRegistry } from './device-registry' +import { loadOrCreateE2EEKeypair } from './e2ee-keypair' import { clearRuntimeMetadata, clearRuntimeMetadataIfOwned, @@ -161,4 +169,89 @@ describe('runtime metadata', () => { expect(directoryMode).toBe(0o700) } ) + + it.runIf(process.platform !== 'win32')( + 'uses hardened atomic writes for runtime credential stores on Unix', + () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-secure-files-')) + tempDirs.push(userDataPath) + + new DeviceRegistry(userDataPath).addDevice('phone') + loadOrCreateE2EEKeypair(userDataPath) + addEnvironmentFromPairingCode(userDataPath, { + name: 'desk', + pairingCode: encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'device-token', + publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64') + }) + }) + + for (const path of [ + join(userDataPath, 'orca-devices.json'), + join(userDataPath, 'orca-e2ee-keypair.json'), + getEnvironmentStorePath(userDataPath) + ]) { + expect(statSync(path).mode & 0o777).toBe(0o600) + } + expect(statSync(userDataPath).mode & 0o777).toBe(0o700) + expect(readdirSync(userDataPath).some((entry) => entry.endsWith('.tmp'))).toBe(false) + } + ) + + it.runIf(process.platform !== 'win32')( + 'hardens existing runtime credential stores before reading them on Unix', + () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-existing-secure-files-')) + tempDirs.push(userDataPath) + const keyMaterial = Buffer.from(new Uint8Array(32).fill(1)).toString('base64') + const pairingCode = encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'device-token', + publicKeyB64: keyMaterial + }) + const environment = addEnvironmentFromPairingCode(userDataPath, { + name: 'desk', + pairingCode + }) + + const devicesPath = join(userDataPath, 'orca-devices.json') + const keypairPath = join(userDataPath, 'orca-e2ee-keypair.json') + const environmentsPath = getEnvironmentStorePath(userDataPath) + writeFileSync( + devicesPath, + JSON.stringify([ + { + deviceId: 'device-1', + name: 'phone', + token: 'token', + pairedAt: 1, + lastSeenAt: 0 + } + ]) + ) + writeFileSync( + keypairPath, + JSON.stringify({ v: 1, publicKeyB64: keyMaterial, secretKeyB64: keyMaterial }) + ) + for (const path of [devicesPath, keypairPath, environmentsPath]) { + chmodSync(path, 0o644) + } + chmodSync(userDataPath, 0o755) + + expect(new DeviceRegistry(userDataPath).getDevice('device-1')).toMatchObject({ + token: 'token', + scope: 'mobile' + }) + expect(loadOrCreateE2EEKeypair(userDataPath).publicKeyB64).toBe(keyMaterial) + expect(listEnvironments(userDataPath)[0]?.id).toBe(environment.id) + + for (const path of [devicesPath, keypairPath, environmentsPath]) { + expect(statSync(path).mode & 0o777).toBe(0o600) + } + expect(statSync(userDataPath).mode & 0o777).toBe(0o700) + } + ) }) diff --git a/src/main/runtime/runtime-metadata.ts b/src/main/runtime/runtime-metadata.ts index 95a7f707c0f..ec46035d7c2 100644 --- a/src/main/runtime/runtime-metadata.ts +++ b/src/main/runtime/runtime-metadata.ts @@ -1,17 +1,6 @@ -import { - chmodSync, - existsSync, - mkdirSync, - readFileSync, - renameSync, - rmSync, - writeFileSync -} from 'fs' -import { execFileSync } from 'child_process' -import { dirname } from 'path' +import { existsSync, readFileSync, rmSync } from 'fs' import { getRuntimeMetadataPath, type RuntimeMetadata } from '../../shared/runtime-bootstrap' - -let cachedWindowsUserSid: string | null | undefined +import { writeSecureJsonFile } from '../../shared/secure-file' export function writeRuntimeMetadata(userDataPath: string, metadata: RuntimeMetadata): void { const metadataPath = getRuntimeMetadataPath(userDataPath) @@ -63,85 +52,5 @@ export function clearRuntimeMetadataIfOwned( } function writeMetadataFile(path: string, metadata: RuntimeMetadata): void { - const dir = dirname(path) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true, mode: 0o700 }) - } - hardenRuntimePath(dir, { isDirectory: true, platform: process.platform }) - const tmpFile = `${path}.tmp` - writeFileSync(tmpFile, JSON.stringify(metadata, null, 2), { - encoding: 'utf-8', - mode: 0o600 - }) - hardenRuntimePath(tmpFile, { isDirectory: false, platform: process.platform }) - renameSync(tmpFile, path) - // Why: runtime bootstrap files carry auth material that lets the local CLI - // attach to a live Orca runtime. The published file must stay scoped to - // the current user. - hardenRuntimePath(path, { isDirectory: false, platform: process.platform }) -} - -function hardenRuntimePath( - targetPath: string, - options: { - isDirectory: boolean - platform: NodeJS.Platform - } -): void { - if (options.platform === 'win32') { - bestEffortRestrictWindowsPath(targetPath) - return - } - chmodSync(targetPath, options.isDirectory ? 0o700 : 0o600) -} - -function bestEffortRestrictWindowsPath(targetPath: string): void { - const currentUserSid = getCurrentWindowsUserSid() - if (!currentUserSid) { - return - } - try { - execFileSync( - 'icacls', - [ - targetPath, - '/inheritance:r', - '/grant:r', - `*${currentUserSid}:(F)`, - '*S-1-5-18:(F)', - '*S-1-5-32-544:(F)' - ], - { - stdio: 'ignore', - windowsHide: true, - timeout: 5000 - } - ) - } catch { - // Why: runtime metadata hardening should not prevent Orca from starting on - // Windows machines where icacls is unavailable or locked down differently. - } -} - -function getCurrentWindowsUserSid(): string | null { - if (cachedWindowsUserSid !== undefined) { - return cachedWindowsUserSid - } - try { - const output = execFileSync('whoami', ['/user', '/fo', 'csv', '/nh'], { - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'ignore'], - windowsHide: true, - timeout: 5000 - }).trim() - const columns = parseCsvLine(output) - cachedWindowsUserSid = columns[1] ?? null - } catch { - cachedWindowsUserSid = null - } - return cachedWindowsUserSid -} - -function parseCsvLine(line: string): string[] { - return line.split(/","/).map((part) => part.replace(/^"/, '').replace(/"$/, '')) + writeSecureJsonFile(path, metadata) } diff --git a/src/main/runtime/runtime-relative-paths.ts b/src/main/runtime/runtime-relative-paths.ts new file mode 100644 index 00000000000..c74d965c0c9 --- /dev/null +++ b/src/main/runtime/runtime-relative-paths.ts @@ -0,0 +1,29 @@ +import { posix, win32 } from 'path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' + +export function joinWorktreeRelativePath(rootPath: string, relativePath: string): string { + const normalizedRelativePath = relativePath.replace(/\\/g, '/') + if (isWindowsAbsolutePathLike(rootPath)) { + return win32.join(rootPath.replace(/\//g, '\\'), ...normalizedRelativePath.split('/')) + } + return posix.join(rootPath, ...normalizedRelativePath.split('/')) +} + +export function normalizeRuntimeRelativePath(relativePath: string): string { + const normalized = relativePath.replace(/\\/g, '/').replace(/\/+$/, '') + if (normalized === '') { + return '' + } + if (!isSafeRuntimeRelativePath(normalized)) { + throw new Error('invalid_relative_path') + } + return normalized +} + +function isSafeRuntimeRelativePath(relativePath: string): boolean { + if (relativePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(relativePath)) { + return false + } + const parts = relativePath.split('/') + return parts.every((part) => part !== '' && part !== '.' && part !== '..') +} diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index d07fc93b01b..acbbcdaa979 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -3,13 +3,18 @@ import { existsSync, mkdtempSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { createConnection, type Socket } from 'net' +import { EventEmitter } from 'events' import { describe, expect, it, vi } from 'vitest' import Database from 'better-sqlite3' +import WebSocket from 'ws' import { OrcaRuntimeService } from './orca-runtime' import { OrchestrationDb } from './orchestration/db' import * as runtimeMetadataModule from './runtime-metadata' import { readRuntimeMetadata } from './runtime-metadata' import { createRuntimeTransportMetadata, OrcaRuntimeRpcServer } from './runtime-rpc' +import { parsePairingCode } from '../../shared/pairing' +import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto' +import { DeviceRegistry } from './device-registry' vi.mock('../git/worktree', () => ({ listWorktrees: vi.fn().mockResolvedValue([ @@ -104,6 +109,73 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } +async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error('timed out waiting for condition') + } + await sleep(20) + } +} + +function connectWs(endpoint: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(endpoint) + ws.once('open', () => resolve(ws)) + ws.once('error', reject) + }) +} + +function nextWsMessage(ws: WebSocket): Promise { + return new Promise((resolve) => { + ws.once('message', (data) => { + resolve(typeof data === 'string' ? data : data.toString('utf-8')) + }) + }) +} + +function waitForWsClose(ws: WebSocket): Promise { + return new Promise((resolve) => { + if (ws.readyState === ws.CLOSED) { + resolve() + return + } + ws.once('close', () => resolve()) + }) +} + +async function authenticateMobileWs(pairingUrl: string): Promise { + const parsed = parsePairingCode(pairingUrl) + expect(parsed).toBeTruthy() + const ws = await connectWs(parsed!.endpoint) + const mobileKeys = generateKeyPair() + const serverPublicKey = Uint8Array.from(Buffer.from(parsed!.publicKeyB64, 'base64')) + const sharedKey = deriveSharedKey(mobileKeys.secretKey, serverPublicKey) + + ws.send( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: Buffer.from(mobileKeys.publicKey).toString('base64') + }) + ) + expect(JSON.parse(await nextWsMessage(ws))).toEqual({ type: 'e2ee_ready' }) + + ws.send( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: parsed!.deviceToken }), sharedKey) + ) + expect(JSON.parse(decrypt(await nextWsMessage(ws), sharedKey)!)).toEqual({ + type: 'e2ee_authenticated' + }) + + return ws +} + +class FakeWebSocket extends EventEmitter { + readonly OPEN = 1 + readyState = this.OPEN +} + describe('OrcaRuntimeRpcServer', () => { const makeStore = (overrides?: { isUnread?: boolean }) => ({ getRepo: (id: string) => @@ -176,6 +248,418 @@ describe('OrcaRuntimeRpcServer', () => { }) }) + it('creates a pairing offer for the active WebSocket transport', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + + const offer = server.createPairingOffer({ address: '100.64.1.20', name: 'CLI test' }) + expect(offer.available).toBe(true) + if (offer.available) { + expect(offer.endpoint).toContain('100.64.1.20') + const parsed = parsePairingCode(offer.pairingUrl) + expect(parsed?.endpoint).toBe(offer.endpoint) + expect(parsed?.deviceToken).toBeTruthy() + expect(parsed?.publicKeyB64).toBeTruthy() + expect(server.getDeviceRegistry()?.getDevice(offer.deviceId)?.scope).toBe('runtime') + } + + await server.stop() + }) + + it('formats pairing-address overrides for IPv6 and host-port tunnel endpoints', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + + try { + const ipv6 = server.createPairingOffer({ address: '::1', name: 'IPv6 test' }) + expect(ipv6.available).toBe(true) + if (ipv6.available) { + expect(ipv6.endpoint).toMatch(/^ws:\/\/\[::1\]:\d+$/) + expect(parsePairingCode(ipv6.pairingUrl)?.endpoint).toBe(ipv6.endpoint) + } + + const tunnel = server.createPairingOffer({ + address: 'tunnel.example.com:443', + name: 'Tunnel test' + }) + expect(tunnel.available).toBe(true) + if (tunnel.available) { + expect(tunnel.endpoint).toBe('ws://tunnel.example.com:443') + } + + const fullUrl = server.createPairingOffer({ + address: 'wss://runtime.example.com/orca', + name: 'Full URL test' + }) + expect(fullUrl.available).toBe(true) + if (fullUrl.available) { + expect(fullUrl.endpoint).toBe('wss://runtime.example.com/orca') + } + } finally { + await server.stop() + } + }) + + it('creates mobile-scoped pairing offers for headless mobile pairing', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + + try { + const offer = server.createPairingOffer({ + address: '100.64.1.20', + name: 'Mobile test', + scope: 'mobile' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + + expect(server.getDeviceRegistry()?.getDevice(offer.deviceId)?.scope).toBe('mobile') + const parsed = parsePairingCode(offer.pairingUrl) + expect(parsed?.endpoint).toBe(offer.endpoint) + expect(parsed?.endpoint).toContain('100.64.1.20') + } finally { + await server.stop() + } + }) + + it('cleans up pre-auth E2EE WebSocket state when the socket closes', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + + try { + const offer = server.createPairingOffer({ + address: '127.0.0.1', + name: 'mobile-test', + scope: 'mobile' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + const parsed = parsePairingCode(offer.pairingUrl)! + const ws = await connectWs(parsed.endpoint) + const mobileKeys = generateKeyPair() + ws.send( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: Buffer.from(mobileKeys.publicKey).toString('base64') + }) + ) + expect(JSON.parse(await nextWsMessage(ws))).toEqual({ type: 'e2ee_ready' }) + expect(server['e2eeChannels'].size).toBe(1) + expect(server['wsConnectionIds'].size).toBe(1) + + ws.close() + await waitForWsClose(ws) + await waitFor(() => server['e2eeChannels'].size === 0 && server['wsConnectionIds'].size === 0) + } finally { + await server.stop() + } + }) + + it('terminates active WebSockets for a revoked mobile device', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + const disconnectSpy = vi.spyOn(runtime, 'onClientDisconnected') + + await server.start() + + try { + const offer = server.createPairingOffer({ + address: '127.0.0.1', + name: 'mobile-test', + scope: 'mobile' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + const first = await authenticateMobileWs(offer.pairingUrl) + const second = await authenticateMobileWs(offer.pairingUrl) + + expect(server.revokeMobileDevice(offer.deviceId)).toBe(true) + await Promise.all([waitForWsClose(first), waitForWsClose(second)]) + await waitFor(() => server['e2eeChannels'].size === 0 && server['wsConnectionIds'].size === 0) + + expect(disconnectSpy).toHaveBeenCalledTimes(1) + } finally { + disconnectSpy.mockRestore() + await server.stop() + } + }) + + it('does not revoke runtime-scoped devices through mobile revocation', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + + try { + const offer = server.createPairingOffer({ + address: '127.0.0.1', + name: 'runtime-test', + scope: 'runtime' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + + expect(server.revokeMobileDevice(offer.deviceId)).toBe(false) + expect(server.getDeviceRegistry()?.getDevice(offer.deviceId)?.scope).toBe('runtime') + } finally { + await server.stop() + } + }) + + it('caps WebSocket long-polls and aborts them when the socket closes', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const db = new OrchestrationDb(':memory:') + runtime.setOrchestrationDb(db) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: false, + longPollCap: 1 + }) + const device = server['deviceRegistry'] ?? null + expect(device).toBeNull() + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + const entry = server['deviceRegistry']!.addDevice('runtime-test', 'runtime') + const ws = new FakeWebSocket() + server['wsConnectionIds'].set(ws as unknown as WebSocket, 'conn-test') + const replies: Record[] = [] + + try { + const first = server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_wait', + method: 'orchestration.check', + deviceToken: entry.token, + params: { terminal: 'term_wait', wait: true, timeoutMs: 10_000 } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {}, + undefined, + ws as unknown as WebSocket + ) + + await waitFor(() => server['activeLongPolls'] === 1) + + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_busy', + method: 'orchestration.check', + deviceToken: entry.token, + params: { terminal: 'term_busy', wait: true, timeoutMs: 10_000 } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {}, + undefined, + ws as unknown as WebSocket + ) + + expect(replies).toContainEqual( + expect.objectContaining({ + id: 'req_busy', + ok: false, + error: expect.objectContaining({ code: 'runtime_busy' }) + }) + ) + expect(server['activeLongPolls']).toBe(1) + + ws.readyState = 3 + ws.emit('close') + await first + + expect(server['activeLongPolls']).toBe(0) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_wait', ok: true })) + } finally { + db.close() + await server.stop() + } + }) + + it('limits mobile-scoped WebSocket tokens to the mobile RPC surface', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const pushRuntimeGit = vi.fn().mockResolvedValue({ ok: true }) + const selectClaudeAccount = vi.fn().mockResolvedValue({ ok: true }) + const selectCodexAccount = vi.fn().mockResolvedValue({ ok: true }) + const removeClaudeAccount = vi.fn().mockResolvedValue({ ok: true }) + const readTerminal = vi.fn().mockResolvedValue({ tail: ['ok'] }) + const runtime = { + getRuntimeId: () => 'test-runtime', + getStatus: vi.fn().mockResolvedValue({ graphStatus: 'ok' }), + pushRuntimeGit, + selectClaudeAccount, + selectCodexAccount, + removeClaudeAccount, + readTerminal + } as unknown as OrcaRuntimeService + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false }) + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + const mobile = server['deviceRegistry']!.addDevice('phone', 'mobile') + const replies: Record[] = [] + + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_forbidden', + method: 'git.push', + deviceToken: mobile.token, + params: { worktree: 'id:wt-1' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_allowed', + method: 'status.get', + deviceToken: mobile.token + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_select_claude', + method: 'accounts.selectClaude', + deviceToken: mobile.token, + params: { accountId: 'claude-account' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_select_codex', + method: 'accounts.selectCodex', + deviceToken: mobile.token, + params: { accountId: null } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_remove_claude', + method: 'accounts.removeClaude', + deviceToken: mobile.token, + params: { accountId: 'claude-account' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_terminal_read', + method: 'terminal.read', + deviceToken: mobile.token, + params: { terminal: 'term-1' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + + expect(replies).toContainEqual( + expect.objectContaining({ + id: 'req_forbidden', + ok: false, + error: expect.objectContaining({ code: 'forbidden' }) + }) + ) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_allowed', ok: true })) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_select_claude', ok: true })) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_select_codex', ok: true })) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_terminal_read', ok: true })) + expect(replies).toContainEqual( + expect.objectContaining({ + id: 'req_remove_claude', + ok: false, + error: expect.objectContaining({ code: 'forbidden' }) + }) + ) + expect(selectClaudeAccount).toHaveBeenCalledWith('claude-account') + expect(selectCodexAccount).toHaveBeenCalledWith(null) + expect(readTerminal).toHaveBeenCalledWith('term-1', { cursor: undefined }) + expect(removeClaudeAccount).not.toHaveBeenCalled() + expect(pushRuntimeGit).not.toHaveBeenCalled() + }) + + it('allows runtime-scoped WebSocket tokens to use the full RPC surface', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const pushRuntimeGit = vi.fn().mockResolvedValue({ ok: true }) + const runtime = { + getRuntimeId: () => 'test-runtime', + pushRuntimeGit + } as unknown as OrcaRuntimeService + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false }) + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + const runtimeDevice = server['deviceRegistry']!.addDevice('cli', 'runtime') + const replies: Record[] = [] + + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_push', + method: 'git.push', + deviceToken: runtimeDevice.token, + params: { worktree: 'id:wt-1' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_push', ok: true })) + expect(pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', undefined, undefined) + }) + it('leaves the last published metadata in place when a runtime stops', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService() diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 19f80642b0d..da555f98833 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -17,9 +17,14 @@ import type { RpcMessageContext, RpcTransport } from './rpc/transport' import { UnixSocketTransport } from './rpc/unix-socket-transport' import { WebSocketTransport } from './rpc/ws-transport' import type { WebSocket } from 'ws' -import { DeviceRegistry } from './device-registry' +import { DeviceRegistry, type DeviceScope } from './device-registry' import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair' import { E2EEChannel } from './rpc/e2ee-channel' +import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' +import { + decodeTerminalStreamFrame, + type TerminalStreamFrame +} from '../../shared/terminal-stream-protocol' const DEFAULT_WS_PORT = 6768 @@ -56,6 +61,89 @@ const KEEPALIVE_INTERVAL_MS = 10_000 // queuing. See design doc §3.1 + §7 risk #2. const LONG_POLL_CAP = 16 +function resolvePairingEndpoint(rawEndpoint: string, address: string | null | undefined): string { + const endpoint = new URL(rawEndpoint) + const override = address?.trim() + if (!override) { + endpoint.hostname = '127.0.0.1' + return formatWebSocketUrl(endpoint) + } + if (/^wss?:\/\//i.test(override)) { + return formatWebSocketUrl(new URL(override)) + } + const parsed = parsePairingAddressOverride(override) + endpoint.hostname = parsed.host.includes(':') + ? `[${parsed.host.replace(/^\[|\]$/g, '')}]` + : parsed.host + if (parsed.port) { + endpoint.port = parsed.port + } + return formatWebSocketUrl(endpoint) +} + +function parsePairingAddressOverride(address: string): { host: string; port: string | null } { + if (address.startsWith('[') || address.split(':').length === 2) { + try { + const parsed = new URL(`ws://${address}`) + return { host: parsed.hostname.replace(/^\[|\]$/g, ''), port: parsed.port || null } + } catch { + return { host: address, port: null } + } + } + return { host: address, port: null } +} + +function formatWebSocketUrl(url: URL): string { + const formatted = url.toString() + return url.pathname === '/' && !url.search && !url.hash ? formatted.replace(/\/$/, '') : formatted +} + +const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ + 'accounts.list', + 'accounts.selectClaude', + 'accounts.selectCodex', + 'accounts.subscribe', + 'accounts.unsubscribe', + 'files.list', + 'files.open', + 'files.read', + 'markdown.readTab', + 'markdown.saveTab', + 'notifications.subscribe', + 'notifications.unsubscribe', + 'repo.hooks', + 'repo.list', + 'session.tabs.activate', + 'session.tabs.close', + 'session.tabs.createTerminal', + 'session.tabs.list', + 'session.tabs.subscribe', + 'session.tabs.unsubscribe', + 'stats.summary', + 'status.get', + 'terminal.clearBuffer', + 'terminal.close', + 'terminal.create', + 'terminal.focus', + 'terminal.getAutoRestoreFit', + 'terminal.list', + 'terminal.multiplex', + 'terminal.read', + 'terminal.rename', + 'terminal.send', + 'terminal.setAutoRestoreFit', + 'terminal.setDisplayMode', + 'terminal.subscribe', + 'terminal.unsubscribe', + 'terminal.updateViewport', + 'worktree.activate', + 'worktree.create', + 'worktree.ps', + 'worktree.rm', + 'worktree.set', + 'worktree.sleep' +]) + // Why: a long-poll request is one whose handler blocks for an unbounded // amount of time waiting for an external event (today, only // `orchestration.check` with `wait === true`). This function is the single @@ -83,6 +171,7 @@ export class OrcaRuntimeRpcServer { private deviceRegistry: DeviceRegistry | null = null private e2eeKeypair: E2EEKeypair | null = null private tlsFingerprint: string | null = null + private wsTransport: WebSocketTransport | null = null private activeTransports: RpcTransport[] = [] private transports: RuntimeTransportMetadata[] = [] // Why: each WebSocket connection has its own E2EE channel that manages the @@ -92,6 +181,10 @@ export class OrcaRuntimeRpcServer { // subscriptions, so the server can reap a closing socket's subscriptions // without affecting other live sockets that share the same deviceToken. private wsConnectionIds = new Map() + private readonly binaryStreamHandlers = new Map< + string, + Map void> + >() // Why: separate from Node's server.maxConnections because we need to count // only long-running dispatches, not every in-flight short RPC. See §3.1 + // §7 risk #2. @@ -134,11 +227,87 @@ export class OrcaRuntimeRpcServer { return this.e2eeKeypair } + revokeMobileDevice(deviceId: string): boolean { + const device = this.deviceRegistry?.getDevice(deviceId) + if (device?.scope !== 'mobile' || !this.deviceRegistry?.removeDevice(deviceId)) { + return false + } + this.wsTransport?.terminateClientConnections(device.token) + return true + } + getWebSocketEndpoint(): string | null { const ws = this.transports.find((t) => t.kind === 'websocket') return ws?.endpoint ?? null } + createPairingOffer(args: { + address?: string | null + name?: string + rotate?: boolean + scope?: DeviceScope + }): + | { available: false } + | { available: true; pairingUrl: string; endpoint: string; deviceId: string } { + const rawEndpoint = this.getWebSocketEndpoint() + const publicKeyB64 = this.getE2EEPublicKey() + if (!rawEndpoint || !this.deviceRegistry || !publicKeyB64) { + return { available: false } + } + + const endpoint = resolvePairingEndpoint(rawEndpoint, args.address) + const deviceName = args.name ?? `CLI ${new Date().toLocaleDateString()}` + const scope = args.scope ?? 'runtime' + const device = args.rotate + ? this.deviceRegistry.rotatePendingDevice(deviceName, scope) + : this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope) + const pairingUrl = encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint, + deviceToken: device.token, + publicKeyB64 + }) + return { available: true, pairingUrl, endpoint, deviceId: device.deviceId } + } + + private registerBinaryStreamHandler( + connectionId: string | undefined, + streamId: number, + handler: (frame: TerminalStreamFrame) => void + ): () => void { + if (!connectionId || !Number.isInteger(streamId) || streamId < 0) { + return () => {} + } + let handlers = this.binaryStreamHandlers.get(connectionId) + if (!handlers) { + handlers = new Map() + this.binaryStreamHandlers.set(connectionId, handlers) + } + handlers.set(streamId, handler) + return () => { + const current = this.binaryStreamHandlers.get(connectionId) + if (!current || current.get(streamId) !== handler) { + return + } + current.delete(streamId) + if (current.size === 0) { + this.binaryStreamHandlers.delete(connectionId) + } + } + } + + private handleWebSocketBinaryMessage(bytes: Uint8Array, ws: WebSocket): void { + const connectionId = this.wsConnectionIds.get(ws) + if (!connectionId) { + return + } + const frame = decodeTerminalStreamFrame(bytes) + if (!frame) { + return + } + this.binaryStreamHandlers.get(connectionId)?.get(frame.streamId)?.(frame) + } + async start(): Promise { if (this.activeTransports.length > 0) { return @@ -217,6 +386,7 @@ export class OrcaRuntimeRpcServer { host: '0.0.0.0', port: this.wsPort }) + this.wsTransport = wsTransport // Why: each WebSocket connection gets an E2EE channel that handles the // handshake before any RPC messages are processed. The channel decrypts @@ -259,6 +429,7 @@ export class OrcaRuntimeRpcServer { ws ) }) + channel.onBinaryMessage((bytes) => this.handleWebSocketBinaryMessage(bytes, ws)) this.e2eeChannels.set(ws, channel) } channel.handleRawMessage(msg) @@ -279,6 +450,7 @@ export class OrcaRuntimeRpcServer { if (connectionId) { this.runtime.cleanupSubscriptionsForConnection(connectionId) this.runtime.cancelMobileDictationForConnection(connectionId) + this.binaryStreamHandlers.delete(connectionId) this.wsConnectionIds.delete(ws) } const channel = this.e2eeChannels.get(ws) @@ -286,7 +458,7 @@ export class OrcaRuntimeRpcServer { channel.destroy() this.e2eeChannels.delete(ws) } - if (!hasOtherConnections) { + if (clientId && !hasOtherConnections) { this.runtime.onClientDisconnected(clientId) } }) @@ -302,6 +474,7 @@ export class OrcaRuntimeRpcServer { // function if it fails to start (e.g., port in use). Log and continue // with Unix socket only. console.error('[runtime] Failed to start WebSocket transport:', error) + this.wsTransport = null } } @@ -328,6 +501,7 @@ export class OrcaRuntimeRpcServer { const transports = this.activeTransports this.activeTransports = [] this.transports = [] + this.wsTransport = null if (transports.length === 0) { return } @@ -447,10 +621,23 @@ export class OrcaRuntimeRpcServer { reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Missing device token'))) return } - if (!this.deviceRegistry?.validateToken(token)) { + const device = this.deviceRegistry?.validateToken(token) + if (!device) { reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Invalid device token'))) return } + if (device.scope === 'mobile' && !MOBILE_RPC_METHOD_ALLOWLIST.has(request.method)) { + reply( + JSON.stringify( + this.buildError( + request.id, + 'forbidden', + `Method '${request.method}' is not available to mobile clients` + ) + ) + ) + return + } // Why: associate the deviceToken with this WebSocket so ws.on('close') // can notify the runtime which mobile client disconnected. @@ -458,12 +645,54 @@ export class OrcaRuntimeRpcServer { wsTransport.setClientId(ws, token) } + const longPoll = isLongPollRequest(request) + if (longPoll && this.activeLongPolls >= this.longPollCap) { + reply( + JSON.stringify( + this.buildError( + request.id, + 'runtime_busy', + 'long-poll capacity reached; retry with backoff' + ) + ) + ) + return + } + + let abortController: AbortController | null = null + let abortOnClose: (() => void) | null = null + if (longPoll) { + this.activeLongPolls += 1 + abortController = new AbortController() + if (ws) { + abortOnClose = () => abortController?.abort() + ws.once('close', abortOnClose) + ws.once('error', abortOnClose) + if (ws.readyState !== ws.OPEN) { + abortController.abort() + } + } + } + const connectionId = ws ? this.wsConnectionIds.get(ws) : undefined - await this.dispatcher.dispatchStreaming(request, reply, { - connectionId, - clientId: token, - sendBinary - }) + try { + await this.dispatcher.dispatchStreaming(request, reply, { + connectionId, + clientId: token, + signal: abortController?.signal, + sendBinary, + registerBinaryStreamHandler: (streamId, handler) => + this.registerBinaryStreamHandler(connectionId, streamId, handler) + }) + } finally { + if (abortOnClose && ws) { + ws.off('close', abortOnClose) + ws.off('error', abortOnClose) + } + if (longPoll) { + this.activeLongPolls = Math.max(0, this.activeLongPolls - 1) + } + } } private buildError(id: string, code: string, message: string): RpcResponse { diff --git a/src/main/ssh/sftp-upload.test.ts b/src/main/ssh/sftp-upload.test.ts new file mode 100644 index 00000000000..40676a34dc9 --- /dev/null +++ b/src/main/ssh/sftp-upload.test.ts @@ -0,0 +1,63 @@ +import { mkdtemp, mkdir, realpath, symlink, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { Writable } from 'stream' +import { describe, expect, it, vi } from 'vitest' +import type { SFTPWrapper } from 'ssh2' +import { uploadBuffer, uploadDirectory, uploadFile } from './sftp-upload' + +function createWritable(): Writable { + return new Writable({ + write(_chunk, _encoding, callback) { + callback() + } + }) +} + +function createSftpMock(): SFTPWrapper { + return { + mkdir: vi.fn((_path: string, cb: (err?: Error | null) => void) => cb(null)), + createWriteStream: vi.fn(() => createWritable()) + } as unknown as SFTPWrapper +} + +describe('sftp-upload', () => { + it('can create the first binary upload chunk without clobbering an existing temp file', async () => { + const sftp = createSftpMock() + + await uploadBuffer(sftp, Buffer.from('png'), '/remote/.logo.orca-upload', { + exclusive: true + }) + + expect(sftp.createWriteStream).toHaveBeenCalledWith('/remote/.logo.orca-upload', { + flags: 'wx' + }) + }) + + it('uses no-clobber writes for nested files during exclusive directory upload', async () => { + const localDir = await mkdtemp(join(tmpdir(), 'orca-sftp-upload-')) + await mkdir(join(localDir, 'nested')) + await writeFile(join(localDir, 'nested', 'asset.txt'), 'asset') + const sftp = createSftpMock() + + await uploadDirectory(sftp, localDir, '/remote/assets', await realpath(localDir), { + exclusive: true + }) + + expect(sftp.mkdir).toHaveBeenCalledWith('/remote/assets/nested', expect.any(Function)) + expect(sftp.createWriteStream).toHaveBeenCalledWith('/remote/assets/nested/asset.txt', { + flags: 'wx' + }) + }) + + it('does not create the remote file when the local source is a symlink', async () => { + const localDir = await mkdtemp(join(tmpdir(), 'orca-sftp-upload-')) + await writeFile(join(localDir, 'target.txt'), 'secret') + await symlink(join(localDir, 'target.txt'), join(localDir, 'link.txt')) + const sftp = createSftpMock() + + await expect(uploadFile(sftp, join(localDir, 'link.txt'), '/remote/link.txt')).rejects.toThrow() + + expect(sftp.createWriteStream).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/sftp-upload.ts b/src/main/ssh/sftp-upload.ts index b7491542b64..b7dd3389590 100644 --- a/src/main/ssh/sftp-upload.ts +++ b/src/main/ssh/sftp-upload.ts @@ -1,16 +1,21 @@ -import { createReadStream } from 'fs' -import { readdir } from 'fs/promises' -import { join as pathJoin } from 'path' +import { constants } from 'fs' +import type { ReadStream } from 'fs' +import { lstat, open, readdir, realpath } from 'fs/promises' +import { isAbsolute, join as pathJoin, relative } from 'path' import type { SFTPWrapper } from 'ssh2' -export function mkdirSftp(sftp: SFTPWrapper, path: string): Promise { +export function mkdirSftp( + sftp: SFTPWrapper, + path: string, + options?: { allowExisting?: boolean } +): Promise { return new Promise((resolve, reject) => { sftp.mkdir(path, (err) => { // Why: SFTP status code 4 (SSH_FX_FAILURE) is a generic code that // OpenSSH returns for "already exists," but could also cover other // failures (e.g. permission denied on parent). We accept this ambiguity // because the next operation (write/recurse) will surface the real error. - if (err && (err as { code?: number }).code !== 4) { + if (err && ((err as { code?: number }).code !== 4 || options?.allowExisting === false)) { reject(err) } else { resolve() @@ -22,58 +27,131 @@ export function mkdirSftp(sftp: SFTPWrapper, path: string): Promise { export function uploadFile( sftp: SFTPWrapper, localPath: string, - remotePath: string + remotePath: string, + options?: { exclusive?: boolean } ): Promise { return new Promise((resolve, reject) => { let settled = false - const readStream = createReadStream(localPath) - const writeStream = sftp.createWriteStream(remotePath) + let readStream: ReadStream | null = null + let fileHandle: Awaited> | null = null + let writeStream: ReturnType | null = null + + const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => { + if (settled) { + return + } + settled = true + readStream?.destroy() + writeStream?.destroy() + void fileHandle?.close().catch(() => {}) + fn(val as never) + } + + void open(localPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + .then(async (handle) => { + if (settled) { + void handle.close().catch(() => {}) + return + } + fileHandle = handle + const statResult = await lstat(localPath) + if (statResult.isSymbolicLink() || !statResult.isFile()) { + throw new Error(`Unsupported upload source: ${localPath}`) + } + const openedStat = await handle.stat() + if ( + !openedStat.isFile() || + openedStat.size !== statResult.size || + (statResult.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== statResult.ino) || + (statResult.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== statResult.dev) + ) { + throw new Error(`File changed during upload: ${localPath}`) + } + // Why: validate the local source before creating the remote write + // target, so rejected sources do not leave empty files behind. + writeStream = sftp.createWriteStream(remotePath, { + flags: options?.exclusive ? 'wx' : 'w' + }) + writeStream.on('close', () => settle(resolve)) + writeStream.on('error', (err) => settle(reject, err)) + readStream = handle.createReadStream() + readStream.on('error', (err) => settle(reject, err)) + readStream.pipe(writeStream) + }) + .catch((err: unknown) => settle(reject, err)) + }) +} + +export function uploadBuffer( + sftp: SFTPWrapper, + buffer: Buffer, + remotePath: string, + options?: { append?: boolean; exclusive?: boolean } +): Promise { + return new Promise((resolve, reject) => { + let settled = false + const writeStream = sftp.createWriteStream(remotePath, { + flags: options?.append ? 'a' : options?.exclusive ? 'wx' : 'w' + }) const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => { if (settled) { return } settled = true - readStream.destroy() writeStream.destroy() fn(val as never) } writeStream.on('close', () => settle(resolve)) writeStream.on('error', (err) => settle(reject, err)) - readStream.on('error', (err) => settle(reject, err)) - - readStream.pipe(writeStream) + writeStream.end(buffer) }) } export async function uploadDirectory( sftp: SFTPWrapper, localDir: string, - remoteDir: string + remoteDir: string, + rootRealPath = localDir, + options?: { exclusive?: boolean } ): Promise { + await assertLocalUploadPathInsideRoot(rootRealPath, localDir) const entries = await readdir(localDir, { withFileTypes: true }) for (const entry of entries) { const localPath = pathJoin(localDir, entry.name) const remotePath = `${remoteDir}/${entry.name}` + await assertLocalUploadPathInsideRoot(rootRealPath, localPath) + const statResult = await lstat(localPath) // Why: skip symlinks and special files (sockets, FIFOs, devices) to // prevent following symlinks that could exfiltrate local files to the // remote. The caller's pre-scan catches symlinks up-front, but this // guard closes the TOCTOU gap if one is created between scan and upload. - if (entry.isSymbolicLink() || (!entry.isFile() && !entry.isDirectory())) { + if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) { continue } - if (entry.isDirectory()) { - await mkdirSftp(sftp, remotePath) - await uploadDirectory(sftp, localPath, remotePath) + if (statResult.isDirectory()) { + await mkdirSftp(sftp, remotePath, { allowExisting: !options?.exclusive }) + await uploadDirectory(sftp, localPath, remotePath, rootRealPath, options) } else { - await uploadFile(sftp, localPath, remotePath) + await uploadFile(sftp, localPath, remotePath, { exclusive: options?.exclusive }) } } } +async function assertLocalUploadPathInsideRoot( + rootRealPath: string, + candidatePath: string +): Promise { + const candidateRealPath = await realpath(candidatePath) + const relativeToRoot = relative(rootRealPath, candidateRealPath) + if (relativeToRoot !== '' && (relativeToRoot.startsWith('..') || isAbsolute(relativeToRoot))) { + throw new Error(`Path escaped upload root: ${candidatePath}`) + } +} + /** * Check whether a path exists on the remote via SFTP lstat. * Returns true if the path exists (file, directory, or symlink). diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index d75807f86ce..cbccd4bb4ce 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -74,6 +74,7 @@ export class SshRelaySession { | null = null private _onReady: ((targetId: string) => void) | null = null private portScanner: PortScanner | null = null + private currentConnection: SshConnection | null = null constructor( readonly targetId: string, @@ -113,6 +114,13 @@ export class SshRelaySession { return (this._state as RelaySessionState) === 'disposed' } + private requireReadyConnection(): SshConnection { + if (!this.currentConnection) { + throw new Error('SSH connection is not active') + } + return this.currentConnection + } + getMux(): SshChannelMultiplexer | null { return this.mux } @@ -129,6 +137,7 @@ export class SshRelaySession { throw new Error(`Cannot establish relay session in state: ${this._state}`) } this._state = 'deploying' + this.currentConnection = conn try { const { transport } = await deployAndLaunchRelay( @@ -239,6 +248,7 @@ export class SshRelaySession { this.abortController = abortController this._state = 'reconnecting' + this.currentConnection = conn // Why: stop scanning before teardownProviders so the polling timer doesn't // fire against a disposed multiplexer. @@ -371,6 +381,7 @@ export class SshRelaySession { this.broadcastEmptyLists() this.teardownProviders('shutdown') this.store.markSshRemotePtyLeases(this.targetId, 'terminated') + this.currentConnection = null this._state = 'disposed' } @@ -386,6 +397,7 @@ export class SshRelaySession { // clearing PTY ownership needed for reattach. this.teardownProviders('connection_lost') this.store.markSshRemotePtyLeases(this.targetId, 'detached') + this.currentConnection = null this._state = 'disposed' } @@ -427,7 +439,9 @@ export class SshRelaySession { const ptyProvider = new SshPtyProvider(this.targetId, mux) registerSshPtyProvider(this.targetId, ptyProvider) - const fsProvider = new SshFilesystemProvider(this.targetId, mux) + const fsProvider = new SshFilesystemProvider(this.targetId, mux, () => + this.requireReadyConnection().sftp() + ) registerSshFilesystemProvider(this.targetId, fsProvider) const gitProvider = new SshGitProvider(this.targetId, mux) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index f46f7e977ce..533879c1fc7 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -85,6 +85,8 @@ import type { WorktreeStartupLaunch, WorkspaceSessionState } from '../shared/types' +import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' +import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' import type { AddIssueCommentBySlugArgs, ClearProjectItemFieldArgs, @@ -139,6 +141,11 @@ import type { E2EConfig } from '../shared/e2e-config' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { AgentStatusIpcPayload } from '../shared/agent-status-types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types' + +type RuntimeEnvironmentSubscriptionHandle = { + unsubscribe: () => void + sendBinary: (bytes: Uint8Array) => void +} import type { RuntimeMobileMarkdownRequest, RuntimeMobileMarkdownResponse @@ -1061,6 +1068,11 @@ export type PreloadApi = { createFile: (args: { filePath: string; connectionId?: string }) => Promise createDir: (args: { dirPath: string; connectionId?: string }) => Promise rename: (args: { oldPath: string; newPath: string; connectionId?: string }) => Promise + copy: (args: { + sourcePath: string + destinationPath: string + connectionId?: string + }) => Promise deletePath: (args: { targetPath: string connectionId?: string @@ -1081,6 +1093,7 @@ export type PreloadApi = { sourcePaths: string[] destDir: string connectionId?: string + ensureDir?: boolean }) => Promise<{ results: ( | { @@ -1102,6 +1115,30 @@ export type PreloadApi = { } )[] }> + stageExternalPathsForRuntimeUpload: (args: { sourcePaths: string[] }) => Promise<{ + sources: ( + | { + sourcePath: string + status: 'staged' + name: string + kind: 'file' | 'directory' + entries: ( + | { relativePath: string; kind: 'directory' } + | { relativePath: string; kind: 'file'; contentBase64: string } + )[] + } + | { + sourcePath: string + status: 'skipped' + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + } + | { + sourcePath: string + status: 'failed' + reason: string + } + )[] + }> resolveDroppedPathsForAgent: (args: { paths: string[] worktreePath: string @@ -1357,6 +1394,7 @@ export type PreloadApi = { runtime: { syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise getStatus: () => Promise + call: (args: { method: string; params?: unknown }) => Promise> getTerminalFitOverrides: () => Promise< { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] > @@ -1376,6 +1414,39 @@ export type PreloadApi = { }) => void ) => () => void } + runtimeEnvironments: { + list: () => Promise + addFromPairingCode: (args: { + name: string + pairingCode: string + }) => Promise<{ environment: PublicKnownRuntimeEnvironment }> + resolve: (args: { selector: string }) => Promise + remove: (args: { selector: string }) => Promise<{ removed: PublicKnownRuntimeEnvironment }> + getStatus: (args: { + selector: string + timeoutMs?: number + }) => Promise> + call: (args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + }) => Promise> + subscribe: ( + args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + }, + callbacks: { + onResponse: (response: RuntimeRpcResponse) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } + ) => Promise + } rateLimits: { get: () => Promise refresh: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index c744b23d047..b9b5d34d123 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -33,6 +33,8 @@ import type { WorktreeRemoteBranchConflictEvent } from '../shared/types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types' +import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' +import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' import type { RuntimeMobileMarkdownRequest, RuntimeMobileMarkdownResponse @@ -98,6 +100,8 @@ import { ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' +import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-subscriptions' +import type { RuntimeEnvironmentSubscriptionHandle } from './runtime-environment-subscriptions' import type { HostedReviewForBranchArgs } from '../shared/hosted-review' type NativeDropResolution = @@ -1631,6 +1635,11 @@ const api = { ipcRenderer.invoke('fs:createDir', args), rename: (args: { oldPath: string; newPath: string; connectionId?: string }): Promise => ipcRenderer.invoke('fs:rename', args), + copy: (args: { + sourcePath: string + destinationPath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('fs:copy', args), deletePath: (args: { targetPath: string connectionId?: string @@ -1663,6 +1672,7 @@ const api = { sourcePaths: string[] destDir: string connectionId?: string + ensureDir?: boolean }): Promise<{ results: ( | { @@ -1684,6 +1694,32 @@ const api = { } )[] }> => ipcRenderer.invoke('fs:importExternalPaths', args), + stageExternalPathsForRuntimeUpload: (args: { + sourcePaths: string[] + }): Promise<{ + sources: ( + | { + sourcePath: string + status: 'staged' + name: string + kind: 'file' | 'directory' + entries: ( + | { relativePath: string; kind: 'directory' } + | { relativePath: string; kind: 'file'; contentBase64: string } + )[] + } + | { + sourcePath: string + status: 'skipped' + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + } + | { + sourcePath: string + status: 'failed' + reason: string + } + )[] + }> => ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args), resolveDroppedPathsForAgent: (args: { paths: string[] worktreePath: string @@ -2277,6 +2313,8 @@ const api = { syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise => ipcRenderer.invoke('runtime:syncWindowGraph', graph), getStatus: (): Promise => ipcRenderer.invoke('runtime:getStatus'), + call: (args: { method: string; params?: unknown }): Promise> => + ipcRenderer.invoke('runtime:call', args), getTerminalFitOverrides: (): Promise< { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] > => ipcRenderer.invoke('runtime:getTerminalFitOverrides'), @@ -2315,6 +2353,47 @@ const api = { } }, + runtimeEnvironments: { + list: (): Promise => + ipcRenderer.invoke('runtimeEnvironments:list'), + addFromPairingCode: (args: { + name: string + pairingCode: string + }): Promise<{ environment: PublicKnownRuntimeEnvironment }> => + ipcRenderer.invoke('runtimeEnvironments:addFromPairingCode', args), + resolve: (args: { selector: string }): Promise => + ipcRenderer.invoke('runtimeEnvironments:resolve', args), + remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> => + ipcRenderer.invoke('runtimeEnvironments:remove', args), + getStatus: (args: { + selector: string + timeoutMs?: number + }): Promise> => + ipcRenderer.invoke('runtimeEnvironments:getStatus', args), + call: (args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + }): Promise> => + ipcRenderer.invoke('runtimeEnvironments:call', args), + subscribe: async ( + args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + }, + callbacks: { + onResponse: (response: RuntimeRpcResponse) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } + ): Promise => + subscribeRuntimeEnvironmentFromPreload(ipcRenderer, args, callbacks) + }, + rateLimits: { get: (): Promise => ipcRenderer.invoke('rateLimits:get'), refresh: (): Promise => ipcRenderer.invoke('rateLimits:refresh'), diff --git a/src/preload/runtime-environment-subscriptions.test.ts b/src/preload/runtime-environment-subscriptions.test.ts new file mode 100644 index 00000000000..31c4447e616 --- /dev/null +++ b/src/preload/runtime-environment-subscriptions.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest' +import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-subscriptions' + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, resolve, reject } +} + +describe('subscribeRuntimeEnvironmentFromPreload', () => { + it('registers the subscription event listener before invoking main', async () => { + const subscription = deferred<{ subscriptionId: string; requestId: string }>() + const invoke = vi.fn(() => subscription.promise as Promise) + const send = vi.fn() + const on = vi.fn() + const removeListener = vi.fn() + const onResponse = vi.fn() + + const cleanupPromise = subscribeRuntimeEnvironmentFromPreload( + { invoke, send, on, removeListener }, + { selector: 'desk', method: 'terminal.subscribe' }, + { onResponse }, + () => 'sub-1' + ) + + expect(on).toHaveBeenCalledWith('runtimeEnvironments:subscriptionEvent', expect.any(Function)) + expect(invoke).toHaveBeenCalledWith('runtimeEnvironments:subscribe', { + selector: 'desk', + method: 'terminal.subscribe', + subscriptionId: 'sub-1' + }) + + const listener = on.mock.calls[0][1] as ( + _event: unknown, + payload: { + subscriptionId: string + type: 'response' + response: { ok: true; id: string; result: unknown; _meta: { runtimeId: string } } + } + ) => void + listener(null, { + subscriptionId: 'sub-1', + type: 'response', + response: { + id: 'rpc-1', + ok: true, + result: { type: 'subscribed' }, + _meta: { runtimeId: 'rt' } + } + }) + expect(onResponse).toHaveBeenCalledWith({ + id: 'rpc-1', + ok: true, + result: { type: 'subscribed' }, + _meta: { runtimeId: 'rt' } + }) + + subscription.resolve({ subscriptionId: 'sub-1', requestId: 'rpc-1' }) + const cleanup = await cleanupPromise + const bytes = new Uint8Array([1, 2, 3]) + cleanup.sendBinary(bytes) + expect(send).toHaveBeenCalledWith('runtimeEnvironments:subscriptionBinary', { + subscriptionId: 'sub-1', + bytes + }) + cleanup.unsubscribe() + expect(removeListener).toHaveBeenCalledWith('runtimeEnvironments:subscriptionEvent', listener) + expect(invoke).toHaveBeenCalledWith('runtimeEnvironments:unsubscribe', { + subscriptionId: 'sub-1' + }) + }) + + it('removes the listener when main rejects the subscribe call', async () => { + const subscription = deferred<{ subscriptionId: string; requestId: string }>() + const invoke = vi.fn(() => subscription.promise as Promise) + const send = vi.fn() + const on = vi.fn() + const removeListener = vi.fn() + + const cleanupPromise = subscribeRuntimeEnvironmentFromPreload( + { invoke, send, on, removeListener }, + { selector: 'desk', method: 'terminal.subscribe' }, + { onResponse: vi.fn() }, + () => 'sub-2' + ) + + const error = new Error('subscribe failed') + subscription.reject(error) + await expect(cleanupPromise).rejects.toThrow(error) + expect(removeListener).toHaveBeenCalledWith( + 'runtimeEnvironments:subscriptionEvent', + on.mock.calls[0][1] + ) + }) +}) diff --git a/src/preload/runtime-environment-subscriptions.ts b/src/preload/runtime-environment-subscriptions.ts new file mode 100644 index 00000000000..d9c30325bd3 --- /dev/null +++ b/src/preload/runtime-environment-subscriptions.ts @@ -0,0 +1,99 @@ +import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' + +type RuntimeEnvironmentSubscribeArgs = { + selector: string + method: string + params?: unknown + timeoutMs?: number +} + +type RuntimeEnvironmentSubscriptionCallbacks = { + onResponse: (response: RuntimeRpcResponse) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void +} + +export type RuntimeEnvironmentSubscriptionHandle = { + unsubscribe: () => void + sendBinary: (bytes: Uint8Array) => void +} + +type RuntimeEnvironmentSubscriptionEvent = + | { subscriptionId: string; type: 'response'; response: RuntimeRpcResponse } + | { subscriptionId: string; type: 'binary'; bytes: Uint8Array } + | { subscriptionId: string; type: 'error'; code: string; message: string } + | { subscriptionId: string; type: 'close' } + +type RuntimeEnvironmentSubscriptionIpc = { + invoke: (channel: string, args: unknown) => Promise + send: (channel: string, args: unknown) => void + on: ( + channel: string, + listener: (event: unknown, payload: RuntimeEnvironmentSubscriptionEvent) => void + ) => void + removeListener: ( + channel: string, + listener: (event: unknown, payload: RuntimeEnvironmentSubscriptionEvent) => void + ) => void +} + +const SUBSCRIPTION_EVENT_CHANNEL = 'runtimeEnvironments:subscriptionEvent' + +function createRuntimeEnvironmentSubscriptionId(): string { + const randomUuid = globalThis.crypto?.randomUUID + if (typeof randomUuid === 'function') { + return randomUuid.call(globalThis.crypto) + } + return `sub-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +export async function subscribeRuntimeEnvironmentFromPreload( + ipc: RuntimeEnvironmentSubscriptionIpc, + args: RuntimeEnvironmentSubscribeArgs, + callbacks: RuntimeEnvironmentSubscriptionCallbacks, + createSubscriptionId = createRuntimeEnvironmentSubscriptionId +): Promise { + const subscriptionId = createSubscriptionId() + const listener = (_event: unknown, event: RuntimeEnvironmentSubscriptionEvent): void => { + if (event.subscriptionId !== subscriptionId) { + return + } + if (event.type === 'response') { + callbacks.onResponse(event.response) + } else if (event.type === 'binary') { + callbacks.onBinary?.(event.bytes) + } else if (event.type === 'error') { + callbacks.onError?.({ code: event.code, message: event.message }) + } else { + callbacks.onClose?.() + } + } + + // Why: streaming RPCs can emit their first frame before ipcMain.handle() + // resolves, so preload must subscribe to the event channel before invoking. + ipc.on(SUBSCRIPTION_EVENT_CHANNEL, listener) + try { + const result = (await ipc.invoke('runtimeEnvironments:subscribe', { + ...args, + subscriptionId + })) as { subscriptionId: string; requestId: string } + if (result.subscriptionId !== subscriptionId) { + ipc.removeListener(SUBSCRIPTION_EVENT_CHANNEL, listener) + throw new Error('Runtime environment subscription id mismatch') + } + } catch (error) { + ipc.removeListener(SUBSCRIPTION_EVENT_CHANNEL, listener) + throw error + } + + return { + unsubscribe: () => { + ipc.removeListener(SUBSCRIPTION_EVENT_CHANNEL, listener) + void ipc.invoke('runtimeEnvironments:unsubscribe', { subscriptionId }) + }, + sendBinary: (bytes) => { + ipc.send('runtimeEnvironments:subscriptionBinary', { subscriptionId, bytes }) + } + } +} diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index ed9120d54d6..590e9ea4665 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: this suite covers relay filesystem RPCs, + file watcher lifecycle edges, and cross-platform path behavior together. */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { FsHandler } from './fs-handler' import { RelayContext } from './context' @@ -93,6 +95,7 @@ describe('FsHandler', () => { expect(methods).toContain('fs.deletePath') expect(methods).toContain('fs.createFile') expect(methods).toContain('fs.createDir') + expect(methods).toContain('fs.createDirNoClobber') expect(methods).toContain('fs.rename') expect(methods).toContain('fs.copy') expect(methods).toContain('fs.realpath') @@ -268,6 +271,13 @@ describe('FsHandler', () => { expect(stats.isDirectory()).toBe(true) }) + it('createDirNoClobber fails when the directory already exists', async () => { + const dirPath = path.join(tmpDir, 'existing') + mkdirSync(dirPath) + + await expect(dispatcher.callRequest('fs.createDirNoClobber', { dirPath })).rejects.toThrow() + }) + it('rename moves files', async () => { const oldPath = path.join(tmpDir, 'old.txt') const newPath = path.join(tmpDir, 'new.txt') @@ -291,6 +301,20 @@ describe('FsHandler', () => { expect(content).toBe('original') }) + it('copy does not overwrite an existing destination', async () => { + const src = path.join(tmpDir, 'src.txt') + const dst = path.join(tmpDir, 'dst.txt') + writeFileSync(src, 'original') + writeFileSync(dst, 'existing') + + await expect( + dispatcher.callRequest('fs.copy', { source: src, destination: dst }) + ).rejects.toThrow('EEXIST') + + const content = await fs.readFile(dst, 'utf-8') + expect(content).toBe('existing') + }) + it('realpath resolves symlinks', async () => { const realFile = path.join(tmpDir, 'real.txt') const linkPath = path.join(tmpDir, 'link.txt') @@ -332,4 +356,44 @@ describe('FsHandler', () => { dispatcher.callNotification('fs.unwatch', { rootPath: tmpDir }) expect(secondUnsubscribe).toHaveBeenCalled() }) + + it('unsubscribes an active stale watch before replacing it', async () => { + const firstUnsubscribe = vi.fn() + const secondUnsubscribe = vi.fn() + mockSubscribe + .mockResolvedValueOnce({ unsubscribe: firstUnsubscribe }) + .mockResolvedValueOnce({ unsubscribe: secondUnsubscribe }) + + let stale = false + await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }, { isStale: () => stale }) + stale = true + await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }, { isStale: () => false }) + + expect(firstUnsubscribe).toHaveBeenCalled() + expect(secondUnsubscribe).not.toHaveBeenCalled() + dispatcher.callNotification('fs.unwatch', { rootPath: tmpDir }) + expect(secondUnsubscribe).toHaveBeenCalled() + }) + + it('replaces a stale watch for the same root before enforcing the watch cap', async () => { + const firstUnsubscribe = vi.fn() + const replacementUnsubscribe = vi.fn() + mockSubscribe + .mockResolvedValueOnce({ unsubscribe: firstUnsubscribe }) + .mockResolvedValueOnce({ unsubscribe: replacementUnsubscribe }) + + let stale = false + await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }, { isStale: () => stale }) + for (let index = 0; index < 19; index++) { + await dispatcher.callRequest('fs.watch', { + rootPath: path.join(tmpDir, `watched-${index}`) + }) + } + + stale = true + await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }, { isStale: () => false }) + + expect(firstUnsubscribe).toHaveBeenCalled() + expect(replacementUnsubscribe).not.toHaveBeenCalled() + }) }) diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 6f29861826c..3bedb3c856b 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: relay filesystem request handling shares + path expansion, file IO, search, streaming reads, and watch lifecycle state. */ import { readdir, writeFile, stat, lstat, mkdir, rename, cp, rm, realpath } from 'fs/promises' import { execFile } from 'child_process' import { join } from 'path' @@ -64,6 +66,7 @@ export class FsHandler { this.dispatcher.onRequest('fs.deletePath', (p) => this.deletePath(p)) this.dispatcher.onRequest('fs.createFile', (p) => this.createFile(p)) this.dispatcher.onRequest('fs.createDir', (p) => this.createDir(p)) + this.dispatcher.onRequest('fs.createDirNoClobber', (p) => this.createDirNoClobber(p)) this.dispatcher.onRequest('fs.rename', (p) => this.rename(p)) this.dispatcher.onRequest('fs.copy', (p) => this.copy(p)) this.dispatcher.onRequest('fs.realpath', (p) => this.realpath(p)) @@ -175,6 +178,11 @@ export class FsHandler { await mkdir(dirPath, { recursive: true }) } + private async createDirNoClobber(params: Record) { + const dirPath = expandTilde(params.dirPath as string) + await mkdir(dirPath, { recursive: false }) + } + private async rename(params: Record) { const oldPath = expandTilde(params.oldPath as string) const newPath = expandTilde(params.newPath as string) @@ -184,7 +192,15 @@ export class FsHandler { private async copy(params: Record) { const source = expandTilde(params.source as string) const destination = expandTilde(params.destination as string) - await cp(source, destination, { recursive: true }) + try { + await cp(source, destination, { recursive: true, force: false, errorOnExist: true }) + } catch (error) { + const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined + if (code === 'EEXIST' || code === 'ERR_FS_CP_EEXIST') { + throw new Error('EEXIST: destination already exists') + } + throw error + } } private async realpath(params: Record) { @@ -266,10 +282,6 @@ export class FsHandler { private async watch(params: Record, context?: RequestContext) { const rootPath = expandTilde(params.rootPath as string) - if (this.watches.size >= 20) { - throw new Error('Maximum number of file watchers reached') - } - const existing = this.watches.get(rootPath) if (existing && !existing.isStale()) { if (existing.setupPromise) { @@ -277,6 +289,14 @@ export class FsHandler { } return } + if (existing?.isStale()) { + existing.unwatchFn?.() + this.watches.delete(rootPath) + } + + if (this.watches.size >= 20) { + throw new Error('Maximum number of file watchers reached') + } const watchState: WatchState = { rootPath, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b70bbbd11c0..68642e47ec1 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -368,6 +368,10 @@ function App(): React.JSX.Element { let reconnectStarted = false void (async () => { try { + // Why: repo/worktree hydration routes through settings.activeRuntimeEnvironmentId. + // Load settings first so a persisted remote runtime does not boot against + // the local filesystem and then hydrate stale local workspace state. + await actions.fetchSettings() await actions.fetchRepos() await actions.fetchAllWorktrees() const persistedUI = await window.api.ui.get() @@ -377,10 +381,6 @@ function App(): React.JSX.Element { hydratePersistedUI: actions.hydratePersistedUI }) const session = await window.api.session.get() - // Why: settings must be loaded before hydrateWorkspaceSession so that - // hydration has access to user preferences. Without this, settings - // would still be null at hydration time. - await actions.fetchSettings() if (!cancelled) { actions.hydrateWorkspaceSession(session) actions.hydrateTabsSession(session) diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts new file mode 100644 index 00000000000..67e92fca0a4 --- /dev/null +++ b/src/renderer/src/app-startup-routing.test.ts @@ -0,0 +1,20 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('renderer startup runtime routing', () => { + it('loads settings before repo and worktree hydration', () => { + const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const startupBlockStart = source.indexOf('void (async () => {') + const startupBlockEnd = source.indexOf('const persistedUI = await window.api.ui.get()') + const startupBlock = source.slice(startupBlockStart, startupBlockEnd) + + expect(startupBlock.indexOf('await actions.fetchSettings()')).toBeGreaterThanOrEqual(0) + expect(startupBlock.indexOf('await actions.fetchSettings()')).toBeLessThan( + startupBlock.indexOf('await actions.fetchRepos()') + ) + expect(startupBlock.indexOf('await actions.fetchSettings()')).toBeLessThan( + startupBlock.indexOf('await actions.fetchAllWorktrees()') + ) + }) +}) diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index b8cd117d62e..0e735654c76 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -70,6 +70,7 @@ import { type PRCommentGroup } from '@/lib/pr-comment-groups' import { useAppStore } from '@/store' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' @@ -371,6 +372,7 @@ function findNearestBraceBlock( type FileRowProps = { file: GitHubPRFile + repoId?: string repoPath: string prNumber: number headSha: string | undefined @@ -384,6 +386,7 @@ type DiffViewMode = 'flat' | 'tree' type DiffTreeNodeProps = { node: DiffTreeNode depth: number + repoId?: string repoPath: string prNumber: number headSha: string | undefined @@ -394,6 +397,7 @@ type DiffTreeNodeProps = { function PRDiffTreeNode({ node, depth, + repoId, repoPath, prNumber, headSha, @@ -406,6 +410,7 @@ function PRDiffTreeNode({ return ( ( + target, + 'github.prFileContents', + { + repo: args.repoId, + prNumber: args.prNumber, + path: args.file.path, + oldPath: args.file.oldPath, + status: args.file.status, + headSha: args.headSha, + baseSha: args.baseSha + }, + { timeoutMs: 30_000 } + ) + : window.api.gh.prFileContents({ + repoPath: args.repoPath, + prNumber: args.prNumber, + path: args.file.path, + oldPath: args.file.oldPath, + status: args.file.status, + headSha: args.headSha, + baseSha: args.baseSha + }) + ) .then((contents) => { touchPRFileContentCache(cacheKey, contents) return contents @@ -700,8 +735,143 @@ function loadPRFileContents(args: { return request } +function getRuntimeTargetForRepoId(repoId: string | null | undefined) { + if (!repoId) { + return null + } + const state = useAppStore.getState() + const target = getActiveRuntimeTarget(state.settings) + if (target.kind !== 'environment') { + return null + } + return state.repos.some((repo) => repo.id === repoId) ? target : null +} + +function addIssueCommentForRepo(args: { + repoId?: string + repoPath: string + number: number + body: string + type?: 'issue' | 'pr' +}): Promise>> { + const target = getRuntimeTargetForRepoId(args.repoId) + if (target) { + return callRuntimeRpc>>( + target, + 'github.addIssueComment', + { repo: args.repoId, number: args.number, body: args.body, type: args.type }, + { timeoutMs: 30_000 } + ) + } + return window.api.gh.addIssueComment({ + repoPath: args.repoPath, + number: args.number, + body: args.body, + type: args.type + }) +} + +function addPRReviewCommentForRepo(args: { + repoId?: string + repoPath: string + prNumber: number + commitId: string + path: string + line: number + startLine?: number + body: string +}): Promise>> { + const target = getRuntimeTargetForRepoId(args.repoId) + if (target) { + return callRuntimeRpc>>( + target, + 'github.addPRReviewComment', + { + repo: args.repoId, + prNumber: args.prNumber, + commitId: args.commitId, + path: args.path, + line: args.line, + startLine: args.startLine, + body: args.body + }, + { timeoutMs: 30_000 } + ) + } + return window.api.gh.addPRReviewComment({ + repoPath: args.repoPath, + prNumber: args.prNumber, + commitId: args.commitId, + path: args.path, + line: args.line, + startLine: args.startLine, + body: args.body + }) +} + +function addPRReviewCommentReplyForRepo(args: { + repoId?: string + repoPath: string + prNumber: number + commentId: number + body: string + threadId?: string + path?: string + line?: number +}): Promise>> { + const target = getRuntimeTargetForRepoId(args.repoId) + if (target) { + return callRuntimeRpc>>( + target, + 'github.addPRReviewCommentReply', + { + repo: args.repoId, + prNumber: args.prNumber, + commentId: args.commentId, + body: args.body, + threadId: args.threadId, + path: args.path, + line: args.line + }, + { timeoutMs: 30_000 } + ) + } + return window.api.gh.addPRReviewCommentReply({ + repoPath: args.repoPath, + prNumber: args.prNumber, + commentId: args.commentId, + body: args.body, + threadId: args.threadId, + path: args.path, + line: args.line + }) +} + +function getWorkItemDetailsForRepo(args: { + repoId?: string + repoPath: string + number: number + type: 'issue' | 'pr' +}): Promise { + const target = getRuntimeTargetForRepoId(args.repoId) + if (target) { + return callRuntimeRpc( + target, + 'github.workItemDetails', + { repo: args.repoId, number: args.number, type: args.type }, + { timeoutMs: 30_000 } + ) + } + return window.api.gh.workItemDetails({ + repoPath: args.repoPath, + number: args.number, + type: args.type + }) +} + function PRFileRow({ file, + repoId, repoPath, prNumber, headSha, @@ -728,6 +898,7 @@ function PRFileRow({ setLoading(true) setError(null) loadPRFileContents({ + repoId, repoPath, prNumber, file, @@ -746,7 +917,7 @@ function PRFileRow({ } return next }) - }, [baseSha, canLoadDiff, contents, file, headSha, loading, prNumber, repoPath]) + }, [baseSha, canLoadDiff, contents, file, headSha, loading, prNumber, repoId, repoPath]) const language = useMemo(() => detectLanguage(file.path), [file.path]) const modelKey = `gh-dialog:pr:${prNumber}:${file.path}` @@ -764,7 +935,8 @@ function PRFileRow({ toast.error('Unable to comment without the PR head SHA.') return false } - const result = await window.api.gh.addPRReviewComment({ + const result = await addPRReviewCommentForRepo({ + repoId, repoPath, prNumber, commitId: headSha, @@ -781,7 +953,7 @@ function PRFileRow({ toast.success('Review comment added.') return true }, - [file.path, headSha, onCommentAdded, prNumber, repoPath] + [file.path, headSha, onCommentAdded, prNumber, repoId, repoPath] ) return ( @@ -1149,6 +1321,7 @@ function CommentCodeContext({ function ConversationTab({ item, + repoId, repoPath, body, comments, @@ -1162,6 +1335,7 @@ function ConversationTab({ onCommentAdded }: { item: GitHubWorkItem + repoId?: string repoPath: string | null body: string comments: PRComment[] @@ -1174,10 +1348,11 @@ function ConversationTab({ onUse: (item: GitHubWorkItem) => void onCommentAdded: (comment: PRComment) => void }): React.JSX.Element { + const settings = useAppStore((s) => s.settings) const authorLabel = item.author ?? 'unknown' const [replyingTo, setReplyingTo] = useState(null) const [commentFilter, setCommentFilter] = useState('all') - const repoAssignees = useRepoAssignees(repoPath) + const repoAssignees = useRepoAssignees(repoPath, repoId, settings) const commentCounts = useMemo(() => getPRCommentAudienceCounts(comments), [comments]) const visibleComments = useMemo( () => filterPRCommentsByAudience(comments, commentFilter), @@ -1209,7 +1384,8 @@ function ConversationTab({ } const result = comment.path && item.type === 'pr' - ? await window.api.gh.addPRReviewCommentReply({ + ? await addPRReviewCommentReplyForRepo({ + repoId, repoPath, prNumber: item.number, commentId: comment.id, @@ -1218,7 +1394,8 @@ function ConversationTab({ path: comment.path, line: comment.line }) - : await window.api.gh.addIssueComment({ + : await addIssueCommentForRepo({ + repoId, repoPath, number: item.number, body: `@${comment.author} ${replyBody}`, @@ -1234,7 +1411,7 @@ function ConversationTab({ toast.success('Reply posted.') return true }, - [item.number, item.type, onCommentAdded, repoPath] + [item.number, item.type, onCommentAdded, repoId, repoPath] ) const startWorkspaceButton = ( @@ -1483,6 +1660,7 @@ function ConversationTab({ {repoPath && ( [0]['updates'] }): Promise { if (args.projectOrigin) { - const res = await window.api.gh.updateIssueBySlug({ + const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const updateArgs = { owner: args.projectOrigin.owner, repo: args.projectOrigin.repo, number: args.number, updates: args.updates - }) + } + const res = + target.kind === 'environment' + ? await callRuntimeRpc>>( + target, + 'github.project.updateIssueBySlug', + updateArgs, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updateIssueBySlug(updateArgs) if (!res.ok) { throw new Error(res.error.message) } @@ -1818,6 +2007,19 @@ async function runIssueUpdate(args: { if (!args.repoPath) { throw new Error('No repo context available for this edit.') } + const target = getRuntimeTargetForRepoId(args.repoId) + if (target) { + const res = await callRuntimeRpc>>( + target, + 'github.updateIssue', + { repo: args.repoId, number: args.number, updates: args.updates }, + { timeoutMs: 30_000 } + ) + if (!res.ok) { + throw new Error(res.error) + } + return + } await window.api.gh.updateIssue({ repoPath: args.repoPath, number: args.number, @@ -1858,6 +2060,7 @@ function GHEditSection({ const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) const { isPending, run } = useImmediateMutation() + const settings = useAppStore((s) => s.settings) // Why: when the dialog opens from a Project view, mutations route through // *BySlug IPCs and we must keep `projectViewCache` in sync alongside @@ -1879,11 +2082,19 @@ function GHEditSection({ // values from a different repo than the writes target. const slugOwner = projectOrigin?.owner ?? null const slugRepo = projectOrigin?.repo ?? null - const repoLabelsByPath = useRepoLabels(projectOrigin ? null : repoPath) - const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo) + const repoLabelsByPath = useRepoLabels( + projectOrigin ? null : repoPath, + projectOrigin ? null : item.repoId, + settings + ) + const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo, settings) const repoLabels = projectOrigin ? repoLabelsBySlug : repoLabelsByPath - const repoAssigneesByPath = useRepoAssignees(projectOrigin ? null : repoPath) - const repoAssigneesBySlug = useRepoAssigneesBySlug(slugOwner, slugRepo, assignees) + const repoAssigneesByPath = useRepoAssignees( + projectOrigin ? null : repoPath, + projectOrigin ? null : item.repoId, + settings + ) + const repoAssigneesBySlug = useRepoAssigneesBySlug(slugOwner, slugRepo, assignees, settings) const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath // Why: sync local assignees when item changes or when the detail fetch @@ -1910,6 +2121,7 @@ function GHEditSection({ run('state', { mutate: () => runIssueUpdate({ + repoId: item.repoId, repoPath, projectOrigin, number: item.number, @@ -1936,6 +2148,7 @@ function GHEditSection({ [ item.id, item.number, + item.repoId, localState, repoPath, projectOrigin, @@ -1957,6 +2170,7 @@ function GHEditSection({ run('labels', { mutate: () => runIssueUpdate({ + repoId: item.repoId, repoPath, projectOrigin, number: item.number, @@ -1981,6 +2195,7 @@ function GHEditSection({ run('labels', { mutate: () => runIssueUpdate({ + repoId: item.repoId, repoPath, projectOrigin, number: item.number, @@ -2006,6 +2221,7 @@ function GHEditSection({ [ item.id, item.number, + item.repoId, localLabels, repoPath, projectOrigin, @@ -2030,6 +2246,7 @@ function GHEditSection({ run('assignees', { mutate: () => runIssueUpdate({ + repoId: item.repoId, repoPath, projectOrigin, number: item.number, @@ -2052,6 +2269,7 @@ function GHEditSection({ run('assignees', { mutate: () => runIssueUpdate({ + repoId: item.repoId, repoPath, projectOrigin, number: item.number, @@ -2072,7 +2290,16 @@ function GHEditSection({ }) } }, - [item.number, repoPath, projectOrigin, localAssignees, patchProjectRowIfNeeded, run, onMutated] + [ + item.number, + item.repoId, + repoPath, + projectOrigin, + localAssignees, + patchProjectRowIfNeeded, + run, + onMutated + ] ) if (item.type === 'pr') { @@ -2267,6 +2494,7 @@ function GHEditSection({ function GHCommentComposer({ className, + repoId, repoPath, issueNumber, itemType, @@ -2274,6 +2502,7 @@ function GHCommentComposer({ onCommentAdded }: { className?: string + repoId?: string repoPath: string issueNumber: number itemType: 'issue' | 'pr' @@ -2300,7 +2529,8 @@ function GHCommentComposer({ } setSubmitting(true) try { - const result = await window.api.gh.addIssueComment({ + const result = await addIssueCommentForRepo({ + repoId, repoPath, number: issueNumber, body: trimmed, @@ -2320,7 +2550,7 @@ function GHCommentComposer({ } finally { setSubmitting(false) } - }, [autoGrow, body, repoPath, issueNumber, itemType, onCommentAdded]) + }, [autoGrow, body, repoId, repoPath, issueNumber, itemType, onCommentAdded]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -2453,17 +2683,23 @@ export default function GitHubItemDialog({ } return s.repos.find((r) => r.path === repoPath)?.issueSourcePreference }) + const runtimeScope = useAppStore((s) => + s.settings?.activeRuntimeEnvironmentId + ? `runtime:${s.settings.activeRuntimeEnvironmentId}` + : 'local' + ) const detailsCacheKey = useMemo(() => { if (!workItem || !repoPath) { return null } return getWorkItemDetailsCacheKey({ + runtimeScope, repoPath, issueSourcePreference, type: workItem.type, number: workItem.number }) - }, [repoPath, workItem, issueSourcePreference]) + }, [runtimeScope, repoPath, workItem, issueSourcePreference]) // Why: reset lifted edit state when the dialog switches items or when the // same item receives an optimistic cache patch from the surrounding table. @@ -2605,7 +2841,8 @@ export default function GitHubItemDialog({ // racing two `gh` subprocesses against each other. const inflight: Promise = cached?.pending ?? - window.api.gh.workItemDetails({ + getWorkItemDetailsForRepo({ + repoId: workItem.repoId, repoPath, number: workItem.number, type: workItem.type @@ -2859,6 +3096,7 @@ export default function GitHubItemDialog({ s.patchLinearIssue) + const settings = useAppStore((s) => s.settings) const { isPending, run } = useImmediateMutation() const { @@ -98,9 +105,9 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): } = editState const teamId = issue.team?.id || null - const states = useTeamStates(teamId) - const labels = useTeamLabels(teamId) - const members = useTeamMembers(teamId) + const states = useTeamStates(teamId, settings) + const labels = useTeamLabels(teamId, settings) + const members = useTeamMembers(teamId, settings) const handleStateChange = useCallback( (stateId: string) => { @@ -113,7 +120,7 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): const stateValue = { name: newState.name, type: newState.type, color: newState.color } run('state', { - mutate: () => window.api.linear.updateIssue({ id: issue.id, updates: { stateId } }), + mutate: () => linearUpdateIssue(settings, issue.id, { stateId }), onOptimistic: () => { onEditStateChange({ state: stateValue }) patchLinearIssue(issue.id, { state: stateValue }) @@ -125,7 +132,7 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): onError: (err) => toast.error(err) }) }, - [issue.id, localState, states.data, patchLinearIssue, run, onEditStateChange] + [issue.id, localState, settings, states.data, patchLinearIssue, run, onEditStateChange] ) const handlePriorityChange = useCallback( @@ -133,7 +140,7 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): const priority = parseInt(value, 10) const prevPriority = localPriority run('priority', { - mutate: () => window.api.linear.updateIssue({ id: issue.id, updates: { priority } }), + mutate: () => linearUpdateIssue(settings, issue.id, { priority }), onOptimistic: () => { onEditStateChange({ priority }) patchLinearIssue(issue.id, { priority }) @@ -145,7 +152,7 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): onError: (err) => toast.error(err) }) }, - [issue.id, localPriority, patchLinearIssue, run, onEditStateChange] + [issue.id, localPriority, settings, patchLinearIssue, run, onEditStateChange] ) const handleAssigneeChange = useCallback( @@ -157,7 +164,7 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): ? { id: member.id, displayName: member.displayName, avatarUrl: member.avatarUrl } : undefined run('assignee', { - mutate: () => window.api.linear.updateIssue({ id: issue.id, updates: { assigneeId } }), + mutate: () => linearUpdateIssue(settings, issue.id, { assigneeId }), onOptimistic: () => { onEditStateChange({ assignee: newAssignee }) patchLinearIssue(issue.id, { assignee: newAssignee }) @@ -169,7 +176,7 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): onError: (err) => toast.error(err) }) }, - [issue.id, localAssignee, members.data, patchLinearIssue, run, onEditStateChange] + [issue.id, localAssignee, settings, members.data, patchLinearIssue, run, onEditStateChange] ) const handleLabelToggle = useCallback( @@ -185,8 +192,7 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): .filter((n): n is string => !!n) run('labels', { - mutate: () => - window.api.linear.updateIssue({ id: issue.id, updates: { labelIds: newLabelIds } }), + mutate: () => linearUpdateIssue(settings, issue.id, { labelIds: newLabelIds }), onOptimistic: () => { onEditStateChange({ labelIds: newLabelIds, labels: newLabels }) patchLinearIssue(issue.id, { labelIds: newLabelIds, labels: newLabels }) @@ -198,7 +204,16 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): onError: (err) => toast.error(err) }) }, - [issue.id, localLabelIds, localLabels, labels.data, patchLinearIssue, run, onEditStateChange] + [ + issue.id, + localLabelIds, + localLabels, + settings, + labels.data, + patchLinearIssue, + run, + onEditStateChange + ] ) const currentStateId = states.data.find( @@ -406,6 +421,7 @@ function CommentFooter({ issueId: string onCommentAdded: (comment: LocalComment) => void }): React.JSX.Element { + const settings = useAppStore((s) => s.settings) const [body, setBody] = useState('') const [submitting, setSubmitting] = useState(false) const textareaRef = useRef(null) @@ -426,7 +442,7 @@ function CommentFooter({ } setSubmitting(true) try { - const result = await window.api.linear.addIssueComment({ issueId, body: trimmed }) + const result = await linearAddIssueComment(settings, issueId, trimmed) const typed = result as { ok: boolean; id?: string; error?: string } if (typed.ok) { setBody('') @@ -443,7 +459,7 @@ function CommentFooter({ } finally { setSubmitting(false) } - }, [body, issueId, onCommentAdded]) + }, [body, issueId, onCommentAdded, settings]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -508,6 +524,7 @@ export default function LinearItemDrawer({ const requestIdRef = useRef(0) const hasEditedRef = useRef(false) const optimisticCommentsRef = useRef([]) + const settings = useAppStore((s) => s.settings) const handleEditStateChange = useCallback((patch: Partial) => { hasEditedRef.current = true @@ -535,8 +552,7 @@ export default function LinearItemDrawer({ // Why: fetch issue and comments independently so a transient comments // failure doesn't discard the successfully-fetched issue data. - window.api.linear - .getIssue({ id: issue.id }) + linearGetIssue(settings, issue.id) .then((issueResult) => { if (requestId !== requestIdRef.current) { return @@ -553,8 +569,7 @@ export default function LinearItemDrawer({ }) .catch(() => {}) - window.api.linear - .issueComments({ issueId: issue.id }) + linearIssueComments(settings, issue.id) .then((commentsResult) => { if (requestId !== requestIdRef.current) { return @@ -579,7 +594,7 @@ export default function LinearItemDrawer({ } }) // oxlint-disable-next-line react-hooks/exhaustive-deps - }, [issue?.id]) + }, [issue?.id, settings]) // Why: same pointer-events fix as GitHubItemDialog — Radix may leave // pointer-events: none on body when overlays transition. diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index f18a3203b59..53d0e646c36 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -6,6 +6,7 @@ import { useActiveWorktree, useWorktreesForRepo } from '@/store/selectors' import { detectLanguage } from '@/lib/language-detect' import { joinPath } from '@/lib/path' import { getConnectionId } from '@/lib/connection-context' +import { listRuntimeFiles } from '@/runtime/runtime-file-client' import { CommandDialog, CommandInput, @@ -254,15 +255,18 @@ export default function QuickOpen(): React.JSX.Element | null { const excludePaths = excludePathsKey ? excludePathsKey.split('\n') : undefined - void window.api.fs - // Why: quick-open shares the active worktree path model with file explorer - // and search, so remote worktrees must include connectionId. Without this, - // Windows resolves Linux roots (e.g. /home/*) as local C:\home\* paths. - .listFiles({ + void listRuntimeFiles( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + { rootPath: worktreePath, - connectionId, excludePaths - }) + } + ) .then((result) => { if (!cancelled) { setFiles(result) @@ -289,7 +293,7 @@ export default function QuickOpen(): React.JSX.Element | null { return () => { cancelled = true } - }, [visible, worktreePath, connectionId, excludePathsKey, filesRequestKey]) + }, [visible, activeWorktreeId, worktreePath, connectionId, excludePathsKey, filesRequestKey]) // Filter files by fuzzy match const filtered = useMemo(() => { diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 7243620838b..3bbc8a03af0 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -26,6 +26,7 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { useRepoMap } from '@/store/selectors' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { @@ -85,9 +86,16 @@ import type { GitLabTodo, GitLabWorkItem, LinearIssue, + Repo, TaskViewPresetId } from '../../../shared/types' import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard' +import { + linearCreateIssue, + linearGetIssue, + linearListTeams, + linearUpdateIssue +} from '@/runtime/runtime-linear-client' type TaskSource = 'github' | 'linear' | 'gitlab' @@ -105,6 +113,18 @@ type TaskQueryPreset = { query: string } +function getRuntimeTargetForRepoId(repoId: string | null | undefined) { + if (!repoId) { + return null + } + const state = useAppStore.getState() + const target = getActiveRuntimeTarget(state.settings) + if (target.kind !== 'environment') { + return null + } + return state.repos.some((repo) => repo.id === repoId) ? target : null +} + type SourceOption = { id: TaskSource label: string @@ -219,10 +239,10 @@ const LINEAR_PRIORITY_LABELS: Record = { function GHStatusCell({ item, - repoPath + repo }: { item: GitHubWorkItem - repoPath: string | null + repo: Repo | null }): React.JSX.Element { const patchWorkItem = useAppStore((s) => s.patchWorkItem) const [localState, setLocalState] = useState(item.state) @@ -235,15 +255,28 @@ function GHStatusCell({ const handleStateChange = useCallback( (newState: 'open' | 'closed') => { - if (newState === localState || !repoPath || item.type !== 'issue') { + if (newState === localState || !repo || item.type !== 'issue') { return } reqRef.current += 1 const reqId = reqRef.current setLocalState(newState) patchWorkItem(item.id, { state: newState }) - window.api.gh - .updateIssue({ repoPath, number: item.number, updates: { state: newState } }) + const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const updatePromise = + target.kind === 'environment' + ? callRuntimeRpc<{ ok?: boolean; error?: string }>( + target, + 'github.updateIssue', + { repo: repo.id, number: item.number, updates: { state: newState } }, + { timeoutMs: 30_000 } + ) + : window.api.gh.updateIssue({ + repoPath: repo.path, + number: item.number, + updates: { state: newState } + }) + updatePromise .then((result) => { if (reqId !== reqRef.current) { return @@ -264,10 +297,10 @@ function GHStatusCell({ toast.error('Failed to update state') }) }, - [item.id, item.number, item.type, localState, repoPath, patchWorkItem] + [item.id, item.number, item.type, localState, repo, patchWorkItem] ) - if (item.type !== 'issue' || !repoPath) { + if (item.type !== 'issue' || !repo) { return ( s.patchLinearIssue) const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue) + const settings = useAppStore((s) => s.settings) const [localState, setLocalState] = useState(issue.state) const reqRef = useRef(0) @@ -342,7 +376,7 @@ function LinearStatusCell({ issue }: { issue: LinearIssue }): React.JSX.Element }, [issue.state]) const teamId = issue.team?.id || null - const states = useTeamStates(teamId) + const states = useTeamStates(teamId, settings) const handleStateChange = useCallback( (stateId: string) => { @@ -357,8 +391,7 @@ function LinearStatusCell({ issue }: { issue: LinearIssue }): React.JSX.Element setLocalState(stateValue) patchLinearIssue(issue.id, { state: stateValue }) - window.api.linear - .updateIssue({ id: issue.id, updates: { stateId } }) + linearUpdateIssue(settings, issue.id, { stateId }) .then((result) => { if (reqId !== reqRef.current) { return @@ -381,7 +414,7 @@ function LinearStatusCell({ issue }: { issue: LinearIssue }): React.JSX.Element toast.error('Failed to update status') }) }, - [issue.id, issue.state, states.data, patchLinearIssue, fetchLinearIssue] + [issue.id, issue.state, settings, states.data, patchLinearIssue, fetchLinearIssue] ) const currentStateId = states.data.find( @@ -442,6 +475,7 @@ function LinearStatusCell({ issue }: { issue: LinearIssue }): React.JSX.Element function LinearPriorityCell({ issue }: { issue: LinearIssue }): React.JSX.Element { const patchLinearIssue = useAppStore((s) => s.patchLinearIssue) const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue) + const settings = useAppStore((s) => s.settings) const [localPriority, setLocalPriority] = useState(issue.priority) const [pending, setPending] = useState(false) const reqRef = useRef(0) @@ -460,8 +494,7 @@ function LinearPriorityCell({ issue }: { issue: LinearIssue }): React.JSX.Elemen setLocalPriority(priority) patchLinearIssue(issue.id, { priority }) setPending(true) - window.api.linear - .updateIssue({ id: issue.id, updates: { priority } }) + linearUpdateIssue(settings, issue.id, { priority }) .then((result) => { if (reqId !== reqRef.current) { return @@ -490,7 +523,7 @@ function LinearPriorityCell({ issue }: { issue: LinearIssue }): React.JSX.Elemen setPending(false) }) }, - [issue.id, issue.priority, localPriority, patchLinearIssue, fetchLinearIssue] + [issue.id, issue.priority, localPriority, settings, patchLinearIssue, fetchLinearIssue] ) const [open, setOpen] = useState(false) @@ -1066,14 +1099,13 @@ export default function TaskPage(): React.JSX.Element { if (taskSource !== 'linear' || !linearStatus.connected) { return } - void window.api.linear - .listTeams() + void linearListTeams(settings) .then(setAvailableTeams) .catch(() => { console.warn('[TaskPage] Failed to fetch Linear teams') }) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [taskSource, linearStatus.connected, taskResumeApplied]) + }, [settings, taskSource, linearStatus.connected, taskResumeApplied]) // Why: stable key for `selectedRepos` so the GitLab fetch effect below // doesn't re-run on every parent re-render just because the array @@ -1619,11 +1651,19 @@ export default function TaskPage(): React.JSX.Element { } setNewIssueSubmitting(true) try { - const result = await window.api.gh.createIssue({ - repoPath: newIssueTargetRepo.path, - title, - body: newIssueBody - }) + const target = getRuntimeTargetForRepoId(newIssueTargetRepo.id) + const result = target + ? await callRuntimeRpc>>( + target, + 'github.createIssue', + { repo: newIssueTargetRepo.id, title, body: newIssueBody }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.createIssue({ + repoPath: newIssueTargetRepo.path, + title, + body: newIssueBody + }) if (!result.ok) { toast.error(result.error || 'Failed to create issue.') return @@ -1659,8 +1699,19 @@ export default function TaskPage(): React.JSX.Element { } setDialogWorkItem(stub) const stubRepoId = newIssueTargetRepo.id - void window.api.gh - .workItem({ repoPath: newIssueTargetRepo.path, number: result.number, type: 'issue' }) + const fullIssuePromise = target + ? callRuntimeRpc>>( + target, + 'github.workItem', + { repo: newIssueTargetRepo.id, number: result.number, type: 'issue' }, + { timeoutMs: 30_000 } + ) + : window.api.gh.workItem({ + repoPath: newIssueTargetRepo.path, + number: result.number, + type: 'issue' + }) + void fullIssuePromise .then((full) => { if (full) { // Why: `full` is `Omit` (IPC shape). @@ -1687,7 +1738,7 @@ export default function TaskPage(): React.JSX.Element { } setNewLinearIssueSubmitting(true) try { - const result = await window.api.linear.createIssue({ + const result = await linearCreateIssue(settings, { teamId: newLinearIssueTargetTeam.id, title, description: newLinearIssueBody || undefined @@ -1711,8 +1762,7 @@ export default function TaskPage(): React.JSX.Element { // Why: auto-open the new issue in the side drawer so the user sees // exactly what was filed, mirroring the GitHub create-issue flow. - void window.api.linear - .getIssue({ id: result.id }) + void linearGetIssue(settings, result.id) .then((full) => { if (full) { setDrawerLinearIssue(full) @@ -1727,6 +1777,7 @@ export default function TaskPage(): React.JSX.Element { newLinearIssueSubmitting, newLinearIssueTargetTeam, newLinearIssueTitle, + settings, setDrawerLinearIssue ]) @@ -2693,7 +2744,7 @@ export default function TaskPage(): React.JSX.Element {
- +
diff --git a/src/renderer/src/components/TelemetryFirstLaunchSurface.tsx b/src/renderer/src/components/TelemetryFirstLaunchSurface.tsx index 50cedad4963..29c9cafd31d 100644 --- a/src/renderer/src/components/TelemetryFirstLaunchSurface.tsx +++ b/src/renderer/src/components/TelemetryFirstLaunchSurface.tsx @@ -1,6 +1,7 @@ // Root-mounted gate for the existing-user first-launch notice. New users -// get NO first-launch surface; see telemetry-plan.md §First-launch experience -// for the rationale. +// get NO first-launch surface — default-on with no first-run notice +// matches the category norm for developer tooling; see telemetry-plan.md +// §First-launch experience for the rationale. // // Cohort marker populated by the migration in `src/main/persistence.ts`: // diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 4d5f987b116..d1b172000dd 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -62,6 +62,7 @@ import { useActivityTerminalPortals, type ActivityTerminalPortalTarget } from './activity/activity-terminal-portal' +import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' const EditorPanel = lazy(() => import('./editor/EditorPanel')) @@ -215,7 +216,9 @@ function Terminal(): React.JSX.Element | null { if (connectionId !== null) { return [] } - return worktreeTabs.flatMap((tab) => state.ptyIdsByTabId[tab.id] ?? []) + return worktreeTabs + .flatMap((tab) => state.ptyIdsByTabId[tab.id] ?? []) + .filter((ptyId) => !isRemoteRuntimePtyId(ptyId)) } ) if (localPtyIds.length > 0) { @@ -640,10 +643,12 @@ function Terminal(): React.JSX.Element | null { // the ambient/default group and open the file in the wrong pane. const targetGroupId = useAppStore.getState().activeGroupIdByWorktree[activeWorktreeId] const connectionId = getConnectionId(activeWorktreeId) ?? undefined + const settings = useAppStore.getState().settings const fileInfo = await createUntitledMarkdownFile( worktree.path, activeWorktreeId, - connectionId + connectionId, + settings ) openFile(fileInfo, { preview: false, targetGroupId }) } catch (err) { diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 1589c555332..7584be239bd 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -23,6 +23,7 @@ import { cn } from '@/lib/utils' import { getWorktreeStatus, getWorktreeStatusLabel } from '@/lib/worktree-status' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { findWorktreeById } from '@/store/slices/worktree-helpers' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { searchWorktrees, type MatchRange, @@ -38,7 +39,13 @@ import { ORCA_BROWSER_FOCUS_REQUEST_EVENT, queueBrowserFocusRequest } from '@/components/browser-pane/browser-focus' -import type { BrowserPage, BrowserWorkspace, Worktree } from '../../../shared/types' +import type { + BrowserPage, + BrowserWorkspace, + GitHubWorkItem, + Repo, + Worktree +} from '../../../shared/types' import { isGitRepoKind } from '../../../shared/repo-kind' type WorktreePaletteItem = { @@ -76,6 +83,55 @@ type BrowserSelection = { page: BrowserPage } +type GitHubWorkItemWithoutRepo = Omit + +function getRuntimeTargetForRepo( + repo: Repo +): { kind: 'environment'; environmentId: string } | null { + const target = getActiveRuntimeTarget(useAppStore.getState().settings) + if (target.kind !== 'environment') { + return null + } + return useAppStore.getState().repos.some((candidate) => candidate.id === repo.id) ? target : null +} + +function getWorkItemForRepo(repo: Repo, number: number): Promise { + const target = getRuntimeTargetForRepo(repo) + if (target) { + return callRuntimeRpc( + target, + 'github.workItem', + { repo: repo.id, number }, + { timeoutMs: 30_000 } + ) + } + return window.api.gh.workItem({ repoPath: repo.path, number }) +} + +function getWorkItemByOwnerRepoForRepo( + repo: Repo, + slug: { owner: string; repo: string }, + number: number, + type: 'issue' | 'pr' +): Promise { + const target = getRuntimeTargetForRepo(repo) + if (target) { + return callRuntimeRpc( + target, + 'github.workItemByOwnerRepo', + { repo: repo.id, owner: slug.owner, ownerRepo: slug.repo, number, type }, + { timeoutMs: 30_000 } + ) + } + return window.api.gh.workItemByOwnerRepo({ + repoPath: repo.path, + owner: slug.owner, + repo: slug.repo, + number, + type + }) +} + function HighlightedText({ text, matchRange @@ -675,8 +731,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { // indefinitely on slow networks. Close immediately and populate the // composer once the lookup returns. closeModal() - void window.api.gh - .workItem({ repoPath: repoForLookup.path, number }) + void getWorkItemByOwnerRepoForRepo(repoForLookup, slug, number, ghLink.type) .then((item) => { const data: Record = { initialRepoId: repoForLookup.id } if (item) { @@ -729,8 +784,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } closeModal() - void window.api.gh - .workItem({ repoPath: repoForLookup.path, number: ghNumber }) + void getWorkItemForRepo(repoForLookup, ghNumber) .then((item) => { const data: Record = { initialRepoId: repoForLookup.id } if (item) { diff --git a/src/renderer/src/components/automations/CreateFromPicker.tsx b/src/renderer/src/components/automations/CreateFromPicker.tsx index e3f4bb7db23..300655e5f7b 100644 --- a/src/renderer/src/components/automations/CreateFromPicker.tsx +++ b/src/renderer/src/components/automations/CreateFromPicker.tsx @@ -11,6 +11,11 @@ import { import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/utils' import type { Repo, Worktree } from '../../../../shared/types' +import { useAppStore } from '@/store' +import { + getRuntimeRepoBaseRefDefault, + searchRuntimeRepoBaseRefs +} from '@/runtime/runtime-repo-client' const DEFAULT_VALUE = '__project_default__' @@ -31,6 +36,9 @@ export function CreateFromPicker({ value: string onValueChange: (baseBranch: string) => void }): React.JSX.Element { + const activeRuntimeEnvironmentId = useAppStore( + (state) => state.settings?.activeRuntimeEnvironmentId ?? null + ) const repo = repoMap.get(repoId) const [open, setOpen] = React.useState(false) const inputRef = React.useRef(null) @@ -73,8 +81,7 @@ export function CreateFromPicker({ } let stale = false setDefaultBaseRef(null) - void window.api.repos - .getBaseRefDefault({ repoId }) + void getRuntimeRepoBaseRefDefault({ activeRuntimeEnvironmentId }, repoId) .then((result) => { if (!stale) { setDefaultBaseRef(result.defaultBaseRef) @@ -88,7 +95,7 @@ export function CreateFromPicker({ return () => { stale = true } - }, [repoId]) + }, [activeRuntimeEnvironmentId, repoId]) React.useEffect(() => { setQuery('') @@ -107,8 +114,7 @@ export function CreateFromPicker({ let stale = false setIsSearching(true) const timer = window.setTimeout(() => { - void window.api.repos - .searchBaseRefs({ repoId, query: trimmedQuery, limit: 30 }) + void searchRuntimeRepoBaseRefs({ activeRuntimeEnvironmentId }, repoId, trimmedQuery, 30) .then((results) => { if (!stale) { setSearchResults(results) @@ -130,7 +136,7 @@ export function CreateFromPicker({ stale = true window.clearTimeout(timer) } - }, [open, query, repoId]) + }, [activeRuntimeEnvironmentId, open, query, repoId]) return (
diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index d05187fc95f..da03b76a964 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -79,6 +79,19 @@ import { ORCA_BROWSER_FOCUS_REQUEST_EVENT, type BrowserFocusRequestDetail } from './browser-focus' +import { + isRemoteRuntimeFileOperation, + statRuntimePath, + type RuntimeFileOperationArgs +} from '@/runtime/runtime-file-client' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import type { + BrowserBackResult, + BrowserGotoResult, + BrowserReloadResult, + BrowserScreenshotResult, + BrowserTabInfo +} from '../../../../shared/runtime-types' import { formatByteCount, formatDownloadFinishedNotice, @@ -158,6 +171,31 @@ function getNotebookPathFromBrowserUrl(url: string): string | null { return filePath?.toLowerCase().endsWith('.ipynb') ? filePath : null } +function getRemoteBrowserKeypressKey(event: React.KeyboardEvent): string | null { + if (event.key.length === 1) { + return null + } + if (event.metaKey || event.ctrlKey || event.altKey) { + return null + } + const supported = new Set([ + 'Enter', + 'Backspace', + 'Delete', + 'Tab', + 'Escape', + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'Home', + 'End', + 'PageUp', + 'PageDown' + ]) + return supported.has(event.key) ? event.key : null +} + function getLoadErrorMetadata(loadError: BrowserLoadError | null): { displayUrl: string host: string | null @@ -278,12 +316,39 @@ export default function BrowserPane({ browserTab: BrowserWorkspaceState isActive: boolean }): React.JSX.Element { + const activeRuntimeEnvironmentId = useAppStore( + (s) => s.settings?.activeRuntimeEnvironmentId ?? null + ) const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace) const browserPages = browserPagesByWorkspace[browserTab.id] ?? EMPTY_BROWSER_PAGES const activeBrowserPage = browserPages.find((page) => page.id === browserTab.activePageId) ?? browserPages[0] ?? null const updateBrowserPageState = useAppStore((s) => s.updateBrowserPageState) const setBrowserPageUrl = useAppStore((s) => s.setBrowserPageUrl) + const runtimeEnvironmentActive = Boolean(activeRuntimeEnvironmentId?.trim()) + + useEffect(() => { + if (!runtimeEnvironmentActive) { + return + } + for (const page of browserPages) { + destroyPersistentWebview(page.id) + } + }, [browserPages, runtimeEnvironmentActive]) + + if (runtimeEnvironmentActive) { + return activeBrowserPage ? ( + + ) : ( +
+ ) + } return (
@@ -304,6 +369,451 @@ export default function BrowserPane({ ) } +function RemoteBrowserPagePane({ + browserTab, + worktreeId, + isActive, + onUpdatePageState, + onSetUrl +}: { + browserTab: BrowserPageState + worktreeId: string + isActive: boolean + onUpdatePageState: (tabId: string, updates: BrowserTabPageState) => void + onSetUrl: (tabId: string, url: string) => void +}): React.JSX.Element { + const settings = useAppStore((s) => s.settings) + const addressBarInputRef = useRef(null) + const imageRef = useRef(null) + const [addressBarValue, setAddressBarValue] = useState(toDisplayUrl(browserTab.url)) + const [screenshotUrl, setScreenshotUrl] = useState(null) + const [remoteError, setRemoteError] = useState(null) + const [busy, setBusy] = useState(false) + const remotePageIdRef = useRef(null) + const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() ?? null + const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) + + useEffect(() => { + if (document.activeElement === addressBarInputRef.current) { + return + } + setAddressBarValue(toDisplayUrl(browserTab.url)) + }, [browserTab.url]) + + const runtimeTarget = useCallback(() => { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' ? target : null + }, [settings]) + + useEffect(() => { + if (!activeRuntimeEnvironmentId) { + return + } + return () => { + const remotePageId = remotePageIdRef.current + if (!remotePageId) { + return + } + const state = useAppStore.getState() + const currentEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() ?? null + const pageStillExists = Object.values(state.browserPagesByWorkspace).some((pages) => + pages.some((page) => page.id === browserTab.id) + ) + if (currentEnvironmentId === activeRuntimeEnvironmentId && pageStillExists) { + return + } + const removedHandle = state.removeRemoteBrowserPageHandle(browserTab.id, remotePageId) + remotePageIdRef.current = null + if (!removedHandle) { + return + } + // Why: remote browser tabs outlive React components on the daemon. Close + // only when the local page is gone or its owning runtime environment is. + void callRuntimeRpc( + { kind: 'environment', environmentId: removedHandle.environmentId }, + 'browser.tabClose', + { worktree: `id:${worktreeId}`, page: removedHandle.remotePageId }, + { timeoutMs: 15_000 } + ).catch(() => {}) + } + }, [activeRuntimeEnvironmentId, browserTab.id, worktreeId]) + + const applyRemoteTabInfo = useCallback( + (tab: Pick): void => { + const safeUrl = redactKagiSessionToken(tab.url || 'about:blank') + onSetUrl(browserTab.id, safeUrl) + onUpdatePageState(browserTab.id, { + title: getBrowserDisplayTitle(tab.title, safeUrl), + loading: false, + loadError: null + }) + setAddressBarValue(toDisplayUrl(safeUrl)) + }, + [browserTab.id, onSetUrl, onUpdatePageState] + ) + + const captureRemoteScreenshot = useCallback( + async (pageId: string): Promise => { + const target = runtimeTarget() + if (!target) { + return + } + const screenshot = await callRuntimeRpc( + target, + 'browser.screenshot', + { worktree: `id:${worktreeId}`, page: pageId, format: 'png' }, + { timeoutMs: 30_000 } + ) + setScreenshotUrl(`data:image/${screenshot.format};base64,${screenshot.data}`) + }, + [runtimeTarget, worktreeId] + ) + + const ensureRemotePage = useCallback(async (): Promise => { + const target = runtimeTarget() + if (!target) { + return null + } + const existingHandle = useAppStore.getState().remoteBrowserPageHandlesByPageId[browserTab.id] + if (existingHandle?.environmentId === target.environmentId) { + remotePageIdRef.current = existingHandle.remotePageId + return existingHandle.remotePageId + } + const initialUrl = + browserTab.url === ORCA_BROWSER_BLANK_URL ? 'about:blank' : browserTab.url || 'about:blank' + const created = await callRuntimeRpc<{ browserPageId: string }>( + target, + 'browser.tabCreate', + { worktree: `id:${worktreeId}`, url: initialUrl }, + { timeoutMs: 30_000 } + ) + remotePageIdRef.current = created.browserPageId + setRemoteBrowserPageHandle(browserTab.id, { + environmentId: target.environmentId, + remotePageId: created.browserPageId + }) + return created.browserPageId + }, [browserTab.id, browserTab.url, runtimeTarget, setRemoteBrowserPageHandle, worktreeId]) + + const refreshRemoteView = useCallback( + async (pageId?: string): Promise => { + const target = runtimeTarget() + const targetPageId = pageId ?? remotePageIdRef.current + if (!target || !targetPageId) { + return + } + const shown = await callRuntimeRpc<{ tab: BrowserTabInfo }>( + target, + 'browser.tabShow', + { worktree: `id:${worktreeId}`, page: targetPageId }, + { timeoutMs: 15_000 } + ) + applyRemoteTabInfo(shown.tab) + await captureRemoteScreenshot(targetPageId) + }, + [applyRemoteTabInfo, captureRemoteScreenshot, runtimeTarget, worktreeId] + ) + + useEffect(() => { + if (!isActive) { + return + } + let cancelled = false + setBusy(true) + setRemoteError(null) + void ensureRemotePage() + .then(async (pageId) => { + if (!pageId || cancelled) { + return + } + await refreshRemoteView(pageId) + }) + .catch((error: unknown) => { + if (!cancelled) { + setRemoteError(error instanceof Error ? error.message : 'Failed to open remote browser.') + } + }) + .finally(() => { + if (!cancelled) { + setBusy(false) + } + }) + return () => { + cancelled = true + } + }, [ensureRemotePage, isActive, refreshRemoteView]) + + useEffect(() => { + if (!isActive) { + return + } + return window.api.ui.onFocusBrowserAddressBar(() => { + addressBarInputRef.current?.focus() + addressBarInputRef.current?.select() + }) + }, [isActive]) + + const runRemoteNavigation = useCallback( + async ( + method: 'browser.goto' | 'browser.back' | 'browser.forward' | 'browser.reload', + url?: string + ) => { + const target = runtimeTarget() + if (!target) { + return + } + const pageId = await ensureRemotePage() + if (!pageId) { + return + } + setBusy(true) + setRemoteError(null) + onUpdatePageState(browserTab.id, { loading: true, loadError: null }) + try { + const params = + method === 'browser.goto' + ? { worktree: `id:${worktreeId}`, page: pageId, url: url ?? 'about:blank' } + : { worktree: `id:${worktreeId}`, page: pageId } + const result = await callRuntimeRpc< + BrowserGotoResult | BrowserBackResult | BrowserReloadResult + >(target, method, params, { timeoutMs: 30_000 }) + applyRemoteTabInfo(result) + await captureRemoteScreenshot(pageId) + } catch (error) { + const message = error instanceof Error ? error.message : 'Remote browser command failed.' + setRemoteError(message) + onUpdatePageState(browserTab.id, { + loading: false, + loadError: { code: 0, description: message, validatedUrl: url ?? browserTab.url } + }) + } finally { + setBusy(false) + } + }, + [ + applyRemoteTabInfo, + browserTab.id, + browserTab.url, + captureRemoteScreenshot, + ensureRemotePage, + onUpdatePageState, + runtimeTarget, + worktreeId + ] + ) + + const navigateToUrl = useCallback( + (url: string): void => { + void runRemoteNavigation('browser.goto', url) + }, + [runRemoteNavigation] + ) + + const submitAddressBar = (): void => { + const searchEngine = useAppStore.getState().browserDefaultSearchEngine + const kagiSessionLink = useAppStore.getState().browserKagiSessionLink + const nextUrl = normalizeBrowserNavigationUrl(addressBarValue, searchEngine, { + kagiSessionLink + }) + if (!nextUrl) { + const message = 'Enter a valid http(s) or localhost URL.' + setRemoteError(message) + onUpdatePageState(browserTab.id, { + loadError: { + code: 0, + description: message, + validatedUrl: redactKagiSessionToken(addressBarValue.trim()) || 'about:blank' + } + }) + return + } + navigateToUrl(nextUrl) + } + + const clickRemoteScreenshot = (event: React.MouseEvent): void => { + const target = runtimeTarget() + const pageId = remotePageIdRef.current + const image = imageRef.current + if (!target || !pageId || !image) { + return + } + const rect = image.getBoundingClientRect() + image.focus() + const scaleX = image.naturalWidth / rect.width + const scaleY = image.naturalHeight / rect.height + const x = Math.round((event.clientX - rect.left) * scaleX) + const y = Math.round((event.clientY - rect.top) * scaleY) + setBusy(true) + setRemoteError(null) + void (async () => { + try { + const params = { worktree: `id:${worktreeId}`, page: pageId } + await callRuntimeRpc( + target, + 'browser.mouseMove', + { ...params, x, y }, + { timeoutMs: 15_000 } + ) + await callRuntimeRpc(target, 'browser.mouseDown', params, { timeoutMs: 15_000 }) + await callRuntimeRpc(target, 'browser.mouseUp', params, { timeoutMs: 15_000 }) + await new Promise((resolve) => window.setTimeout(resolve, 300)) + await refreshRemoteView(pageId) + } catch (error) { + setRemoteError(error instanceof Error ? error.message : 'Remote click failed.') + } finally { + setBusy(false) + } + })() + } + + const handleRemoteScreenshotKeyDown = (event: React.KeyboardEvent): void => { + if (isEditableKeyboardTarget(event.target)) { + return + } + const target = runtimeTarget() + const pageId = remotePageIdRef.current + if (!target || !pageId) { + return + } + const params = { worktree: `id:${worktreeId}`, page: pageId } + const text = + event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey ? event.key : null + const key = text ? null : getRemoteBrowserKeypressKey(event) + if (!text && !key) { + return + } + event.preventDefault() + setRemoteError(null) + void (async () => { + try { + if (text) { + await callRuntimeRpc( + target, + 'browser.keyboardInsertText', + { ...params, text }, + { timeoutMs: 15_000 } + ) + } else if (key) { + await callRuntimeRpc( + target, + 'browser.keypress', + { ...params, key }, + { timeoutMs: 15_000 } + ) + } + await new Promise((resolve) => window.setTimeout(resolve, 100)) + await refreshRemoteView(pageId) + } catch (error) { + setRemoteError(error instanceof Error ? error.message : 'Remote keyboard input failed.') + } + })() + } + + const handleRemoteScreenshotWheel = (event: React.WheelEvent): void => { + const target = runtimeTarget() + const pageId = remotePageIdRef.current + if (!target || !pageId) { + return + } + event.preventDefault() + setRemoteError(null) + void callRuntimeRpc( + target, + 'browser.mouseWheel', + { + worktree: `id:${worktreeId}`, + page: pageId, + dx: Math.round(event.deltaX), + dy: Math.round(event.deltaY) + }, + { timeoutMs: 15_000 } + ) + .then(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 100)) + await refreshRemoteView(pageId) + }) + .catch((error: unknown) => { + setRemoteError(error instanceof Error ? error.message : 'Remote scroll failed.') + }) + } + + return ( +
+
+ + + + +
+
+ {screenshotUrl ? ( + + ) : ( +
+ {busy ? ( + + ) : ( + + )} +
+ {busy ? 'Opening remote browser' : 'Remote browser'} +
+
+ This pane is rendered from the active runtime server. +
+
+ )} + {remoteError ? ( +
+ {remoteError} +
+ ) : null} +
+
+ ) +} + function BrowserPagePane({ browserTab, workspaceId, @@ -1563,14 +2073,22 @@ function BrowserPagePane({ } try { - await window.api.fs.authorizeExternalPath({ targetPath: notebookPath }) - const stat = await window.api.fs.stat({ filePath: notebookPath }) + const activeWorktree = store.allWorktrees().find((w) => w.id === worktreeId) + const fileContext: RuntimeFileOperationArgs = { + settings: store.settings, + worktreeId, + worktreePath: activeWorktree?.path, + connectionId: undefined + } + if (!isRemoteRuntimeFileOperation(fileContext, notebookPath)) { + await window.api.fs.authorizeExternalPath({ targetPath: notebookPath }) + } + const stat = await statRuntimePath(fileContext, notebookPath) if (stat.isDirectory) { navigateBrowserUrl(url) return } - const activeWorktree = store.allWorktrees().find((w) => w.id === worktreeId) let relativePath = notebookPath if (activeWorktree?.path && isPathInsideWorktree(notebookPath, activeWorktree.path)) { relativePath = diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index 83d4b6181ea..b444d713d54 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -9,6 +9,10 @@ import { useAppStore } from '@/store' import { joinPath } from '@/lib/path' import { setWithLRU } from '@/lib/scroll-cache' import { getConnectionId } from '@/lib/connection-context' +import { findWorktreeById } from '@/store/slices/worktree-helpers' +import { writeRuntimeFile } from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { getRuntimeGitBranchDiff, getRuntimeGitDiff } from '@/runtime/runtime-git-client' import '@/lib/monaco-setup' import { Button } from '@/components/ui/button' import type { OpenFile } from '@/store/slices/editor' @@ -207,26 +211,40 @@ export default function CombinedDiffViewer({ let result: GitDiffResult try { const connectionId = getConnectionId(file.worktreeId) ?? undefined + const state = useAppStore.getState() + const fileSettings = settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId) result = isBranchMode && branchCompare - ? ((await window.api.git.branchDiff({ - worktreePath: file.filePath, - compare: { - baseRef: branchCompare.baseRef, - baseOid: branchCompare.baseOid!, - headOid: branchCompare.headOid!, - mergeBase: branchCompare.mergeBase! + ? ((await getRuntimeGitBranchDiff( + { + settings: fileSettings, + worktreeId: file.worktreeId, + worktreePath: file.filePath, + connectionId }, - filePath: entry.path, - oldPath: entry.oldPath, - connectionId - })) as GitDiffResult) - : ((await window.api.git.diff({ - worktreePath: file.filePath, - filePath: entry.path, - staged: 'area' in entry && entry.area === 'staged', - connectionId - })) as GitDiffResult) + { + compare: { + baseRef: branchCompare.baseRef, + baseOid: branchCompare.baseOid!, + headOid: branchCompare.headOid!, + mergeBase: branchCompare.mergeBase! + }, + filePath: entry.path, + oldPath: entry.oldPath + } + )) as GitDiffResult) + : ((await getRuntimeGitDiff( + { + settings: fileSettings, + worktreeId: file.worktreeId, + worktreePath: file.filePath, + connectionId + }, + { + filePath: entry.path, + staged: 'area' in entry && entry.area === 'staged' + } + )) as GitDiffResult) } catch { result = { kind: 'text', @@ -261,6 +279,7 @@ export default function CombinedDiffViewer({ branchCompare?.mergeBase, branchEntries, file.filePath, + file.runtimeEnvironmentId, isBranchMode, uncommittedEntries ] @@ -287,7 +306,20 @@ export default function CombinedDiffViewer({ const absolutePath = joinPath(file.filePath, section.path) try { const connectionId = getConnectionId(file.worktreeId) ?? undefined - await window.api.fs.writeFile({ filePath: absolutePath, content, connectionId }) + const state = useAppStore.getState() + const worktree = file.worktreeId + ? findWorktreeById(state.worktreesByRepo, file.worktreeId) + : null + await writeRuntimeFile( + { + settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId), + worktreeId: file.worktreeId, + worktreePath: worktree?.path ?? null, + connectionId + }, + absolutePath, + content + ) setSections((prev) => prev.map((s, i) => (i === index ? { ...s, modifiedContent: content, dirty: false } : s)) ) @@ -295,7 +327,7 @@ export default function CombinedDiffViewer({ console.error('Save failed:', err) } }, - [file.filePath, file.worktreeId, sections] + [file.filePath, file.runtimeEnvironmentId, file.worktreeId, sections] ) const handleSectionSaveRef = useRef(handleSectionSave) diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 57ddc16864f..872e43e1766 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -237,6 +237,7 @@ export function EditorContent({ content={editorContent} filePath={activeFile.filePath} worktreeId={activeFile.worktreeId} + runtimeEnvironmentId={activeFile.runtimeEnvironmentId} scrollCacheKey={`${editorViewStateKey}:rich`} onContentChange={onContentChangeWithFm} onDirtyStateHint={handleDirtyStateHint} diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index f7067db3144..3be4b61f5aa 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -38,6 +38,7 @@ import EditorViewToggle, { } from './EditorViewToggle' import { EditorContent } from './EditorContent' import { scrollTopCache, cursorPositionCache, diffViewStateCache } from '@/lib/scroll-cache' +import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import type { GitDiffResult } from '../../../../shared/types' import { getOpenFilesForExternalFileChange, @@ -60,6 +61,19 @@ import { isMarkdownPreviewShortcut } from './markdown-preview-controls' import type { EditorToggleValue } from './EditorViewToggle' +import { + createRuntimePath, + getRuntimeFileReadScope, + readRuntimeFileContent, + renameRuntimePath, + runtimePathExists +} from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { + getRuntimeGitBranchDiff, + getRuntimeGitDiff, + getRuntimeGitScope +} from '@/runtime/runtime-git-client' const isMac = navigator.userAgent.includes('Mac') const isLinux = navigator.userAgent.includes('Linux') @@ -199,6 +213,7 @@ function EditorPanelInner({ const activeFileWorktreeId = activeFile?.worktreeId ?? null const activeFileMode = activeFile?.mode ?? null const activeFileDiffSource = activeFile?.diffSource + const activeFileRuntimeEnvironmentId = activeFile?.runtimeEnvironmentId const activeViewStateId = activeViewStateIdProp ?? activeFileId const [fileContents, setFileContents] = useState>({}) const [diffContents, setDiffContents] = useState>({}) @@ -389,18 +404,26 @@ function EditorPanelInner({ try { const connectionId = getConnectionId(worktreeId ?? null) ?? undefined const restoredOpenFile = openFilesRef.current.find((file) => file.id === id) - if ( - !connectionId && - restoredOpenFile?.filePath === filePath && - restoredOpenFile.relativePath === filePath - ) { + const activeSettings = useAppStore.getState().settings + const readSettings = settingsForRuntimeOwner( + activeSettings, + restoredOpenFile?.runtimeEnvironmentId + ) + if (restoredOpenFile?.filePath === filePath && restoredOpenFile.relativePath === filePath) { + if (readSettings?.activeRuntimeEnvironmentId?.trim() || connectionId) { + // Why: restored external-file tabs contain client-local absolute + // paths. Remote runtime and SSH workspaces cannot read those paths + // without an explicit upload/import flow. + throw new Error('External local files are not available for remote workspaces.') + } // Why: external files selected through OS/browser/drop flows are // authorized in the main process, but that grant is in-memory. On // session restore, re-authorize only tabs that were stored with an // absolute relativePath because they came from outside a worktree. await window.api.fs.authorizeExternalPath({ targetPath: filePath }) } - const key = inFlightReadKey(connectionId, filePath) + const readScope = getRuntimeFileReadScope(readSettings, connectionId) + const key = inFlightReadKey(readScope, filePath) // Why: share the IPC round-trip across split-pane EditorPanels viewing // the same file. The first caller starts the read and registers the // promise; concurrent callers (triggered by the same external-change @@ -408,7 +431,13 @@ function EditorPanelInner({ // downstream setContent transactions. let pending = inFlightFileReads.get(key) if (!pending) { - pending = window.api.fs.readFile({ filePath, connectionId }) as Promise + pending = readRuntimeFileContent({ + settings: readSettings, + filePath, + relativePath: restoredOpenFile?.relativePath, + worktreeId, + connectionId + }) as Promise inFlightFileReads.set(key, pending) // Why: limit deduplication to synchronous callers (like N split panes // responding to the exact same event loop dispatch). Caching the promise @@ -468,6 +497,9 @@ function EditorPanelInner({ ? file.branchCompare : null const connectionId = getConnectionId(file.worktreeId) ?? undefined + const activeSettings = useAppStore.getState().settings + const fileSettings = settingsForRuntimeOwner(activeSettings, file.runtimeEnvironmentId) + const gitScope = getRuntimeGitScope(fileSettings, connectionId) // Why: Changes view mode runs on top of an edit-mode tab and asks git // for an unstaged diff against HEAD for that file. Use the 'unstaged' // diff-source key so multiple Changes tabs across split panes share one @@ -479,7 +511,7 @@ function EditorPanelInner({ const compareAgainstHead = file.mode === 'edit' const key = inFlightDiffKey( { ...file, diffSource: effectiveDiffSource }, - connectionId, + gitScope, compareAgainstHead ) // Why: same rationale as inFlightFileReads above — a single external @@ -490,25 +522,37 @@ function EditorPanelInner({ if (!pending) { pending = ( effectiveDiffSource === 'branch' && branchCompare - ? window.api.git.branchDiff({ - worktreePath, - compare: { - baseRef: branchCompare.baseRef, - baseOid: branchCompare.baseOid!, - headOid: branchCompare.headOid!, - mergeBase: branchCompare.mergeBase! + ? getRuntimeGitBranchDiff( + { + settings: fileSettings, + worktreeId: file.worktreeId, + worktreePath, + connectionId }, - filePath: file.relativePath, - oldPath: file.branchOldPath, - connectionId - }) - : window.api.git.diff({ - worktreePath, - filePath: file.relativePath, - staged: effectiveDiffSource === 'staged', - compareAgainstHead, - connectionId - }) + { + compare: { + baseRef: branchCompare.baseRef, + baseOid: branchCompare.baseOid!, + headOid: branchCompare.headOid!, + mergeBase: branchCompare.mergeBase! + }, + filePath: file.relativePath, + oldPath: file.branchOldPath + } + ) + : getRuntimeGitDiff( + { + settings: fileSettings, + worktreeId: file.worktreeId, + worktreePath, + connectionId + }, + { + filePath: file.relativePath, + staged: effectiveDiffSource === 'staged', + compareAgainstHead + } + ) ) as Promise inFlightDiffReads.set(key, pending) queueMicrotask(() => { @@ -885,10 +929,20 @@ function EditorPanelInner({ oldPath.length - renameDialogFile.relativePath.length - 1 ) const newPath = `${worktreeRoot}/${newRelPath}` + const connectionId = getConnectionId(renameDialogFile.worktreeId) ?? undefined + const fileContext = { + settings: settingsForRuntimeOwner( + useAppStore.getState().settings, + renameDialogFile.runtimeEnvironmentId + ), + worktreeId: renameDialogFile.worktreeId, + worktreePath: worktreeRoot, + connectionId + } // Prevent silently overwriting an existing file (but allow keeping // the current name — the file's own path is not a conflict). - if (newPath !== oldPath && (await window.api.shell.pathExists(newPath))) { + if (newPath !== oldPath && (await runtimePathExists(fileContext, newPath))) { setRenameError('A file with that name already exists') return } @@ -925,12 +979,12 @@ function EditorPanelInner({ // if the directory already exists (assertNotExists guard), so only call // it when the directory is not yet on disk. const newDir = newPath.slice(0, newPath.lastIndexOf('/')) - if (newDir !== worktreeRoot && !(await window.api.shell.pathExists(newDir))) { - await window.api.fs.createDir({ dirPath: newDir }) + if (newDir !== worktreeRoot && !(await runtimePathExists(fileContext, newDir))) { + await createRuntimePath(fileContext, newDir, 'directory') } try { - await window.api.fs.rename({ oldPath, newPath }) + await renameRuntimePath(fileContext, oldPath, newPath) } catch (err) { setRenameError(err instanceof Error ? err.message : 'Failed to rename file') return @@ -941,6 +995,7 @@ function EditorPanelInner({ filePath: newPath, relativePath: newRelPath, worktreeId: renameDialogFile.worktreeId, + runtimeEnvironmentId: renameDialogFile.runtimeEnvironmentId, language: detectLanguage(newRelPath), mode: 'edit' }) @@ -1008,6 +1063,7 @@ function EditorPanelInner({ filePath: activeFilePath, relativePath: activeFileRelativePath, worktreeId: activeFileWorktreeId, + runtimeEnvironmentId: activeFileRuntimeEnvironmentId, language: shortcutLanguage }) } @@ -1019,6 +1075,7 @@ function EditorPanelInner({ activeFileMode, activeFilePath, activeFileRelativePath, + activeFileRuntimeEnvironmentId, activeFileWorktreeId, openMarkdownPreview ]) @@ -1230,6 +1287,7 @@ function EditorPanelInner({ filePath: activeFile.filePath, relativePath: activeFile.relativePath, worktreeId: activeFile.worktreeId, + runtimeEnvironmentId: activeFile.runtimeEnvironmentId, language: resolvedLanguage }) } @@ -1242,6 +1300,17 @@ function EditorPanelInner({ {canShowMarkdownPreview && } { + if ( + isLocalPathOpenBlocked( + settingsForRuntimeOwner(settings, activeFile.runtimeEnvironmentId), + { + connectionId: getConnectionId(activeFile.worktreeId) + } + ) + ) { + showLocalPathOpenBlockedToast() + return + } window.api.shell.openPath(activeFile.filePath) }} > @@ -1419,6 +1488,13 @@ function EditorPanelInner({ ?.path ?? '') : '' } + disableBrowse={Boolean( + settingsForRuntimeOwner( + settings, + renameDialogFile?.runtimeEnvironmentId + )?.activeRuntimeEnvironmentId?.trim() || + (renameDialogFile ? getConnectionId(renameDialogFile.worktreeId) : null) + )} externalError={renameError} onClose={() => { setRenameDialogFileId(null) diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index bebadac8475..05ab3574c0e 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -21,6 +21,7 @@ import { Input } from '@/components/ui/input' import { useAppStore } from '@/store' import { toast } from 'sonner' import { computeEditorFontSize } from '@/lib/editor-font-zoom' +import { getConnectionId } from '@/lib/connection-context' import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache' import { detectLanguage } from '@/lib/language-detect' import type { MarkdownDocument, Worktree } from '../../../../shared/types' @@ -48,7 +49,9 @@ import { } from './markdown-preview-search' import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit' import { openHttpLink } from '@/lib/http-link-routing' +import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import { markdownPreviewUrlTransform } from './markdown-preview-url-transform' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { buildMarkdownTableOfContents } from './markdown-table-of-contents' import { MarkdownTableOfContentsPanel } from './MarkdownTableOfContentsPanel' @@ -188,9 +191,25 @@ export default function MarkdownPreview({ const setMarkdownViewMode = useAppStore((s) => s.setMarkdownViewMode) const setPendingEditorReveal = useAppStore((s) => s.setPendingEditorReveal) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const sourceRuntimeEnvironmentId = useAppStore( + (s) => s.openFiles.find((file) => file.filePath === filePath)?.runtimeEnvironmentId + ) const sourceWorktree = findWorktreeForMarkdownPreviewPath(worktreesByRepo, filePath) + const sourceConnectionId = sourceWorktree ? getConnectionId(sourceWorktree.id) : null const worktreeRoot = sourceWorktree?.path ?? null const settings = useAppStore((s) => s.settings) + const imageRuntimeContext = useMemo( + () => + sourceWorktree + ? { + settings: settingsForRuntimeOwner(settings, sourceRuntimeEnvironmentId), + worktreeId: sourceWorktree.id, + worktreePath: sourceWorktree.path, + connectionId: sourceConnectionId + } + : undefined, + [settings, sourceConnectionId, sourceRuntimeEnvironmentId, sourceWorktree] + ) const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) const editorFontSize = computeEditorFontSize(14, editorFontZoomLevel) const isDark = @@ -516,6 +535,20 @@ export default function MarkdownPreview({ return } if (parsed.protocol === 'file:') { + if ( + isLocalPathOpenBlocked( + settingsForRuntimeOwner( + useAppStore.getState().settings, + sourceRuntimeEnvironmentId + ), + { connectionId: sourceConnectionId } + ) + ) { + // Why: modifier-open delegates to the client OS. Server-local + // file:// targets from remote runtime/SSH worktrees cannot be opened locally. + showLocalPathOpenBlockedToast() + return + } const classified = resolveMarkdownLinkTarget(href, filePath, worktreeRoot) if (classified?.kind === 'markdown') { // Why: use the classifier's stripped absolutePath (no `:line:col` @@ -565,10 +598,25 @@ export default function MarkdownPreview({ void activateMarkdownLink(href, { sourceFilePath: filePath, worktreeId: sourceWorktree.id, - worktreeRoot: sourceWorktree.path + worktreeRoot: sourceWorktree.path, + runtimeEnvironmentId: sourceRuntimeEnvironmentId }) return } + if ( + isLocalPathOpenBlocked( + settingsForRuntimeOwner( + useAppStore.getState().settings, + sourceRuntimeEnvironmentId + ), + { connectionId: sourceConnectionId } + ) + ) { + // Why: without a workspace match, opening a file URI delegates to + // the client OS. Remote runtime/SSH paths are not local files. + showLocalPathOpenBlockedToast() + return + } void window.api.shell.openFileUri(target.toString()) return } @@ -587,6 +635,7 @@ export default function MarkdownPreview({ filePath: absolutePath, relativePath, worktreeId: targetWorktree.id, + runtimeEnvironmentId: sourceRuntimeEnvironmentId, language, mode: 'edit' }) @@ -610,6 +659,7 @@ export default function MarkdownPreview({ filePath: absolutePath, relativePath, worktreeId: targetWorktree.id, + runtimeEnvironmentId: sourceRuntimeEnvironmentId, language }, { anchor: target.hash ? target.hash.slice(1) : null } @@ -621,6 +671,7 @@ export default function MarkdownPreview({ filePath: absolutePath, relativePath, worktreeId: targetWorktree.id, + runtimeEnvironmentId: sourceRuntimeEnvironmentId, language, mode: 'edit' }) @@ -642,7 +693,7 @@ export default function MarkdownPreview({ // eslint-disable-next-line react-hooks/rules-of-hooks -- react-markdown // instantiates component overrides as regular React components, so hooks // are valid here despite the lowercase function name. - const resolvedSrc = useLocalImageSrc(src, filePath) + const resolvedSrc = useLocalImageSrc(src, filePath, undefined, imageRuntimeContext) const handleImageClick = (event: React.MouseEvent): void => { if (!isMarkdownPreviewOpenModifier(event, isMac)) { return @@ -657,7 +708,8 @@ export default function MarkdownPreview({ void activateMarkdownLink(src, { sourceFilePath: filePath, worktreeId: sourceWorktree.id, - worktreeRoot: sourceWorktree.path + worktreeRoot: sourceWorktree.path, + runtimeEnvironmentId: sourceRuntimeEnvironmentId }) } @@ -752,6 +804,7 @@ export default function MarkdownPreview({ activateMarkdownLink, isDark, isMac, + imageRuntimeContext, markdownDocumentIndex, onOpenDocument, openFile, @@ -759,6 +812,8 @@ export default function MarkdownPreview({ scrollToAnchor, setMarkdownViewMode, setPendingEditorReveal, + sourceConnectionId, + sourceRuntimeEnvironmentId, sourceWorktree, worktreeRoot, worktreesByRepo diff --git a/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx b/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx index 3d9def2b593..a702e9d6d5b 100644 --- a/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx +++ b/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx @@ -9,6 +9,7 @@ import { import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' import { findWorktreeById } from '@/store/slices/worktree-helpers' +import { getRuntimeGitRemoteFileUrl } from '@/runtime/runtime-git-client' type MonacoGutterContextMenuProps = { open: boolean @@ -60,12 +61,15 @@ export function MonacoGutterContextMenu({ return } const connectionId = getConnectionId(activeFile?.worktreeId ?? null) ?? undefined - const url = await window.api.git.remoteFileUrl({ - worktreePath: worktree.path, - relativePath, - line, - connectionId - }) + const url = await getRuntimeGitRemoteFileUrl( + { + settings: state.settings, + worktreeId: activeFile.worktreeId, + worktreePath: worktree.path, + connectionId + }, + { relativePath, line } + ) if (url) { window.api.ui.writeClipboardText(url) } diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 096ed5062f7..163b31c8bef 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -11,6 +11,7 @@ import { RichMarkdownToolbar } from './RichMarkdownToolbar' import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' import { useLocalImagePick } from './useLocalImagePick' import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { getConnectionId } from '@/lib/connection-context' import { slashCommands, syncDocLinkMenu, syncSlashMenu } from './rich-markdown-commands' import type { DocLinkMenuRow, @@ -35,7 +36,9 @@ import { normalizeSoftBreaks } from './rich-markdown-normalize' import { autoFocusRichEditor } from './rich-markdown-auto-focus' import { handleRichMarkdownCut } from './rich-markdown-cut-handler' import { openHttpLink } from '@/lib/http-link-routing' +import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import { toast } from 'sonner' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { isSingleEmptyTopLevelOrderedList } from './rich-markdown-list-continuation' import { absolutePathToFileUri as toFileUrlForOsEscape, @@ -54,6 +57,7 @@ type RichMarkdownEditorProps = { content: string filePath: string worktreeId: string + runtimeEnvironmentId?: string | null scrollCacheKey: string onContentChange: (content: string) => void onDirtyStateHint: (dirty: boolean) => void @@ -168,6 +172,7 @@ export default function RichMarkdownEditor({ content, filePath, worktreeId, + runtimeEnvironmentId, scrollCacheKey, onContentChange, onDirtyStateHint, @@ -179,6 +184,7 @@ export default function RichMarkdownEditor({ headerSlot }: RichMarkdownEditorProps): React.JSX.Element { const rootRef = useRef(null) + const settings = useAppStore((s) => s.settings) const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) const activateMarkdownLink = useAppStore((s) => s.activateMarkdownLink) const worktreeRoot = useAppStore((s) => { @@ -341,7 +347,12 @@ export default function RichMarkdownEditor({ if (!src) { return false } - void activateMarkdownLink(src, { sourceFilePath: filePath, worktreeId, worktreeRoot }) + void activateMarkdownLink(src, { + sourceFilePath: filePath, + worktreeId, + worktreeRoot, + runtimeEnvironmentId + }) return true } if (clickedNode?.type.name === 'markdownDocLink') { @@ -370,7 +381,20 @@ export default function RichMarkdownEditor({ } if (classified.kind === 'external') { openHttpLink(classified.url, { forceSystemBrowser: true }) - } else if (classified.kind === 'markdown') { + return true + } + if ( + isLocalPathOpenBlocked( + settingsForRuntimeOwner(useAppStore.getState().settings, runtimeEnvironmentId), + { connectionId: getConnectionId(worktreeId) } + ) + ) { + // Why: Shift-click opens through the client OS. Server-local paths + // from remote runtime/SSH worktrees are not meaningful on this client. + showLocalPathOpenBlockedToast() + return true + } + if (classified.kind === 'markdown') { void window.api.shell.pathExists(classified.absolutePath).then((exists) => { if (!exists) { toast.error(`File not found: ${classified.relativePath}`) @@ -383,7 +407,12 @@ export default function RichMarkdownEditor({ } return true } - void activateMarkdownLink(href, { sourceFilePath: filePath, worktreeId, worktreeRoot }) + void activateMarkdownLink(href, { + sourceFilePath: filePath, + worktreeId, + worktreeRoot, + runtimeEnvironmentId + }) return true } }, @@ -497,22 +526,30 @@ export default function RichMarkdownEditor({ useModifierHeldClass(rootRef, isMac) - // Why: the custom Image extension reads filePath from editor.storage to resolve - // relative image src values to file:// URLs for display. After updating the - // stored path we dispatch a no-op transaction so ProseMirror re-renders image - // nodes with the new resolved src (renderHTML reads storage at render time). + // Why: the custom Image extension reads filePath/runtimeContext from storage + // to resolve relative image src values. After updating storage we dispatch a + // no-op transaction so ProseMirror re-renders image nodes with the new source. useEffect(() => { if (editor) { isApplyingProgrammaticUpdateRef.current = true try { // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(editor.storage as any).image.filePath = filePath + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(editor.storage as any).image.runtimeContext = worktreeRoot + ? { + settings: settingsForRuntimeOwner(settings, runtimeEnvironmentId), + worktreeId, + worktreePath: worktreeRoot, + connectionId: getConnectionId(worktreeId) + } + : undefined editor.view.dispatch(editor.state.tr) } finally { isApplyingProgrammaticUpdateRef.current = false } } - }, [editor, filePath]) + }, [editor, filePath, runtimeEnvironmentId, settings, worktreeId, worktreeRoot]) // Why: the doc link NodeView reads the document list from storage to style // resolved vs. missing links. The no-op transaction with meta flag triggers @@ -531,7 +568,7 @@ export default function RichMarkdownEditor({ } }, [editor, markdownDocuments]) - const handleLocalImagePick = useLocalImagePick(editor, filePath) + const handleLocalImagePick = useLocalImagePick(editor, filePath, worktreeId, runtimeEnvironmentId) useEffect(() => { handleLocalImagePickRef.current = handleLocalImagePick @@ -546,7 +583,8 @@ export default function RichMarkdownEditor({ } = useLinkBubble(editor, rootRef, linkBubble, setLinkBubble, setIsEditingLink, { sourceFilePath: filePath, worktreeId, - worktreeRoot + worktreeRoot, + runtimeEnvironmentId }) useEffect(() => { diff --git a/src/renderer/src/components/editor/UntitledFileRenameDialog.tsx b/src/renderer/src/components/editor/UntitledFileRenameDialog.tsx index a015a011b39..de63bfdb4dd 100644 --- a/src/renderer/src/components/editor/UntitledFileRenameDialog.tsx +++ b/src/renderer/src/components/editor/UntitledFileRenameDialog.tsx @@ -16,6 +16,7 @@ type UntitledFileRenameDialogProps = { currentName: string worktreePath: string externalError?: string | null + disableBrowse?: boolean onClose: () => void onConfirm: (newRelativePath: string) => void } @@ -25,6 +26,7 @@ export function UntitledFileRenameDialog({ currentName, worktreePath, externalError, + disableBrowse = false, onClose, onConfirm }: UntitledFileRenameDialogProps): React.JSX.Element { @@ -143,8 +145,11 @@ export function UntitledFileRenameDialog({ variant="outline" size="icon" className="h-8 w-8 shrink-0" + disabled={disableBrowse} onClick={() => void handleBrowse()} - title="Browse folders" + title={ + disableBrowse ? 'Folder picker unavailable for remote files' : 'Browse folders' + } > diff --git a/src/renderer/src/components/editor/editor-autosave-controller.test.ts b/src/renderer/src/components/editor/editor-autosave-controller.test.ts index b36b5b472e9..6edf848c54e 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.test.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: autosave behavior depends on event wiring, + dirty drafts, remote routing, quiesce, and failure cleanup in one harness. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createStore, type StoreApi } from 'zustand/vanilla' import { createEditorSlice } from '@/store/slices/editor' @@ -7,6 +9,11 @@ import { requestEditorFileSave, requestEditorSaveQuiesce } from './editor-autosa import { attachEditorAutosaveController } from './editor-autosave-controller' import { registerPendingEditorFlush } from './editor-pending-flush' import { __clearSelfWriteRegistryForTests, hasRecentSelfWrite } from './editor-self-write-registry' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '@/runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' type WindowStub = { addEventListener: Window['addEventListener'] @@ -18,6 +25,9 @@ type WindowStub = { fs: { writeFile: ReturnType } + runtimeEnvironments?: { + call: ReturnType + } } } @@ -107,6 +117,70 @@ describe('attachEditorAutosaveController', () => { } }) + it('saves remote files through the owning runtime environment', async () => { + clearRuntimeCompatibilityCacheForTests() + const writeFile = vi.fn().mockResolvedValue(undefined) + const runtimeCall = vi.fn().mockResolvedValue({ + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-env-1' } + }) + const runtimeTransportCall = vi.fn((args: RuntimeEnvironmentCallRequest) => { + return ( + createCompatibleRuntimeStatusResponseIfNeeded(args, 'runtime-env-1') ?? runtimeCall(args) + ) + }) + const eventTarget = new EventTarget() + vi.stubGlobal('window', { + addEventListener: eventTarget.addEventListener.bind(eventTarget), + removeEventListener: eventTarget.removeEventListener.bind(eventTarget), + dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget), + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + api: { + fs: { writeFile }, + runtimeEnvironments: { call: runtimeTransportCall } + } + } satisfies WindowStub) + + const store = createEditorStore() + store.setState({ + settings: { + editorAutoSave: true, + editorAutoSaveDelayMs: 1000, + activeRuntimeEnvironmentId: 'env-2' + } as never, + worktreesByRepo: { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/remote/repo' }] as never + } + }) + store.getState().openFile({ + filePath: '/remote/repo/file.ts', + relativePath: 'file.ts', + worktreeId: 'wt-1', + runtimeEnvironmentId: 'env-1', + language: 'typescript', + mode: 'edit' + }) + store.getState().setEditorDraft('/remote/repo/file.ts', 'edited') + store.getState().markFileDirty('/remote/repo/file.ts', true) + + const cleanup = attachEditorAutosaveController(store) + try { + await requestDirtyFileSave() + + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.write', + params: { worktree: 'wt-1', relativePath: 'file.ts', content: 'edited' }, + timeoutMs: 15_000 + }) + expect(writeFile).not.toHaveBeenCalled() + } finally { + cleanup() + } + }) + it('flushes mounted rich-editor changes before restart-driven dirty-file saves', async () => { const writeFile = vi.fn().mockResolvedValue(undefined) const eventTarget = new EventTarget() diff --git a/src/renderer/src/components/editor/editor-autosave-controller.ts b/src/renderer/src/components/editor/editor-autosave-controller.ts index 1d94c6955ed..73c2817d7ca 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.ts @@ -1,7 +1,13 @@ +/* eslint-disable max-lines -- Why: autosave owns the save queue, quiesce +coordination, and dirty-file shutdown hooks; keeping those lifecycles together +avoids split-brain saves across visible and hidden editors. */ import type { StoreApi } from 'zustand' import type { AppState } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { getConnectionId } from '@/lib/connection-context' +import { findWorktreeById } from '@/store/slices/worktree-helpers' +import { writeRuntimeFile } from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { canAutoSaveOpenFile, getOpenFilesForExternalFileChange, @@ -69,6 +75,9 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { const contentToSave = state.editorDrafts[file.id] ?? fallbackContent const connectionId = getConnectionId(liveFile.worktreeId) ?? undefined + const worktree = liveFile.worktreeId + ? findWorktreeById(state.worktreesByRepo ?? {}, liveFile.worktreeId) + : null // Why: stamp before the write so the fs:changed event that our own // write produces is ignored by useEditorExternalWatch instead of // round-tripping back into a setContent that jumps the cursor to the @@ -76,11 +85,16 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { // debounce window). See editor-self-write-registry. recordSelfWrite(liveFile.filePath) try { - await window.api.fs.writeFile({ - filePath: liveFile.filePath, - content: contentToSave, - connectionId - }) + await writeRuntimeFile( + { + settings: settingsForRuntimeOwner(state.settings, liveFile.runtimeEnvironmentId), + worktreeId: liveFile.worktreeId, + worktreePath: worktree?.path ?? null, + connectionId + }, + liveFile.filePath, + contentToSave + ) } catch (error) { // Why: the self-write stamp is only valid if a disk write actually // happened. Clearing it on failure keeps the external watcher from diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index 4cc560b2636..89e8478d134 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -14,6 +14,7 @@ import { BlockMath, InlineMath } from '@tiptap/extension-mathematics' import { Markdown } from '@tiptap/markdown' import { createLowlight, common } from 'lowlight' import { loadLocalImageSrc, onImageCacheInvalidated } from './useLocalImageSrc' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { RawMarkdownHtmlBlock, RawMarkdownHtmlInline } from './raw-markdown-html' import { MarkdownDocLink } from './rich-markdown-doc-link' import { RichMarkdownCodeBlock } from './RichMarkdownCodeBlock' @@ -56,7 +57,7 @@ export function createRichMarkdownExtensions({ // and works identically in dev and production modes. Image.extend({ addStorage() { - return { filePath: '' } + return { filePath: '', runtimeContext: undefined as RuntimeFileOperationArgs | undefined } }, addNodeView() { return ({ node, HTMLAttributes }) => { @@ -80,11 +81,14 @@ export function createRichMarkdownExtensions({ const loadImage = (src: string | undefined): void => { const fp = this.storage.filePath as string + const runtimeContext = this.storage.runtimeContext as + | RuntimeFileOperationArgs + | undefined if (src && fp) { // Why: when IPC resolution fails (e.g. unsupported format), // the ternary falls back to the raw src so the browser can // attempt its own loading rather than leaving a broken image. - void loadLocalImageSrc(src, fp).then((resolved) => { + void loadLocalImageSrc(src, fp, undefined, runtimeContext).then((resolved) => { img.src = resolved ? resolved : src }) } else if (src) { diff --git a/src/renderer/src/components/editor/useLinkBubble.ts b/src/renderer/src/components/editor/useLinkBubble.ts index a91b199b110..76941e0dc43 100644 --- a/src/renderer/src/components/editor/useLinkBubble.ts +++ b/src/renderer/src/components/editor/useLinkBubble.ts @@ -20,6 +20,7 @@ export function useLinkBubble( sourceFilePath: string worktreeId: string worktreeRoot: string | null + runtimeEnvironmentId?: string | null } ): { handleLinkSave: (href: string) => void @@ -108,7 +109,8 @@ export function useLinkBubble( void activateMarkdownLink(linkBubble.href, { sourceFilePath: linkContext.sourceFilePath, worktreeId: linkContext.worktreeId, - worktreeRoot: linkContext.worktreeRoot + worktreeRoot: linkContext.worktreeRoot, + runtimeEnvironmentId: linkContext.runtimeEnvironmentId }) }, [ activateMarkdownLink, @@ -116,6 +118,7 @@ export function useLinkBubble( linkContext.sourceFilePath, linkContext.worktreeId, linkContext.worktreeRoot, + linkContext.runtimeEnvironmentId, rootRef ]) diff --git a/src/renderer/src/components/editor/useLocalImagePick.ts b/src/renderer/src/components/editor/useLocalImagePick.ts index 8c1446ac236..444a849ca1d 100644 --- a/src/renderer/src/components/editor/useLocalImagePick.ts +++ b/src/renderer/src/components/editor/useLocalImagePick.ts @@ -2,8 +2,18 @@ import { useCallback } from 'react' import { toast } from 'sonner' import type { Editor } from '@tiptap/react' import { extractIpcErrorMessage, getImageCopyDestination } from './rich-markdown-image-utils' +import { useAppStore } from '@/store' +import { getConnectionId } from '@/lib/connection-context' +import { basename, dirname } from '@/lib/path' +import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' -export function useLocalImagePick(editor: Editor | null, filePath: string): () => Promise { +export function useLocalImagePick( + editor: Editor | null, + filePath: string, + worktreeId: string | null, + runtimeEnvironmentId?: string | null +): () => Promise { return useCallback(async () => { if (!editor) { return @@ -18,6 +28,45 @@ export function useLocalImagePick(editor: Editor | null, filePath: string): () = if (!srcPath) { return } + const connectionId = getConnectionId(worktreeId) ?? undefined + const settings = settingsForRuntimeOwner( + useAppStore.getState().settings, + runtimeEnvironmentId + ) + if (settings?.activeRuntimeEnvironmentId?.trim() || connectionId) { + const worktreePath = getWorktreePath(worktreeId) + if (settings?.activeRuntimeEnvironmentId?.trim() && !worktreePath) { + toast.error('Worktree path not available.') + return + } + // Why: picked images are client-local files while remote markdown lives + // on the server. Upload beside the markdown file before inserting the + // relative image path so preview/save works from any client. + const { results } = await importExternalPathsToRuntime( + { + settings, + worktreeId, + worktreePath, + connectionId + }, + [srcPath], + dirname(filePath) + ) + const imported = results.find((result) => result.status === 'imported') + if (!imported) { + toast.error('Failed to insert image.') + return + } + editor + .chain() + .focus() + .insertContentAt(insertPos, { + type: 'image', + attrs: { src: basename(imported.destPath) } + }) + .run() + return + } // Why: copy the image next to the markdown file and insert a relative path // so the markdown stays portable and doesn't bloat with base64 data. const { imageName, destPath } = await getImageCopyDestination(filePath, srcPath) @@ -35,5 +84,14 @@ export function useLocalImagePick(editor: Editor | null, filePath: string): () = } catch (err) { toast.error(extractIpcErrorMessage(err, 'Failed to insert image.')) } - }, [editor, filePath]) + }, [editor, filePath, runtimeEnvironmentId, worktreeId]) +} + +function getWorktreePath(worktreeId: string | null): string | null { + if (!worktreeId) { + return null + } + const state = useAppStore.getState() + const worktrees = Object.values(state.worktreesByRepo ?? {}).flat() + return worktrees.find((worktree) => worktree.id === worktreeId)?.path ?? null } diff --git a/src/renderer/src/components/editor/useLocalImageSrc.test.ts b/src/renderer/src/components/editor/useLocalImageSrc.test.ts new file mode 100644 index 00000000000..736be5a7672 --- /dev/null +++ b/src/renderer/src/components/editor/useLocalImageSrc.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { getLocalImageCacheKey } from './useLocalImageSrc' + +describe('getLocalImageCacheKey', () => { + it('scopes local markdown image cache entries by runtime owner', () => { + const localKey = getLocalImageCacheKey('/repo/docs/logo.png', null, { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + const remoteKey = getLocalImageCacheKey('/repo/docs/logo.png', null, { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + const otherRemoteKey = getLocalImageCacheKey('/repo/docs/logo.png', null, { + settings: { activeRuntimeEnvironmentId: 'env-2' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + + expect(localKey).not.toBe(remoteKey) + expect(remoteKey).not.toBe(otherRemoteKey) + }) +}) diff --git a/src/renderer/src/components/editor/useLocalImageSrc.ts b/src/renderer/src/components/editor/useLocalImageSrc.ts index 3206e99b353..1f55756fba7 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.ts @@ -1,5 +1,7 @@ import { useEffect, useState } from 'react' import { resolveImageAbsolutePath } from './markdown-preview-links' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' +import { readRuntimeFilePreview } from '@/runtime/runtime-file-client' // Why: the renderer is served from http://localhost in dev mode, so file:// // URLs in tags are blocked by cross-origin restrictions. Loading images @@ -9,6 +11,21 @@ import { resolveImageAbsolutePath } from './markdown-preview-links' const BLOB_URL_CACHE_MAX_SIZE = 100 const blobUrlCache = new Map() +export function getLocalImageCacheKey( + absolutePath: string, + connectionId?: string | null, + runtimeContext?: Omit & { connectionId?: string | null } +): string { + const runtimeEnvironmentId = + runtimeContext?.settings?.activeRuntimeEnvironmentId?.trim() ?? 'client' + return [ + runtimeEnvironmentId, + runtimeContext?.connectionId ?? connectionId ?? 'local', + runtimeContext?.worktreeId ?? 'unknown-worktree', + absolutePath + ].join('\0') +} + // Why: blob URLs hold references to in-memory Blob objects; without eviction // the cache grows without bound and leaks memory. We evict the oldest entry // (Map iteration order is insertion order) and revoke its blob URL so the @@ -97,7 +114,8 @@ function isExternalUrl(src: string): boolean { export function useLocalImageSrc( rawSrc: string | undefined, filePath: string, - connectionId?: string | null + connectionId?: string | null, + runtimeContext?: Omit & { connectionId?: string | null } ): string | undefined { const [generation, setGeneration] = useState(cacheGeneration) @@ -113,8 +131,11 @@ export function useLocalImageSrc( return rawSrc } const absolutePath = resolveImageAbsolutePath(rawSrc, filePath) - if (absolutePath && blobUrlCache.has(absolutePath)) { - return blobUrlCache.get(absolutePath) + if (absolutePath) { + const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext) + if (blobUrlCache.has(cacheKey)) { + return blobUrlCache.get(cacheKey) + } } return undefined }) @@ -136,21 +157,21 @@ export function useLocalImageSrc( return } - if (blobUrlCache.has(absolutePath)) { - setDisplaySrc(blobUrlCache.get(absolutePath)) + const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext) + if (blobUrlCache.has(cacheKey)) { + setDisplaySrc(blobUrlCache.get(cacheKey)) return } let cancelled = false - window.api.fs - .readFile({ filePath: absolutePath, connectionId: connectionId ?? undefined }) + readImagePreview(absolutePath, connectionId, runtimeContext) .then((result) => { if (cancelled) { return } if (result.isBinary && result.content) { const url = base64ToBlobUrl(result.content, result.mimeType ?? 'image/png') - cacheBlobUrl(absolutePath, url) + cacheBlobUrl(cacheKey, url) setDisplaySrc(url) } else { // Why: if the file exists but is not binary (e.g. an SVG stored as @@ -168,7 +189,7 @@ export function useLocalImageSrc( return () => { cancelled = true } - }, [rawSrc, filePath, generation, connectionId]) + }, [rawSrc, filePath, generation, connectionId, runtimeContext]) return displaySrc } @@ -181,7 +202,8 @@ export function useLocalImageSrc( export async function loadLocalImageSrc( rawSrc: string, filePath: string, - connectionId?: string | null + connectionId?: string | null, + runtimeContext?: Omit & { connectionId?: string | null } ): Promise { if ( rawSrc.startsWith('http://') || @@ -197,19 +219,17 @@ export async function loadLocalImageSrc( return null } - const cached = blobUrlCache.get(absolutePath) + const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext) + const cached = blobUrlCache.get(cacheKey) if (cached) { return cached } try { - const result = await window.api.fs.readFile({ - filePath: absolutePath, - connectionId: connectionId ?? undefined - }) + const result = await readImagePreview(absolutePath, connectionId, runtimeContext) if (result.isBinary && result.content) { const url = base64ToBlobUrl(result.content, result.mimeType ?? 'image/png') - cacheBlobUrl(absolutePath, url) + cacheBlobUrl(cacheKey, url) return url } // Why: if the file is not binary (e.g. an SVG stored as text) or content @@ -222,3 +242,23 @@ export async function loadLocalImageSrc( return null } + +function readImagePreview( + absolutePath: string, + connectionId?: string | null, + runtimeContext?: Omit & { connectionId?: string | null } +) { + if (!runtimeContext) { + return window.api.fs.readFile({ + filePath: absolutePath, + connectionId: connectionId ?? undefined + }) + } + return readRuntimeFilePreview( + { + ...runtimeContext, + connectionId: runtimeContext.connectionId ?? connectionId ?? undefined + }, + absolutePath + ) +} diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ts b/src/renderer/src/components/editor/useMarkdownDocuments.ts index f0fdb092c7d..96a0c3ec682 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ts @@ -3,6 +3,8 @@ import type { MarkdownDocument } from '../../../../shared/types' import { useAppStore } from '@/store' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { getConnectionId } from '@/lib/connection-context' +import { listRuntimeMarkdownDocuments, statRuntimePath } from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor' import { createMarkdownDocumentIndex, resolveMarkdownDocLink } from './markdown-doc-links' @@ -48,10 +50,18 @@ export function useMarkdownDocuments( const requestId = requestRef.current + 1 requestRef.current = requestId try { - const documents = await window.api.fs.listMarkdownDocuments({ - rootPath: worktreePath, - connectionId: connectionId ?? undefined - }) + const documents = await listRuntimeMarkdownDocuments( + { + settings: settingsForRuntimeOwner( + useAppStore.getState().settings, + activeFile.runtimeEnvironmentId + ), + worktreeId, + worktreePath, + connectionId: connectionId ?? undefined + }, + worktreePath + ) if (requestRef.current !== requestId) { return } @@ -68,18 +78,26 @@ export function useMarkdownDocuments( })) } } - }, [connectionId, worktreeId, worktreePath]) + }, [activeFile.runtimeEnvironmentId, connectionId, worktreeId, worktreePath]) const openMarkdownDocument = useCallback( async (document: MarkdownDocument): Promise => { - if (!worktreeId) { + if (!worktreeId || !worktreePath) { return } try { - const stats = await window.api.fs.stat({ - filePath: document.filePath, - connectionId: connectionId ?? undefined - }) + const stats = await statRuntimePath( + { + settings: settingsForRuntimeOwner( + useAppStore.getState().settings, + activeFile.runtimeEnvironmentId + ), + worktreeId, + worktreePath, + connectionId: connectionId ?? undefined + }, + document.filePath + ) if (stats.isDirectory) { await refreshMarkdownDocuments() return @@ -94,10 +112,18 @@ export function useMarkdownDocuments( relativePath: document.relativePath, worktreeId, language: 'markdown', + runtimeEnvironmentId: activeFile.runtimeEnvironmentId, mode: 'edit' }) }, - [connectionId, openFile, refreshMarkdownDocuments, worktreeId] + [ + activeFile.runtimeEnvironmentId, + connectionId, + openFile, + refreshMarkdownDocuments, + worktreeId, + worktreePath + ] ) useEffect(() => { diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index b16d06ffaf6..a83df7ae613 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -15,6 +15,7 @@ import { } from '@/lib/orchestration-setup-state' import { notifyProjectNotesSelectionChanged } from '@/lib/open-project-notes-tab' import { requestProjectNotesTabClose } from '@/lib/project-notes-close-request' +import { linkRuntimeProjectNote, showRuntimeProjectNote } from '@/runtime/runtime-notes-client' import { useAppStore } from '@/store' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { TerminalTab } from '../../../../shared/types' @@ -213,10 +214,11 @@ export function FloatingTerminalPanel({ .find((candidate) => candidate.id === activeWorktreeId) const projectId = state.repos.find((candidate) => candidate.id === worktree?.repoId)?.id ?? worktree?.repoId + const settings = state.settings let label = 'Project Notes' if (noteId && projectId) { try { - const result = await window.api.notes.show({ + const result = await showRuntimeProjectNote(settings, { projectId, worktreeId: activeWorktreeId, note: noteId @@ -226,7 +228,7 @@ export function FloatingTerminalPanel({ label = 'Project Notes' } if (projectId) { - await window.api.notes.link({ + await linkRuntimeProjectNote(settings, { projectId, worktreeId: activeWorktreeId, note: noteId, diff --git a/src/renderer/src/components/github-project/ProjectCell.tsx b/src/renderer/src/components/github-project/ProjectCell.tsx index 4182e068d92..59ccf7ea2d2 100644 --- a/src/renderer/src/components/github-project/ProjectCell.tsx +++ b/src/renderer/src/components/github-project/ProjectCell.tsx @@ -9,14 +9,17 @@ import { TYPE_FIELD_DATA_TYPE } from './columns' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' -import type { GitHubAssignableUser } from '../../../../shared/types' +import { useRepoAssigneesBySlug, useRepoLabelsBySlug } from '@/hooks/useGitHubSlugMetadata' +import { useAppStore } from '@/store' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { GitHubIssueType, GitHubProjectField, GitHubProjectFieldMutationValue, GitHubProjectLabel, GitHubProjectRow, - GitHubProjectUser + GitHubProjectUser, + ListIssueTypesBySlugResult } from '../../../../shared/github-project-types' type Props = { @@ -264,6 +267,7 @@ function IssueTypeCell({ const [open, setOpen] = useState(false) const [options, setOptions] = useState([]) const [loading, setLoading] = useState(false) + const settings = useAppStore((s) => s.settings) const [owner, repo] = (row.content.repository ?? '').split('/') React.useEffect(() => { @@ -272,8 +276,17 @@ function IssueTypeCell({ } let cancelled = false setLoading(true) - window.api.gh - .listIssueTypesBySlug({ owner, repo }) + const target = getActiveRuntimeTarget(settings) + const request = + target.kind === 'environment' + ? callRuntimeRpc( + target, + 'github.project.listIssueTypesBySlug', + { owner, repo }, + { timeoutMs: 30_000 } + ) + : window.api.gh.listIssueTypesBySlug({ owner, repo }) + request .then((res) => { if (cancelled) { return @@ -290,7 +303,7 @@ function IssueTypeCell({ return () => { cancelled = true } - }, [open, owner, repo]) + }, [open, owner, repo, settings]) const trigger = ( @@ -713,8 +726,7 @@ function AssigneesCell({ }): React.JSX.Element { const assignees = row.content.assignees const [open, setOpen] = useState(false) - const [options, setOptions] = useState([]) - const [loading, setLoading] = useState(false) + const settings = useAppStore((s) => s.settings) const [owner, repo] = (row.content.repository ?? '').split('/') @@ -732,37 +744,12 @@ function AssigneesCell({ [assignees] ) - // Why: only hit the slug-addressed user list when the popover actually - // opens — the assignable-users query can be expensive for large repos. - React.useEffect(() => { - if (!open || !owner || !repo) { - return - } - let cancelled = false - setLoading(true) - window.api.gh - .listAssignableUsersBySlug({ - owner, - repo, - seedLogins: seedKey ? seedKey.split(',') : [] - }) - .then((res) => { - if (cancelled) { - return - } - if (res.ok) { - setOptions(res.users) - } - }) - .finally(() => { - if (!cancelled) { - setLoading(false) - } - }) - return () => { - cancelled = true - } - }, [open, owner, repo, seedKey]) + const metadata = useRepoAssigneesBySlug( + open ? owner : null, + open ? repo : null, + seedKey ? seedKey.split(',') : [], + settings + ) const labelContent = assignees.length === 0 ? null : assignees.map((u) => ) @@ -791,10 +778,10 @@ function AssigneesCell({ {!owner || !repo ? (
Row has no repo slug.
- ) : loading ? ( + ) : metadata.loading ? (
Loading…
) : ( - options.map((u) => { + metadata.data.map((u) => { const isOn = assignees.some((a) => a.login === u.login) return ( - {loading ? ( + {metadata.loading ? (
Loading…
) : ( - users.map((u) => { + metadata.data.map((u) => { const isOn = selected.includes(u.login) return ( - {loading ? ( + {metadata.loading ? (
Loading…
) : ( - options.map((name) => { + metadata.data.map((name) => { const isOn = selected.includes(name) return ( +
+ onServerPathChange(event.target.value)} + /> + + +
+ + ) : ( + + )}
{ e.preventDefault() onClone() @@ -71,21 +130,38 @@ export function RepoStep({
+ {runtimeActive && ( +
+ + onCloneDestinationChange(event.target.value)} + /> +
+ )}
Workspace - {workspaceDir} + + {runtimeActive ? 'Runtime server' : workspaceDir} +
- SSH? Set hosts up in Settings + {runtimeActive ? 'Server paths only' : 'SSH? Set hosts up in Settings'}
diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.ts index df477c1eb3a..dc0c0bbd405 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow.ts @@ -8,7 +8,7 @@ import { applyDocumentTheme } from '@/lib/document-theme' import { track } from '@/lib/telemetry' import { buildAgentPickedPayload } from './agent-picked-payload' import { isGitRepoKind } from '../../../../shared/repo-kind' -import type { GlobalSettings, OnboardingState, TuiAgent } from '../../../../shared/types' +import type { GlobalSettings, OnboardingState, Repo, TuiAgent } from '../../../../shared/types' import type { NotificationDraft } from './NotificationStep' import { DEFAULT_ONBOARDING_FEATURE_SETUP_SELECTION, @@ -19,6 +19,7 @@ import { } from './onboarding-feature-setup' import { STEPS, type StepNumber } from './use-onboarding-flow-types' import { persistStep, useCloseWith, usePersistCurrentStep } from './use-onboarding-flow-persistence' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' export { STEPS } from './use-onboarding-flow-types' export type { StepId, StepNumber } from './use-onboarding-flow-types' @@ -38,6 +39,7 @@ export function useOnboardingFlow( const pathFailureReason = useAppStore((s) => s.pathFailureReason) const fetchRepos = useAppStore((s) => s.fetchRepos) const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) + const addRepoPath = useAppStore((s) => s.addRepoPath) const openModal = useAppStore((s) => s.openModal) const initialStep = Math.min(Math.max(onboarding.lastCompletedStep, 0), STEPS.length - 1) @@ -69,6 +71,8 @@ export function useOnboardingFlow( const [featureSetupTerminalSelection, setFeatureSetupTerminalSelection] = useState(null) const [cloneUrl, setCloneUrl] = useState('') + const [serverPath, setServerPath] = useState('') + const [cloneDestination, setCloneDestination] = useState('') const [busyLabel, setBusyLabel] = useState(null) const [error, setError] = useState(null) @@ -394,35 +398,62 @@ export function useOnboardingFlow( ] ) - const openFolder = useCallback(async () => { - // Why: re-entry guard — rapid Cmd+Enter must not launch duplicate pickers. - if (busyLabel !== null) { - return - } - setError(null) - track('onboarding_step4_path_clicked', { path: 'open_folder' }) - const path = await window.api.repos.pickFolder() - if (!path) { - track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'cancelled' }) - return - } - setBusyLabel('Opening project…') - try { - let result = await window.api.repos.add({ path }) - if ('error' in result && result.error.includes('Not a valid git repository')) { - result = await window.api.repos.add({ path, kind: 'folder' }) + const openFolder = useCallback( + async (kind: 'git' | 'folder' = 'git') => { + // Why: re-entry guard — rapid Cmd+Enter must not launch duplicate pickers. + if (busyLabel !== null) { + return } - if ('error' in result) { - throw new Error(result.error) + setError(null) + if (settings?.activeRuntimeEnvironmentId?.trim()) { + const path = serverPath.trim() + if (!path) { + const message = 'Enter a server path.' + setError(message) + return + } + track('onboarding_step4_path_clicked', { path: 'open_folder' }) + setBusyLabel(kind === 'git' ? 'Opening project…' : 'Opening folder…') + try { + const repo = await addRepoPath(path, kind) + if (!repo) { + track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' }) + return + } + await completeRepo(repo.id, isGitRepoKind(repo), 'open_folder') + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' }) + } finally { + setBusyLabel(null) + } + return } - await completeRepo(result.repo.id, isGitRepoKind(result.repo), 'open_folder') - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' }) - } finally { - setBusyLabel(null) - } - }, [busyLabel, completeRepo]) + track('onboarding_step4_path_clicked', { path: 'open_folder' }) + const path = await window.api.repos.pickFolder() + if (!path) { + track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'cancelled' }) + return + } + setBusyLabel('Opening project…') + try { + let result = await window.api.repos.add({ path }) + if ('error' in result && result.error.includes('Not a valid git repository')) { + result = await window.api.repos.add({ path, kind: 'folder' }) + } + if ('error' in result) { + throw new Error(result.error) + } + await completeRepo(result.repo.id, isGitRepoKind(result.repo), 'open_folder') + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' }) + } finally { + setBusyLabel(null) + } + }, + [addRepoPath, busyLabel, completeRepo, serverPath, settings?.activeRuntimeEnvironmentId] + ) const clone = useCallback(async () => { // Why: re-entry guard — prevents Enter spamming from triggering duplicate clones. @@ -435,12 +466,30 @@ export function useOnboardingFlow( } setError(null) track('onboarding_step4_path_clicked', { path: 'clone_url' }) + const target = getActiveRuntimeTarget(settings) + const destination = + target.kind === 'environment' ? cloneDestination.trim() : settings.workspaceDir + if (!destination) { + const message = 'Enter a server path for the clone destination.' + setError(message) + return + } setBusyLabel('Cloning repo…') try { - const repo = await window.api.repos.clone({ - url: trimmed, - destination: settings.workspaceDir - }) + const repo = + target.kind === 'environment' + ? ( + await callRuntimeRpc<{ repo: Repo }>( + target, + 'repo.clone', + { url: trimmed, destination }, + { timeoutMs: 10 * 60_000 } + ) + ).repo + : await window.api.repos.clone({ + url: trimmed, + destination + }) await completeRepo(repo.id, true, 'clone_url') } catch (err) { setError(err instanceof Error ? err.message : String(err)) @@ -451,7 +500,7 @@ export function useOnboardingFlow( } finally { setBusyLabel(null) } - }, [busyLabel, cloneUrl, completeRepo, settings]) + }, [busyLabel, cloneDestination, cloneUrl, completeRepo, settings]) const skip = useCallback(async () => { if (busyLabel) { @@ -521,6 +570,10 @@ export function useOnboardingFlow( hasSelectedFeatureSetup, cloneUrl, setCloneUrl, + serverPath, + setServerPath, + cloneDestination, + setCloneDestination, busyLabel, error, detectedSet, diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index b401307d286..886af2ef3bc 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -17,6 +17,7 @@ import { } from './checks-panel-content' import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh' import type { PRInfo, PRCheckDetail, PRComment } from '../../../../shared/types' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' export default function ChecksPanel(): React.JSX.Element { const activeWorktree = useActiveWorktree() @@ -377,11 +378,20 @@ export default function ChecksPanel(): React.JSX.Element { } setTitleSaving(true) try { - const ok = await window.api.gh.updatePRTitle({ - repoPath: repo.path, - prNumber: pr.number, - title: titleDraft.trim() - }) + const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const ok = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.updatePRTitle', + { repo: repo.id, prNumber: pr.number, title: titleDraft.trim() }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updatePRTitle({ + repoPath: repo.path, + prNumber: pr.number, + title: titleDraft.trim() + }) if (ok) { // Re-fetch PR to get updated title await fetchPRForBranch(repo.path, branch, { force: true, linkedPRNumber: linkedPR }) diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index 54a7e186f32..ecda45f3348 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -272,7 +272,7 @@ function FileExplorerInner(): React.JSX.Element { scrollRef }) - const handleDuplicate = useFileDuplicate({ worktreePath, refreshDir }) + const handleDuplicate = useFileDuplicate({ activeWorktreeId, worktreePath, refreshDir }) if (!worktreePath) { return ( diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index f0370bced7c..229ee3c070e 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -29,6 +29,7 @@ import type { GitFileStatus } from '../../../../shared/types' import { STATUS_LABELS } from './status-display' import type { TreeNode } from './file-explorer-types' import { useFileExplorerRowDrag } from './useFileExplorerRowDrag' +import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' const ORCA_PATH_MIME = 'text/x-orca-file-path' @@ -372,7 +373,26 @@ export function FileExplorerRow({ Open Markdown Preview )} - window.api.shell.openPath(node.path)}> + { + const state = useAppStore.getState() + const activeWorktree = Object.values(state.worktreesByRepo) + .flat() + .find((worktree) => worktree.id === activeWorktreeId) + const activeRepo = activeWorktree + ? state.repos.find((repo) => repo.id === activeWorktree.repoId) + : null + if ( + isLocalPathOpenBlocked(state.settings, { + connectionId: activeRepo?.connectionId ?? null + }) + ) { + showLocalPathOpenBlockedToast() + return + } + window.api.shell.openPath(node.path) + }} + > {revealLabel} diff --git a/src/renderer/src/components/right-sidebar/NotesPanel.tsx b/src/renderer/src/components/right-sidebar/NotesPanel.tsx index 6b934f7d0bb..b54ffc4d125 100644 --- a/src/renderer/src/components/right-sidebar/NotesPanel.tsx +++ b/src/renderer/src/components/right-sidebar/NotesPanel.tsx @@ -23,6 +23,11 @@ import { useActiveWorktree, useRepoById } from '@/store/selectors' import { cn } from '@/lib/utils' import { getProjectNotesEntityId, openProjectNotesTab } from '@/lib/open-project-notes-tab' import { NOTES_ACTIVE_CHANGED_EVENT } from '@/lib/notes-events' +import { + deleteRuntimeProjectNote, + listRuntimeProjectNotes, + renameRuntimeProjectNote +} from '@/runtime/runtime-notes-client' import type { NoteSummary } from '../../../../shared/notes-types' export default function NotesPanel(): React.JSX.Element { @@ -30,6 +35,7 @@ export default function NotesPanel(): React.JSX.Element { const repo = useRepoById(activeWorktree?.repoId ?? null) const projectId = repo?.id ?? activeWorktree?.repoId ?? null const worktreeId = activeWorktree?.id ?? null + const settings = useAppStore((s) => s.settings) const [notes, setNotes] = useState([]) const [loading, setLoading] = useState(false) @@ -49,14 +55,14 @@ export default function NotesPanel(): React.JSX.Element { setLoading(true) setError(null) try { - const result = await window.api.notes.list({ projectId, worktreeId, limit: 100 }) + const result = await listRuntimeProjectNotes(settings, { projectId, worktreeId, limit: 100 }) setNotes(result.notes) } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { setLoading(false) } - }, [projectId, worktreeId]) + }, [projectId, settings, worktreeId]) useEffect(() => { void refresh() @@ -106,7 +112,7 @@ export default function NotesPanel(): React.JSX.Element { return } try { - const result = await window.api.notes.rename({ + const result = await renameRuntimeProjectNote(settings, { projectId, worktreeId, note: note.id, @@ -126,7 +132,7 @@ export default function NotesPanel(): React.JSX.Element { toast.error(err instanceof Error ? err.message : `Failed to rename '${note.title}'.`) } }, - [projectId, refresh, renameValue, worktreeId] + [projectId, refresh, renameValue, settings, worktreeId] ) const cancelRename = useCallback(() => { @@ -140,7 +146,7 @@ export default function NotesPanel(): React.JSX.Element { } setDeleting(true) try { - await window.api.notes.delete({ projectId, worktreeId, note: deleteTarget.id }) + await deleteRuntimeProjectNote(settings, { projectId, worktreeId, note: deleteTarget.id }) const entityId = getProjectNotesEntityId(projectId, deleteTarget.id) const state = useAppStore.getState() const tabIdsToClose: string[] = [] @@ -162,7 +168,7 @@ export default function NotesPanel(): React.JSX.Element { } finally { setDeleting(false) } - }, [deleteTarget, deleting, projectId, refresh, worktreeId]) + }, [deleteTarget, deleting, projectId, refresh, settings, worktreeId]) const copyNotePath = useCallback(async (note: NoteSummary): Promise => { await navigator.clipboard.writeText(note.relativePath) diff --git a/src/renderer/src/components/right-sidebar/PRActions.tsx b/src/renderer/src/components/right-sidebar/PRActions.tsx index bcb51d55324..bb913938c25 100644 --- a/src/renderer/src/components/right-sidebar/PRActions.tsx +++ b/src/renderer/src/components/right-sidebar/PRActions.tsx @@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip' import type { PRInfo, Repo, Worktree } from '../../../../shared/types' import { runWorktreeDeleteWithToast } from '../sidebar/delete-worktree-flow' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' const MERGE_METHODS = ['squash', 'merge', 'rebase'] as const @@ -39,11 +40,20 @@ export default function PRActions({ setMergeError(null) setMergeMenuOpen(false) try { - const result = await window.api.gh.mergePR({ - repoPath: repo.path, - prNumber: pr.number, - method - }) + const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const result = + target.kind === 'environment' + ? await callRuntimeRpc<{ ok: true } | { ok: false; error: string }>( + target, + 'github.mergePR', + { repo: repo.id, prNumber: pr.number, method }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.mergePR({ + repoPath: repo.path, + prNumber: pr.number, + method + }) if (!result.ok) { setMergeError(result.error) } else { @@ -55,7 +65,7 @@ export default function PRActions({ setMerging(false) } }, - [repo.path, pr.number, onRefreshPR] + [repo.id, repo.path, pr.number, onRefreshPR] ) useEffect(() => { diff --git a/src/renderer/src/components/right-sidebar/Search.tsx b/src/renderer/src/components/right-sidebar/Search.tsx index d85c5228a8c..c5c535317d0 100644 --- a/src/renderer/src/components/right-sidebar/Search.tsx +++ b/src/renderer/src/components/right-sidebar/Search.tsx @@ -3,6 +3,7 @@ import { useVirtualizer } from '@tanstack/react-virtual' import { useAppStore } from '@/store' import { useActiveWorktree } from '@/store/selectors' import { getConnectionId } from '@/lib/connection-context' +import { searchRuntimeFiles } from '@/runtime/runtime-file-client' import type { SearchFileResult, SearchMatch } from '../../../../shared/types' import { buildSearchRows } from './search-rows' import { cancelRevealFrame, openMatchResult } from './search-match-open' @@ -173,20 +174,27 @@ export default function Search(): React.JSX.Element { try { const state = useAppStore.getState() const connectionId = getConnectionId(activeWorktreeId!) ?? undefined - const results = await window.api.fs.search({ - query: query.trim(), - rootPath: worktreePath, - connectionId, - caseSensitive: - state.fileSearchStateByWorktree[activeWorktreeId!]?.caseSensitive ?? false, - wholeWord: state.fileSearchStateByWorktree[activeWorktreeId!]?.wholeWord ?? false, - useRegex: state.fileSearchStateByWorktree[activeWorktreeId!]?.useRegex ?? false, - includePattern: - state.fileSearchStateByWorktree[activeWorktreeId!]?.includePattern || undefined, - excludePattern: - state.fileSearchStateByWorktree[activeWorktreeId!]?.excludePattern || undefined, - maxResults: SEARCH_MAX_RESULTS - }) + const results = await searchRuntimeFiles( + { + settings: state.settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + { + query: query.trim(), + rootPath: worktreePath, + caseSensitive: + state.fileSearchStateByWorktree[activeWorktreeId!]?.caseSensitive ?? false, + wholeWord: state.fileSearchStateByWorktree[activeWorktreeId!]?.wholeWord ?? false, + useRegex: state.fileSearchStateByWorktree[activeWorktreeId!]?.useRegex ?? false, + includePattern: + state.fileSearchStateByWorktree[activeWorktreeId!]?.includePattern || undefined, + excludePattern: + state.fileSearchStateByWorktree[activeWorktreeId!]?.excludePattern || undefined, + maxResults: SEARCH_MAX_RESULTS + } + ) if (latestSearchIdRef.current === searchId) { updateActiveSearchState({ results }) } diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 7047bf1e8d4..a3aeea40cce 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -30,6 +30,7 @@ import { } from 'lucide-react' import { useAppStore } from '@/store' import { useActiveWorktree, useRepoById, useWorktreeMap } from '@/store/selectors' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' import { detectLanguage } from '@/lib/language-detect' import { basename, dirname, joinPath } from '@/lib/path' import { cn } from '@/lib/utils' @@ -92,6 +93,16 @@ import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' import { getConnectionId } from '@/lib/connection-context' +import { + bulkStageRuntimeGitPaths, + bulkUnstageRuntimeGitPaths, + commitRuntimeGit, + discardRuntimeGitPath, + getRuntimeGitBranchCompare, + stageRuntimeGitPath, + unstageRuntimeGitPath +} from '@/runtime/runtime-git-client' +import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client' import { PullRequestIcon } from './checks-panel-content' import type { DiffComment, @@ -233,6 +244,7 @@ function SourceControlInner(): React.JSX.Element { const remoteStatusesByWorktree = useAppStore((s) => s.remoteStatusesByWorktree) const isRemoteOperationActive = useAppStore((s) => s.isRemoteOperationActive) const inFlightRemoteOpKind = useAppStore((s) => s.inFlightRemoteOpKind) + const settings = useAppStore((s) => s.settings) const hostedReviewCache = useAppStore((s) => s.hostedReviewCache) const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch) const updateRepo = useAppStore((s) => s.updateRepo) @@ -366,6 +378,7 @@ function SourceControlInner(): React.JSX.Element { } const connectionId = getConnectionId(activeWorktreeId) ?? undefined await refreshGitStatusForWorktree({ + settings: useAppStore.getState().settings, worktreeId: activeWorktreeId, worktreePath, connectionId, @@ -407,8 +420,7 @@ function SourceControlInner(): React.JSX.Element { setDefaultBaseRef(null) let stale = false - void window.api.repos - .getBaseRefDefault({ repoId: activeRepo.id }) + void getRuntimeRepoBaseRefDefault(useAppStore.getState().settings, activeRepo.id) .then((result) => { if (!stale) { // Why: IPC now returns a `{ defaultBaseRef, remoteCount }` envelope; @@ -436,7 +448,8 @@ function SourceControlInner(): React.JSX.Element { const hasUncommittedEntries = entries.length > 0 const branchName = activeWorktree?.branch.replace(/^refs\/heads\//, '') ?? 'HEAD' - const hostedReviewCacheKey = activeRepo && branchName ? `${activeRepo.path}::${branchName}` : null + const hostedReviewCacheKey = + activeRepo && branchName ? getHostedReviewCacheKey(activeRepo.path, branchName, settings) : null const hostedReview: HostedReviewInfo | null = hostedReviewCacheKey ? (hostedReviewCache[hostedReviewCacheKey]?.data ?? null) : null @@ -612,11 +625,15 @@ function SourceControlInner(): React.JSX.Element { setCommitInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true })) setCommitErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) try { - const commitResult = await window.api.git.commit({ - worktreePath, - message, - connectionId - }) + const commitResult = await commitRuntimeGit( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + message + ) if (!commitResult.success) { setCommitErrors((prev) => ({ ...prev, @@ -948,7 +965,15 @@ function SourceControlInner(): React.JSX.Element { setIsExecutingBulk(true) try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - await window.api.git.bulkStage({ worktreePath, filePaths: bulkStagePaths, connectionId }) + await bulkStageRuntimeGitPaths( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + bulkStagePaths + ) await refreshActiveGitStatusAfterMutation() clearSelection() } finally { @@ -969,7 +994,15 @@ function SourceControlInner(): React.JSX.Element { setIsExecutingBulk(true) try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - await window.api.git.bulkUnstage({ worktreePath, filePaths: bulkUnstagePaths, connectionId }) + await bulkUnstageRuntimeGitPaths( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + bulkUnstagePaths + ) await refreshActiveGitStatusAfterMutation() clearSelection() } finally { @@ -999,7 +1032,15 @@ function SourceControlInner(): React.JSX.Element { setIsExecutingBulk(true) try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - await window.api.git.bulkStage({ worktreePath, filePaths: paths, connectionId }) + await bulkStageRuntimeGitPaths( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + paths + ) await refreshActiveGitStatusAfterMutation() clearSelection() } finally { @@ -1034,7 +1075,15 @@ function SourceControlInner(): React.JSX.Element { setIsExecutingBulk(true) try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - await window.api.git.bulkStage({ worktreePath, filePaths, connectionId }) + await bulkStageRuntimeGitPaths( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + filePaths + ) await refreshActiveGitStatusAfterMutation() clearSelection() } finally { @@ -1086,7 +1135,15 @@ function SourceControlInner(): React.JSX.Element { setIsExecutingBulk(true) try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - await window.api.git.bulkUnstage({ worktreePath, filePaths: paths, connectionId }) + await bulkUnstageRuntimeGitPaths( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + paths + ) await refreshActiveGitStatusAfterMutation() clearSelection() } finally { @@ -1132,11 +1189,15 @@ function SourceControlInner(): React.JSX.Element { try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - const result = await window.api.git.branchCompare({ - worktreePath, - baseRef: effectiveBaseRef, - connectionId - }) + const result = await getRuntimeGitBranchCompare( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + effectiveBaseRef + ) setGitBranchCompareResult(activeWorktreeId, requestKey, result) } catch (error) { setGitBranchCompareResult(activeWorktreeId, requestKey, { @@ -1314,7 +1375,15 @@ function SourceControlInner(): React.JSX.Element { } try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - await window.api.git.stage({ worktreePath, filePath, connectionId }) + await stageRuntimeGitPath( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + filePath + ) await refreshActiveGitStatusAfterMutation() } catch { // git operation failed silently @@ -1330,7 +1399,15 @@ function SourceControlInner(): React.JSX.Element { } try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - await window.api.git.unstage({ worktreePath, filePath, connectionId }) + await unstageRuntimeGitPath( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + filePath + ) await refreshActiveGitStatusAfterMutation() } catch { // git operation failed silently @@ -1357,7 +1434,15 @@ function SourceControlInner(): React.JSX.Element { relativePath: filePath }) const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - await window.api.git.discard({ worktreePath, filePath, connectionId }) + await discardRuntimeGitPath( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + filePath + ) notifyEditorExternalFileChange({ worktreeId: activeWorktreeId, worktreePath, @@ -1438,7 +1523,15 @@ function SourceControlInner(): React.JSX.Element { const errors: unknown[] = [] const result = await runDiscardAllForArea(area, paths, { bulkUnstage: (filePaths) => - window.api.git.bulkUnstage({ worktreePath, filePaths, connectionId }), + bulkUnstageRuntimeGitPaths( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + filePaths + ), discardMany, discardOne: discardSingle, onError: (error) => { diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.ts index 3929c54065d..d7fafc21d8e 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -1,4 +1,5 @@ -import type { GitStatusResult, GitUpstreamStatus } from '../../../../shared/types' +import { getRuntimeGitStatus } from '@/runtime/runtime-git-client' +import type { GitStatusResult, GitUpstreamStatus, GlobalSettings } from '../../../../shared/types' export type GitStatusRefreshDeps = { setGitStatus: (worktreeId: string, status: GitStatusResult) => void @@ -15,17 +16,21 @@ export type GitStatusRefreshDeps = { } export async function refreshGitStatusForWorktree({ + settings, worktreeId, worktreePath, connectionId, deps }: { + settings?: Pick | null worktreeId: string worktreePath: string connectionId?: string deps: GitStatusRefreshDeps }): Promise { - const status = (await window.api.git.status({ + const status = (await getRuntimeGitStatus({ + settings, + worktreeId, worktreePath, connectionId })) as GitStatusResult diff --git a/src/renderer/src/components/right-sidebar/useFileDeletion.ts b/src/renderer/src/components/right-sidebar/useFileDeletion.ts index 44996e80790..6aeb95dd24b 100644 --- a/src/renderer/src/components/right-sidebar/useFileDeletion.ts +++ b/src/renderer/src/components/right-sidebar/useFileDeletion.ts @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { dirname } from '@/lib/path' import { getConnectionId } from '@/lib/connection-context' +import { findWorktreeById } from '@/store/slices/worktree-helpers' import { isPathEqualOrDescendant } from './file-explorer-paths' import type { TreeNode } from './file-explorer-types' import { @@ -11,6 +12,12 @@ import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' import { commitFileExplorerOp } from './fileExplorerUndoRedo' +import { + deleteRuntimePath, + isRemoteRuntimeFileOperation, + readRuntimeFileContent, + writeRuntimeFile +} from '@/runtime/runtime-file-client' type UseFileDeletionParams = { activeWorktreeId: string | null @@ -54,7 +61,18 @@ export function useFileDeletion({ inFlightRef.current.add(node.path) const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - const isRemote = connectionId !== undefined + const state = useAppStore.getState() + const worktree = activeWorktreeId + ? findWorktreeById(state.worktreesByRepo, activeWorktreeId) + : null + const fileContext = { + settings: state.settings, + worktreeId: activeWorktreeId, + worktreePath: worktree?.path ?? null, + connectionId + } + const isRemote = + connectionId !== undefined || isRemoteRuntimeFileOperation(fileContext, node.path) // Why: remote deletes go through `rm` on the relay — there is no OS-level // Trash/Recycle Bin, so the operation is permanent. Require an explicit @@ -93,7 +111,13 @@ export function useFileDeletion({ let undoContent: string | undefined if (!node.isDirectory) { try { - const rf = await window.api.fs.readFile({ filePath: node.path, connectionId }) + const rf = await readRuntimeFileContent({ + settings: fileContext.settings, + filePath: node.path, + relativePath: node.relativePath, + worktreeId: activeWorktreeId ?? undefined, + connectionId + }) if (!rf.isBinary) { undoContent = rf.content } @@ -103,28 +127,16 @@ export function useFileDeletion({ } } - await window.api.fs.deletePath({ - targetPath: node.path, - connectionId, - recursive: node.isDirectory - }) + await deleteRuntimePath(fileContext, node.path, node.isDirectory) if (undoContent !== undefined) { commitFileExplorerOp({ undo: async () => { - await window.api.fs.writeFile({ - filePath: node.path, - content: undoContent, - connectionId - }) + await writeRuntimeFile(fileContext, node.path, undoContent) await refreshDir(parentDir) }, redo: async () => { - await window.api.fs.deletePath({ - targetPath: node.path, - connectionId, - recursive: node.isDirectory - }) + await deleteRuntimePath(fileContext, node.path, node.isDirectory) await refreshDir(parentDir) } }) diff --git a/src/renderer/src/components/right-sidebar/useFileDuplicate.ts b/src/renderer/src/components/right-sidebar/useFileDuplicate.ts index 255b380ca5a..82ef96b1353 100644 --- a/src/renderer/src/components/right-sidebar/useFileDuplicate.ts +++ b/src/renderer/src/components/right-sidebar/useFileDuplicate.ts @@ -2,6 +2,9 @@ import { useCallback } from 'react' import { toast } from 'sonner' import { basename, dirname, joinPath } from '@/lib/path' import type { TreeNode } from './file-explorer-types' +import { useAppStore } from '@/store' +import { copyRuntimePath, runtimePathExists } from '@/runtime/runtime-file-client' +import { getConnectionId } from '@/lib/connection-context' /** * Electron's ipcRenderer.invoke wraps errors as: @@ -17,11 +20,13 @@ function extractIpcErrorMessage(err: unknown, fallback: string): string { } type UseFileDuplicateParams = { + activeWorktreeId: string | null worktreePath: string | null refreshDir: (dirPath: string) => Promise } export function useFileDuplicate({ + activeWorktreeId, worktreePath, refreshDir }: UseFileDuplicateParams): (node: TreeNode) => void { @@ -37,12 +42,19 @@ export function useFileDuplicate({ const ext = dotIndex > 0 ? name.slice(dotIndex) : '' const run = async (): Promise => { + const settings = useAppStore.getState().settings + const context = { + settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId: getConnectionId(activeWorktreeId) ?? undefined + } // Why: generate a unique "stem copy.ext", "stem copy 2.ext", … name // so we never collide with an existing file. pathExists checks are // sequential to avoid TOCTOU races with COPYFILE_EXCL on the backend. let candidate = joinPath(dir, `${stem} copy${ext}`) let n = 2 - while (await window.api.shell.pathExists(candidate)) { + while (await runtimePathExists(context, candidate)) { candidate = joinPath(dir, `${stem} copy ${n}${ext}`) n += 1 } @@ -58,7 +70,7 @@ export function useFileDuplicate({ // eslint-disable-next-line no-constant-condition while (true) { try { - await window.api.shell.copyFile({ srcPath: node.path, destPath: candidate }) + await copyRuntimePath(context, node.path, candidate) break } catch (err) { const isEexist = @@ -87,6 +99,6 @@ export function useFileDuplicate({ } void run() }, - [worktreePath, refreshDir] + [activeWorktreeId, worktreePath, refreshDir] ) } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts index 30c8f964881..32716165c12 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts @@ -10,6 +10,7 @@ import { detectLanguage } from '@/lib/language-detect' import { getConnectionId } from '@/lib/connection-context' import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' import { commitFileExplorerOp } from './fileExplorerUndoRedo' +import { renameRuntimePath } from '@/runtime/runtime-file-client' function extractIpcErrorMessage(err: unknown, fallback: string): string { if (!(err instanceof Error)) { @@ -281,16 +282,22 @@ export function useFileExplorerDragDrop({ try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - await window.api.fs.rename({ oldPath: sourcePath, newPath, connectionId }) + const fileContext = { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + } + await renameRuntimePath(fileContext, sourcePath, newPath) commitFileExplorerOp({ undo: async () => { - await window.api.fs.rename({ oldPath: newPath, newPath: sourcePath, connectionId }) + await renameRuntimePath(fileContext, newPath, sourcePath) await Promise.all([refreshDir(destDir), refreshDir(sourceDir)]) remapOpenTabsForMovedPath(newPath, sourcePath) }, redo: async () => { - await window.api.fs.rename({ oldPath: sourcePath, newPath, connectionId }) + await renameRuntimePath(fileContext, sourcePath, newPath) await Promise.all([refreshDir(sourceDir), refreshDir(destDir)]) remapOpenTabsForMovedPath(sourcePath, newPath) } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts b/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts index b08ddb30532..9a253a476ab 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts @@ -3,6 +3,8 @@ import type { Dispatch, SetStateAction } from 'react' import { toast } from 'sonner' import { getConnectionId } from '@/lib/connection-context' import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { useAppStore } from '@/store' +import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' type UseFileExplorerImportParams = { worktreePath: string | null @@ -61,21 +63,26 @@ export function useFileExplorerImport({ void (async () => { try { - const { results } = await window.api.fs.importExternalPaths({ - sourcePaths: paths, - destDir: destinationDir, - connectionId - }) + const settings = useAppStore.getState().settings + const { results } = await importExternalPathsToRuntime( + { + settings, + worktreeId: wtId, + worktreePath: worktreePathRef.current, + connectionId + }, + paths, + destinationDir + ) // Refresh the destination directory once per gesture await refreshDirRef.current(destinationDir) // Why: only select (highlight) the first imported file — don't trigger - // the full reveal machinery (scrollToIndex + flash) because the user - // already knows where they dropped the file. The reveal's aggressive - // scroll-to-center races with FS watcher refreshes and can snap the - // viewport back to the top of the tree. + // the full reveal machinery because watcher refreshes can otherwise + // snap the tree viewport away from the user's drop target. const imported = results.filter((r) => r.status === 'imported') + const skipped = results.filter((r) => r.status === 'skipped') const failed = results.filter((r) => r.status === 'failed') if (imported.length > 0) { @@ -85,6 +92,9 @@ export function useFileExplorerImport({ if (failed.length > 0) { const noun = failed.length === 1 ? 'file' : 'files' toast.error(`Failed to import ${failed.length} ${noun}.`) + } else if (skipped.length > 0 && imported.length === 0) { + const noun = skipped.length === 1 ? 'file' : 'files' + toast.error(`Skipped ${skipped.length} ${noun}.`) } } catch (err) { toast.error(extractIpcErrorMessage(err, 'Failed to import files.')) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts b/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts index 65d495437be..a4064115397 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts @@ -9,6 +9,7 @@ import { extractIpcErrorMessage, renameFileOnDisk } from '@/lib/rename-file' import type { InlineInput } from './FileExplorerRow' import type { TreeNode } from './file-explorer-types' import { commitFileExplorerOp } from './fileExplorerUndoRedo' +import { createRuntimePath, deleteRuntimePath } from '@/runtime/runtime-file-client' type UseFileExplorerInlineInputParams = { activeWorktreeId: string | null @@ -110,6 +111,12 @@ export function useFileExplorerInlineInput({ } const run = async (): Promise => { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + const fileContext = { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + } if (inlineInput.type === 'rename' && inlineInput.existingPath) { await renameFileOnDisk({ oldPath: inlineInput.existingPath, @@ -121,29 +128,31 @@ export function useFileExplorerInlineInput({ } else { const fullPath = joinPath(inlineInput.parentPath, name) try { - await (inlineInput.type === 'folder' - ? window.api.fs.createDir({ dirPath: fullPath, connectionId }) - : window.api.fs.createFile({ filePath: fullPath, connectionId })) + await createRuntimePath( + fileContext, + fullPath, + inlineInput.type === 'folder' ? 'directory' : 'file' + ) const parentForRefresh = inlineInput.parentPath if (inlineInput.type === 'folder') { commitFileExplorerOp({ undo: async () => { - await window.api.fs.deletePath({ targetPath: fullPath, connectionId }) + await deleteRuntimePath(fileContext, fullPath, true) await refreshDir(parentForRefresh) }, redo: async () => { - await window.api.fs.createDir({ dirPath: fullPath, connectionId }) + await createRuntimePath(fileContext, fullPath, 'directory') await refreshDir(parentForRefresh) } }) } else { commitFileExplorerOp({ undo: async () => { - await window.api.fs.deletePath({ targetPath: fullPath, connectionId }) + await deleteRuntimePath(fileContext, fullPath) await refreshDir(parentForRefresh) }, redo: async () => { - await window.api.fs.createFile({ filePath: fullPath, connectionId }) + await createRuntimePath(fileContext, fullPath, 'file') await refreshDir(parentForRefresh) } }) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts index a09c31b3333..7c52f0a756d 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts @@ -5,6 +5,8 @@ import { getConnectionId } from '@/lib/connection-context' import type { DirCache, TreeNode } from './file-explorer-types' import { splitPathSegments } from './path-tree' import { shouldIncludeFileExplorerEntry } from './file-explorer-entries' +import { readRuntimeDirectory } from '@/runtime/runtime-file-client' +import { useAppStore } from '@/store' type UseFileExplorerTreeResult = { dirCache: Record @@ -48,7 +50,15 @@ export function useFileExplorerTree( })) try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - const entries = await window.api.fs.readDir({ dirPath, connectionId }) + const entries = await readRuntimeDirectory( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + dirPath + ) if (depth === -1) { setRootError(null) } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts index 2d0c9290074..6aae3550b35 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { FsChangedPayload } from '../../../../shared/types' import { + canonicalizeFileExplorerWatchPath, getExternalFileChangeRelativePath, payloadRequiresDeferredTreeRefresh } from './useFileExplorerWatch' @@ -32,6 +33,22 @@ describe('getExternalFileChangeRelativePath', () => { ).toBe('config/settings.json') }) + it('matches Windows paths case-insensitively before deriving the relative path', () => { + expect( + getExternalFileChangeRelativePath('C:\\Repo', 'c:\\repo\\config\\settings.json', false) + ).toBe('config/settings.json') + }) + + it('preserves UNC roots when deriving the relative path', () => { + expect( + getExternalFileChangeRelativePath( + '//Server/Share/Repo', + '//server/share/repo/config/settings.json', + false + ) + ).toBe('config/settings.json') + }) + it('ignores paths outside the active worktree', () => { expect( getExternalFileChangeRelativePath('/repo', '/other/config/settings.json', false) @@ -62,6 +79,29 @@ describe('getExternalFileChangeRelativePath', () => { }) }) +describe('canonicalizeFileExplorerWatchPath', () => { + it('returns event paths with the watched worktree casing for UNC cache lookups', () => { + expect( + canonicalizeFileExplorerWatchPath('//Server/Share/Repo', '//server/share/repo/src/index.ts') + ).toBe('//Server/Share/Repo/src/index.ts') + }) + + it('preserves the watched worktree separator style for Windows cache lookups', () => { + expect(canonicalizeFileExplorerWatchPath('C:\\Repo', 'c:\\repo\\src\\index.ts')).toBe( + 'C:\\Repo\\src\\index.ts' + ) + }) + + it('rejects sibling UNC shares whose path merely shares a prefix', () => { + expect( + canonicalizeFileExplorerWatchPath( + '//Server/Share/Repo', + '//server/share/repository/src/index.ts' + ) + ).toBeNull() + }) +}) + describe('payloadRequiresDeferredTreeRefresh', () => { function payload(events: FsChangedPayload['events'], worktreePath = '/repo'): FsChangedPayload { return { worktreePath, events } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts index 22c9dbd0205..5f926604e81 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts @@ -3,14 +3,19 @@ import type { Dispatch, SetStateAction } from 'react' import type { FsChangedPayload } from '../../../../shared/types' import type { DirCache } from './file-explorer-types' import type { InlineInput } from './FileExplorerRow' -import { normalizeRelativePath } from '@/lib/path' -import { normalizeAbsolutePath } from './file-explorer-paths' -import { dirname } from '@/lib/path' +import { joinPath, normalizeRelativePath, dirname } from '@/lib/path' +import { + isPathInsideOrEqual, + normalizeRuntimePathForComparison, + relativePathInsideRoot +} from '../../../../shared/cross-platform-path' import { purgeDirCacheSubtree, purgeExpandedDirsSubtree, clearStalePendingReveal } from './file-explorer-watcher-reconcile' +import { useAppStore } from '@/store' +import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client' type UseFileExplorerWatchParams = { worktreePath: string | null @@ -35,17 +40,8 @@ export function getExternalFileChangeRelativePath( return null } - const normalizedWorktreePath = normalizeAbsolutePath(worktreePath) - const normalizedAbsolutePath = normalizeAbsolutePath(absolutePath) - // Why: `normalizeAbsolutePath` preserves the trailing slash for filesystem - // roots (`/` on POSIX, `C:/` on Windows drive roots) but strips it from all - // other paths. Blindly appending `/` would produce `//` or `C://`, so - // external edits under a root worktree would fail the prefix check and be - // silently dropped. Treat those roots as already-terminated prefixes. - const isFsRoot = normalizedWorktreePath === '/' || /^[A-Za-z]:\/$/.test(normalizedWorktreePath) - const worktreePrefix = isFsRoot ? normalizedWorktreePath : `${normalizedWorktreePath}/` - - if (!normalizedAbsolutePath.startsWith(worktreePrefix)) { + const relativePath = relativePathInsideRoot(worktreePath, absolutePath) + if (relativePath === null || relativePath === '') { return null } @@ -54,14 +50,37 @@ export function getExternalFileChangeRelativePath( // filesystem watcher reports absolute paths, so normalize them here before // the explorer refresh path returns; otherwise terminal edits refresh the // tree but leave the editor's cached file contents stale. - return normalizeRelativePath(normalizedAbsolutePath.slice(worktreePrefix.length)) + return normalizeRelativePath(relativePath) +} + +export function canonicalizeFileExplorerWatchPath( + worktreePath: string, + absolutePath: string +): string | null { + const relativePath = relativePathInsideRoot(worktreePath, absolutePath) + if (relativePath === null) { + return null + } + + const rootPath = normalizeExplorerAbsolutePath(worktreePath) + return relativePath === '' ? rootPath : joinPath(rootPath, relativePath) +} + +function normalizeExplorerAbsolutePath(path: string): string { + if (path === '/' || /^[A-Za-z]:[\\/]$/.test(path)) { + return path + } + return path.replace(/[\\/]+$/, '') } export function payloadRequiresDeferredTreeRefresh( payload: FsChangedPayload, currentWorktreePath: string ): boolean { - if (normalizeAbsolutePath(payload.worktreePath) !== normalizeAbsolutePath(currentWorktreePath)) { + if ( + normalizeRuntimePathForComparison(payload.worktreePath) !== + normalizeRuntimePathForComparison(currentWorktreePath) + ) { return false } @@ -91,6 +110,8 @@ export function useFileExplorerWatch({ dragSourcePath, isNativeDragOver }: UseFileExplorerWatchParams): void { + const activeRuntimeEnvironmentId = useAppStore((s) => s.settings?.activeRuntimeEnvironmentId) + // Keep refs for values accessed inside the event handler to avoid // re-subscribing the IPC listener on every render. const dirCacheRef = useRef(dirCache) @@ -151,7 +172,8 @@ export function useFileExplorerWatch({ // the old worktree can arrive after the switch. Processing them against // the new worktree's tree state would corrupt dirCache (design §3). if ( - normalizeAbsolutePath(payload.worktreePath) !== normalizeAbsolutePath(currentWorktreePath) + normalizeRuntimePathForComparison(payload.worktreePath) !== + normalizeRuntimePathForComparison(currentWorktreePath) ) { return } @@ -169,13 +191,19 @@ export function useFileExplorerWatch({ let needsFullRefresh = false for (const evt of payload.events) { - const normalizedPath = normalizeAbsolutePath(evt.absolutePath) - if (evt.kind === 'overflow') { needsFullRefresh = true break } + const normalizedPath = canonicalizeFileExplorerWatchPath( + currentWorktreePath, + evt.absolutePath + ) + if (!normalizedPath) { + continue + } + if (evt.kind === 'delete') { // Why: for delete events, isDirectory is undefined from the watcher // (the path no longer exists). Infer from dirCache: if the deleted @@ -193,27 +221,27 @@ export function useFileExplorerWatch({ // Clear selectedPath if it points into the deleted subtree setSelectedPath((prev) => { - if (prev && normalizeAbsolutePath(prev) === normalizedPath) { - return null - } if ( prev && - wasDirectory && - normalizeAbsolutePath(prev).startsWith(`${normalizedPath}/`) + normalizeRuntimePathForComparison(prev) === + normalizeRuntimePathForComparison(normalizedPath) ) { return null } + if (prev && wasDirectory && isPathInsideOrEqual(normalizedPath, prev)) { + return null + } return prev }) // Invalidate the parent directory - const parent = normalizeAbsolutePath(dirname(normalizedPath)) + const parent = normalizeExplorerAbsolutePath(dirname(normalizedPath)) if (parent in cache) { dirsToRefresh.add(parent) } } else if (evt.kind === 'create') { // Invalidate the parent directory - const parent = normalizeAbsolutePath(dirname(normalizedPath)) + const parent = normalizeExplorerAbsolutePath(dirname(normalizedPath)) if (parent in cache) { dirsToRefresh.add(parent) } @@ -239,7 +267,7 @@ export function useFileExplorerWatch({ for (const dirPath of dirsToRefresh) { // Check the dir is the root or an expanded directory or already in cache if ( - dirPath === normalizeAbsolutePath(currentWorktreePath) || + dirPath === normalizeExplorerAbsolutePath(currentWorktreePath) || exp.has(dirPath) || dirPath in dirCacheRef.current ) { @@ -271,14 +299,52 @@ export function useFileExplorerWatch({ processPayload(payload) } - const unsubscribeListener = window.api.fs.onFsChanged(handleFsChanged) + let disposed = false + let unsubscribeListener: (() => void) | null = null + if (activeRuntimeEnvironmentId?.trim() && activeWorktreeId) { + // Why: remote runtime watch events do not enter the local Electron + // fs:changed bus, so the Explorer subscribes directly while it is mounted. + void subscribeRuntimeFileChanges( + { + settings: { activeRuntimeEnvironmentId }, + worktreeId: activeWorktreeId, + worktreePath, + connectionId: undefined + }, + handleFsChanged, + (err) => { + console.warn('[filesystem-watch] failed to subscribe to runtime file changes', { + worktreeId: activeWorktreeId, + worktreePath, + error: err.message + }) + } + ) + .then((unsubscribe) => { + if (disposed) { + unsubscribe() + return + } + unsubscribeListener = unsubscribe + }) + .catch((err) => { + console.warn('[filesystem-watch] failed to subscribe to runtime file changes', { + worktreeId: activeWorktreeId, + worktreePath, + error: err instanceof Error ? err.message : String(err) + }) + }) + } else { + unsubscribeListener = window.api.fs.onFsChanged(handleFsChanged) + } return () => { - unsubscribeListener() + disposed = true + unsubscribeListener?.() deferredRef.current = [] processPayloadRef.current = null } - }, [worktreePath, activeWorktreeId, setDirCache, setSelectedPath]) + }, [worktreePath, activeWorktreeId, activeRuntimeEnvironmentId, setDirCache, setSelectedPath]) // ── Flush deferred events when interaction ends ──────────────────── useEffect(() => { diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index 89121ff88bc..a871ca1a4e7 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -48,7 +48,9 @@ async function usePollingOnce(status: GitStatusResult): Promise { }) vi.doMock('@/store', () => ({ - useAppStore: (selector: (s: PollState) => unknown) => selector(state) + useAppStore: Object.assign((selector: (s: PollState) => unknown) => selector(state), { + getState: () => ({ settings: null }) + }) })) vi.doMock('@/store/selectors', () => ({ @@ -78,7 +80,9 @@ async function usePollingOnce(status: GitStatusResult): Promise { const { useGitStatusPolling: runPolling } = await import('./useGitStatusPolling') GitStatusPollingHarness({ runPolling }) - await Promise.resolve() + await vi.waitFor(() => { + expect(state.setGitStatus).toHaveBeenCalled() + }) return state } diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index 4ef3c3770e9..07874284dfe 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -4,6 +4,7 @@ import { useActiveWorktree, useAllWorktrees, useRepoById, useRepoMap } from '@/s import type { GitConflictOperation } from '../../../../shared/types' import { isGitRepoKind } from '../../../../shared/repo-kind' import { getConnectionId } from '@/lib/connection-context' +import { getRuntimeGitConflictOperation } from '@/runtime/runtime-git-client' import { refreshGitStatusForWorktree } from './git-status-refresh' const POLL_INTERVAL_MS = 3000 @@ -54,6 +55,7 @@ export function useGitStatusPolling(): void { try { const connectionId = getConnectionId(activeWorktreeId) ?? undefined await refreshGitStatusForWorktree({ + settings: useAppStore.getState().settings, worktreeId: activeWorktreeId, worktreePath, connectionId, @@ -109,7 +111,9 @@ export function useGitStatusPolling(): void { const pollStale = async (): Promise => { for (const { id, path } of staleConflictWorktrees) { try { - const op = (await window.api.git.conflictOperation({ + const op = (await getRuntimeGitConflictOperation({ + settings: useAppStore.getState().settings, + worktreeId: id, worktreePath: path, connectionId: getConnectionId(id) ?? undefined })) as GitConflictOperation diff --git a/src/renderer/src/components/settings/BaseRefPicker.tsx b/src/renderer/src/components/settings/BaseRefPicker.tsx index b5e586f317e..e3c8ddd67d9 100644 --- a/src/renderer/src/components/settings/BaseRefPicker.tsx +++ b/src/renderer/src/components/settings/BaseRefPicker.tsx @@ -2,6 +2,11 @@ import { useEffect, useState } from 'react' import { ScrollArea } from '../ui/scroll-area' import { Button } from '../ui/button' import { Input } from '../ui/input' +import { useAppStore } from '@/store' +import { + getRuntimeRepoBaseRefDefault, + searchRuntimeRepoBaseRefs +} from '@/runtime/runtime-repo-client' type BaseRefPickerProps = { repoId: string @@ -16,6 +21,9 @@ export function BaseRefPicker({ onSelect, onUsePrimary }: BaseRefPickerProps): React.JSX.Element { + const activeRuntimeEnvironmentId = useAppStore( + (state) => state.settings?.activeRuntimeEnvironmentId ?? null + ) // Why: null until the IPC resolves (or when the repo has no default base ref // available). We avoid seeding with 'origin/main' because that would display // a fabricated default in repos that don't actually have origin/main. @@ -34,7 +42,7 @@ export function BaseRefPicker({ const loadDefaultBaseRef = async (): Promise => { try { - const result = await window.api.repos.getBaseRefDefault({ repoId }) + const result = await getRuntimeRepoBaseRefDefault({ activeRuntimeEnvironmentId }, repoId) if (!stale) { setDefaultBaseRef(result.defaultBaseRef) setRemoteCount(result.remoteCount) @@ -60,7 +68,7 @@ export function BaseRefPicker({ return () => { stale = true } - }, [repoId]) + }, [activeRuntimeEnvironmentId, repoId]) useEffect(() => { const trimmedQuery = baseRefQuery.trim() @@ -74,12 +82,7 @@ export function BaseRefPicker({ setIsSearchingBaseRefs(true) const timer = window.setTimeout(() => { - void window.api.repos - .searchBaseRefs({ - repoId, - query: trimmedQuery, - limit: 20 - }) + void searchRuntimeRepoBaseRefs({ activeRuntimeEnvironmentId }, repoId, trimmedQuery, 20) .then((results) => { if (!stale) { setBaseRefResults(results) @@ -102,7 +105,7 @@ export function BaseRefPicker({ stale = true window.clearTimeout(timer) } - }, [baseRefQuery, repoId]) + }, [activeRuntimeEnvironmentId, baseRefQuery, repoId]) const effectiveBaseRef = currentBaseRef ?? defaultBaseRef diff --git a/src/renderer/src/components/settings/ManageSessionsSection.tsx b/src/renderer/src/components/settings/ManageSessionsSection.tsx index 92148821647..8efeafa903d 100644 --- a/src/renderer/src/components/settings/ManageSessionsSection.tsx +++ b/src/renderer/src/components/settings/ManageSessionsSection.tsx @@ -96,6 +96,9 @@ function getConfirmCopy(confirm: PendingConfirm): { } export function ManageSessionsSection(): React.JSX.Element { + const activeRuntimeEnvironmentId = useAppStore( + (s) => s.settings?.activeRuntimeEnvironmentId ?? null + ) const [sessions, setSessions] = useState([]) const [isRefreshing, setIsRefreshing] = useState(true) const [hasLoadedOnce, setHasLoadedOnce] = useState(false) @@ -176,6 +179,14 @@ export function ManageSessionsSection(): React.JSX.Element { }, []) const refresh = useCallback(async (): Promise => { + if (activeRuntimeEnvironmentId?.trim()) { + if (isMounted.current) { + setSessions([]) + setIsRefreshing(false) + setHasLoadedOnce(true) + } + return [] + } setIsRefreshing(true) try { const result = await window.api.pty.management.listSessions() @@ -198,7 +209,7 @@ export function ManageSessionsSection(): React.JSX.Element { setHasLoadedOnce(true) } } - }, []) + }, [activeRuntimeEnvironmentId]) useEffect(() => { void refresh() @@ -275,6 +286,29 @@ export function ManageSessionsSection(): React.JSX.Element { const copy = useMemo(() => getConfirmCopy(confirm), [confirm]) const isBusy = busyKind !== null || daemonActions.isBusy + if (activeRuntimeEnvironmentId?.trim()) { + return ( +
+
+

Manage Sessions

+

+ Session management is unavailable while a remote runtime server is active. +

+
+ +
+ Switch back to the local runtime to restart or kill local daemon sessions. +
+
+
+ ) + } + return (
diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.tsx b/src/renderer/src/components/settings/RepositoryHooksSection.tsx index fc5b9505638..6e6cbf23fc9 100644 --- a/src/renderer/src/components/settings/RepositoryHooksSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHooksSection.tsx @@ -5,6 +5,8 @@ import { AlertTriangle } from 'lucide-react' import { toast } from 'sonner' import { Button } from '../ui/button' import { SearchableSetting } from './SearchableSetting' +import { useAppStore } from '@/store' +import { readRuntimeIssueCommand, writeRuntimeIssueCommand } from '@/runtime/runtime-hooks-client' type RepositoryHooksSectionProps = { repo: Repo @@ -154,6 +156,7 @@ export function RepositoryHooksSection({ onClearLegacyHooks, onUpdateSetupRunPolicy }: RepositoryHooksSectionProps): React.JSX.Element { + const settings = useAppStore((s) => s.settings) // Why: distinguish "file has unrecognised top-level keys" from "file is // genuinely malformed" so users see a helpful update prompt instead of a // confusing parse-error when a newer Orca version adds keys to `orca.yaml`. @@ -192,8 +195,7 @@ export function RepositoryHooksSection({ // Why: settings only edit the local override, but we still need to know // whether `orca.yaml` defines a shared default so the helper copy can // explain what happens when the override is blank. - void window.api.hooks - .readIssueCommand({ repoId }) + void readRuntimeIssueCommand(settings, repoId) .then((result) => { if (cancelled) { return @@ -215,18 +217,18 @@ export function RepositoryHooksSection({ cancelled = true const draft = issueCommandDraftRef.current.trim() if (draft !== lastCommittedIssueCommandRef.current) { - void window.api.hooks.writeIssueCommand({ repoId, content: draft }).catch((err) => { + void writeRuntimeIssueCommand(settings, repoId, draft).catch((err) => { console.error('[RepositoryHooksSection] Failed to save issue command on unmount:', err) }) } } - }, [repo.id]) + }, [repo.id, settings]) const commitIssueCommand = useCallback(async (): Promise => { const trimmed = issueCommandDraft.trim() setIssueCommandDraft(trimmed) try { - await window.api.hooks.writeIssueCommand({ repoId: repo.id, content: trimmed }) + await writeRuntimeIssueCommand(settings, repo.id, trimmed) lastCommittedIssueCommandRef.current = trimmed setIssueCommandSaveError(null) } catch (err) { @@ -235,7 +237,7 @@ export function RepositoryHooksSection({ setIssueCommandSaveError(message) toast.error(message) } - }, [issueCommandDraft, repo.id]) + }, [issueCommandDraft, repo.id, settings]) return (
diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx new file mode 100644 index 00000000000..b6254ba3226 --- /dev/null +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -0,0 +1,413 @@ +import { Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' +import type { GlobalSettings } from '../../../../shared/types' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { SearchableSetting } from './SearchableSetting' +import type { SettingsSearchEntry } from './settings-search' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../ui/dialog' + +const LOCAL_RUNTIME_VALUE = '__local__' + +export const RUNTIME_ENVIRONMENTS_SEARCH_ENTRY: SettingsSearchEntry = { + title: 'Active Server', + description: 'Choose local desktop or a saved remote Orca server.', + keywords: [ + 'runtime', + 'environment', + 'server', + 'client', + 'remote', + 'pairing', + 'cloud', + 'vm', + 'dev box' + ] +} + +type RuntimeEnvironmentsPaneProps = { + settings: GlobalSettings + switchRuntimeEnvironment: (environmentId: string | null) => Promise +} + +export function RuntimeEnvironmentsPane({ + settings, + switchRuntimeEnvironment +}: RuntimeEnvironmentsPaneProps): React.JSX.Element { + const [environments, setEnvironments] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [switchingValue, setSwitchingValue] = useState(null) + const [removingId, setRemovingId] = useState(null) + const [pendingSwitchValue, setPendingSwitchValue] = useState(null) + const [pendingRemove, setPendingRemove] = useState(null) + const [switchError, setSwitchError] = useState(null) + const [removeError, setRemoveError] = useState(null) + const [name, setName] = useState('') + const [pairingCode, setPairingCode] = useState('') + const activeValue = settings.activeRuntimeEnvironmentId ?? LOCAL_RUNTIME_VALUE + const isBusy = isSaving || switchingValue !== null || removingId !== null + const removingActiveServer = pendingRemove?.id === settings.activeRuntimeEnvironmentId + + const loadEnvironments = async (): Promise => { + setIsLoading(true) + try { + setEnvironments(await window.api.runtimeEnvironments.list()) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to load runtime environments.') + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + void loadEnvironments() + }, []) + + const addEnvironment = async (): Promise => { + const trimmedName = name.trim() + const trimmedPairingCode = pairingCode.trim() + if (!trimmedName || !trimmedPairingCode) { + toast.error('Name and pairing code are required.') + return + } + const duplicate = environments.find( + (environment) => environment.name.trim().toLowerCase() === trimmedName.toLowerCase() + ) + if (duplicate) { + toast.error(`A server named "${duplicate.name}" already exists.`) + return + } + setIsSaving(true) + try { + const result = await window.api.runtimeEnvironments.addFromPairingCode({ + name: trimmedName, + pairingCode: trimmedPairingCode + }) + setName('') + setPairingCode('') + await loadEnvironments() + toast.success(`Saved ${result.environment.name}. Use Active Server to switch when ready.`) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to save runtime environment.') + } finally { + setIsSaving(false) + } + } + + const removeEnvironment = async ( + environment: PublicKnownRuntimeEnvironment + ): Promise => { + setRemovingId(environment.id) + setRemoveError(null) + try { + if (settings.activeRuntimeEnvironmentId === environment.id) { + const switched = await switchRuntimeEnvironment(null) + if (!switched) { + setRemoveError('Could not switch to Local desktop. Fix the issue and try again.') + return false + } + } + await window.api.runtimeEnvironments.remove({ selector: environment.id }) + await loadEnvironments() + toast.success(`Removed ${environment.name}.`) + return true + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to remove runtime environment.' + setRemoveError(message) + toast.error(message) + return false + } finally { + setRemovingId(null) + } + } + + const switchToValue = async (value: string): Promise => { + setSwitchingValue(value) + setSwitchError(null) + try { + const switched = await switchRuntimeEnvironment(value === LOCAL_RUNTIME_VALUE ? null : value) + if (switched) { + toast.success(`Switched to ${getEnvironmentLabel(value)}.`) + return true + } + setSwitchError('Could not switch servers. Fix the issue and try again.') + return false + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to switch servers.' + setSwitchError(message) + toast.error(message) + return false + } finally { + setSwitchingValue(null) + } + } + + const getEnvironmentLabel = (value: string): string => { + if (value === LOCAL_RUNTIME_VALUE) { + return 'Local desktop' + } + return environments.find((environment) => environment.id === value)?.name ?? 'remote server' + } + + return ( + +
+
+ +

+ Local keeps today's desktop behavior. Saved servers route supported client calls + through the remote runtime. +

+
+
+ + +
+
+ +
+
+ + setName(event.target.value)} + placeholder="Dev box" + className="h-8 text-xs" + /> +
+
+ + setPairingCode(event.target.value)} + placeholder="orca://pair#..." + className="h-8 min-w-0 font-mono text-xs" + /> +

+ Run orca serve --pairing-address <host> on the + server and paste the printed pairing URL. +

+
+ +
+ +
+ {environments.length === 0 ? ( +
No saved servers.
+ ) : ( +
+ {environments.map((environment) => ( +
+
+
{environment.name}
+
+ {environment.endpoints[0]?.endpoint ?? 'No endpoint'} +
+
+ +
+ ))} +
+ )} +
+ + { + if (!open && switchingValue === null) { + setSwitchError(null) + setPendingSwitchValue(null) + } + }} + > + + + Switch Server + + Orca will close remote terminals and browser tabs from the current server before + loading projects from the next server. + + + {pendingSwitchValue ? ( +
+
Switch to
+
+ {getEnvironmentLabel(pendingSwitchValue)} +
+
+ ) : null} + {switchError ?

{switchError}

: null} + + + + +
+
+ + { + if (!open && removingId === null) { + setRemoveError(null) + setPendingRemove(null) + } + }} + > + + + Remove Server + + {removingActiveServer + ? 'Removing the active server first switches Orca back to Local desktop and closes remote terminals and browser tabs for that server.' + : 'This removes the saved server from Orca. It does not change the active server.'} + + + {pendingRemove ? ( +
+
{pendingRemove.name}
+
+ {pendingRemove.endpoints[0]?.endpoint ?? 'No endpoint'} +
+
+ ) : null} + {removeError ?

{removeError}

: null} + + + + +
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 4932313f9bf..9e0cc8cb531 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -4,6 +4,7 @@ import { BarChart3, Bell, Bot, + Cable, FlaskConical, GitBranch, Globe, @@ -59,11 +60,16 @@ import { } from './DeveloperPermissionsPane' import { ComputerUsePane, COMPUTER_USE_PANE_SEARCH_ENTRIES } from './ComputerUsePane' import { MobileSettingsPane, MOBILE_SETTINGS_PANE_SEARCH_ENTRIES } from './MobileSettingsPane' +import { + RuntimeEnvironmentsPane, + RUNTIME_ENVIRONMENTS_SEARCH_ENTRY +} from './RuntimeEnvironmentsPane' import { PrivacyPane } from './PrivacyPane' import { PRIVACY_PANE_SEARCH_ENTRIES } from './privacy-search' import { SettingsSidebar } from './SettingsSidebar' import { SettingsSection } from './SettingsSection' import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search' +import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' type SettingsNavTarget = | 'general' @@ -84,6 +90,7 @@ type SettingsNavTarget = | 'experimental' | 'agents' | 'orchestration' + | 'servers' | 'mobile' | 'repo' @@ -173,6 +180,7 @@ function isEditableTarget(target: EventTarget | null): boolean { function Settings(): React.JSX.Element { const settings = useAppStore((s) => s.settings) const updateSettings = useAppStore((s) => s.updateSettings) + const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment) const fetchSettings = useAppStore((s) => s.fetchSettings) const closeSettingsPage = useAppStore((s) => s.closeSettingsPage) const repos = useAppStore((s) => s.repos) @@ -355,7 +363,7 @@ function Settings(): React.JSX.Element { return [repo.id, { hasHooks: false, hooks: null, mayNeedUpdate: false }] as const } try { - const result = await window.api.hooks.check({ repoId: repo.id }) + const result = await checkRuntimeHooks(settings, repo.id) return [repo.id, result] as const } catch { return [repo.id, { hasHooks: false, hooks: null, mayNeedUpdate: false }] as const @@ -382,7 +390,7 @@ function Settings(): React.JSX.Element { return () => { stale = true } - }, [repos]) + }, [repos, settings]) const applyTheme = useCallback((theme: 'system' | 'dark' | 'light') => { applyDocumentTheme(theme) @@ -455,6 +463,14 @@ function Settings(): React.JSX.Element { icon: Network, searchEntries: ORCHESTRATION_PANE_SEARCH_ENTRIES }, + { + id: 'servers', + title: 'Servers', + description: 'Run this client locally or through a remote Orca server.', + icon: Server, + searchEntries: [RUNTIME_ENVIRONMENTS_SEARCH_ENTRY], + badge: 'Beta' + }, { id: 'mobile', title: 'Mobile', @@ -522,7 +538,7 @@ function Settings(): React.JSX.Element { id: 'ssh', title: 'SSH', description: 'Remote SSH connections.', - icon: Server, + icon: Cable, searchEntries: SSH_PANE_SEARCH_ENTRIES }, { @@ -826,6 +842,19 @@ function Settings(): React.JSX.Element { + + + + ([]) + const activeRuntimeEnvironmentId = useAppStore((s) => s.settings?.activeRuntimeEnvironmentId) const paths = repo.symlinkPaths ?? [] const queryTrimmed = query.trim().replace(/^\/+/, '') useEffect(() => { + if (activeRuntimeEnvironmentId?.trim()) { + setEntries([]) + return + } let cancelled = false void window.api.fs .readDir({ dirPath: repo.path, connectionId: repo.connectionId ?? undefined }) @@ -44,7 +50,7 @@ export function WorktreeSymlinksSection({ return () => { cancelled = true } - }, [repo.path, repo.connectionId]) + }, [activeRuntimeEnvironmentId, repo.path, repo.connectionId]) const filtered = useMemo(() => { const q = queryTrimmed.toLowerCase() diff --git a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx index 5678c9300a6..d24aeda7e0f 100644 --- a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx @@ -13,6 +13,7 @@ import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/di import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { isGitRepoKind } from '../../../../shared/repo-kind' import type { Repo } from '../../../../shared/types' @@ -46,6 +47,12 @@ export function useCreateRepo( }, []) const handlePickParent = useCallback(async () => { + if (useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim()) { + // Why: the native folder picker returns a client-local path. Runtime + // project creation needs an explicit server parent path. + toast.error('Enter a server parent path.') + return + } const dir = await window.api.repos.pickDirectory() if (dir) { setCreateParent(dir) @@ -63,11 +70,25 @@ export function useCreateRepo( setIsCreating(true) setCreateError(null) try { - const result = await window.api.repos.create({ - parentPath, - name, - kind: createKind - }) + const settings = useAppStore.getState().settings + const target = getActiveRuntimeTarget(settings) + const result = + target.kind === 'environment' + ? await callRuntimeRpc<{ repo: Repo } | { error: string }>( + target, + 'repo.create', + { + parentPath, + name, + kind: createKind + }, + { timeoutMs: 60_000 } + ) + : await window.api.repos.create({ + parentPath, + name, + kind: createKind + }) // Why: if the user closed the dialog or clicked Back mid-create, // createGenRef was bumped by resetCreateState. Ignore stale results. if (gen !== createGenRef.current) { @@ -147,6 +168,7 @@ export function useCreateRepo( createError, isCreating, setCreateName, + setCreateParent, setCreateKind, setCreateError, resetCreateState, @@ -235,7 +257,9 @@ type CreateStepProps = { createKind: RepoKind createError: string | null isCreating: boolean + manualParentEntry?: boolean onNameChange: (value: string) => void + onParentChange: (value: string) => void onKindChange: (kind: RepoKind) => void onPickParent: () => void onCreate: () => void @@ -247,7 +271,9 @@ export function CreateStep({ createKind, createError, isCreating, + manualParentEntry = false, onNameChange, + onParentChange, onKindChange, onPickParent, onCreate @@ -333,11 +359,21 @@ export function CreateStep({ />
- {/* Location. The "Choose…" button morphs into a summary + Change once picked. */} + {/* Location. The local flow uses a folder picker; runtime servers need + manual server-path entry because the client cannot browse that filesystem yet. */}
Location - {createParent ? ( + {manualParentEntry ? ( + onParentChange(e.target.value)} + placeholder="/home/user/projects" + className="h-11 text-sm font-mono" + disabled={isCreating} + spellCheck={false} + /> + ) : createParent ? (
diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index 7f207bf6224..1a83b4b45de 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -11,12 +11,14 @@ import { DialogDescription } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { track } from '@/lib/telemetry' import { RemoteStep, CloneStep, useRemoteRepo } from './AddRepoSteps' import { CreateStep, useCreateRepo } from './AddRepoCreateStep' import { SetupStep } from './AddRepoSetupStep' import { getDefaultCloneParent } from './clone-defaults' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { isGitRepoKind } from '../../../../shared/repo-kind' import type { Repo, Worktree } from '../../../../shared/types' @@ -24,6 +26,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const activeModal = useAppStore((s) => s.activeModal) const closeModal = useAppStore((s) => s.closeModal) const addRepo = useAppStore((s) => s.addRepo) + const addRepoPath = useAppStore((s) => s.addRepoPath) const repos = useAppStore((s) => s.repos) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) @@ -35,6 +38,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const [step, setStep] = useState<'add' | 'clone' | 'remote' | 'create' | 'setup'>('add') const [addedRepo, setAddedRepo] = useState(null) const [isAdding, setIsAdding] = useState(false) + const [serverPath, setServerPath] = useState('') + const [isAddingServerPath, setIsAddingServerPath] = useState(false) const [cloneUrl, setCloneUrl] = useState('') const [cloneDestination, setCloneDestination] = useState('') const [isCloning, setIsCloning] = useState(false) @@ -71,6 +76,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { createError, isCreating, setCreateName, + setCreateParent, setCreateKind, setCreateError, resetCreateState, @@ -95,15 +101,19 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { if (cloneDestination) { return } + if (settings?.activeRuntimeEnvironmentId?.trim()) { + return + } if (!settings?.workspaceDir) { return } cloneStepAutoFilledRef.current = true setCloneDestination(getDefaultCloneParent(settings.workspaceDir)) - }, [step, cloneDestination, settings?.workspaceDir]) + }, [step, cloneDestination, settings?.activeRuntimeEnvironmentId, settings?.workspaceDir]) const isOpen = activeModal === 'add-repo' const repoId = addedRepo?.id ?? '' + const isRuntimeEnvironmentActive = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) const worktrees = useMemo(() => { return worktreesByRepo[repoId] ?? [] @@ -127,6 +137,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { setStep('add') setAddedRepo(null) setIsAdding(false) + setServerPath('') + setIsAddingServerPath(false) setCloneUrl('') setCloneDestination('') setIsCloning(false) @@ -162,13 +174,42 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { } }, [addRepo, fetchWorktrees, closeModal]) + const handleAddServerPath = useCallback( + async (kind: 'git' | 'folder') => { + const path = serverPath.trim() + if (!path) { + return + } + setIsAddingServerPath(true) + try { + const repo = await addRepoPath(path, kind) + if (repo && isGitRepoKind(repo)) { + setAddedRepo(repo) + await fetchWorktrees(repo.id) + setStep('setup') + } else if (repo) { + closeModal() + } + } finally { + setIsAddingServerPath(false) + } + }, + [addRepoPath, closeModal, fetchWorktrees, serverPath] + ) + const handlePickDestination = useCallback(async () => { + if (settings?.activeRuntimeEnvironmentId?.trim()) { + // Why: the native folder picker returns a client-local path. Runtime + // clone destinations must be typed as server paths. + toast.error('Enter a server path for the clone destination.') + return + } const dir = await window.api.repos.pickDirectory() if (dir) { setCloneDestination(dir) setCloneError(null) } - }, []) + }, [settings?.activeRuntimeEnvironmentId]) const handleClone = useCallback(async () => { const trimmedUrl = cloneUrl.trim() @@ -180,10 +221,24 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { setCloneError(null) setCloneProgress(null) try { - const repo = (await window.api.repos.clone({ - url: trimmedUrl, - destination: cloneDestination.trim() - })) as Repo + const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const repo = + target.kind === 'environment' + ? ( + await callRuntimeRpc<{ repo: Repo }>( + target, + 'repo.clone', + { + url: trimmedUrl, + destination: cloneDestination.trim() + }, + { timeoutMs: 10 * 60_000 } + ) + ).repo + : ((await window.api.repos.clone({ + url: trimmedUrl, + destination: cloneDestination.trim() + })) as Repo) // Why: if the user closed the dialog or clicked Back during the clone, // cloneGenRef will have been bumped by resetState. Ignore this stale result. if (gen !== cloneGenRef.current) { @@ -310,7 +365,76 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
- {step === 'add' ? ( + {step === 'add' && isRuntimeEnvironmentActive ? ( + <> + + Add a server project + + Add a Git repository or folder that already exists on the selected runtime server. + + + +
+
+ + setServerPath(event.target.value)} + placeholder="/home/user/project" + className="h-11 text-sm font-mono" + disabled={isAddingServerPath} + autoFocus + spellCheck={false} + /> +
+
+ + +
+
+ + +
+
+ + ) : step === 'add' ? ( <> Add a project @@ -411,6 +535,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { cloneError={cloneError} cloneProgress={cloneProgress} isCloning={isCloning} + disableDestinationPicker={isRuntimeEnvironmentActive} onUrlChange={(value) => { setCloneUrl(value) setCloneError(null) @@ -429,10 +554,15 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { createKind={createKind} createError={createError} isCreating={isCreating} + manualParentEntry={isRuntimeEnvironmentActive} onNameChange={(value) => { setCreateName(value) setCreateError(null) }} + onParentChange={(value) => { + setCreateParent(value) + setCreateError(null) + }} onKindChange={(kind) => { setCreateKind(kind) setCreateError(null) diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.tsx b/src/renderer/src/components/sidebar/AddRepoSteps.tsx index a40c23c1400..aaf6eb78aee 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoSteps.tsx @@ -306,6 +306,7 @@ type CloneStepProps = { cloneError: string | null cloneProgress: { phase: string; percent: number } | null isCloning: boolean + disableDestinationPicker?: boolean onUrlChange: (value: string) => void onDestChange: (value: string) => void onPickDestination: () => void @@ -318,6 +319,7 @@ export function CloneStep({ cloneError, cloneProgress, isCloning, + disableDestinationPicker = false, onUrlChange, onDestChange, onPickDestination, @@ -369,7 +371,8 @@ export function CloneStep({ size="sm" className="h-8 px-2 shrink-0" onClick={onPickDestination} - disabled={isCloning} + disabled={isCloning || disableDestinationPicker} + title={disableDestinationPicker ? 'Enter a server path manually' : 'Choose folder'} > diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index e9f6a69d4f1..646506caf78 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useCallback, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' import { Badge } from '@/components/ui/badge' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { @@ -74,6 +75,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ const openModal = useAppStore((s) => s.openModal) const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch) + const settings = useAppStore((s) => s.settings) const fetchIssue = useAppStore((s) => s.fetchIssue) const cardProps = useAppStore((s) => s.worktreeCardProperties) const handleEditIssue = useCallback( @@ -175,7 +177,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ const branch = branchDisplayName(worktree.branch) const isFolder = repo ? isFolderRepo(repo) : false - const hostedReviewCacheKey = repo && branch ? `${repo.path}::${branch}` : '' + const hostedReviewCacheKey = + repo && branch ? getHostedReviewCacheKey(repo.path, branch, settings) : '' const issueCacheKey = repo && worktree.linkedIssue ? `${repo.path}::${worktree.linkedIssue}` : '' // Subscribe to ONLY the specific cache entry, not entire review/issue caches. diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index 59ac54b5245..31b9f77245b 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -27,6 +27,7 @@ import type { Worktree } from '../../../../shared/types' import { isFolderRepo } from '../../../../shared/repo-kind' import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flow' import { runSleepWorktrees } from './sleep-worktree-flow' +import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' type Props = { worktree: Worktree @@ -100,8 +101,16 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ }, []) const handleOpenInFinder = useCallback(() => { + if ( + isLocalPathOpenBlocked(useAppStore.getState().settings, { + connectionId: repo?.connectionId ?? null + }) + ) { + showLocalPathOpenBlockedToast() + return + } window.api.shell.openPath(worktree.path) - }, [worktree.path]) + }, [repo?.connectionId, worktree.path]) const handleCopyPath = useCallback(() => { window.api.ui.writeClipboardText(worktree.path) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index a58b538d123..c423a029b69 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -45,6 +45,7 @@ import { pruneWorktreeSelection, updateWorktreeSelection } from './worktree-multi-selection' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' // How long to wait after a sortEpoch bump before actually re-sorting. // Prevents jarring position shifts when background events (AI starting work, @@ -820,7 +821,15 @@ const WorktreeList = React.memo(function WorktreeList() { if (sortBy !== 'smart' || sortedIds.length === 0 || !sessionHasHadPty.current) { return } - void window.api.worktrees.persistSortOrder({ orderedIds: sortedIds }) + const target = getActiveRuntimeTarget(useAppStore.getState().settings) + void (target.kind === 'environment' + ? callRuntimeRpc( + target, + 'worktree.persistSortOrder', + { orderedIds: sortedIds }, + { timeoutMs: 15_000 } + ) + : window.api.worktrees.persistSortOrder({ orderedIds: sortedIds })) }, [sortedIds, sortBy]) // Flatten, filter, and apply stable sort order via the shared utility so diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index 8a09b74d6df..bf6ae9c7993 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -657,6 +657,10 @@ export function ResourceUsageStatusSegment({ const setActiveView = useAppStore((s) => s.setActiveView) const openSpacePage = useAppStore((s) => s.openSpacePage) const repos = useAppStore((s) => s.repos) + const activeRuntimeEnvironmentId = useAppStore( + (s) => s.settings?.activeRuntimeEnvironmentId ?? null + ) + const runtimeEnvironmentActive = Boolean(activeRuntimeEnvironmentId?.trim()) const [open, setOpen] = useState(false) const [sortOption, setSortOption] = useState('memory') @@ -667,6 +671,10 @@ export function ResourceUsageStatusSegment({ const [sessionsError, setSessionsError] = useState(false) const [killConfirm, setKillConfirm] = useState(null) const [killing, setKilling] = useState(false) + // Why: this segment only understands the local Electron PTY/resource daemon. + // While a runtime server is active, hiding local samples avoids showing or + // killing sessions from the wrong machine. + const resourceSnapshot = runtimeEnvironmentActive ? null : snapshot // Why: after a kill confirms and the session unmounts, focus would otherwise // fall to . We park a ref on the popover body so we can restore focus @@ -674,6 +682,11 @@ export function ResourceUsageStatusSegment({ const popoverBodyRef = useRef(null) const refreshSessions = useCallback(async () => { + if (runtimeEnvironmentActive) { + setSessions([]) + setSessionsError(false) + return + } try { const result = await window.api.pty.listSessions() setSessions(result) @@ -681,7 +694,7 @@ export function ResourceUsageStatusSegment({ } catch { setSessionsError(true) } - }, []) + }, [runtimeEnvironmentActive]) const daemonActions = useDaemonActions({ onRestartSettled: () => { @@ -698,7 +711,7 @@ export function ResourceUsageStatusSegment({ // background at a slower rate so the badge count stays reasonably fresh // without keeping the Memory IPC hot. useEffect(() => { - if (!open) { + if (!open || runtimeEnvironmentActive) { return } void fetchSnapshot() @@ -713,9 +726,14 @@ export function ResourceUsageStatusSegment({ return () => { window.clearInterval(memTimer) } - }, [open, fetchSnapshot, refreshSessions]) + }, [open, runtimeEnvironmentActive, fetchSnapshot, refreshSessions]) useEffect(() => { + if (runtimeEnvironmentActive) { + setSessions([]) + setSessionsError(false) + return + } const refreshIfVisible = (): void => { if (document.visibilityState === 'visible' && document.hasFocus()) { void refreshSessions() @@ -733,7 +751,7 @@ export function ResourceUsageStatusSegment({ window.removeEventListener('focus', refreshIfVisible) document.removeEventListener('visibilitychange', refreshIfVisible) } - }, [refreshSessions]) + }, [runtimeEnvironmentActive, refreshSessions]) const repoDisplayNameById = useMemo(() => { const map = new Map() @@ -766,8 +784,8 @@ export function ResourceUsageStatusSegment({ // feel laggy because the segment is always mounted in the status bar. const unifiedRepos = useMemo( () => - open - ? mergeSnapshotAndSessions(snapshot, sessions, { + open && !runtimeEnvironmentActive + ? mergeSnapshotAndSessions(resourceSnapshot, sessions, { tabsByWorktree, ptyIdsByTabId, runtimePaneTitlesByTabId, @@ -778,7 +796,8 @@ export function ResourceUsageStatusSegment({ : [], [ open, - snapshot, + runtimeEnvironmentActive, + resourceSnapshot, sessions, tabsByWorktree, ptyIdsByTabId, @@ -794,7 +813,7 @@ export function ResourceUsageStatusSegment({ // Build the bound set with a single flat walk instead of nested Object // iterations to keep this light on every store update. const orphanCount = useMemo(() => { - if (!workspaceSessionReady) { + if (!workspaceSessionReady || runtimeEnvironmentActive) { return 0 } const bound = new Set() @@ -812,32 +831,36 @@ export function ResourceUsageStatusSegment({ } } return n - }, [sessions, ptyIdsByTabId, workspaceSessionReady]) + }, [sessions, ptyIdsByTabId, workspaceSessionReady, runtimeEnvironmentActive]) const { totalMemory, totalCpu, hostShare, memBadgeLabel } = useMemo(() => { - const memory = snapshot?.totalMemory ?? 0 - const cpu = snapshot?.totalCpu ?? 0 - const hostTotal = snapshot?.host.totalMemory ?? 0 + const memory = resourceSnapshot?.totalMemory ?? 0 + const cpu = resourceSnapshot?.totalCpu ?? 0 + const hostTotal = resourceSnapshot?.host.totalMemory ?? 0 return { totalMemory: memory, totalCpu: cpu, hostShare: hostTotal > 0 ? (memory / hostTotal) * 100 : 0, - memBadgeLabel: snapshot ? formatMemory(memory) : '—' + memBadgeLabel: resourceSnapshot ? formatMemory(memory) : '—' } - }, [snapshot]) + }, [resourceSnapshot]) // Why: memorySnapshotError is null both for "last fetch succeeded" and // "never fetched". When the segment is mounted but the popover hasn't // been opened, fetchMemorySnapshot has never run, so a sessions IPC // failure on the always-on poll would otherwise be silent. Treat the // absence of any snapshot plus a sessions error as unreachable too. - const daemonUnreachable = sessionsError && (memorySnapshotError !== null || snapshot === null) + const daemonUnreachable = + !runtimeEnvironmentActive && + sessionsError && + (memorySnapshotError !== null || snapshot === null) // Why: a partial failure where the sessions IPC fails but the snapshot // IPC still works was silently invisible after the merge — the old // SessionsTabPanel surfaced it as "Terminal sessions unavailable". Show // a slim inline notice so the user understands why the session list is // empty/stale even though the resource numbers look fine. - const sessionsOnlyError = sessionsError && memorySnapshotError === null + const sessionsOnlyError = + !runtimeEnvironmentActive && sessionsError && memorySnapshotError === null const toggleRepo = useCallback((repoId: string): void => { setCollapsedRepos((prev) => { @@ -1060,7 +1083,7 @@ export function ResourceUsageStatusSegment({ - Restart daemon + {runtimeEnvironmentActive ? 'Unavailable for runtime servers' : 'Restart daemon'} @@ -1076,7 +1099,7 @@ export function ResourceUsageStatusSegment({ - Kill all sessions + {runtimeEnvironmentActive ? 'Unavailable for runtime servers' : 'Kill all sessions'}
@@ -1122,7 +1145,7 @@ export function ResourceUsageStatusSegment({
)} - {snapshot && ( + {resourceSnapshot && (
@@ -1182,7 +1205,7 @@ export function ResourceUsageStatusSegment({ inner tree owns its own scroll. The footer renders below this shell when orphan-bulk-kill is available. */}
- {(unifiedRepos.length > 0 || snapshot) && ( + {(unifiedRepos.length > 0 || resourceSnapshot) && (
@@ -1346,7 +1373,7 @@ export function ResourceUsageStatusSegment({ - + {!runtimeEnvironmentActive && } ) } diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index a33cc025318..1e24243f01b 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -22,7 +22,7 @@ import { basename, normalizeRelativePath } from '@/lib/path' import { getEditorDisplayLabel } from '@/components/editor/editor-labels' import { renameFileOnDisk } from '@/lib/rename-file' import { detectLanguage } from '@/lib/language-detect' -import { useWorktreeById } from '@/store/selectors' +import { useRepoById, useWorktreeById } from '@/store/selectors' import { useAppStore } from '@/store' import { STATUS_COLORS, STATUS_LABELS } from '../right-sidebar/status-display' import type { GitFileStatus } from '../../../../shared/types' @@ -35,6 +35,8 @@ import { type DropIndicator } from './drop-indicator' import { canOpenMarkdownPreview } from '@/components/editor/markdown-preview-controls' +import { showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' +import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard' const isMac = navigator.userAgent.includes('Mac') const isLinux = navigator.userAgent.includes('Linux') @@ -74,6 +76,7 @@ export default function EditorFileTab({ dropIndicator?: DropIndicator }): React.JSX.Element { const worktree = useWorktreeById(file.worktreeId) + const repo = useRepoById(worktree?.repoId ?? null) // Why: no transform/transition/isDragging styling — the drag design is // that tabs stay visually anchored; only the blue insertion bar moves. const { attributes, listeners, setNodeRef } = useSortable({ @@ -379,6 +382,7 @@ export default function EditorFileTab({ filePath: file.filePath, relativePath: file.relativePath, worktreeId: file.worktreeId, + runtimeEnvironmentId: file.runtimeEnvironmentId, language: resolvedLanguage }) }} @@ -407,6 +411,16 @@ export default function EditorFileTab({ { + if ( + shouldBlockEditorTabLocalOpen( + useAppStore.getState().settings, + file.runtimeEnvironmentId, + repo?.connectionId ?? null + ) + ) { + showLocalPathOpenBlockedToast() + return + } window.api.shell.openPath(file.filePath) }} > diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 7f15947cfc6..91eef724ba0 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -38,6 +38,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { listRuntimeProjectNotes } from '@/runtime/runtime-notes-client' import type { NoteSummary } from '../../../../shared/notes-types' const isMac = navigator.userAgent.includes('Mac') @@ -173,6 +174,7 @@ function TabBarInner({ const defaultWindowsPowerShellImplementation = useAppStore( (s) => s.settings?.terminalWindowsPowerShellImplementation ?? 'auto' ) + const settings = useAppStore((s) => s.settings) const [pwshAvailable, setPwshAvailable] = useState(false) const [projectNotes, setProjectNotes] = useState([]) const [projectNotesLoading, setProjectNotesLoading] = useState(false) @@ -237,7 +239,7 @@ function TabBarInner({ setProjectNotesLoading(true) setProjectNotesError(null) try { - const result = await window.api.notes.list({ + const result = await listRuntimeProjectNotes(settings, { projectId: context.projectId, worktreeId: context.worktreeId, limit: 100 @@ -250,7 +252,7 @@ function TabBarInner({ } } void refreshProjectNotes() - }, [newTabMenuOpen, onNewNotesTab, targetNotesWorktreeId]) + }, [newTabMenuOpen, onNewNotesTab, settings, targetNotesWorktreeId]) const terminalMap = useMemo(() => new Map(tabs.map((t) => [t.id, t])), [tabs]) const editorMap = useMemo( diff --git a/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts b/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts new file mode 100644 index 00000000000..21b850b21fa --- /dev/null +++ b/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard' + +describe('shouldBlockEditorTabLocalOpen', () => { + it('blocks remote-owned editor tabs even after the active runtime switches local', () => { + expect(shouldBlockEditorTabLocalOpen({ activeRuntimeEnvironmentId: null }, 'env-1', null)).toBe( + true + ) + }) + + it('blocks SSH editor tabs without a runtime owner', () => { + expect( + shouldBlockEditorTabLocalOpen({ activeRuntimeEnvironmentId: null }, undefined, 'ssh-1') + ).toBe(true) + }) + + it('allows local editor tabs without runtime or SSH ownership', () => { + expect( + shouldBlockEditorTabLocalOpen({ activeRuntimeEnvironmentId: null }, undefined, null) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.ts b/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.ts new file mode 100644 index 00000000000..37302887f6d --- /dev/null +++ b/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.ts @@ -0,0 +1,13 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { isLocalPathOpenBlocked } from '@/lib/local-path-open-guard' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' + +export function shouldBlockEditorTabLocalOpen( + settings: Pick | null | undefined, + fileRuntimeEnvironmentId: string | null | undefined, + connectionId: string | null | undefined +): boolean { + return isLocalPathOpenBlocked(settingsForRuntimeOwner(settings, fileRuntimeEnvironmentId), { + connectionId + }) +} diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index dabf845257f..593653ef4a2 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -16,6 +16,7 @@ import { requestEditorFileClose } from '../editor/editor-autosave' import { focusTerminalTabSurface } from '../../lib/focus-terminal-tab-surface' import { getProjectNotesEntityId } from '../../lib/open-project-notes-tab' import { requestProjectNotesTabClose } from '../../lib/project-notes-close-request' +import { linkRuntimeProjectNote, showRuntimeProjectNote } from '@/runtime/runtime-notes-client' export type GroupEditorItem = OpenFile & { tabId: string } export type GroupBrowserItem = BrowserTabState & { tabId: string } @@ -484,7 +485,8 @@ export function useTabGroupWorkspaceModel({ closeToRight, createSplitGroup, newBrowserTab: () => { - const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank' + const state = useAppStore.getState() + const defaultUrl = state.browserDefaultUrl ?? 'about:blank' createBrowserTab(worktreeId, defaultUrl, { title: 'New Browser Tab', focusAddressBar: true @@ -513,7 +515,13 @@ export function useTabGroupWorkspaceModel({ } try { const connectionId = getConnectionId(worktreeId) ?? undefined - const fileInfo = await createUntitledMarkdownFile(path, worktreeId, connectionId) + const settings = useAppStore.getState().settings + const fileInfo = await createUntitledMarkdownFile( + path, + worktreeId, + connectionId, + settings + ) openFile(fileInfo, { preview: false, targetGroupId: groupId }) } catch (err) { toast.error(extractIpcErrorMessage(err, 'Failed to create untitled markdown file.')) @@ -524,9 +532,19 @@ export function useTabGroupWorkspaceModel({ let label = 'Project Notes' if (noteId) { try { - const result = await window.api.notes.show({ projectId, worktreeId, note: noteId }) + const settings = useAppStore.getState().settings + const result = await showRuntimeProjectNote(settings, { + projectId, + worktreeId, + note: noteId + }) label = result.note.title - await window.api.notes.link({ projectId, worktreeId, note: noteId, kind: 'active' }) + await linkRuntimeProjectNote(settings, { + projectId, + worktreeId, + note: noteId, + kind: 'active' + }) } catch { label = 'Project Notes' } diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 3a3469b471a..909d1f91977 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -14,6 +14,7 @@ import TerminalSearch from '@/components/TerminalSearch' import type { PtyTransport } from './pty-transport' import { fitPanes, isWindowsUserAgent, shellEscapePath } from './pane-helpers' import { getConnectionId } from '@/lib/connection-context' +import { resolveTerminalDropTargetShell } from './terminal-drop-handler' import { EMPTY_LAYOUT, paneLeafId, serializeTerminalLayout } from './layout-serialization' import { applyExpandedLayoutTo, @@ -42,6 +43,12 @@ import { import { getDriverForPty, onDriverChange } from '@/lib/pane-manager/mobile-driver-state' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture' +import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { + getRemoteRuntimePtyEnvironmentId, + getRemoteRuntimeTerminalHandle +} from '@/runtime/runtime-terminal-stream' // Why: registry lives in a leaf module so the store slice can import it // without re-entering the `slice → TerminalPane → store → slice` cycle @@ -452,10 +459,10 @@ export default function TerminalPane({ executeClosePane(paneId) return } - void window.api.pty - .hasChildProcesses(ptyId) - .then((hasChildren) => { - if (hasChildren) { + const settings = useAppStore.getState().settings + void inspectRuntimeTerminalProcess(settings, ptyId) + .then((process) => { + if (process.hasChildProcesses) { setCloseConfirmPaneId(paneId) } else { executeClosePane(paneId) @@ -1081,20 +1088,21 @@ export default function TerminalPane({ if (!transport) { return } - // Why: the explorer passes the worktree-absolute path via a DOM - // MIME, so for SSH worktrees this is a remote POSIX path destined - // for the remote shell. Quote for the target shell (remote = posix) - // rather than the client OS; otherwise a Windows client dropping - // onto an SSH-Linux worktree would emit Windows-style quoting. - // Why: `typeof === 'string'` (not `!== null`) so an unhydrated - // store (`undefined`) is treated as local and falls through to - // client-OS quoting, rather than being misclassified as remote. - const isRemote = typeof getConnectionId(worktreeId) === 'string' - const targetShell: 'posix' | 'windows' = isRemote - ? 'posix' - : isWindowsUserAgent() - ? 'windows' - : 'posix' + const state = useAppStore.getState() + const worktreePath = + Object.values(state.worktreesByRepo ?? {}) + .flat() + .find((worktree) => worktree.id === worktreeId)?.path ?? + cwd ?? + filePath + const targetShell = resolveTerminalDropTargetShell({ + activeRuntimeEnvironmentId: state.settings?.activeRuntimeEnvironmentId, + worktreePath, + // Why: internal Explorer drags paste a worktree-absolute path + // directly into the target shell. Runtime drops use the runtime + // worktree's path shape; legacy SSH drops remain POSIX. + connectionId: getConnectionId(worktreeId) + }) transport.sendInput(shellEscapePath(filePath, targetShell)) // Move focus to the terminal so the user can keep typing where the // dropped path just landed. Without this, focus stays on the file @@ -1256,7 +1264,21 @@ export default function TerminalPane({ // active-mobile-subscriber path and the held-no-subscriber // path (docs/mobile-fit-hold.md), so the banner unmounts // and the PTY returns to desktop dims in either case. - void window.api.runtime.restoreTerminalFit(ptyId) + const remoteHandle = getRemoteRuntimeTerminalHandle(ptyId) + const environmentId = + getRemoteRuntimePtyEnvironmentId(ptyId) ?? + settingsRef.current?.activeRuntimeEnvironmentId ?? + null + if (remoteHandle && environmentId) { + void callRuntimeRpc( + { kind: 'environment', environmentId }, + 'terminal.restoreFit', + { terminal: remoteHandle }, + { timeoutMs: 15_000 } + ).catch(() => {}) + } else { + void window.api.runtime.restoreTerminalFit(ptyId).catch(() => {}) + } } }} > diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 518964f6274..ffc8cf5c70d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -24,7 +24,7 @@ type StoreState = { repos: { id: string; connectionId?: string | null }[] sshConnectionStates: Map cacheTimerByKey: Record - settings: { promptCacheTimerEnabled?: boolean } | null + settings: { promptCacheTimerEnabled?: boolean; activeRuntimeEnvironmentId?: string | null } | null codexRestartNoticeByPtyId: Record< string, { previousAccountLabel: string; nextAccountLabel: string } @@ -138,6 +138,19 @@ vi.mock('./pty-transport', () => ({ }) })) +vi.mock('./remote-runtime-pty-transport', () => ({ + createRemoteRuntimePtyTransport: vi.fn( + (_environmentId: string, options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + } + ) +})) + function createMockTransport(initialPtyId: string | null = null): MockTransport { let ptyId = initialPtyId return { @@ -957,6 +970,65 @@ describe('connectPanePty', () => { expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-local-detached') }) + it('attaches remote runtime PTY handles instead of creating a replacement terminal', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'remote:terminal-1' }] + }, + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'env-1' + } + } as StoreState + + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(transport.connect).not.toHaveBeenCalled() + expect(transport.attach).toHaveBeenCalledWith( + expect.objectContaining({ existingPtyId: 'remote:terminal-1' }) + ) + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:terminal-1') + }) + + it('constructs restored encoded remote PTYs with their owning runtime environment', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'remote:env-1@@terminal-1' }] + }, + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'env-2' + } + } as StoreState + + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('env-1', expect.any(Object)) + expect(transport.attach).toHaveBeenCalledWith( + expect.objectContaining({ existingPtyId: 'remote:env-1@@terminal-1' }) + ) + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:env-1@@terminal-1') + }) + it('persists a restarted pane PTY id and uses it on the next remount', async () => { const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index eac55170eba..544422456ca 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -6,6 +6,7 @@ import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { useAppStore } from '@/store' import type { PtyConnectResult } from './pty-transport' import { createIpcPtyTransport } from './pty-transport' +import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport' import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding' import type { PtyConnectionDeps } from './pty-connection-types' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' @@ -20,6 +21,7 @@ import { } from './layout-serialization' import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics' import { registerPtySerializer, registerPtyTitleSource } from './pty-buffer-serializer' +import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' import { discardTerminalOutput, flushTerminalOutput, @@ -30,6 +32,7 @@ import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' +const REMOTE_PTY_ID_PREFIX = 'remote:' // Why: when multiple panes/tabs need the same deferred SSH connection, // the first one calls ssh.connect() and subsequent ones must wait for it @@ -48,6 +51,10 @@ function formatSshSessionExpiredMessage(): string { return 'Previous SSH session expired. Start a new terminal to continue.' } +function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean { + return typeof ptyId === 'string' && ptyId.startsWith(REMOTE_PTY_ID_PREFIX) +} + function sshPromptConnectOutcomeForStatus( status: string | undefined, sawNonDisconnected: boolean @@ -340,7 +347,17 @@ export function connectPanePty( const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find((t) => t.id === deps.tabId) const shellOverride = tab?.shellOverride - const transport = createIpcPtyTransport({ + const restoredPtyIdForTransport = + deps.restoredLeafId && deps.restoredPtyIdByLeafId + ? (deps.restoredPtyIdByLeafId[deps.restoredLeafId] ?? null) + : null + const remoteRuntimeOwnerForTransport = + (restoredPtyIdForTransport + ? getRemoteRuntimePtyEnvironmentId(restoredPtyIdForTransport) + : null) ?? (tab?.ptyId ? getRemoteRuntimePtyEnvironmentId(tab.ptyId) : null) + const activeRuntimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() || null + const runtimeEnvironmentId = remoteRuntimeOwnerForTransport ?? activeRuntimeEnvironmentId + const transportOptions = { cwd: deps.cwd, env: paneEnv, command: paneStartup?.command, @@ -374,7 +391,10 @@ export function connectPanePty( const title = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] currentState.setAgentStatus(cacheKey, payload, title) } - }) + } + const transport = runtimeEnvironmentId + ? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions) + : createIpcPtyTransport(transportOptions) const hasExistingPaneTransport = deps.paneTransportsRef.current.size > 0 deps.paneTransportsRef.current.set(pane.id, transport) @@ -466,7 +486,9 @@ export function connectPanePty( if (!proposed || proposed.cols <= 0 || proposed.rows <= 0) { return } - window.api.pty.reportGeometry(currentPtyId, proposed.cols, proposed.rows) + if (!isRemoteRuntimePtyId(currentPtyId)) { + window.api.pty.reportGeometry(currentPtyId, proposed.cols, proposed.rows) + } } const geometryReportObserver = typeof ResizeObserver === 'undefined' @@ -580,9 +602,9 @@ export function connectPanePty( // calls from the same renderer. The cooperation gate at pty:spawn time // sees pendingByPaneKey populated. Settle/clear later echoes the gen // token captured here. See docs/mobile-prefer-renderer-scrollback.md. - const preSignalPromise = window.api.pty - .declarePendingPaneSerializer(cacheKey) - .catch(() => null) + const preSignalPromise = runtimeEnvironmentId + ? Promise.resolve(null) + : window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null) const spawnedRaw = transport.connect({ url: '', @@ -601,8 +623,10 @@ export function connectPanePty( typeof spawnedPtyId === 'string' ? spawnedPtyId : transport.getPtyId() const gen = await preSignalPromise if (typeof gen === 'number' && resolvedPtyId) { - registerPaneSerializerFor(resolvedPtyId) - void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) + if (!isRemoteRuntimePtyId(resolvedPtyId)) { + registerPaneSerializerFor(resolvedPtyId) + void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) + } } else if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) } @@ -742,7 +766,9 @@ export function connectPanePty( if (connectResult.coldRestore) { // Snapshot superseded the cold-restore payload — ack it so the // daemon does not redeliver it on the next reattach. - window.api.pty.ackColdRestore(ptyId) + if (!isRemoteRuntimePtyId(ptyId)) { + window.api.pty.ackColdRestore(ptyId) + } } } else if (connectResult?.replay) { // Relay replay holds the last 100 KB of raw output. The xterm may @@ -753,7 +779,9 @@ export function connectPanePty( writeReplayData(connectResult.replay) writeReplayData(POST_REPLAY_FOCUS_REPORTING_RESET) if (connectResult.coldRestore) { - window.api.pty.ackColdRestore(ptyId) + if (!isRemoteRuntimePtyId(ptyId)) { + window.api.pty.ackColdRestore(ptyId) + } } } else if (connectResult?.coldRestore) { // restoreScrollbackBuffers() already wrote the saved xterm buffer @@ -771,7 +799,9 @@ export function connectPanePty( // crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so // reset them to match the fresh shell's expectations. writeReplayData(POST_REPLAY_MODE_RESET) - window.api.pty.ackColdRestore(ptyId) + if (!isRemoteRuntimePtyId(ptyId)) { + window.api.pty.ackColdRestore(ptyId) + } } // Why: when a mobile-fit override is active, skip sending desktop dims // to the PTY — the PTY is already at phone dimensions and must stay there. @@ -782,7 +812,9 @@ export function connectPanePty( // Why: POSIX only delivers SIGWINCH when terminal dimensions actually // change. Sending it explicitly guarantees restored TUIs repaint at // the correct cursor position after snapshot replay. - window.api.pty.signal(ptyId, 'SIGWINCH') + if (!isRemoteRuntimePtyId(ptyId)) { + window.api.pty.signal(ptyId, 'SIGWINCH') + } scheduleRuntimeGraphSync() } @@ -946,9 +978,10 @@ export function connectPanePty( // cooperation gate uniformly applies to remote sessions. Issue // declare and connect back-to-back; Electron preserves order. See // docs/mobile-prefer-renderer-scrollback.md. - const preSignalPromise = window.api.pty - .declarePendingPaneSerializer(cacheKey) - .catch(() => null) + const preSignalPromise = + runtimeEnvironmentId || isRemoteRuntimePtyId(pendingSessionId) + ? Promise.resolve(null) + : window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null) let expiredReattachError = false const reattachPromise = transport.connect({ url: '', @@ -991,7 +1024,9 @@ export function connectPanePty( handleReattachResult(result, pendingSessionId) const gen = await preSignalPromise if (typeof gen === 'number') { - void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) + if (!isRemoteRuntimePtyId(pendingSessionId)) { + void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) + } } }) .catch(async (err) => { @@ -1051,6 +1086,7 @@ export function connectPanePty( // the reattach and spawn a fresh session instead. const deferredReattachSessionId = candidateReattachSessionId && + !isRemoteRuntimePtyId(candidateReattachSessionId) && isSessionOwnedByWorktree(candidateReattachSessionId, deps.worktreeId) ? candidateReattachSessionId : null @@ -1074,9 +1110,10 @@ export function connectPanePty( // channel preserves order. See // docs/mobile-prefer-renderer-scrollback.md (Renderer-side prerequisite // requirement #4). - const preSignalPromise = window.api.pty - .declarePendingPaneSerializer(cacheKey) - .catch(() => null) + const preSignalPromise = + runtimeEnvironmentId || isRemoteRuntimePtyId(deferredReattachSessionId) + ? Promise.resolve(null) + : window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null) let expiredReattachError = false const reattachPromise = transport.connect({ @@ -1112,7 +1149,9 @@ export function connectPanePty( handleReattachResult(result, deferredReattachSessionId) const gen = await preSignalPromise if (typeof gen === 'number') { - void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) + if (!isRemoteRuntimePtyId(deferredReattachSessionId)) { + void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) + } } }) .catch(async (err) => { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index dd6c0737429..252c2f018ba 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -1,5 +1,13 @@ /* oxlint-disable max-lines */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../../shared/terminal-stream-protocol' describe('createIpcPtyTransport', () => { const originalWindow = (globalThis as { window?: typeof window }).window @@ -553,3 +561,215 @@ describe('createIpcPtyTransport', () => { expect(transport.getPtyId()).toBeNull() }) }) + +describe('createRemoteRuntimePtyTransport', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + const runtimeCall = vi.fn() + const runtimeSubscribe = vi.fn() + let subscriptionCallbacks: { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } | null = null + let unsubscribe: { + unsubscribe: () => void + sendBinary: (bytes: Uint8Array) => void + } | null = null + let unsubscribeFn: ReturnType void>> | null = null + + beforeEach(() => { + vi.resetModules() + runtimeCall.mockReset() + runtimeSubscribe.mockReset() + subscriptionCallbacks = null + unsubscribeFn = vi.fn<() => void>() + unsubscribe = { + unsubscribe: unsubscribeFn, + sendBinary: vi.fn() + } + runtimeCall.mockResolvedValue({ + id: 'rpc-create', + ok: true, + result: { + terminal: { + handle: 'term-remote', + worktreeId: 'repo1::/remote/wt', + title: null, + surface: 'background' + } + }, + _meta: { runtimeId: 'runtime-remote' } + }) + runtimeSubscribe.mockImplementation( + async (_args: unknown, callbacks: typeof subscriptionCallbacks) => { + subscriptionCallbacks = callbacks + queueMicrotask(() => { + subscriptionCallbacks?.onResponse({ + id: 'rpc-multiplex', + ok: true, + result: { type: 'ready' }, + _meta: { runtimeId: 'runtime-remote' } + }) + }) + return unsubscribe + } + ) + + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + runtimeEnvironments: { + ...originalWindow?.api?.runtimeEnvironments, + call: runtimeCall, + subscribe: runtimeSubscribe + } + } + } as unknown as typeof window + }) + + afterEach(() => { + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + function latestRemoteSubscribePayload(): { streamId: number } { + const send = unsubscribe?.sendBinary as unknown as + | { mock: { calls: [Uint8Array][] } } + | undefined + const frames = + send?.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe) ?? [] + const frame = frames.at(-1) + if (!frame) { + throw new Error('missing remote terminal subscribe frame') + } + const payload = decodeTerminalStreamJson<{ streamId: number }>(frame.payload) + if (!payload) { + throw new Error('invalid remote terminal subscribe frame') + } + return payload + } + + it('creates and subscribes to a terminal on the active remote runtime', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onReplayData = vi.fn() + const onData = vi.fn() + const onConnect = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'repo1::/remote/wt', + command: 'claude', + env: { ORCA_TAB_ID: 'tab-1' }, + tabId: 'tab-1', + leafId: 'pane:1' + }) + + const result = await transport.connect({ + url: '', + callbacks: { onReplayData, onData, onConnect } + }) + + expect(result).toEqual({ id: 'remote:env-1@@term-remote', replay: '' }) + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.create', + params: { + worktree: 'repo1::/remote/wt', + command: 'claude', + env: { ORCA_TAB_ID: 'tab-1' }, + focus: false + }, + timeoutMs: 15_000 + }) + expect(runtimeSubscribe).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'terminal.multiplex', + params: {} + }), + expect.any(Object) + ) + const { streamId } = latestRemoteSubscribePayload() + + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotStart, + streamId, + seq: 1, + payload: encodeTerminalStreamJson({ kind: 'scrollback' }) + }) + ) + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotChunk, + streamId, + seq: 2, + payload: encodeTerminalStreamText('hello') + }) + ) + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotEnd, + streamId, + seq: 3, + payload: new Uint8Array() + }) + ) + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId, + seq: 4, + payload: encodeTerminalStreamText(' world') + }) + ) + + expect(onReplayData).toHaveBeenCalledWith('hello') + expect(onConnect).toHaveBeenCalled() + expect(onData).toHaveBeenCalledWith(' world') + }) + + it('forwards input and cleanup through runtime RPC', async () => { + vi.useFakeTimers() + try { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'repo1::/remote/wt', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + await transport.connect({ url: '', callbacks: {} }) + const { streamId } = latestRemoteSubscribePayload() + runtimeCall.mockClear() + const send = unsubscribe?.sendBinary as unknown as { + mockClear: () => void + mock: { calls: [Uint8Array][] } + } + send.mockClear() + + expect(transport.sendInput('ls\r')).toBe(true) + await vi.runOnlyPendingTimersAsync() + expect(runtimeCall).not.toHaveBeenCalled() + const inputFrame = decodeTerminalStreamFrame(send.mock.calls[0][0]) + expect(inputFrame?.opcode).toBe(TerminalStreamOpcode.Input) + expect(inputFrame?.streamId).toBe(streamId) + + transport.disconnect() + expect(unsubscribeFn).toHaveBeenCalled() + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.close', + params: { terminal: 'term-remote' }, + timeoutMs: 15_000 + }) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 98aeff0354c..8ccc6e8a300 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -38,10 +38,165 @@ export type { export { extractLastOscTitle } from '../../../../shared/agent-detection' const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' +const STALE_TITLE_TIMEOUT = 3000 // ms before stale working title is cleared // Why: onAgentStatus callback added to IpcPtyTransportOptions in pty-dispatcher // so the OSC 9999 status payloads can be forwarded to the store. +type PtyOutputCallbacks = Parameters[0]['callbacks'] + +type PtyOutputProcessorOptions = Pick< + IpcPtyTransportOptions, + | 'onTitleChange' + | 'onBell' + | 'onAgentBecameIdle' + | 'onAgentBecameWorking' + | 'onAgentExited' + | 'onAgentStatus' +> + +type ProcessPtyOutputOptions = { + replayingBufferedData?: boolean + suppressAttentionEvents?: boolean +} + +export function createPtyOutputProcessor({ + onTitleChange, + onBell, + onAgentBecameIdle, + onAgentBecameWorking, + onAgentExited, + onAgentStatus +}: PtyOutputProcessorOptions): { + processData: ( + data: string, + callbacks: PtyOutputCallbacks, + options?: ProcessPtyOutputOptions + ) => void + clearAccumulatedState: () => void + clearStaleTitleTimer: () => void + resetBellDetector: () => void +} { + const bellDetector = createBellDetector() + const processAgentStatusChunk = createAgentStatusOscProcessor() + let lastEmittedTitle: string | null = null + let staleTitleTimer: ReturnType | null = null + const agentTracker = + onAgentBecameIdle || onAgentBecameWorking || onAgentExited + ? createAgentStatusTracker( + (title) => { + onAgentBecameIdle?.(title) + }, + onAgentBecameWorking, + onAgentExited + ) + : null + + function applyObservedTerminalTitle(title: string): void { + // Why: cursor-agent's native OSC title is the literal string "Cursor Agent" + // and it re-emits that title many times per turn (on every internal redraw) + // even while it's actively working. Orca drives the cursor spinner/unread + // path by injecting its own synthesized "⠋ Cursor Agent" and "Cursor ready" + // frames from the hook server (see src/main/index.ts). If we let cursor's + // bare title through, it lands in `runtimePaneTitlesByTabId` — where + // `getWorktreeStatus` reads from — and flips the sidebar dot back to solid + // within a second of the spinner appearing. Dropping the bare title before + // it reaches the store leaves the synthesized frame as the last-applied + // state until the next hook event overwrites it. Match is literal (trimmed, + // case-insensitive) so any task/chat title cursor auto-generates still + // passes through unchanged. + if (title.trim().toLowerCase() === 'cursor agent') { + return + } + lastEmittedTitle = normalizeTerminalTitle(title) + onTitleChange?.(lastEmittedTitle, title) + agentTracker?.handleTitle(title) + } + + function clearStaleTitleTimer(): void { + if (staleTitleTimer) { + clearTimeout(staleTitleTimer) + staleTitleTimer = null + } + } + + function processData( + data: string, + callbacks: PtyOutputCallbacks, + options: ProcessPtyOutputOptions = {} + ): void { + const suppressAttentionEvents = options.suppressAttentionEvents === true + // Why: OSC 9999 is a renderer-only control protocol. Parse it before + // xterm sees the bytes, and keep parser state across chunks so partial + // PTY reads do not drop valid status updates or print escape garbage. + const processed = processAgentStatusChunk(data) + data = processed.cleanData + // Why: mirror the onBell / onAgentBecameIdle guard below — during eager-buffer + // replay we must not surface stale agent-status payloads from a prior app + // session into the live store. The parser still consumes the bytes so they + // do not leak into xterm, we just suppress the callback. + if (onAgentStatus && !suppressAttentionEvents) { + for (const payload of processed.payloads) { + onAgentStatus(payload) + } + } + if (options.replayingBufferedData && callbacks.onReplayData) { + callbacks.onReplayData(data) + } else { + callbacks.onData?.(data) + } + if (onTitleChange) { + // Why: feed EVERY OSC title in the chunk through the observer, not just + // the last one. node-pty + the main-process 8ms batch window commonly + // coalesce multiple title updates into a single IPC payload — for Pi's + // 80ms spinner + agent_end idle cycle, the last title in the chunk is + // the idle one and the intermediate working frames were silently + // dropped, so the worktree card never observed the working state. + // Processing titles in order preserves the working→idle transition + // that detectAgentStatusFromTitle and agentTracker both key off. + const titles = extractAllOscTitles(data) + if (titles.length > 0) { + clearStaleTitleTimer() + for (const title of titles) { + applyObservedTerminalTitle(title) + } + } else if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') { + clearStaleTitleTimer() + staleTitleTimer = setTimeout(() => { + staleTitleTimer = null + if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') { + const cleared = clearWorkingIndicators(lastEmittedTitle) + lastEmittedTitle = cleared + onTitleChange(cleared, cleared) + agentTracker?.handleTitle(cleared) + } + }, STALE_TITLE_TIMEOUT) + } + } + // Why: BEL is the attention signal. The detector is stateful across + // chunks so a BEL sitting inside an OSC sequence (e.g. Claude's + // `\e]0;title\a`) is correctly ignored — only true terminal bells raise + // attention. suppressAttentionEvents gates this during eager-buffer replay + // so historical BELs do not produce fresh alerts on cold reattach. + if (onBell && bellDetector.chunkContainsBell(data) && !suppressAttentionEvents) { + onBell() + } + } + + function clearAccumulatedState(): void { + clearStaleTitleTimer() + agentTracker?.reset() + bellDetector.reset() + } + + return { + processData, + clearAccumulatedState, + clearStaleTitleTimer, + resetBellDetector: () => bellDetector.reset() + } +} + export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTransport { const { cwd, @@ -65,30 +220,24 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra let connected = false let destroyed = false let ptyId: string | null = null - const bellDetector = createBellDetector() // Why: eager PTY buffers contain output produced before the pane attached — // often from the previous app session. We still replay that data so titles // and scrollback restore correctly, but it must not produce fresh bells, // unread marks, or notifications for unrelated worktrees just because Orca // is reconnecting background terminals on launch. let suppressAttentionEvents = false - const processAgentStatusChunk = createAgentStatusOscProcessor() - let lastEmittedTitle: string | null = null - let staleTitleTimer: ReturnType | null = null - const agentTracker = - onAgentBecameIdle || onAgentBecameWorking || onAgentExited - ? createAgentStatusTracker( - (title) => { - if (!suppressAttentionEvents) { - onAgentBecameIdle?.(title) - } - }, - onAgentBecameWorking, - onAgentExited - ) - : null - - const STALE_TITLE_TIMEOUT = 3000 // ms before stale working title is cleared + const outputProcessor = createPtyOutputProcessor({ + onTitleChange, + onBell, + onAgentBecameIdle: (title) => { + if (!suppressAttentionEvents) { + onAgentBecameIdle?.(title) + } + }, + onAgentBecameWorking, + onAgentExited, + onAgentStatus + }) let storedCallbacks: Parameters[0]['callbacks'] = {} function unregisterPtyHandlers(id: string): void { @@ -103,27 +252,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra ptyReplayHandlers.delete(id) } - function applyObservedTerminalTitle(title: string): void { - // Why: cursor-agent's native OSC title is the literal string "Cursor Agent" - // and it re-emits that title many times per turn (on every internal redraw) - // even while it's actively working. Orca drives the cursor spinner/unread - // path by injecting its own synthesized "⠋ Cursor Agent" and "Cursor ready" - // frames from the hook server (see src/main/index.ts). If we let cursor's - // bare title through, it lands in `runtimePaneTitlesByTabId` — where - // `getWorktreeStatus` reads from — and flips the sidebar dot back to solid - // within a second of the spinner appearing. Dropping the bare title before - // it reaches the store leaves the synthesized frame as the last-applied - // state until the next hook event overwrites it. Match is literal (trimmed, - // case-insensitive) so any task/chat title cursor auto-generates still - // passes through unchanged. - if (title.trim().toLowerCase() === 'cursor agent') { - return - } - lastEmittedTitle = normalizeTerminalTitle(title) - onTitleChange?.(lastEmittedTitle, title) - agentTracker?.handleTitle(title) - } - // Why: true while we're replaying buffered/attach-time bytes into the // terminal. Routes those bytes through onReplayData so the renderer can // engage the replay guard — otherwise xterm auto-replies to embedded @@ -144,78 +272,15 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } }) ptyDataHandlers.set(id, (data) => { - // Why: OSC 9999 is a renderer-only control protocol. Parse it before - // xterm sees the bytes, and keep parser state across chunks so partial - // PTY reads do not drop valid status updates or print escape garbage. - const processed = processAgentStatusChunk(data) - data = processed.cleanData - // Why: mirror the onBell / onAgentBecameIdle guard below — during eager-buffer - // replay we must not surface stale agent-status payloads from a prior app - // session into the live store. The parser still consumes the bytes so they - // do not leak into xterm, we just suppress the callback. - if (onAgentStatus && !suppressAttentionEvents) { - for (const payload of processed.payloads) { - onAgentStatus(payload) - } - } - if (replayingBufferedData && storedCallbacks.onReplayData) { - storedCallbacks.onReplayData(data) - } else { - storedCallbacks.onData?.(data) - } - if (onTitleChange) { - // Why: feed EVERY OSC title in the chunk through the observer, not just - // the last one. node-pty + the main-process 8ms batch window commonly - // coalesce multiple title updates into a single IPC payload — for Pi's - // 80ms spinner + agent_end idle cycle, the last title in the chunk is - // the idle one and the intermediate working frames were silently - // dropped, so the worktree card never observed the working state. - // Processing titles in order preserves the working→idle transition - // that detectAgentStatusFromTitle and agentTracker both key off. - const titles = extractAllOscTitles(data) - if (titles.length > 0) { - if (staleTitleTimer) { - clearTimeout(staleTitleTimer) - staleTitleTimer = null - } - for (const title of titles) { - applyObservedTerminalTitle(title) - } - } else if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') { - if (staleTitleTimer) { - clearTimeout(staleTitleTimer) - } - staleTitleTimer = setTimeout(() => { - staleTitleTimer = null - if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') { - const cleared = clearWorkingIndicators(lastEmittedTitle) - lastEmittedTitle = cleared - onTitleChange(cleared, cleared) - agentTracker?.handleTitle(cleared) - } - }, STALE_TITLE_TIMEOUT) - } - } - // Why: BEL is the attention signal. The detector is - // stateful across chunks so a BEL sitting inside an OSC sequence - // (e.g. Claude's `\e]0;title\a`) is correctly ignored — only true - // terminal bells raise attention. suppressAttentionEvents gates this - // during the synchronous eager-buffer replay so a historical BEL - // captured from the prior session does not produce a fresh alert on - // cold reattach. - if (onBell && bellDetector.chunkContainsBell(data) && !suppressAttentionEvents) { - onBell() - } + outputProcessor.processData(data, storedCallbacks, { + replayingBufferedData, + suppressAttentionEvents + }) }) } function clearAccumulatedState(): void { - if (staleTitleTimer) { - clearTimeout(staleTitleTimer) - staleTitleTimer = null - } - agentTracker?.reset() - bellDetector.reset() + outputProcessor.clearAccumulatedState() } function registerPtyExitHandler(id: string): void { @@ -383,17 +448,14 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra // working→idle transition (and phantom cache-timer write) for a session // that was never live in this app instance. Cancel it so the replay has // no lingering side effects. - if (staleTitleTimer) { - clearTimeout(staleTitleTimer) - staleTitleTimer = null - } + outputProcessor.clearStaleTitleTimer() // Why: eager-buffered bytes may end mid-OSC (truncated/partial session // data), leaving bellDetector with inOsc = true. Without resetting, the // next real BEL in live data would be silently classified as an OSC // terminator and dropped. BEL is the sole attention signal per the PR // design, so this reset guards the attention pipeline against a silent // regression driven by replay state leaking into the live stream. - bellDetector.reset() + outputProcessor.resetBellDetector() } } bufferHandle.dispose() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts new file mode 100644 index 00000000000..cc86cd5df80 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts @@ -0,0 +1,81 @@ +export type RemoteRuntimePtyBatcher = { + push: (data: string) => void + flush: () => void + clear: () => void +} + +export type RemoteRuntimeViewportBatcher = { + queue: (cols: number, rows: number) => void + flush: () => void + clear: () => void +} + +export function createRemoteRuntimePtyTextBatcher( + delayMs: number, + onFlush: (text: string) => void +): RemoteRuntimePtyBatcher { + let pending = '' + let timer: ReturnType | null = null + + const clear = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + } + + const flush = (): void => { + const text = pending + pending = '' + clear() + if (text) { + onFlush(text) + } + } + + return { + push(data: string): void { + pending += data + if (!timer) { + timer = setTimeout(flush, delayMs) + } + }, + flush, + clear + } +} + +export function createRemoteRuntimeViewportBatcher( + delayMs: number, + onFlush: (cols: number, rows: number) => void +): RemoteRuntimeViewportBatcher { + let pending: { cols: number; rows: number } | null = null + let timer: ReturnType | null = null + + const clear = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + } + + const flush = (): void => { + const viewport = pending + pending = null + clear() + if (viewport) { + onFlush(viewport.cols, viewport.rows) + } + } + + return { + queue(cols: number, rows: number): void { + pending = { cols, rows } + if (!timer) { + timer = setTimeout(flush, delayMs) + } + }, + flush, + clear + } +} diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-binary-control.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-binary-control.ts new file mode 100644 index 00000000000..be400ee578f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-binary-control.ts @@ -0,0 +1,47 @@ +import { + TerminalStreamOpcode, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../../shared/terminal-stream-protocol' + +export type RemoteRuntimeBinarySender = (bytes: Uint8Array) => void + +export function sendRemoteRuntimeTerminalInputFrame( + sendBinary: RemoteRuntimeBinarySender | null, + streamId: number | null, + text: string +): boolean { + if (!sendBinary || streamId === null) { + return false + } + sendBinary( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId, + seq: 0, + payload: encodeTerminalStreamText(text) + }) + ) + return true +} + +export function sendRemoteRuntimeTerminalResizeFrame( + sendBinary: RemoteRuntimeBinarySender | null, + streamId: number | null, + cols: number, + rows: number +): boolean { + if (!sendBinary || streamId === null) { + return false + } + sendBinary( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Resize, + streamId, + seq: 0, + payload: encodeTerminalStreamJson({ cols, rows }) + }) + ) + return true +} diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts new file mode 100644 index 00000000000..c880d2c7c78 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -0,0 +1,512 @@ +/* eslint-disable max-lines -- Why: remote runtime PTY behavior spans JSON fallback, binary stream, lifecycle, and parser coverage; keeping the matrix together catches transport regressions. */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + decodeTerminalStreamText, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../../shared/terminal-stream-protocol' + +describe('createRemoteRuntimePtyTransport', () => { + const runtimeCall = vi.fn() + const runtimeSubscribe = vi.fn() + const subscriptionSendBinary = vi.fn() + let subscriptionCallbacks: { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } | null = null + + function emitMultiplexReady(): void { + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'ready' } + }) + } + + function latestSubscribePayload(): { + streamId: number + terminal: string + client: { id: string; type: string } + viewport?: { cols: number; rows: number } + } { + const frames = subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe) + const frame = frames.at(-1) + if (!frame) { + throw new Error('missing terminal subscribe frame') + } + const payload = decodeTerminalStreamJson<{ + streamId: number + terminal: string + client: { id: string; type: string } + viewport?: { cols: number; rows: number } + }>(frame.payload) + if (!payload) { + throw new Error('invalid terminal subscribe payload') + } + return payload + } + + function emitOutput(streamId: number, data: string): void { + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId, + seq: 1, + payload: encodeTerminalStreamText(data) + }) + ) + } + + function emitSnapshot(streamId: number, data: string): void { + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotStart, + streamId, + seq: 1, + payload: encodeTerminalStreamJson({ kind: 'scrollback' }) + }) + ) + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotChunk, + streamId, + seq: 2, + payload: encodeTerminalStreamText(data) + }) + ) + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotEnd, + streamId, + seq: 3, + payload: new Uint8Array() + }) + ) + } + + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + subscriptionCallbacks = null + subscriptionSendBinary.mockReset() + runtimeCall.mockResolvedValue({ ok: true, result: { terminal: { handle: 'terminal-1' } } }) + runtimeSubscribe.mockImplementation( + async (_args: unknown, callbacks: typeof subscriptionCallbacks) => { + subscriptionCallbacks = callbacks + queueMicrotask(emitMultiplexReady) + return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary } + } + ) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall, + subscribe: runtimeSubscribe + } + } + }) + }) + + it('attaches to an existing remote runtime terminal handle', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:terminal-1', + cols: 120, + rows: 40, + callbacks: { onError } + }) + + await vi.waitFor(() => { + expect(runtimeSubscribe).toHaveBeenCalled() + }) + + expect(onError).not.toHaveBeenCalled() + expect(transport.getPtyId()).toBe('remote:terminal-1') + expect(runtimeSubscribe).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'terminal.multiplex', + params: {} + }), + expect.any(Object) + ) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + expect(latestSubscribePayload()).toMatchObject({ + terminal: 'terminal-1', + client: { id: 'desktop:tab-1:pane:1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 } + }) + }) + + it('routes encoded restored terminal ids to their owning runtime environment', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-2', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-1', + cols: 120, + rows: 40, + callbacks: {} + }) + + await vi.waitFor(() => { + expect(runtimeSubscribe).toHaveBeenCalled() + }) + + expect(runtimeSubscribe).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'terminal.multiplex' + }), + expect.any(Object) + ) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + expect(latestSubscribePayload()).toMatchObject({ + terminal: 'terminal-1', + viewport: { cols: 120, rows: 40 } + }) + }) + + it('closes a remote terminal created after the pane was destroyed', async () => { + let resolveCreate: (value: unknown) => void = () => {} + runtimeCall.mockImplementation((args) => { + if (args.method === 'terminal.create') { + return new Promise((resolve) => { + resolveCreate = resolve + }) + } + return Promise.resolve({ ok: true, result: {} }) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + const connect = transport.connect({ url: '', callbacks: {} }) + transport.destroy?.() + resolveCreate({ ok: true, result: { terminal: { handle: 'terminal-late' } } }) + await connect + + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.close', + params: { terminal: 'terminal-late' }, + timeoutMs: 15_000 + }) + }) + + it('unsubscribes a remote terminal subscription that resolves after destroy', async () => { + let resolveSubscribe: (value: { + unsubscribe: () => void + sendBinary: typeof subscriptionSendBinary + }) => void = () => {} + const unsubscribe = vi.fn() + runtimeSubscribe.mockImplementation( + (_args: unknown, callbacks: typeof subscriptionCallbacks) => { + subscriptionCallbacks = callbacks + return new Promise<{ unsubscribe: () => void; sendBinary: typeof subscriptionSendBinary }>( + (resolve) => { + resolveSubscribe = (value) => { + resolve(value) + queueMicrotask(emitMultiplexReady) + } + } + ) + } + ) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + const connect = transport.connect({ url: '', callbacks: {} }) + await vi.waitFor(() => { + expect(runtimeSubscribe).toHaveBeenCalled() + }) + transport.destroy?.() + resolveSubscribe({ unsubscribe, sendBinary: subscriptionSendBinary }) + await connect + + expect(unsubscribe).toHaveBeenCalled() + expect(transport.getPtyId()).toBeNull() + }) + + it('processes remote data chunks through title, bell, and OSC 9999 handlers before onData', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onData = vi.fn() + const onTitleChange = vi.fn() + const onBell = vi.fn() + const onAgentStatus = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + onTitleChange, + onBell, + onAgentStatus + }) + + await transport.connect({ url: '', callbacks: { onData } }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const { streamId } = latestSubscribePayload() + emitOutput( + streamId, + 'before\x1b]9999;{"state":"working","prompt":"ship it","agentType":"codex"}\x07after\x1b]0;. Claude working\x07\x07' + ) + + expect(onAgentStatus).toHaveBeenCalledWith({ + state: 'working', + prompt: 'ship it', + agentType: 'codex' + }) + expect(onData).toHaveBeenCalledWith('beforeafter\x1b]0;. Claude working\x07\x07') + expect(onTitleChange).toHaveBeenCalledWith('. Claude working', '. Claude working') + expect(onBell).toHaveBeenCalledTimes(1) + }) + + it('processes binary remote data chunks through the terminal parser', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onData = vi.fn() + const onTitleChange = vi.fn() + const onBell = vi.fn() + const onAgentStatus = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + onTitleChange, + onBell, + onAgentStatus + }) + + await transport.connect({ url: '', callbacks: { onData } }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const { streamId } = latestSubscribePayload() + emitOutput( + streamId, + 'before\x1b]9999;{"state":"working","prompt":"ship it","agentType":"codex"}\x07after' + ) + + expect(onAgentStatus).toHaveBeenCalledWith({ + state: 'working', + prompt: 'ship it', + agentType: 'codex' + }) + expect(onData).toHaveBeenCalledWith('beforeafter') + }) + + it('does not report PTY exit when the remote runtime subscription closes', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onExit = vi.fn() + const onDisconnect = vi.fn() + const onPtyExit = vi.fn() + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1', + onPtyExit + }) + + await transport.connect({ url: '', callbacks: { onExit, onDisconnect, onError } }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + subscriptionCallbacks?.onClose?.() + + expect(onExit).not.toHaveBeenCalled() + expect(onDisconnect).not.toHaveBeenCalled() + expect(onPtyExit).not.toHaveBeenCalled() + expect(onError).toHaveBeenCalledWith('Remote Orca runtime closed the connection.') + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2)) + }) + + it('resubscribes with the latest pane viewport after the remote stream closes', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + await transport.connect({ url: '', cols: 80, rows: 24, callbacks: {} }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + expect(latestSubscribePayload().viewport).toEqual({ cols: 80, rows: 24 }) + + expect(transport.resize(132, 43)).toBe(true) + subscriptionCallbacks?.onClose?.() + + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => { + expect(latestSubscribePayload().viewport).toEqual({ cols: 132, rows: 43 }) + }) + }) + + it('coalesces rapid remote terminal input before sending it to the runtime', async () => { + vi.useFakeTimers() + try { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + await transport.connect({ url: '', callbacks: {} }) + const { streamId } = latestSubscribePayload() + runtimeCall.mockClear() + subscriptionSendBinary.mockClear() + + expect(transport.sendInput('a')).toBe(true) + expect(transport.sendInput('b')).toBe(true) + expect(runtimeCall).not.toHaveBeenCalled() + + await vi.runOnlyPendingTimersAsync() + + expect(runtimeCall).not.toHaveBeenCalled() + expect(subscriptionSendBinary).toHaveBeenCalledTimes(1) + const frame = decodeTerminalStreamFrame(subscriptionSendBinary.mock.calls[0][0]) + expect(frame?.opcode).toBe(TerminalStreamOpcode.Input) + expect(frame?.streamId).toBe(streamId) + expect(frame ? decodeTerminalStreamText(frame.payload) : '').toBe('ab') + } finally { + vi.useRealTimers() + } + }) + + it('sends coalesced terminal input as binary frames once the stream is established', async () => { + vi.useFakeTimers() + try { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + await transport.connect({ url: '', callbacks: {} }) + const { streamId } = latestSubscribePayload() + runtimeCall.mockClear() + subscriptionSendBinary.mockClear() + + expect(transport.sendInput('a')).toBe(true) + expect(transport.sendInput('b')).toBe(true) + await vi.runOnlyPendingTimersAsync() + + expect(runtimeCall).not.toHaveBeenCalled() + expect(subscriptionSendBinary).toHaveBeenCalledTimes(1) + const frame = decodeTerminalStreamFrame(subscriptionSendBinary.mock.calls[0][0]) + expect(frame?.opcode).toBe(TerminalStreamOpcode.Input) + expect(frame?.streamId).toBe(streamId) + expect(frame ? decodeTerminalStreamText(frame.payload) : '').toBe('ab') + } finally { + vi.useRealTimers() + } + }) + + it('coalesces rapid remote viewport updates before sending the latest size', async () => { + vi.useFakeTimers() + try { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + await transport.connect({ url: '', callbacks: {} }) + const { streamId } = latestSubscribePayload() + runtimeCall.mockClear() + subscriptionSendBinary.mockClear() + + expect(transport.resize(80, 24)).toBe(true) + expect(transport.resize(120, 40)).toBe(true) + expect(runtimeCall).not.toHaveBeenCalled() + + await vi.runOnlyPendingTimersAsync() + + expect(runtimeCall).not.toHaveBeenCalled() + expect(subscriptionSendBinary).toHaveBeenCalledTimes(1) + const frame = decodeTerminalStreamFrame(subscriptionSendBinary.mock.calls[0][0]) + expect(frame?.opcode).toBe(TerminalStreamOpcode.Resize) + expect(frame?.streamId).toBe(streamId) + expect(frame ? decodeTerminalStreamJson(frame.payload) : null).toEqual({ + cols: 120, + rows: 40 + }) + } finally { + vi.useRealTimers() + } + }) + + it('replays remote scrollback through the parser without firing stale attention events', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onReplayData = vi.fn() + const onTitleChange = vi.fn() + const onBell = vi.fn() + const onAgentStatus = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + onTitleChange, + onBell, + onAgentStatus + }) + + await transport.connect({ url: '', callbacks: { onReplayData } }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const { streamId } = latestSubscribePayload() + emitSnapshot( + streamId, + 'before\x1b]9999;{"state":"working","prompt":"old","agentType":"codex"}\x07after\x1b]0;Remote title\x07\x07' + ) + + expect(onReplayData).toHaveBeenCalledWith('beforeafter\x1b]0;Remote title\x07\x07') + expect(onTitleChange).toHaveBeenCalledWith('Remote title', 'Remote title') + expect(onAgentStatus).not.toHaveBeenCalled() + expect(onBell).not.toHaveBeenCalled() + }) + + it('replays binary snapshot chunks without firing stale attention events', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onReplayData = vi.fn() + const onTitleChange = vi.fn() + const onBell = vi.fn() + const onAgentStatus = vi.fn() + const onConnect = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + onTitleChange, + onBell, + onAgentStatus + }) + + await transport.connect({ url: '', callbacks: { onReplayData, onConnect } }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const { streamId } = latestSubscribePayload() + emitSnapshot( + streamId, + 'before\x1b]9999;{"state":"working","prompt":"old","agentType":"codex"}\x07after' + ) + + expect(onReplayData).toHaveBeenCalledWith('beforeafter') + expect(onAgentStatus).not.toHaveBeenCalled() + expect(onBell).not.toHaveBeenCalled() + expect(onConnect).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts new file mode 100644 index 00000000000..5f2d890a206 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -0,0 +1,324 @@ +/* eslint-disable max-lines -- Why: remote PTY transport keeps lifecycle, JSON fallback, and binary stream wiring together so reconnect/destroy ordering stays testable as one behavior surface. */ +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { RuntimeTerminalCreate } from '../../../../shared/runtime-types' +import type { PtyConnectResult, PtyTransport, IpcPtyTransportOptions } from './pty-dispatcher' +import { createPtyOutputProcessor } from './pty-transport' +import { unwrapRuntimeRpcResult } from '../../runtime/runtime-rpc-client' +import { + getRemoteRuntimePtyEnvironmentId, + getRemoteRuntimeTerminalHandle, + runtimeTerminalErrorMessage, + toRemoteRuntimePtyId +} from '../../runtime/runtime-terminal-stream' +import { + getRemoteRuntimeTerminalMultiplexer, + type RemoteRuntimeMultiplexedTerminal +} from '../../runtime/remote-runtime-terminal-multiplexer' +import { + createRemoteRuntimePtyTextBatcher, + createRemoteRuntimeViewportBatcher +} from './remote-runtime-pty-batching' +import { setFitOverride } from '@/lib/pane-manager/mobile-fit-overrides' +import { setDriverForPty } from '@/lib/pane-manager/mobile-driver-state' + +const REMOTE_TERMINAL_INPUT_FLUSH_MS = 8 +const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33 + +export function createRemoteRuntimePtyTransport( + runtimeEnvironmentId: string, + opts: IpcPtyTransportOptions = {} +): PtyTransport { + const { + command, + env, + worktreeId, + tabId, + leafId, + onPtyExit, + onPtySpawn, + onTitleChange, + onBell, + onAgentBecameIdle, + onAgentBecameWorking, + onAgentExited, + onAgentStatus + } = opts + let connected = false + let destroyed = false + let handle: string | null = null + let remotePtyId: string | null = null + let currentRuntimeEnvironmentId = runtimeEnvironmentId + let multiplexedStream: RemoteRuntimeMultiplexedTerminal | null = null + let desiredViewport: { cols: number; rows: number } | null = null + let storedCallbacks: Parameters[0]['callbacks'] = {} + let resubscribing = false + const clientId = `desktop:${tabId ?? 'tab'}:${leafId ?? 'leaf'}` + const outputProcessor = createPtyOutputProcessor({ + onTitleChange, + onBell, + onAgentBecameIdle, + onAgentBecameWorking, + onAgentExited, + onAgentStatus + }) + + async function callRuntime(method: string, params?: unknown): Promise { + const response = await window.api.runtimeEnvironments.call({ + selector: currentRuntimeEnvironmentId, + method, + params, + timeoutMs: 15_000 + }) + return unwrapRuntimeRpcResult(response as RuntimeRpcResponse) + } + + async function closeRemoteTerminal(handleOverride?: string): Promise { + const targetHandle = handleOverride ?? handle + if (!targetHandle) { + return + } + try { + await callRuntime('terminal.close', { terminal: targetHandle }) + } catch { + // Best-effort parity with local disconnect/kill. + } + } + + const inputBatcher = createRemoteRuntimePtyTextBatcher(REMOTE_TERMINAL_INPUT_FLUSH_MS, (text) => { + const targetHandle = handle + if (!connected || !targetHandle) { + return + } + if (multiplexedStream?.sendInput(text)) { + return + } + void callRuntime('terminal.send', { + terminal: targetHandle, + text, + client: { id: clientId, type: 'desktop' } + }) + }) + + function sendViewportUpdate(cols: number, rows: number): void { + const targetHandle = handle + if (!connected || !targetHandle) { + return + } + if (multiplexedStream?.resize(cols, rows)) { + return + } + void callRuntime('terminal.updateViewport', { + terminal: targetHandle, + client: { id: clientId, type: 'desktop' }, + viewport: { cols, rows } + }).catch(() => {}) + } + + const viewportBatcher = createRemoteRuntimeViewportBatcher( + REMOTE_TERMINAL_VIEWPORT_FLUSH_MS, + sendViewportUpdate + ) + + function rememberViewport(cols: number, rows: number): void { + desiredViewport = { cols, rows } + } + + async function subscribeToHandle(): Promise { + if (!handle) { + return + } + const subscribedHandle = handle + const nextStream = await getRemoteRuntimeTerminalMultiplexer( + currentRuntimeEnvironmentId + ).subscribeTerminal({ + terminal: subscribedHandle, + client: { id: clientId, type: 'desktop' }, + viewport: desiredViewport ?? undefined, + callbacks: { + onData: (data) => outputProcessor.processData(data, storedCallbacks), + onSnapshot: (data) => { + if (data) { + outputProcessor.processData(data, storedCallbacks, { + replayingBufferedData: true, + suppressAttentionEvents: true + }) + } + }, + onSubscribed: () => { + storedCallbacks.onConnect?.() + storedCallbacks.onStatus?.('shell') + }, + onEnd: () => { + outputProcessor.clearAccumulatedState() + connected = false + storedCallbacks.onExit?.(0) + storedCallbacks.onDisconnect?.() + if (remotePtyId) { + onPtyExit?.(remotePtyId) + } + }, + onError: (message) => storedCallbacks.onError?.(message), + onFitOverrideChanged: (event) => { + if (remotePtyId) { + setFitOverride(remotePtyId, event.mode, event.cols, event.rows) + } + }, + onDriverChanged: (driver) => { + if (remotePtyId) { + setDriverForPty(remotePtyId, driver) + } + }, + onTransportClose: () => { + multiplexedStream = null + if (destroyed || !connected || !handle || resubscribing) { + return + } + resubscribing = true + void subscribeToHandle() + .catch((error) => storedCallbacks.onError?.(runtimeTerminalErrorMessage(error))) + .finally(() => { + resubscribing = false + }) + } + } + }) + if (destroyed || !connected || handle !== subscribedHandle) { + nextStream.close() + return + } + multiplexedStream = nextStream + } + + return { + async connect(options) { + storedCallbacks = options.callbacks + if (destroyed || !worktreeId) { + return + } + + try { + const created = await callRuntime<{ terminal: RuntimeTerminalCreate }>('terminal.create', { + worktree: worktreeId, + command, + env, + focus: false + }) + handle = created.terminal.handle + if (destroyed) { + await closeRemoteTerminal(created.terminal.handle) + return + } + + remotePtyId = toRemoteRuntimePtyId(handle, currentRuntimeEnvironmentId) + connected = true + desiredViewport = { + cols: options.cols ?? 80, + rows: options.rows ?? 24 + } + onPtySpawn?.(remotePtyId) + + await subscribeToHandle() + if (destroyed || !connected || !remotePtyId) { + return + } + + return { + id: remotePtyId, + replay: '' + } satisfies PtyConnectResult + } catch (error) { + storedCallbacks.onError?.(runtimeTerminalErrorMessage(error)) + return undefined + } + }, + + attach(options) { + storedCallbacks = options.callbacks + currentRuntimeEnvironmentId = + getRemoteRuntimePtyEnvironmentId(options.existingPtyId) ?? runtimeEnvironmentId + handle = getRemoteRuntimeTerminalHandle(options.existingPtyId) + if (!handle) { + connected = false + remotePtyId = null + storedCallbacks.onError?.('Remote runtime terminal id is invalid.') + return + } + remotePtyId = options.existingPtyId + connected = true + desiredViewport = { + cols: options.cols ?? 80, + rows: options.rows ?? 24 + } + void subscribeToHandle().catch((error) => { + connected = false + storedCallbacks.onError?.(runtimeTerminalErrorMessage(error)) + }) + }, + + disconnect() { + inputBatcher.flush() + viewportBatcher.flush() + outputProcessor.clearAccumulatedState() + if (!connected && !handle) { + return + } + connected = false + const id = remotePtyId + multiplexedStream?.close() + multiplexedStream = null + void closeRemoteTerminal() + handle = null + remotePtyId = null + storedCallbacks.onDisconnect?.() + if (id) { + onPtyExit?.(id) + } + }, + + detach() { + inputBatcher.flush() + viewportBatcher.flush() + outputProcessor.clearAccumulatedState() + connected = false + multiplexedStream?.close() + multiplexedStream = null + storedCallbacks = {} + }, + + sendInput(data: string): boolean { + if (!connected || !handle) { + return false + } + // Why: remote terminal input currently crosses the runtime RPC boundary; + // coalescing same-frame key bursts avoids a per-keystroke remote round-trip. + inputBatcher.push(data) + return true + }, + + resize(cols: number, rows: number): boolean { + if (!connected || !handle) { + return false + } + rememberViewport(cols, rows) + // Why: xterm fit can emit resize bursts while the user drags panes or + // restores layouts. Remote runtimes only need the last viewport in a frame. + viewportBatcher.queue(cols, rows) + return true + }, + + isConnected() { + return connected + }, + + getPtyId() { + return remotePtyId + }, + + destroy() { + destroyed = true + this.disconnect() + inputBatcher.clear() + viewportBatcher.clear() + } + } +} diff --git a/src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts b/src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts index fe8f89c5f7c..3420f1806d1 100644 --- a/src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts +++ b/src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts @@ -3,7 +3,12 @@ import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd' function installGetCwd(fn: (id: string) => Promise): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only shim for window.api.pty.getCwd - ;(globalThis as any).window = { api: { pty: { getCwd: fn } } } + ;(globalThis as any).window = { + api: { + pty: { getCwd: fn }, + runtimeEnvironments: { call: vi.fn() } + } + } } describe('resolveSplitCwd', () => { @@ -104,4 +109,17 @@ describe('resolveSplitCwd', () => { expect(result).toBe('/worktree') expect(getCwd).not.toHaveBeenCalled() }) + + it('skips local PTY IPC for remote runtime PTY ids', async () => { + const getCwd = vi.fn() + installGetCwd(getCwd as unknown as (id: string) => Promise) + const result = await resolveSplitCwd({ + paneCwdMap: new Map(), + sourcePaneId: 1, + sourcePtyId: 'remote:term-1', + fallbackCwd: '/remote/worktree' + }) + expect(result).toBe('/remote/worktree') + expect(getCwd).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/terminal-pane/resolve-split-cwd.ts b/src/renderer/src/components/terminal-pane/resolve-split-cwd.ts index ce06202350c..300543d4718 100644 --- a/src/renderer/src/components/terminal-pane/resolve-split-cwd.ts +++ b/src/renderer/src/components/terminal-pane/resolve-split-cwd.ts @@ -3,6 +3,7 @@ // `/proc`-or-lsof-backed IPC fallback for shells that never emit OSC 7 (agent // TUIs, minimal sh). Both layers can legitimately come back empty, so the // helper always finishes by returning the caller's worktree-root fallback. +import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' export type PaneCwdEntry = { cwd: string; confirmed: boolean } @@ -32,7 +33,7 @@ export async function resolveSplitCwd(args: { // 2) Ask the PTY provider (/proc or lsof). Enforce a soft timeout // renderer-side so a slow SSH relay can't stall the split. - if (sourcePtyId) { + if (sourcePtyId && !isRemoteRuntimePtyId(sourcePtyId)) { try { const ipcCwd = await Promise.race([ window.api.pty.getCwd(sourcePtyId).catch(() => null), diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts new file mode 100644 index 00000000000..2a4a29743c5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + toastLoading: vi.fn(() => 'toast-1'), + toastDismiss: vi.fn(), + toastError: vi.fn(), + importExternalPathsToRuntime: vi.fn(), + storeState: { + settings: { activeRuntimeEnvironmentId: 'env-1' as string | null }, + worktreesByRepo: { + repo1: [{ id: 'wt-1', path: '/remote/repo' }] + } + } +})) + +vi.mock('sonner', () => ({ + toast: { + loading: mocks.toastLoading, + dismiss: mocks.toastDismiss, + error: mocks.toastError, + message: vi.fn() + } +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mocks.storeState + } +})) + +vi.mock('@/runtime/runtime-file-client', () => ({ + importExternalPathsToRuntime: mocks.importExternalPathsToRuntime +})) + +import { handleTerminalFileDrop, resolveTerminalDropTargetShell } from './terminal-drop-handler' + +describe('handleTerminalFileDrop', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } + mocks.storeState.worktreesByRepo = { + repo1: [{ id: 'wt-1', path: '/remote/repo' }] + } + }) + + it('uploads client-local drops into the active runtime before pasting paths', async () => { + mocks.importExternalPathsToRuntime.mockResolvedValue({ + results: [ + { + sourcePath: '/Users/me/logo.png', + status: 'imported', + destPath: '/remote/repo/.orca/drops/logo.png', + kind: 'file', + renamed: false + } + ] + }) + const sendInput = vi.fn() + const focus = vi.fn() + const manager = { + getActivePane: () => ({ id: 1, terminal: { focus } }), + getPanes: () => [] + } + const paneTransports = new Map([[1, { sendInput }]]) + + await handleTerminalFileDrop({ + manager: manager as never, + paneTransports: paneTransports as never, + worktreeId: 'wt-1', + cwd: undefined, + data: { paths: ['/Users/me/logo.png'], target: 'terminal' } + }) + + expect(mocks.importExternalPathsToRuntime).toHaveBeenCalledWith( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + ['/Users/me/logo.png'], + '/remote/repo/.orca/drops' + ) + expect(sendInput).toHaveBeenCalledWith('/remote/repo/.orca/drops/logo.png ') + expect(focus).toHaveBeenCalled() + expect(mocks.toastError).not.toHaveBeenCalled() + expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1') + }) + + it('uses Windows shell paths for forward-slash UNC runtime worktrees', async () => { + mocks.storeState.worktreesByRepo = { + repo1: [{ id: 'wt-1', path: '//server/share/repo' }] + } + mocks.importExternalPathsToRuntime.mockResolvedValue({ + results: [ + { + sourcePath: '/Users/me/logo.png', + status: 'imported', + destPath: '//server/share/repo\\.orca\\drops\\logo.png', + kind: 'file', + renamed: false + } + ] + }) + const sendInput = vi.fn() + const focus = vi.fn() + const manager = { + getActivePane: () => ({ id: 1, terminal: { focus } }), + getPanes: () => [] + } + const paneTransports = new Map([[1, { sendInput }]]) + + await handleTerminalFileDrop({ + manager: manager as never, + paneTransports: paneTransports as never, + worktreeId: 'wt-1', + cwd: undefined, + data: { paths: ['/Users/me/logo.png'], target: 'terminal' } + }) + + expect(mocks.importExternalPathsToRuntime).toHaveBeenCalledWith( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '//server/share/repo' + }, + ['/Users/me/logo.png'], + '\\\\server\\share\\repo\\.orca\\drops' + ) + expect(sendInput).toHaveBeenCalledWith('\\\\server\\share\\repo\\.orca\\drops\\logo.png ') + }) +}) + +describe('resolveTerminalDropTargetShell', () => { + it('uses runtime worktree path shape for active Windows runtimes', () => { + expect( + resolveTerminalDropTargetShell({ + activeRuntimeEnvironmentId: 'env-1', + worktreePath: '//Server/Share/Repo', + connectionId: null, + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' + }) + ).toBe('windows') + }) + + it('uses runtime worktree path shape for active POSIX runtimes', () => { + expect( + resolveTerminalDropTargetShell({ + activeRuntimeEnvironmentId: 'env-1', + worktreePath: '/home/orca/repo', + connectionId: null, + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + }) + ).toBe('posix') + }) + + it('keeps legacy SSH drops POSIX when no runtime environment is active', () => { + expect( + resolveTerminalDropTargetShell({ + activeRuntimeEnvironmentId: null, + worktreePath: 'C:\\repo', + connectionId: 'ssh-1', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + }) + ).toBe('posix') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts index 52220bf6af4..df4677e2719 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts @@ -5,6 +5,8 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { useAppStore } from '@/store' import { isWindowsUserAgent, shellEscapePath } from './pane-helpers' import type { PtyTransport } from './pty-transport' +import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' +import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' type Args = { manager: PaneManager @@ -14,6 +16,32 @@ type Args = { data: { paths: string[]; target: string; tabId?: string } } +export type TerminalTargetShell = 'posix' | 'windows' + +export function getTerminalTargetShellForWorktreePath(worktreePath: string): TerminalTargetShell { + return isWindowsPathLike(worktreePath) ? 'windows' : 'posix' +} + +export function resolveTerminalDropTargetShell({ + activeRuntimeEnvironmentId, + worktreePath, + connectionId, + userAgent +}: { + activeRuntimeEnvironmentId: string | null | undefined + worktreePath: string | null | undefined + connectionId: string | null | undefined + userAgent?: string +}): TerminalTargetShell { + if (activeRuntimeEnvironmentId?.trim() && worktreePath) { + return getTerminalTargetShellForWorktreePath(worktreePath) + } + if (typeof connectionId === 'string') { + return 'posix' + } + return isWindowsUserAgent(userAgent) ? 'windows' : 'posix' +} + /** * Handle a native file drop targeted at a terminal pane. * @@ -36,6 +64,51 @@ export async function handleTerminalFileDrop(args: Args): Promise { if (!transport) { return } + const settings = useAppStore.getState().settings + const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() + const worktreePath = resolveWorktreePath(worktreeId, cwd) + if (!worktreePath) { + toast.error('Worktree path not available.') + return + } + + if (activeRuntimeEnvironmentId) { + const targetShell = getTerminalTargetShellForWorktreePath(worktreePath) + const destinationDir = joinRuntimeDropDir(worktreePath) + const pending = toast.loading( + `Uploading ${data.paths.length} file${data.paths.length === 1 ? '' : 's'} to runtime…` + ) + try { + const { results } = await importExternalPathsToRuntime( + { + settings, + worktreeId, + worktreePath + }, + data.paths, + destinationDir + ) + const imported = results.filter((result) => result.status === 'imported') + const skipped = results.filter((result) => result.status === 'skipped') + const failed = results.filter((result) => result.status === 'failed') + const liveTransport = paneTransports.get(paneId) + if (liveTransport) { + for (const result of imported) { + const shellPath = isWindowsPathLike(worktreePath) + ? result.destPath.replace(/\//g, '\\') + : result.destPath + liveTransport.sendInput(`${shellEscapePath(shellPath, targetShell)} `) + } + pane.terminal.focus() + } + reportUploadSkipsAndFailures(skipped, failed) + } catch (err) { + toast.error(extractIpcErrorMessage(err, 'Failed to upload files.')) + } finally { + toast.dismiss(pending) + } + return + } // Why: `getConnectionId` returns `string` (SSH), `null` (local repo found), // or `undefined` (store not hydrated / worktree not found). Treat @@ -47,11 +120,11 @@ export async function handleTerminalFileDrop(args: Args): Promise { return } const isRemote = connectionId !== null - const targetShell: 'posix' | 'windows' = isRemote - ? 'posix' - : isWindowsUserAgent() - ? 'windows' - : 'posix' + const targetShell = resolveTerminalDropTargetShell({ + activeRuntimeEnvironmentId: null, + worktreePath, + connectionId + }) // Why: local fast path — no IPC round-trip, no toast — preserves today's // zero-latency drop behavior. Trailing space separates multiple paths in @@ -64,12 +137,6 @@ export async function handleTerminalFileDrop(args: Args): Promise { return } - const worktreePath = resolveWorktreePath(worktreeId, cwd) - if (!worktreePath) { - toast.error('Worktree path not available.') - return - } - const pending = toast.loading( `Uploading ${data.paths.length} file${data.paths.length === 1 ? '' : 's'} to remote…` ) @@ -90,22 +157,7 @@ export async function handleTerminalFileDrop(args: Args): Promise { } pane.terminal.focus() } - if (skipped.length > 0) { - // Why: symlink rejection is policy, not error — show as neutral - // message. Mixed skips collapse to a single "items" count to avoid - // enumerating every reason. - const symlinkCount = skipped.filter((s) => s.reason === 'symlink').length - const noun = skipped.length === 1 ? 'item' : 'items' - toast.message( - symlinkCount === skipped.length - ? `Skipped ${skipped.length} symlink${skipped.length === 1 ? '' : 's'}.` - : `Skipped ${skipped.length} ${noun}.` - ) - } - if (failed.length > 0) { - const noun = failed.length === 1 ? 'file' : 'files' - toast.error(`Failed to upload ${failed.length} ${noun}.`) - } + reportUploadSkipsAndFailures(skipped, failed) } catch (err) { toast.error(extractIpcErrorMessage(err, 'Failed to upload files.')) } finally { @@ -113,9 +165,42 @@ export async function handleTerminalFileDrop(args: Args): Promise { } } +function reportUploadSkipsAndFailures( + skipped: { reason: string }[], + failed: { reason: string }[] +): void { + if (skipped.length > 0) { + // Why: symlink rejection is policy, not error — show as neutral + // message. Mixed skips collapse to a single "items" count to avoid + // enumerating every reason. + const symlinkCount = skipped.filter((s) => s.reason === 'symlink').length + const noun = skipped.length === 1 ? 'item' : 'items' + toast.message( + symlinkCount === skipped.length + ? `Skipped ${skipped.length} symlink${skipped.length === 1 ? '' : 's'}.` + : `Skipped ${skipped.length} ${noun}.` + ) + } + if (failed.length > 0) { + const noun = failed.length === 1 ? 'file' : 'files' + toast.error(`Failed to upload ${failed.length} ${noun}.`) + } +} + function resolveWorktreePath(worktreeId: string, fallbackCwd: string | undefined): string | null { const state = useAppStore.getState() const allWorktrees = Object.values(state.worktreesByRepo ?? {}).flat() const worktree = allWorktrees.find((w) => w.id === worktreeId) return worktree?.path ?? fallbackCwd ?? null } + +function joinRuntimeDropDir(worktreePath: string): string { + if (isWindowsPathLike(worktreePath)) { + return `${worktreePath.replace(/[\\/]+$/, '').replace(/\//g, '\\')}\\.orca\\drops` + } + return `${worktreePath.replace(/[\\/]+$/, '')}/.orca/drops` +} + +function isWindowsPathLike(path: string): boolean { + return isWindowsAbsolutePathLike(path) || path.includes('\\') +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index d767824bf70..5aa8b5766e2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: terminal link routing has intertwined local, +SSH, and runtime cases; keeping them in one suite prevents fixture drift. */ import type { IDisposable, ILink } from '@xterm/xterm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PaneManager } from '@/lib/pane-manager/pane-manager' @@ -9,18 +11,29 @@ import { openDetectedFilePath } from './terminal-link-handlers' import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing' +import { getConnectionId } from '@/lib/connection-context' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '@/runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' const openUrlMock = vi.fn() const openFileUriMock = vi.fn() +const openFilePathMock = vi.fn() const openFileMock = vi.fn() const authorizeExternalPathMock = vi.fn() const statMock = vi.fn().mockResolvedValue({ isDirectory: false }) +const runtimeEnvironmentCallMock = vi.fn() +const runtimeEnvironmentTransportCallMock = vi.fn() const setActiveWorktreeMock = vi.fn() const createBrowserTabMock = vi.fn() const deps = { worktreeId: 'wt-1', worktreePath: '/tmp' } const storeState = { - settings: undefined as { openLinksInApp?: boolean } | undefined, + settings: undefined as + | { openLinksInApp?: boolean; activeRuntimeEnvironmentId?: string | null } + | undefined, setActiveWorktree: setActiveWorktreeMock, createBrowserTab: createBrowserTabMock, openFile: openFileMock @@ -44,12 +57,22 @@ vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() })) +vi.mock('@/lib/connection-context', () => ({ + getConnectionId: vi.fn(() => null) +})) + function setPlatform(userAgent: string): void { vi.stubGlobal('navigator', { userAgent }) } beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() vi.clearAllMocks() + runtimeEnvironmentTransportCallMock.mockReset() + runtimeEnvironmentTransportCallMock.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCallMock(args) + }) + vi.mocked(getConnectionId).mockReturnValue(null) storeState.settings = undefined registerHttpLinkStoreAccessor(() => storeState) vi.stubGlobal('window', { @@ -57,12 +80,14 @@ beforeEach(() => { shell: { openUrl: openUrlMock, openFileUri: openFileUriMock, + openFilePath: openFilePathMock, pathExists: vi.fn().mockResolvedValue(true) }, fs: { authorizeExternalPath: authorizeExternalPathMock, stat: statMock - } + }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCallMock } } }) }) @@ -240,6 +265,127 @@ describe('handleOscLink', () => { }) ) }) + + it('stats remote-runtime file links through the active runtime environment', async () => { + setPlatform('Macintosh') + storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } + runtimeEnvironmentCallMock.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: { size: 1, isDirectory: false, mtime: 1 }, + _meta: { runtimeId: 'remote-runtime' } + }) + + openDetectedFilePath('/tmp/src/main.ts', null, null, deps) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(authorizeExternalPathMock).not.toHaveBeenCalled() + expect(statMock).not.toHaveBeenCalled() + await vi.waitFor(() => { + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.stat', + params: { worktree: 'wt-1', relativePath: 'src/main.ts' }, + timeoutMs: 15_000 + }) + }) + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/tmp/src/main.ts', + relativePath: 'src/main.ts' + }) + ) + }) + + it('stats remote-runtime file links through the owning PTY runtime environment', async () => { + setPlatform('Macintosh') + storeState.settings = { activeRuntimeEnvironmentId: 'env-2' } + runtimeEnvironmentCallMock.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: { size: 1, isDirectory: false, mtime: 1 }, + _meta: { runtimeId: 'remote-runtime' } + }) + + openDetectedFilePath('/tmp/src/main.ts', null, null, { + ...deps, + runtimeEnvironmentId: 'env-1' + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + await vi.waitFor(() => { + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.stat', + params: { worktree: 'wt-1', relativePath: 'src/main.ts' }, + timeoutMs: 15_000 + }) + }) + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/tmp/src/main.ts', + relativePath: 'src/main.ts', + runtimeEnvironmentId: 'env-1' + }) + ) + }) + + it('opens SSH file links through Orca without local authorization', async () => { + setPlatform('Macintosh') + vi.mocked(getConnectionId).mockReturnValue('ssh-1') + + openDetectedFilePath('/home/me/repo/src/main.ts', null, null, { + worktreeId: 'wt-1', + worktreePath: '/home/me/repo' + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(authorizeExternalPathMock).not.toHaveBeenCalled() + expect(statMock).toHaveBeenCalledWith({ + filePath: '/home/me/repo/src/main.ts', + connectionId: 'ssh-1' + }) + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/home/me/repo/src/main.ts', + relativePath: 'src/main.ts' + }) + ) + }) + + it('does not open SSH html file links as client-local file browser tabs', async () => { + setPlatform('Macintosh') + vi.mocked(getConnectionId).mockReturnValue('ssh-1') + + openDetectedFilePath('/home/me/repo/report.html', null, null, { + worktreeId: 'wt-1', + worktreePath: '/home/me/repo' + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(createBrowserTabMock).not.toHaveBeenCalled() + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/home/me/repo/report.html', + relativePath: 'report.html' + }) + ) + }) + + it('does not ask the client OS to open SSH directories', async () => { + setPlatform('Macintosh') + vi.mocked(getConnectionId).mockReturnValue('ssh-1') + statMock.mockResolvedValueOnce({ isDirectory: true }) + + openDetectedFilePath('/home/me/repo/src', null, null, { + worktreeId: 'wt-1', + worktreePath: '/home/me/repo' + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(openFilePathMock).not.toHaveBeenCalled() + expect(openFileMock).not.toHaveBeenCalled() + }) }) describe('createFilePathLinkProvider range bounds', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 13af7f80be9..582fac1e131 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -13,6 +13,13 @@ import { absolutePathToFileUri } from '@/components/editor/markdown-internal-lin import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { openHttpLink } from '@/lib/http-link-routing' +import { + isRemoteRuntimeFileOperation, + runtimePathExists, + statRuntimePath, + type RuntimeFileOperationArgs +} from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' export type LinkHandlerDeps = { worktreeId: string @@ -21,6 +28,8 @@ export type LinkHandlerDeps = { managerRef: React.RefObject linkProviderDisposablesRef: React.RefObject> pathExistsCache: Map + runtimeEnvironmentId?: string | null + getRuntimeEnvironmentIdForPane?: (paneId: number) => string | null } type TerminalLinkEvent = Pick & @@ -64,28 +73,47 @@ function openHtmlFileInBrowser(filePath: string, worktreeId: string): void { store.createBrowserTab(worktreeId, fileUrl, { title, activate: true }) } +function getTerminalFileContext( + worktreeId: string, + worktreePath: string, + runtimeEnvironmentId?: string | null +): RuntimeFileOperationArgs { + const settings = useAppStore.getState().settings + return { + settings: settingsForRuntimeOwner(settings, runtimeEnvironmentId), + worktreeId: worktreeId || null, + worktreePath, + connectionId: getConnectionId(worktreeId || null) ?? undefined + } +} + export function openDetectedFilePath( filePath: string, line: number | null, column: number | null, - deps: Pick + deps: Pick ): void { - const { worktreeId, worktreePath } = deps + const { runtimeEnvironmentId, worktreeId, worktreePath } = deps void (async () => { let statResult try { - const connectionId = getConnectionId(deps.worktreeId ?? null) ?? undefined - // Why: remote paths don't need local auth — the relay is the security boundary. - if (!connectionId) { + const fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId) + const isRemoteRuntimePath = isRemoteRuntimeFileOperation(fileContext, filePath) + // Why: remote paths don't need local auth — the relay/runtime is the security boundary. + if (!fileContext.connectionId && !isRemoteRuntimePath) { await window.api.fs.authorizeExternalPath({ targetPath: filePath }) } - statResult = await window.api.fs.stat({ filePath, connectionId }) + statResult = await statRuntimePath(fileContext, filePath) } catch { return } if (statResult.isDirectory) { + const fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId) + if (fileContext.connectionId || isRemoteRuntimeFileOperation(fileContext, filePath)) { + return + } await window.api.shell.openFilePath(filePath) return } @@ -94,7 +122,12 @@ export function openDetectedFilePath( // as source in Monaco — ⌘/Ctrl+click on an HTML path in the terminal should // feel like clicking an http link and render the page, not dump HTML source. // Mirrors the editor's "Open Preview to the Side" action. - if (isHtmlFilePath(filePath)) { + const fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId) + if ( + isHtmlFilePath(filePath) && + !fileContext.connectionId && + !isRemoteRuntimeFileOperation(fileContext, filePath) + ) { openHtmlFileInBrowser(filePath, worktreeId) return } @@ -120,7 +153,8 @@ export function openDetectedFilePath( relativePath, worktreeId: worktreeId || '', language: detectLanguage(filePath), - mode: 'edit' + mode: 'edit', + runtimeEnvironmentId: runtimeEnvironmentId ?? undefined }) if (line !== null) { @@ -172,9 +206,18 @@ export function createFilePathLinkProvider( return null } - const cachedExists = pathExistsCache.get(resolved.absolutePath) - const exists = cachedExists ?? (await window.api.shell.pathExists(resolved.absolutePath)) - pathExistsCache.set(resolved.absolutePath, exists) + const runtimeEnvironmentId = + deps.getRuntimeEnvironmentIdForPane?.(paneId) ?? deps.runtimeEnvironmentId ?? null + const cacheKey = `${runtimeEnvironmentId ?? 'active'}\0${resolved.absolutePath}` + const cachedExists = pathExistsCache.get(cacheKey) + const fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId) + const exists = + cachedExists ?? + (fileContext.connectionId || + isRemoteRuntimeFileOperation(fileContext, resolved.absolutePath) + ? await runtimePathExists(fileContext, resolved.absolutePath) + : await window.api.shell.pathExists(resolved.absolutePath)) + pathExistsCache.set(cacheKey, exists) if (!exists) { return null } @@ -197,7 +240,8 @@ export function createFilePathLinkProvider( } openDetectedFilePath(resolved.absolutePath, resolved.line, resolved.column, { worktreeId, - worktreePath + worktreePath, + runtimeEnvironmentId }) }, hover: () => { @@ -235,7 +279,7 @@ export function handleOscLink( rawText: string, event: TerminalLinkEvent | undefined, deps: Pick & - Partial> + Partial> ): void { if (!isTerminalLinkActivation(event)) { return diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 5823891ad7f..af06537c444 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -38,6 +38,7 @@ import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-optio import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' import { connectPanePty } from './pty-connection' import type { PtyTransport } from './pty-transport' +import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard' import { fitAndFocusPanes, fitPanes } from './pane-helpers' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' @@ -296,7 +297,11 @@ export function useTerminalPaneLifecycle({ startupCwd, managerRef, linkProviderDisposablesRef, - pathExistsCache + pathExistsCache, + getRuntimeEnvironmentIdForPane: (paneId) => { + const ptyId = paneTransportsRef.current.get(paneId)?.getPtyId() + return ptyId ? getRemoteRuntimePtyEnvironmentId(ptyId) : null + } } let resizeRaf: number | null = null const queueResizeAll = (focusActive: boolean): void => { @@ -488,7 +493,10 @@ export function useTerminalPaneLifecycle({ pane.terminal.options.linkHandler = { allowNonHttpProtocols: true, activate: (event, text) => { - handleOscLink(text, event as MouseEvent | undefined, linkDeps) + handleOscLink(text, event as MouseEvent | undefined, { + ...linkDeps, + runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null + }) // Why: Cmd/Ctrl+clicking a link activates Orca handling (open file, // new browser tab, system browser) which can steal focus from the // terminal before the click's mouseup reaches ownerDocument. Without @@ -685,7 +693,13 @@ export function useTerminalPaneLifecycle({ if (!event) { return } - void handleOscLink(url, event, linkDeps) + const activePane = managerRef.current?.getActivePane() + void handleOscLink(url, event, { + ...linkDeps, + runtimeEnvironmentId: activePane + ? (linkDeps.getRuntimeEnvironmentIdForPane?.(activePane.id) ?? null) + : null + }) // Why: Cmd/Ctrl+click on a plain-text URL (WebLinksAddon) takes focus // away from the terminal before the click's mouseup reaches // ownerDocument. That leaves xterm's SelectionService drag-select diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 88c2e46b317..3acdc7f96b4 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -17,6 +17,7 @@ import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agen import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { tuiAgentToAgentKind } from '@/lib/telemetry' import { isGitRepoKind } from '../../../shared/repo-kind' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { GitHubWorkItem, GitPushTarget, @@ -49,6 +50,9 @@ import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-sug import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths' +import { joinPath } from '@/lib/path' +import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' +import { checkRuntimeHooks, readRuntimeIssueCommand } from '@/runtime/runtime-hooks-client' export type UseComposerStateOptions = { initialRepoId?: string @@ -392,6 +396,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // multi-instance routing). const agentPromptRef = useRef(agentPrompt) agentPromptRef.current = agentPrompt + const connectionIdRef = useRef(connectionId) + connectionIdRef.current = connectionId const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId) @@ -404,18 +410,30 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS null ) const selectedRepoPath = selectedRepo?.path + const selectedRepoPathRef = useRef(selectedRepoPath) + selectedRepoPathRef.current = selectedRepoPath + const settingsRef = useRef(settings) + settingsRef.current = settings useEffect(() => { - if (!selectedRepoPath) { + if (!selectedRepo || !selectedRepoPath) { setSelectedRepoSlug(null) return } let cancelled = false - void ( - window.api.gh.repoSlug({ repoPath: selectedRepoPath }) as Promise<{ - owner: string - repo: string - } | null> - ) + const target = getActiveRuntimeTarget(settings) + const request = + target.kind === 'environment' + ? callRuntimeRpc<{ owner: string; repo: string } | null>( + target, + 'github.repoSlug', + { repo: selectedRepo.id }, + { timeoutMs: 30_000 } + ) + : (window.api.gh.repoSlug({ repoPath: selectedRepoPath }) as Promise<{ + owner: string + repo: string + } | null>) + void request .then((result) => { if (cancelled) { return @@ -430,7 +448,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return () => { cancelled = true } - }, [selectedRepoPath]) + }, [selectedRepo, selectedRepoPath, settings]) const sparsePresetsForRepo = sparsePresetsByRepo[repoId] const sparsePresets = sparsePresetsForRepo ?? EMPTY_SPARSE_PRESETS const normalizedSparseDirectories = useMemo( @@ -702,8 +720,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setYamlHooks(null) setCheckedHooksRepoId(null) - void window.api.hooks - .check({ repoId }) + void checkRuntimeHooks(settings, repoId) .then((result) => { if (!cancelled) { setYamlHooks(result.hooks) @@ -717,8 +734,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } }) - void window.api.hooks - .readIssueCommand({ repoId }) + void readRuntimeIssueCommand(settings, repoId) .then((result) => { if (!cancelled) { setIssueCommandTemplate(result.effectiveContent ?? '') @@ -735,7 +751,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return () => { cancelled = true } - }, [repoId]) + }, [repoId, settings]) // Why: warm the Start-from picker's PR cache on composer mount and whenever // the selected repo changes so opening the picker paints instantly from @@ -779,8 +795,17 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setLinkItemsLoading(true) const lookupRepoId = selectedRepo.id - void window.api.gh - .listWorkItems({ repoPath: selectedRepo.path, limit: 100 }) + const target = getActiveRuntimeTarget(settings) + const request = + target.kind === 'environment' + ? callRuntimeRpc>>( + target, + 'github.listWorkItems', + { repo: selectedRepo.id, limit: 100 }, + { timeoutMs: 30_000 } + ) + : window.api.gh.listWorkItems({ repoPath: selectedRepo.path, limit: 100 }) + void request .then((envelope) => { if (!cancelled) { // Why: IPC payload omits repoId — stamp it here from the repo we @@ -827,7 +852,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return () => { cancelled = true } - }, [linkPopoverOpen, selectedRepo]) + }, [linkPopoverOpen, selectedRepo, settings]) useEffect(() => { if (!linkPopoverOpen || !selectedRepo || normalizedLinkQuery.directNumber === null) { @@ -843,8 +868,20 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // resolving direct lookups against the selected repo instead of requiring a // text match in the recent-items list. const lookupRepoId = selectedRepo.id - void window.api.gh - .workItem({ repoPath: selectedRepo.path, number: normalizedLinkQuery.directNumber }) + const target = getActiveRuntimeTarget(settings) + const request = + target.kind === 'environment' + ? callRuntimeRpc>>( + target, + 'github.workItem', + { repo: selectedRepo.id, number: normalizedLinkQuery.directNumber }, + { timeoutMs: 30_000 } + ) + : window.api.gh.workItem({ + repoPath: selectedRepo.path, + number: normalizedLinkQuery.directNumber + }) + void request .then((item) => { if (!cancelled) { setLinkDirectItem( @@ -866,7 +903,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return () => { cancelled = true } - }, [linkPopoverOpen, normalizedLinkQuery.directNumber, selectedRepo]) + }, [linkPopoverOpen, normalizedLinkQuery.directNumber, selectedRepo, settings]) const applyLinkedWorkItem = useCallback( (item: GitHubWorkItem): void => { @@ -975,23 +1012,165 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }, [name] ) + + const addComposerAttachments = useCallback((paths: string[]): void => { + if (paths.length === 0) { + return + } + setAttachmentPaths((current) => { + const next = [...current] + for (const pathValue of paths) { + if (!next.includes(pathValue)) { + next.push(pathValue) + } + } + return next + }) + }, []) + + const insertComposerFolderPaths = useCallback((folderPaths: string[]): void => { + if (folderPaths.length === 0) { + return + } + // Why: de-dup within a single drop — the OS occasionally delivers the + // same folder twice when a user drags from a selection that includes both + // the item and its parent, and we don't want to insert it multiple times. + const uniqueFolderPaths = Array.from(new Set(folderPaths)) + // Why: wrap paths containing shell metacharacters in double quotes (and + // escape embedded quotes) so inserted folder refs stay a single token if + // pasted into a terminal. Simple paths stay unadorned to match OS drops. + const formatPath = (p: string): string => { + if (/[\s"'$`\\()[\]{}*?!;&|<>#~]/.test(p)) { + return `"${p.replace(/(["\\$`])/g, '\\$1')}"` + } + return p + } + const insertion = uniqueFolderPaths.map(formatPath).join(' ') + const textarea = promptTextareaRef.current + // Why: compute selection, insertion, and caret target OUTSIDE the + // setAgentPrompt updater so the updater stays pure. React Strict Mode + // double-invokes updaters in dev, and batching can delay execution. + const current = agentPromptRef.current + const selStart = textarea?.selectionStart ?? current.length + const selEnd = textarea?.selectionEnd ?? current.length + const before = current.slice(0, selStart) + const after = current.slice(selEnd) + // Why: pad with single spaces when the caret sits directly against other + // text so the folder path doesn't merge into an adjacent word. + const needsLeadingSpace = before.length > 0 && !/\s$/.test(before) + const needsTrailingSpace = after.length > 0 && !/^\s/.test(after) + const padded = `${needsLeadingSpace ? ' ' : ''}${insertion}${needsTrailingSpace ? ' ' : ''}` + const caret = before.length + padded.length + if (textarea) { + requestAnimationFrame(() => { + textarea.focus() + textarea.setSelectionRange(caret, caret) + }) + } + // Why: pass a plain value (not an updater) since `before`/`after` were + // already resolved from `agentPromptRef.current`; this keeps the state + // write side-effect-free under Strict-Mode double-render. + setAgentPrompt(before + padded + after) + }, []) + + const uploadComposerPaths = useCallback( + async ( + sourcePaths: string[], + targetSettings = settings, + targetConnectionId = connectionId, + targetRepoPath = selectedRepoPath + ): Promise<{ filePaths: string[]; folderPaths: string[] } | null> => { + if (!targetSettings?.activeRuntimeEnvironmentId?.trim() && !targetConnectionId) { + return null + } + if (!targetRepoPath) { + toast.error('No remote repository path is available for attachments.') + return { filePaths: [], folderPaths: [] } + } + const destinationDir = joinPath(targetRepoPath, '.orca/drops') + const { results } = await importExternalPathsToRuntime( + { + settings: targetSettings, + worktreeId: targetRepoPath, + worktreePath: targetRepoPath, + connectionId: targetConnectionId ?? undefined + }, + sourcePaths, + destinationDir, + { ensureDestinationDir: true } + ) + const filePaths: string[] = [] + const folderPaths: string[] = [] + let skippedOrFailed = 0 + for (const result of results) { + if (result.status !== 'imported') { + skippedOrFailed += 1 + continue + } + if (result.kind === 'directory') { + folderPaths.push(result.destPath) + } else { + filePaths.push(result.destPath) + } + } + if (skippedOrFailed > 0) { + toast.error('Some attachments could not be uploaded.') + } + return { filePaths, folderPaths } + }, + [connectionId, selectedRepoPath, settings] + ) + const handleAddAttachment = useCallback(async (): Promise => { try { const selectedPath = await window.api.shell.pickAttachment() if (!selectedPath) { return } - setAttachmentPaths((current) => { - if (current.includes(selectedPath)) { - return current - } - return [...current, selectedPath] - }) + const uploaded = await uploadComposerPaths([selectedPath]) + if (uploaded) { + addComposerAttachments(uploaded.filePaths) + insertComposerFolderPaths(uploaded.folderPaths) + return + } + addComposerAttachments([selectedPath]) } catch (error) { const message = error instanceof Error ? error.message : 'Failed to add attachment.' toast.error(message) } - }, []) + }, [addComposerAttachments, insertComposerFolderPaths, uploadComposerPaths]) + + const applyLocalComposerDrop = useCallback( + async (paths: string[]): Promise => { + const fileAttachments: string[] = [] + const folderPaths: string[] = [] + for (const filePath of paths) { + try { + await window.api.fs.authorizeExternalPath({ targetPath: filePath }) + const stat = await window.api.fs.stat({ filePath }) + if (stat.isDirectory) { + folderPaths.push(filePath) + } else { + fileAttachments.push(filePath) + } + } catch { + // Skip paths we cannot authorize or stat. + } + } + + addComposerAttachments(fileAttachments) + insertComposerFolderPaths(folderPaths) + }, + [addComposerAttachments, insertComposerFolderPaths] + ) + const addComposerAttachmentsRef = useRef(addComposerAttachments) + addComposerAttachmentsRef.current = addComposerAttachments + const insertComposerFolderPathsRef = useRef(insertComposerFolderPaths) + insertComposerFolderPathsRef.current = insertComposerFolderPaths + const uploadComposerPathsRef = useRef(uploadComposerPaths) + uploadComposerPathsRef.current = uploadComposerPaths + const applyLocalComposerDropRef = useRef(applyLocalComposerDrop) + applyLocalComposerDropRef.current = applyLocalComposerDrop // Why: native OS file drops onto the composer are captured by the preload // bridge (see `data-native-file-drop-target="composer"` markers) and relayed @@ -1015,82 +1194,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return } void (async () => { - const fileAttachments: string[] = [] - const folderPaths: string[] = [] - for (const filePath of data.paths) { - try { - await window.api.fs.authorizeExternalPath({ targetPath: filePath }) - const stat = await window.api.fs.stat({ filePath }) - if (stat.isDirectory) { - folderPaths.push(filePath) - } else { - fileAttachments.push(filePath) - } - } catch { - // Skip paths we cannot authorize or stat. - } - } - - if (fileAttachments.length > 0) { - setAttachmentPaths((current) => { - const next = [...current] - for (const p of fileAttachments) { - if (!next.includes(p)) { - next.push(p) - } - } - return next - }) - } - - if (folderPaths.length > 0) { - // Why: de-dup within a single drop — the OS occasionally delivers - // the same folder twice when a user drags from a selection that - // includes both the item and its parent, and we don't want to - // insert it multiple times. - const uniqueFolderPaths = Array.from(new Set(folderPaths)) - // Why: wrap paths containing shell metacharacters in double quotes - // (and escape embedded quotes) so the inserted text reads as a - // single token if the user pastes it into a terminal. Simple paths - // stay unadorned to match how Finder/Explorer drops appear. - const formatPath = (p: string): string => { - if (/[\s"'$`\\()[\]{}*?!;&|<>#~]/.test(p)) { - return `"${p.replace(/(["\\$`])/g, '\\$1')}"` - } - return p - } - const insertion = uniqueFolderPaths.map(formatPath).join(' ') - const textarea = promptTextareaRef.current - // Why: compute selection, insertion, and caret target OUTSIDE the - // setAgentPrompt updater so the updater stays pure. React Strict - // Mode double-invokes updaters in dev, and batching can delay - // execution — reading `textarea.selectionStart` inside the updater - // risks seeing a shifted caret. Read `agentPromptRef.current` for - // the latest prompt because this effect subscribes once and the - // outer closure's `agentPrompt` would be stale. - const current = agentPromptRef.current - const selStart = textarea?.selectionStart ?? current.length - const selEnd = textarea?.selectionEnd ?? current.length - const before = current.slice(0, selStart) - const after = current.slice(selEnd) - // Why: pad with single spaces when the caret sits directly against - // other text so the folder path doesn't merge into an adjacent word. - const needsLeadingSpace = before.length > 0 && !/\s$/.test(before) - const needsTrailingSpace = after.length > 0 && !/^\s/.test(after) - const padded = `${needsLeadingSpace ? ' ' : ''}${insertion}${needsTrailingSpace ? ' ' : ''}` - const caret = before.length + padded.length - if (textarea) { - // Restore the caret to the end of the inserted text after React flushes. - requestAnimationFrame(() => { - textarea.focus() - textarea.setSelectionRange(caret, caret) - }) - } - // Why: pass a plain value (not an updater) since `before`/`after` - // were already resolved from `agentPromptRef.current`; this keeps - // the state write side-effect-free under Strict-Mode double-render. - setAgentPrompt(before + padded + after) + const uploaded = await uploadComposerPathsRef.current( + data.paths, + settingsRef.current, + connectionIdRef.current, + selectedRepoPathRef.current + ) + if (uploaded) { + addComposerAttachmentsRef.current(uploaded.filePaths) + insertComposerFolderPathsRef.current(uploaded.folderPaths) + return } + await applyLocalComposerDropRef.current(data.paths) })() }) return () => { @@ -1231,15 +1346,31 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return } setPushTarget(undefined) - void window.api.worktrees - .resolvePrBase({ - repoId: repoForItem.id, - prNumber: item.number, - ...(item.branchName ? { headRefName: item.branchName } : {}), - ...(item.isCrossRepository !== undefined - ? { isCrossRepository: item.isCrossRepository } - : {}) - }) + const target = getActiveRuntimeTarget(settings) + const resolvePrBase = + target.kind === 'local' + ? window.api.worktrees.resolvePrBase({ + repoId: repoForItem.id, + prNumber: item.number, + ...(item.branchName ? { headRefName: item.branchName } : {}), + ...(item.isCrossRepository !== undefined + ? { isCrossRepository: item.isCrossRepository } + : {}) + }) + : callRuntimeRpc<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }>( + target, + 'worktree.resolvePrBase', + { + repo: repoForItem.id, + prNumber: item.number, + ...(item.branchName ? { headRefName: item.branchName } : {}), + ...(item.isCrossRepository !== undefined + ? { isCrossRepository: item.isCrossRepository } + : {}) + }, + { timeoutMs: 30_000 } + ) + void resolvePrBase .then((result) => { if ('error' in result) { setBaseBranch(undefined) @@ -1255,7 +1386,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS toast.error(error instanceof Error ? error.message : 'Failed to resolve PR base.') }) }, - [applyLinkedWorkItem, eligibleRepos, handleBaseBranchPrSelect, selectedRepo] + [applyLinkedWorkItem, eligibleRepos, handleBaseBranchPrSelect, selectedRepo, settings] ) // Why: GitLab parallel of handleSmartGitHubItemSelect. For a picked diff --git a/src/renderer/src/hooks/useEditorExternalWatch.test.ts b/src/renderer/src/hooks/useEditorExternalWatch.test.ts index f6f809ff472..1b0b823f6c7 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.test.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.test.ts @@ -20,6 +20,10 @@ import { getWatchedTargetKey } from './useEditorExternalWatch' import { useAppStore } from '@/store' +import { + getOpenFilesForExternalFileChange, + notifyEditorExternalFileChange +} from '@/components/editor/editor-autosave' describe('getWatchedTargetKey', () => { it('changes when a worktree gains an SSH connection id', () => { @@ -27,13 +31,33 @@ describe('getWatchedTargetKey', () => { getWatchedTargetKey({ worktreeId: 'wt-1', worktreePath: '/repo', - connectionId: undefined + connectionId: undefined, + runtimeEnvironmentId: undefined }) ).not.toBe( getWatchedTargetKey({ worktreeId: 'wt-1', worktreePath: '/repo', - connectionId: 'conn-1' + connectionId: 'conn-1', + runtimeEnvironmentId: undefined + }) + ) + }) + + it('changes when the active runtime environment changes', () => { + expect( + getWatchedTargetKey({ + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: undefined, + runtimeEnvironmentId: undefined + }) + ).not.toBe( + getWatchedTargetKey({ + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: undefined, + runtimeEnvironmentId: 'env-1' }) ) }) @@ -104,10 +128,16 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { worktreeId: string worktreePath: string connectionId: string | undefined + runtimeEnvironmentId: string | undefined } | undefined => worktreePath === '/repo' - ? { worktreeId: 'wt-1', worktreePath: '/repo', connectionId: undefined } + ? { + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: undefined, + runtimeEnvironmentId: undefined + } : undefined const fileNotes = { @@ -182,4 +212,76 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { dispose() }) + + it('reloads Windows editor tabs when watcher event casing differs', () => { + const file = { + id: 'file-win', + worktreeId: 'wt-win', + worktreePath: 'C:\\Repo', + filePath: 'C:\\Repo\\notes.md', + relativePath: 'notes.md', + mode: 'edit' as const, + isDirty: false + } + vi.mocked(useAppStore.getState).mockReturnValue({ + openFiles: [file], + setExternalMutation + } as never) + vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([file] as never) + const { handleFsChanged, dispose } = createExternalWatchEventHandler(() => ({ + worktreeId: 'wt-win', + worktreePath: 'C:\\Repo', + connectionId: undefined, + runtimeEnvironmentId: 'env-1' + })) + + handleFsChanged({ + worktreePath: 'c:\\repo', + events: [{ kind: 'update', absolutePath: 'c:\\repo\\notes.md', isDirectory: false }] + }) + vi.advanceTimersByTime(100) + + expect(notifyEditorExternalFileChange).toHaveBeenCalledWith({ + worktreeId: 'wt-win', + worktreePath: 'C:\\Repo', + relativePath: 'notes.md' + }) + dispose() + }) + + it('reloads UNC editor tabs without collapsing the share root', () => { + const file = { + id: 'file-unc', + worktreeId: 'wt-unc', + worktreePath: '//Server/Share/Repo', + filePath: '//Server/Share/Repo/notes.md', + relativePath: 'notes.md', + mode: 'edit' as const, + isDirty: false + } + vi.mocked(useAppStore.getState).mockReturnValue({ + openFiles: [file], + setExternalMutation + } as never) + vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([file] as never) + const { handleFsChanged, dispose } = createExternalWatchEventHandler(() => ({ + worktreeId: 'wt-unc', + worktreePath: '//Server/Share/Repo', + connectionId: undefined, + runtimeEnvironmentId: 'env-1' + })) + + handleFsChanged({ + worktreePath: '//server/share/repo', + events: [{ kind: 'update', absolutePath: '//server/share/repo/notes.md', isDirectory: false }] + }) + vi.advanceTimersByTime(100) + + expect(notifyEditorExternalFileChange).toHaveBeenCalledWith({ + worktreeId: 'wt-unc', + worktreePath: '//Server/Share/Repo', + relativePath: 'notes.md' + }) + dispose() + }) }) diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index 26e97953c1f..d6d1081837b 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -5,8 +5,8 @@ import { useEffect, useMemo, useRef } from 'react' import { useAppStore } from '@/store' import { basename, joinPath } from '@/lib/path' -import { normalizeAbsolutePath } from '@/components/right-sidebar/file-explorer-paths' import { getExternalFileChangeRelativePath } from '@/components/right-sidebar/useFileExplorerWatch' +import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' import { getOpenFilesForExternalFileChange, notifyEditorExternalFileChange @@ -15,6 +15,7 @@ import { hasRecentSelfWrite } from '@/components/editor/editor-self-write-regist import type { FsChangedPayload } from '../../../shared/types' import { findWorktreeById } from '@/store/slices/worktree-helpers' import type { OpenFile } from '@/store/slices/editor' +import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client' // Why: atomic-write patterns (Claude Code's Edit tool, editors like vim, // VSCode) land as a short burst of `update` events — or `delete + create` on @@ -25,7 +26,7 @@ import type { OpenFile } from '@/store/slices/editor' // and black out the window (issue #826). Coalescing per (worktreeId + path) // on a short debounce collapses that burst into one reload notification. const EXTERNAL_RELOAD_DEBOUNCE_MS = 75 -const pendingExternalReloadTimers = new Map() +const pendingExternalReloadTimers = new Map>() function warnExternalWatchFailure(target: WatchedTarget, err: unknown): void { console.warn('[filesystem-watch] failed to watch worktree', { @@ -44,9 +45,9 @@ function scheduleDebouncedExternalReload(notification: { const key = `${notification.worktreeId}::${notification.relativePath}` const existing = pendingExternalReloadTimers.get(key) if (existing !== undefined) { - window.clearTimeout(existing) + globalThis.clearTimeout(existing) } - const handle = window.setTimeout(() => { + const handle = globalThis.setTimeout(() => { pendingExternalReloadTimers.delete(key) notifyEditorExternalFileChange(notification) }, EXTERNAL_RELOAD_DEBOUNCE_MS) @@ -57,6 +58,7 @@ type WatchedTarget = { worktreeId: string worktreePath: string connectionId: string | undefined + runtimeEnvironmentId: string | undefined } type ExternalWatchNotification = { @@ -69,7 +71,7 @@ export function getWatchedTargetKey(target: WatchedTarget): string { // Why: SSH worktrees can exist in the store before their remote filesystem // provider is ready. Include connectionId so a local/unknown placeholder // watch is replaced by the real SSH watch when the repo metadata hydrates. - return `${target.worktreeId}::${target.worktreePath}::${target.connectionId ?? 'local'}` + return `${target.worktreeId}::${target.worktreePath}::${target.connectionId ?? 'local'}::${target.runtimeEnvironmentId ?? 'client'}` } // Why: macOS atomic writes (Claude Code Edit, vim :w, VSCode save) deliver a @@ -106,6 +108,7 @@ export function useEditorExternalWatch(): void { const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const repos = useAppStore((s) => s.repos) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + const runtimeEnvironmentId = useAppStore((s) => s.settings?.activeRuntimeEnvironmentId) // Why: unify the target computation and the dependency key into one memo so // there's a single source of truth. The derived string key drives the @@ -134,17 +137,20 @@ export function useEditorExternalWatch(): void { const target = { worktreeId: id, worktreePath: wt.path, - connectionId: repo?.connectionId ?? undefined + connectionId: repo?.connectionId ?? undefined, + runtimeEnvironmentId: runtimeEnvironmentId?.trim() || undefined } nextTargets.push(target) parts.push(getWatchedTargetKey(target)) } return { targets: nextTargets, targetsKey: parts.join('|') } - }, [openFiles, worktreesByRepo, repos, activeWorktreeId]) + }, [openFiles, worktreesByRepo, repos, activeWorktreeId, runtimeEnvironmentId]) const targetsRef = useRef([]) const latestTargetsRef = useRef(targets) latestTargetsRef.current = targets + const remoteWatchUnsubsRef = useRef(new Map void>()) + const fsChangedHandlerRef = useRef<((payload: FsChangedPayload) => void) | null>(null) // Why: diff previous vs next targets so unchanged worktrees keep their // existing subscription. Tearing down every subscription on each targetsKey @@ -159,12 +165,55 @@ export function useEditorExternalWatch(): void { const added = nextTargets.filter((t) => !prevKeys.has(getWatchedTargetKey(t))) for (const target of removed) { - void window.api.fs.unwatchWorktree({ - worktreePath: target.worktreePath, - connectionId: target.connectionId - }) + const key = getWatchedTargetKey(target) + const remoteUnsubscribe = remoteWatchUnsubsRef.current.get(key) + if (remoteUnsubscribe) { + remoteUnsubscribe() + remoteWatchUnsubsRef.current.delete(key) + } else { + void window.api.fs.unwatchWorktree({ + worktreePath: target.worktreePath, + connectionId: target.connectionId + }) + } } for (const target of added) { + if (target.runtimeEnvironmentId) { + const key = getWatchedTargetKey(target) + let cancelled = false + const pendingUnsubscribe = (): void => { + cancelled = true + } + remoteWatchUnsubsRef.current.set(key, pendingUnsubscribe) + void subscribeRuntimeFileChanges( + { + settings: { activeRuntimeEnvironmentId: target.runtimeEnvironmentId }, + worktreeId: target.worktreeId, + worktreePath: target.worktreePath, + connectionId: target.connectionId + }, + (payload) => fsChangedHandlerRef.current?.(payload), + (err) => warnExternalWatchFailure(target, err) + ) + .then((unsubscribe) => { + if (cancelled) { + unsubscribe() + return + } + if (remoteWatchUnsubsRef.current.get(key) === pendingUnsubscribe) { + remoteWatchUnsubsRef.current.set(key, unsubscribe) + } else { + unsubscribe() + } + }) + .catch((err) => { + if (remoteWatchUnsubsRef.current.get(key) === pendingUnsubscribe) { + remoteWatchUnsubsRef.current.delete(key) + } + warnExternalWatchFailure(target, err) + }) + continue + } void window.api.fs .watchWorktree({ worktreePath: target.worktreePath, @@ -188,25 +237,37 @@ export function useEditorExternalWatch(): void { // single always-mounted effect avoids re-subscribing on every targetsKey // change (which would otherwise miss events fired during re-subscription). useEffect(() => { + const remoteWatchUnsubs = remoteWatchUnsubsRef.current const { handleFsChanged, dispose } = createExternalWatchEventHandler((worktreePath) => targetsRef.current.find( - (t) => normalizeAbsolutePath(t.worktreePath) === normalizeAbsolutePath(worktreePath) + (t) => + normalizeRuntimePathForComparison(t.worktreePath) === + normalizeRuntimePathForComparison(worktreePath) ) ) const unsubscribe = window.api.fs.onFsChanged(handleFsChanged) + fsChangedHandlerRef.current = handleFsChanged return () => { unsubscribe() dispose() + fsChangedHandlerRef.current = null // Why: final unmount must tear down every outstanding subscription. // The differential watch effect above intentionally never unwatches on // cleanup, so this is the only place that clears them. for (const target of targetsRef.current) { - void window.api.fs.unwatchWorktree({ - worktreePath: target.worktreePath, - connectionId: target.connectionId - }) + const key = getWatchedTargetKey(target) + const remoteUnsubscribe = remoteWatchUnsubs.get(key) + if (remoteUnsubscribe) { + remoteUnsubscribe() + } else { + void window.api.fs.unwatchWorktree({ + worktreePath: target.worktreePath, + connectionId: target.connectionId + }) + } } + remoteWatchUnsubs.clear() targetsRef.current = [] // Why: deliberately do NOT clear pendingExternalReloadTimers here. // The map is module-scoped, so in React StrictMode (dev) the first @@ -253,7 +314,7 @@ export function createExternalWatchEventHandler( continue } if (evt.kind === 'create' || evt.kind === 'update') { - createOrUpdatePaths.add(normalizeAbsolutePath(evt.absolutePath)) + createOrUpdatePaths.add(normalizeRuntimePathForComparison(evt.absolutePath)) } } for (const createdPath of createOrUpdatePaths) { @@ -346,7 +407,7 @@ export function createExternalWatchEventHandler( file.worktreeId === target.worktreeId && (file.mode === 'edit' || file.mode === 'markdown-preview') && file.externalMutation && - createOrUpdatePaths.has(normalizeAbsolutePath(file.filePath)) + createOrUpdatePaths.has(normalizeRuntimePathForComparison(file.filePath)) ) { state.setExternalMutation(file.id, null) } @@ -384,7 +445,7 @@ export function createExternalWatchEventHandler( const relativePath = getExternalFileChangeRelativePath( target.worktreePath, - normalizeAbsolutePath(evt.absolutePath), + evt.absolutePath, evt.isDirectory ) if (relativePath) { @@ -481,7 +542,7 @@ function buildDeletePathByFileId( const deletePaths = new Set() for (const evt of payload.events) { if (evt.kind === 'delete') { - deletePaths.add(normalizeAbsolutePath(evt.absolutePath)) + deletePaths.add(normalizeRuntimePathForComparison(evt.absolutePath)) } } const result = new Map() @@ -493,7 +554,7 @@ function buildDeletePathByFileId( if (!deletedIdSet.has(file.id) || file.worktreeId !== worktreeId) { continue } - const normalized = normalizeAbsolutePath(file.filePath) + const normalized = normalizeRuntimePathForComparison(file.filePath) if (deletePaths.has(normalized)) { result.set(file.id, normalized) } @@ -509,7 +570,7 @@ function collectDeletedOpenEditorIds( const deletePaths = new Set() for (const evt of payload.events) { if (evt.kind === 'delete') { - deletePaths.add(normalizeAbsolutePath(evt.absolutePath)) + deletePaths.add(normalizeRuntimePathForComparison(evt.absolutePath)) } } if (deletePaths.size === 0) { @@ -523,7 +584,7 @@ function collectDeletedOpenEditorIds( ) { continue } - if (deletePaths.has(normalizeAbsolutePath(file.filePath))) { + if (deletePaths.has(normalizeRuntimePathForComparison(file.filePath))) { result.push(file.id) } } @@ -564,7 +625,7 @@ function hasRenameCorrelatedCreate( if (!deletedIdSet.has(file.id)) { continue } - deletedBasenames.add(basename(normalizeAbsolutePath(file.filePath))) + deletedBasenames.add(basename(file.filePath)) } if (deletedBasenames.size === 0) { return false @@ -573,7 +634,7 @@ function hasRenameCorrelatedCreate( if (evt.kind !== 'create' || evt.isDirectory === true) { continue } - if (deletedBasenames.has(basename(normalizeAbsolutePath(evt.absolutePath)))) { + if (deletedBasenames.has(basename(evt.absolutePath))) { return true } } diff --git a/src/renderer/src/hooks/useGitHubSlugMetadata.ts b/src/renderer/src/hooks/useGitHubSlugMetadata.ts index 06861899518..49decd64ec5 100644 --- a/src/renderer/src/hooks/useGitHubSlugMetadata.ts +++ b/src/renderer/src/hooks/useGitHubSlugMetadata.ts @@ -5,7 +5,12 @@ // existing repoPath-keyed hooks stay focused on the local-workspace flow // and so this file remains under the lint line cap. import { useEffect, useRef, useState } from 'react' -import type { GitHubAssignableUser } from '../../../shared/types' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import type { GitHubAssignableUser, GlobalSettings } from '../../../shared/types' +import type { + ListAssignableUsersBySlugResult, + ListLabelsBySlugResult +} from '../../../shared/github-project-types' import { clearMetadataRequestStore, createMetadataRequestStore, @@ -29,7 +34,8 @@ export function clearGitHubSlugMetadataCache(): void { export function useRepoLabelsBySlug( owner: string | null, - repo: string | null + repo: string | null, + settings?: Pick | null ): MetadataState { const [state, setState] = useState>({ data: [], @@ -42,7 +48,11 @@ export function useRepoLabelsBySlug( if (!owner || !repo) { return } - const key = `${owner}/${repo}` + const target = getActiveRuntimeTarget(settings) + const key = + target.kind === 'environment' + ? `runtime:${target.environmentId}:${owner}/${repo}` + : `${owner}/${repo}` const cached = getFreshMetadata(slugLabelStore, key) if (cached) { @@ -64,7 +74,15 @@ export function useRepoLabelsBySlug( error: null })) loadMetadata(slugLabelStore, key, () => - window.api.gh.listLabelsBySlug({ owner, repo }).then((res) => { + (target.kind === 'environment' + ? callRuntimeRpc( + target, + 'github.project.listLabelsBySlug', + { owner, repo }, + { timeoutMs: 30_000 } + ) + : window.api.gh.listLabelsBySlug({ owner, repo }) + ).then((res) => { if (!res.ok) { throw new Error(res.error.message) } @@ -88,7 +106,7 @@ export function useRepoLabelsBySlug( error: err instanceof Error ? err.message : 'Failed to load labels' })) }) - }, [owner, repo]) + }, [owner, repo, settings]) return state } @@ -96,7 +114,8 @@ export function useRepoLabelsBySlug( export function useRepoAssigneesBySlug( owner: string | null, repo: string | null, - seedLogins?: string[] + seedLogins?: string[], + settings?: Pick | null ): MetadataState { const [state, setState] = useState>({ data: [], @@ -113,7 +132,11 @@ export function useRepoAssigneesBySlug( if (!owner || !repo) { return } - const key = `${owner}/${repo}#${seedKey}` + const target = getActiveRuntimeTarget(settings) + const key = + target.kind === 'environment' + ? `runtime:${target.environmentId}:${owner}/${repo}#${seedKey}` + : `${owner}/${repo}#${seedKey}` const cached = getFreshMetadata(slugAssigneeStore, key) if (cached) { @@ -133,19 +156,26 @@ export function useRepoAssigneesBySlug( loading: true, error: null })) + const args = { + owner, + repo, + ...(seedKey ? { seedLogins: seedKey.split(',') } : {}) + } loadMetadata(slugAssigneeStore, key, () => - window.api.gh - .listAssignableUsersBySlug({ - owner, - repo, - ...(seedKey ? { seedLogins: seedKey.split(',') } : {}) - }) - .then((res) => { - if (!res.ok) { - throw new Error(res.error.message) - } - return res.users - }) + (target.kind === 'environment' + ? callRuntimeRpc( + target, + 'github.project.listAssignableUsersBySlug', + args, + { timeoutMs: 30_000 } + ) + : window.api.gh.listAssignableUsersBySlug(args) + ).then((res) => { + if (!res.ok) { + throw new Error(res.error.message) + } + return res.users + }) ) .then((data) => { if (activeKeyRef.current !== requestKey) { @@ -164,7 +194,7 @@ export function useRepoAssigneesBySlug( error: err instanceof Error ? err.message : 'Failed to load assignees' })) }) - }, [owner, repo, seedKey]) + }, [owner, repo, seedKey, settings]) return state } diff --git a/src/renderer/src/hooks/useGlobalFileDrop.test.ts b/src/renderer/src/hooks/useGlobalFileDrop.test.ts new file mode 100644 index 00000000000..1439e401c69 --- /dev/null +++ b/src/renderer/src/hooks/useGlobalFileDrop.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { shouldUploadRemoteEditorFileDrop } from './useGlobalFileDrop' + +describe('shouldUploadRemoteEditorFileDrop', () => { + it('does not upload editor drops for local workspaces', () => { + expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: null }, null)).toBe(false) + }) + + it('uploads editor drops while a runtime environment is active', () => { + expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: 'env-1' }, null)).toBe( + true + ) + }) + + it('uploads editor drops for SSH workspaces', () => { + expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: null }, 'ssh-1')).toBe( + true + ) + }) +}) diff --git a/src/renderer/src/hooks/useGlobalFileDrop.ts b/src/renderer/src/hooks/useGlobalFileDrop.ts index b5020e94271..4f0564fee00 100644 --- a/src/renderer/src/hooks/useGlobalFileDrop.ts +++ b/src/renderer/src/hooks/useGlobalFileDrop.ts @@ -1,8 +1,24 @@ import { useEffect } from 'react' +import { toast } from 'sonner' import { detectLanguage } from '@/lib/language-detect' import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links' import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' +import { joinPath } from '@/lib/path' +import { + importExternalPathsToRuntime, + isRemoteRuntimeFileOperation, + statRuntimePath, + type RuntimeFileOperationArgs +} from '@/runtime/runtime-file-client' +import type { GlobalSettings } from '../../../shared/types' + +export function shouldUploadRemoteEditorFileDrop( + settings: Pick | null | undefined, + connectionId: string | null | undefined +): boolean { + return Boolean(settings?.activeRuntimeEnvironmentId?.trim() || connectionId?.trim()) +} export function useGlobalFileDrop(): void { useEffect(() => { @@ -19,6 +35,58 @@ export function useGlobalFileDrop(): void { const activeWorktree = store.allWorktrees().find((w) => w.id === activeWorktreeId) const worktreePath = activeWorktree?.path + const connectionId = getConnectionId(activeWorktreeId) ?? undefined + const dropSettings = store.settings + const runtimeEnvironmentId = dropSettings?.activeRuntimeEnvironmentId?.trim() || undefined + if (shouldUploadRemoteEditorFileDrop(dropSettings, connectionId)) { + if (!worktreePath) { + toast.error('No remote workspace path is available for dropped files.') + return + } + void (async () => { + try { + // Why: OS file drops provide client-local paths. Remote runtime and + // SSH editors must upload into the server worktree before opening. + const destinationDir = joinPath(worktreePath, '.orca/drops') + const { results } = await importExternalPathsToRuntime( + { + settings: dropSettings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + data.paths, + destinationDir, + { ensureDestinationDir: true } + ) + const imported = results.filter((result) => result.status === 'imported') + for (const result of imported) { + if (result.kind === 'directory') { + continue + } + const maybeRelative = toWorktreeRelativePath(result.destPath, worktreePath) + store.setActiveTabType('editor') + store.openFile( + { + filePath: result.destPath, + relativePath: maybeRelative ?? result.destPath, + worktreeId: activeWorktreeId, + runtimeEnvironmentId, + language: detectLanguage(result.destPath), + mode: 'edit' + }, + { suppressActiveRuntimeFallback: runtimeEnvironmentId === undefined } + ) + } + if (results.some((result) => result.status !== 'imported')) { + toast.error('Some dropped files could not be uploaded.') + } + } catch { + toast.error('Failed to upload dropped files.') + } + })() + return + } // Why: the relay payload now sends all paths in one gesture-scoped event. // Loop over every dropped file so multi-file editor drops still open @@ -26,12 +94,18 @@ export function useGlobalFileDrop(): void { for (const filePath of data.paths) { void (async () => { try { - const connectionId = getConnectionId(activeWorktreeId) ?? undefined - // Why: remote paths don't need local auth — the relay is the security boundary. - if (!connectionId) { + const fileContext: RuntimeFileOperationArgs = { + settings: store.settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + } + const isRemoteRuntimePath = isRemoteRuntimeFileOperation(fileContext, filePath) + // Why: remote paths don't need local auth — the relay/runtime is the security boundary. + if (!connectionId && !isRemoteRuntimePath) { await window.api.fs.authorizeExternalPath({ targetPath: filePath }) } - const stat = await window.api.fs.stat({ filePath, connectionId }) + const stat = await statRuntimePath(fileContext, filePath) if (stat.isDirectory) { return } diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 65ee868a261..ada47027c8e 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -40,6 +40,10 @@ export { resolveZoomTarget } from './resolve-zoom-target' const ZOOM_STEP = 0.5 +function isRuntimeEnvironmentActive(): boolean { + return Boolean(useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim()) +} + export function useIpcEvents(): void { useEffect(() => { const unsubs: (() => void)[] = [] @@ -48,12 +52,23 @@ export function useIpcEvents(): void { unsubs.push( window.api.repos.onChanged(() => { + if (isRuntimeEnvironmentActive()) { + // Why: this event comes from the local Electron store. While a + // runtime server is selected, repo hydration must be driven by the + // selected server instead of local-disk changes. + return + } useAppStore.getState().fetchRepos() }) ) unsubs.push( window.api.worktrees.onChanged(async (data: { repoId: string }) => { + if (isRuntimeEnvironmentActive()) { + // Why: local worktree events carry local repo ids. Fetching the + // active runtime with those ids can purge or overwrite server state. + return + } // Why: diff before vs. after fetchWorktrees to detect server-side // deletions (CLI `orca worktree rm`, other window, out-of-band RPC) // and purge worktree-scoped state for removed ids. Without this, @@ -83,12 +98,18 @@ export function useIpcEvents(): void { unsubs.push( window.api.worktrees.onBaseStatus((event) => { + if (isRuntimeEnvironmentActive()) { + return + } useAppStore.getState().updateWorktreeBaseStatus(event) }) ) unsubs.push( window.api.worktrees.onRemoteBranchConflict((event) => { + if (isRuntimeEnvironmentActive()) { + return + } useAppStore.getState().updateWorktreeRemoteBranchConflict(event) }) ) @@ -230,6 +251,12 @@ export function useIpcEvents(): void { unsubs.push( window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup }) => { void (async () => { + if (isRuntimeEnvironmentActive()) { + // Why: local CLI-created worktree events carry local repo/worktree + // ids. Runtime server activation arrives through runtime state, + // not this local Electron event. + return + } // Why: fetch worktrees first so the activation helper can resolve // the CLI-created worktree via findWorktreeById — it arrived from // the main process and is not yet in the renderer state. @@ -250,6 +277,15 @@ export function useIpcEvents(): void { window.api.ui.onCreateTerminal( ({ requestId, worktreeId, command, title, ptyId, activate, tabId }) => { try { + if (isRuntimeEnvironmentActive()) { + if (requestId) { + window.api.ui.replyTerminalCreate({ + requestId, + error: 'Local terminal reveal is unavailable while a remote runtime is active' + }) + } + return + } const store = useAppStore.getState() const shouldActivate = activate !== false if (shouldActivate) { @@ -324,6 +360,13 @@ export function useIpcEvents(): void { unsubs.push( window.api.ui.onRequestTerminalCreate((data) => { try { + if (isRuntimeEnvironmentActive()) { + window.api.ui.replyTerminalCreate({ + requestId: data.requestId, + error: 'Local terminal creation is unavailable while a remote runtime is active' + }) + return + } const store = useAppStore.getState() const worktreeId = data.worktreeId ?? store.activeWorktreeId if (!worktreeId) { @@ -510,6 +553,9 @@ export function useIpcEvents(): void { unsubs.push( window.api.browser.onGuestLoadFailed(({ browserPageId, loadError }) => { + if (isRuntimeEnvironmentActive()) { + return + } useAppStore.getState().updateBrowserPageState(browserPageId, { loading: false, loadError, @@ -525,6 +571,9 @@ export function useIpcEvents(): void { // This IPC pushes the live URL/title from main after goto/click/back/reload. unsubs.push( window.api.browser.onNavigationUpdate(({ browserPageId, url, title }) => { + if (isRuntimeEnvironmentActive()) { + return + } const store = useAppStore.getState() store.setBrowserPageUrl(browserPageId, url) store.updateBrowserPageState(browserPageId, { title, loading: false }) @@ -537,6 +586,9 @@ export function useIpcEvents(): void { // before browser commands so the webview can start and registerGuest fires. unsubs.push( window.api.browser.onActivateView(() => { + if (isRuntimeEnvironmentActive()) { + return + } useAppStore.getState().setActiveTabType('browser') }) ) @@ -552,6 +604,9 @@ export function useIpcEvents(): void { // pre-staging for whenever the user next visits that worktree. unsubs.push( window.api.browser.onPaneFocus(({ worktreeId, browserPageId }) => { + if (isRuntimeEnvironmentActive()) { + return + } const store = useAppStore.getState() // Why: main sends `worktreeId: null` if the tab closed between the // bridge resolving tabSwitch and getWorktreeIdForTab running. Falling @@ -568,6 +623,9 @@ export function useIpcEvents(): void { unsubs.push( window.api.browser.onOpenLinkInOrcaTab(({ browserPageId, url }) => { + if (isRuntimeEnvironmentActive()) { + return + } const store = useAppStore.getState() const sourcePage = Object.values(store.browserPagesByWorkspace) .flat() @@ -587,6 +645,9 @@ export function useIpcEvents(): void { // capture keyboard focus and bypass the renderer's window-level keydown. unsubs.push( window.api.ui.onNewBrowserTab(() => { + if (isRuntimeEnvironmentActive()) { + return + } const store = useAppStore.getState() const worktreeId = store.activeWorktreeId if (worktreeId) { @@ -604,6 +665,15 @@ export function useIpcEvents(): void { unsubs.push( window.api.ui.onRequestTabCreate((data) => { try { + if (isRuntimeEnvironmentActive()) { + // Why: browser automation targets client-local Electron webviews. + // Runtime agents cannot see or control those surfaces. + window.api.ui.replyTabCreate({ + requestId: data.requestId, + error: 'Browser tabs are unavailable while a remote runtime is active' + }) + return + } const store = useAppStore.getState() const worktreeId = data.worktreeId ?? store.activeWorktreeId if (!worktreeId) { @@ -643,6 +713,13 @@ export function useIpcEvents(): void { unsubs.push( window.api.ui.onRequestTabSetProfile((data) => { try { + if (isRuntimeEnvironmentActive()) { + window.api.ui.replyTabSetProfile({ + requestId: data.requestId, + error: 'Browser profiles are unavailable while a remote runtime is active' + }) + return + } const store = useAppStore.getState() const owningWorkspace = Object.values(store.browserTabsByWorktree) .flat() @@ -684,6 +761,13 @@ export function useIpcEvents(): void { unsubs.push( window.api.ui.onRequestTabClose((data) => { try { + if (isRuntimeEnvironmentActive()) { + window.api.ui.replyTabClose({ + requestId: data.requestId, + error: 'Browser tabs are unavailable while a remote runtime is active' + }) + return + } const store = useAppStore.getState() const explicitTargetId = data.tabId ?? null let tabToClose = @@ -1121,12 +1205,17 @@ export function useIpcEvents(): void { // Why: hydrate mobile-fit overrides before terminal panes run their first // attach/fit logic, so a renderer reload doesn't undo active mobile fits. - void window.api.runtime.getTerminalFitOverrides().then((overrides) => { - hydrateOverrides(overrides) - }) + if (!isRuntimeEnvironmentActive()) { + void window.api.runtime.getTerminalFitOverrides().then((overrides) => { + hydrateOverrides(overrides) + }) + } unsubs.push( window.api.runtime.onTerminalFitOverrideChanged((event) => { + if (isRuntimeEnvironmentActive()) { + return + } setFitOverride(event.ptyId, event.mode, event.cols, event.rows) }) ) @@ -1137,6 +1226,9 @@ export function useIpcEvents(): void { // know which PTYs are currently driven by mobile. See // docs/mobile-presence-lock.md. window.api.runtime.onTerminalDriverChanged((event) => { + if (isRuntimeEnvironmentActive()) { + return + } setDriverForPty(event.ptyId, event.driver) }) ) diff --git a/src/renderer/src/hooks/useIssueMetadata.ts b/src/renderer/src/hooks/useIssueMetadata.ts index c715d6cb550..deec6cb6a9b 100644 --- a/src/renderer/src/hooks/useIssueMetadata.ts +++ b/src/renderer/src/hooks/useIssueMetadata.ts @@ -1,6 +1,15 @@ +/* eslint-disable max-lines -- Why: repo metadata hooks share TTL caches and +Linear/GitHub cache invalidation entrypoints used by the issue dialog. */ import { useEffect, useRef, useState } from 'react' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { + linearTeamLabels, + linearTeamMembers, + linearTeamStates +} from '@/runtime/runtime-linear-client' import type { GitHubAssignableUser, + GlobalSettings, LinearWorkflowState, LinearLabel, LinearMember @@ -23,7 +32,11 @@ type MetadataState = { const ghLabelStore = createMetadataRequestStore() const ghAssigneeStore = createMetadataRequestStore() -export function useRepoLabels(repoPath: string | null): MetadataState { +export function useRepoLabels( + repoPath: string | null, + repoId?: string | null, + settings?: Pick | null +): MetadataState { const [state, setState] = useState>({ data: [], loading: false, @@ -32,29 +45,41 @@ export function useRepoLabels(repoPath: string | null): MetadataState const activeKeyRef = useRef(null) useEffect(() => { - if (!repoPath) { + const target = getActiveRuntimeTarget(settings) + if (!repoPath && !(target.kind === 'environment' && repoId)) { return } + const cacheKey = + target.kind === 'environment' ? `runtime:${target.environmentId}:${repoId}` : (repoPath ?? '') - const cached = getFreshMetadata(ghLabelStore, repoPath) + const cached = getFreshMetadata(ghLabelStore, cacheKey) if (cached) { - if (activeKeyRef.current !== repoPath) { + if (activeKeyRef.current !== cacheKey) { setState({ data: cached.data, loading: false, error: null }) - activeKeyRef.current = repoPath + activeKeyRef.current = cacheKey } return } - activeKeyRef.current = repoPath - const requestKey = repoPath + activeKeyRef.current = cacheKey + const requestKey = cacheKey setState((s) => ({ ...s, data: s.data.length ? ([] as typeof s.data) : s.data, loading: true, error: null })) - loadMetadata(ghLabelStore, repoPath, () => - window.api.gh.listLabels({ repoPath }).then((labels) => labels as string[]) + loadMetadata(ghLabelStore, cacheKey, () => + target.kind === 'environment' + ? callRuntimeRpc( + target, + 'github.listLabels', + { repo: repoId ?? '' }, + { timeoutMs: 30_000 } + ) + : window.api.gh + .listLabels({ repoPath: repoPath ?? '' }) + .then((labels) => labels as string[]) ) .then((data) => { if (activeKeyRef.current !== requestKey) { @@ -73,12 +98,16 @@ export function useRepoLabels(repoPath: string | null): MetadataState error: err instanceof Error ? err.message : 'Failed to load labels' })) }) - }, [repoPath]) + }, [repoId, repoPath, settings]) return state } -export function useRepoAssignees(repoPath: string | null): MetadataState { +export function useRepoAssignees( + repoPath: string | null, + repoId?: string | null, + settings?: Pick | null +): MetadataState { const [state, setState] = useState>({ data: [], loading: false, @@ -87,31 +116,41 @@ export function useRepoAssignees(repoPath: string | null): MetadataState(null) useEffect(() => { - if (!repoPath) { + const target = getActiveRuntimeTarget(settings) + if (!repoPath && !(target.kind === 'environment' && repoId)) { return } + const cacheKey = + target.kind === 'environment' ? `runtime:${target.environmentId}:${repoId}` : (repoPath ?? '') - const cached = getFreshMetadata(ghAssigneeStore, repoPath) + const cached = getFreshMetadata(ghAssigneeStore, cacheKey) if (cached) { - if (activeKeyRef.current !== repoPath) { + if (activeKeyRef.current !== cacheKey) { setState({ data: cached.data, loading: false, error: null }) - activeKeyRef.current = repoPath + activeKeyRef.current = cacheKey } return } - activeKeyRef.current = repoPath - const requestKey = repoPath + activeKeyRef.current = cacheKey + const requestKey = cacheKey setState((s) => ({ ...s, data: s.data.length ? ([] as typeof s.data) : s.data, loading: true, error: null })) - loadMetadata(ghAssigneeStore, repoPath, () => - window.api.gh - .listAssignableUsers({ repoPath }) - .then((users) => users as GitHubAssignableUser[]) + loadMetadata(ghAssigneeStore, cacheKey, () => + target.kind === 'environment' + ? callRuntimeRpc( + target, + 'github.listAssignableUsers', + { repo: repoId ?? '' }, + { timeoutMs: 30_000 } + ) + : window.api.gh + .listAssignableUsers({ repoPath: repoPath ?? '' }) + .then((users) => users as GitHubAssignableUser[]) ) .then((data) => { if (activeKeyRef.current !== requestKey) { @@ -130,7 +169,7 @@ export function useRepoAssignees(repoPath: string | null): MetadataState() const linearLabelStore = createMetadataRequestStore() const linearMemberStore = createMetadataRequestStore() +function linearMetadataCacheKey( + teamId: string, + settings: Pick | null | undefined +): string { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' ? `runtime:${target.environmentId}:${teamId}` : teamId +} + export function clearLinearMetadataCache(): void { clearMetadataRequestStore(linearStateStore) clearMetadataRequestStore(linearLabelStore) @@ -152,7 +199,10 @@ export function clearGitHubMetadataCache(): void { clearMetadataRequestStore(ghAssigneeStore) } -export function useTeamStates(teamId: string | null): MetadataState { +export function useTeamStates( + teamId: string | null, + settings?: Pick | null +): MetadataState { const [state, setState] = useState>({ data: [], loading: false, @@ -165,25 +215,26 @@ export function useTeamStates(teamId: string | null): MetadataState ({ ...s, data: s.data.length ? ([] as typeof s.data) : s.data, loading: true, error: null })) - loadMetadata(linearStateStore, teamId, () => - window.api.linear.teamStates({ teamId }).then((states) => states as LinearWorkflowState[]) + loadMetadata(linearStateStore, cacheKey, () => + linearTeamStates(settings, teamId).then((states) => states as LinearWorkflowState[]) ) .then((data) => { if (activeKeyRef.current !== requestKey) { @@ -202,12 +253,15 @@ export function useTeamStates(teamId: string | null): MetadataState { +export function useTeamLabels( + teamId: string | null, + settings?: Pick | null +): MetadataState { const [state, setState] = useState>({ data: [], loading: false, @@ -220,25 +274,26 @@ export function useTeamLabels(teamId: string | null): MetadataState ({ ...s, data: s.data.length ? ([] as typeof s.data) : s.data, loading: true, error: null })) - loadMetadata(linearLabelStore, teamId, () => - window.api.linear.teamLabels({ teamId }).then((labels) => labels as LinearLabel[]) + loadMetadata(linearLabelStore, cacheKey, () => + linearTeamLabels(settings, teamId).then((labels) => labels as LinearLabel[]) ) .then((data) => { if (activeKeyRef.current !== requestKey) { @@ -257,12 +312,15 @@ export function useTeamLabels(teamId: string | null): MetadataState { +export function useTeamMembers( + teamId: string | null, + settings?: Pick | null +): MetadataState { const [state, setState] = useState>({ data: [], loading: false, @@ -275,25 +333,26 @@ export function useTeamMembers(teamId: string | null): MetadataState ({ ...s, data: s.data.length ? ([] as typeof s.data) : s.data, loading: true, error: null })) - loadMetadata(linearMemberStore, teamId, () => - window.api.linear.teamMembers({ teamId }).then((members) => members as LinearMember[]) + loadMetadata(linearMemberStore, cacheKey, () => + linearTeamMembers(settings, teamId).then((members) => members as LinearMember[]) ) .then((data) => { if (activeKeyRef.current !== requestKey) { @@ -312,7 +371,7 @@ export function useTeamMembers(teamId: string | null): MetadataState finish(true), BRACKETED_PASTE_QUIET_MS) } - unsubscribe = subscribeToPtyData(ptyId, (data) => { + const observeData = (data: string): void => { // Why: keep just enough recent bytes that an escape sequence split // across two IPC frames is still detectable. 64 bytes >> 8-byte // sequence; cheap and bounded. @@ -151,7 +154,26 @@ function waitForInputBoxReady(ptyId: string, timeoutMs: number): Promise { + if (settled) { + remoteUnsubscribe() + return + } + unsubscribe = remoteUnsubscribe + }) + .catch(() => finish(false)) + } else { + unsubscribe = subscribeToPtyData(ptyId, observeData) + } const hardTimer = window.setTimeout(() => finish(false), timeoutMs) }) diff --git a/src/renderer/src/lib/agent-ready-wait.ts b/src/renderer/src/lib/agent-ready-wait.ts index 70af9f83910..84b75c721f9 100644 --- a/src/renderer/src/lib/agent-ready-wait.ts +++ b/src/renderer/src/lib/agent-ready-wait.ts @@ -1,6 +1,7 @@ import { detectAgentStatusFromTitle } from '../../../shared/agent-detection' import { isShellProcess } from '@/lib/tui-agent-startup' import { useAppStore } from '@/store' +import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' // Why: agent CLIs vary widely in how they signal readiness. Title-based // detection (OSC titles parsed by detectAgentStatusFromTitle) is the tightest @@ -89,7 +90,8 @@ export async function waitForAgentReady( } try { - const foreground = (await window.api.pty.getForegroundProcess(ptyId))?.toLowerCase() ?? '' + const process = await inspectRuntimeTerminalProcess(useAppStore.getState().settings, ptyId) + const foreground = process.foregroundProcess?.toLowerCase() ?? '' if ( foreground === expectedProcess || foreground.startsWith(`${expectedProcess}.`) || @@ -103,8 +105,7 @@ export async function waitForAgentReady( // polls so the shell's own startup children don't spoof readiness on // cold-start. Never accept it while the foreground is still a shell. if (attempt >= 4 && !isShellProcess(foreground)) { - const hasChildProcesses = await window.api.pty.hasChildProcesses(ptyId) - if (hasChildProcesses) { + if (process.hasChildProcesses) { return { ready: true, reason: 'child-process' } } } diff --git a/src/renderer/src/lib/codex-session-restart.test.ts b/src/renderer/src/lib/codex-session-restart.test.ts index e32c733b54c..e3ec0811c8e 100644 --- a/src/renderer/src/lib/codex-session-restart.test.ts +++ b/src/renderer/src/lib/codex-session-restart.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import { markLiveCodexSessionsForRestart } from './codex-session-restart' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '@/runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' const ACCOUNT_A = 'account-a@example.com' const ACCOUNT_B = 'account-b@example.com' @@ -8,8 +13,16 @@ const ACCOUNT_C = 'account-c@example.com' describe('markLiveCodexSessionsForRestart', () => { const originalWindow = (globalThis as { window?: typeof window }).window + const runtimeEnvironmentCall = vi.fn() + const runtimeEnvironmentTransportCall = vi.fn() beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) useAppStore.setState({ tabsByWorktree: { wt1: [ @@ -39,7 +52,12 @@ describe('markLiveCodexSessionsForRestart', () => { ...originalWindow?.api, pty: { ...originalWindow?.api?.pty, - getForegroundProcess: vi.fn() + getForegroundProcess: vi.fn(), + hasChildProcesses: vi.fn().mockResolvedValue(false) + }, + runtimeEnvironments: { + ...originalWindow?.api?.runtimeEnvironments, + call: runtimeEnvironmentTransportCall } } } as unknown as typeof window @@ -143,4 +161,52 @@ describe('markLiveCodexSessionsForRestart', () => { nextAccountLabel: ACCOUNT_C }) }) + + it('inspects remote runtime PTYs through the active runtime environment', async () => { + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + tabsByWorktree: { + wt1: [ + { + id: 'tab-1', + ptyId: 'remote:term-1', + worktreeId: 'wt1', + title: 'orca-1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + ptyIdsByTabId: { + 'tab-1': ['remote:term-1'] + } + }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { + process: { foregroundProcess: 'codex', hasChildProcesses: true } + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + + expect(window.api.pty.getForegroundProcess).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.inspectProcess', + params: { terminal: 'term-1' }, + timeoutMs: 15_000 + }) + expect(useAppStore.getState().codexRestartNoticeByPtyId['remote:term-1']).toEqual({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + }) }) diff --git a/src/renderer/src/lib/codex-session-restart.ts b/src/renderer/src/lib/codex-session-restart.ts index a3a71f2d669..6e7a46bb156 100644 --- a/src/renderer/src/lib/codex-session-restart.ts +++ b/src/renderer/src/lib/codex-session-restart.ts @@ -1,5 +1,6 @@ import type { AppState } from '@/store' import { useAppStore } from '@/store' +import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' function normalizeProcessName(processName: string | null): string | null { if (!processName) { @@ -34,7 +35,11 @@ async function getLiveCodexSessionPtyIds(state: AppState): Promise { // does not always do that. The foreground PTY process is the stable // source of truth for whether this live tab is actually running Codex. const foregroundProcesses = await Promise.all( - ptyIds.map((ptyId) => window.api.pty.getForegroundProcess(ptyId)) + ptyIds.map((ptyId) => + inspectRuntimeTerminalProcess(state.settings, ptyId).then( + (inspection) => inspection.foregroundProcess + ) + ) ) return ptyIds.filter((_, index) => isCodexForegroundProcess(foregroundProcesses[index])) }) diff --git a/src/renderer/src/lib/create-untitled-markdown.test.ts b/src/renderer/src/lib/create-untitled-markdown.test.ts index 0b43bfcea72..e5768ffede4 100644 --- a/src/renderer/src/lib/create-untitled-markdown.test.ts +++ b/src/renderer/src/lib/create-untitled-markdown.test.ts @@ -1,5 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createUntitledMarkdownFile } from './create-untitled-markdown' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '@/runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' describe('createUntitledMarkdownFile', () => { afterEach(() => { @@ -7,7 +12,13 @@ describe('createUntitledMarkdownFile', () => { }) it('retries with the next untitled name when createFile loses the EEXIST race', async () => { - const pathExists = vi.fn(async (filePath: string) => filePath.endsWith('untitled.md')) + const pathExists = vi.fn() + const stat = vi.fn(async (args: { filePath: string }) => { + if (args.filePath.endsWith('untitled.md')) { + return { size: 0, isDirectory: false, mtime: 1 } + } + throw new Error('ENOENT: no such file') + }) const createFile = vi .fn() .mockRejectedValueOnce(new Error('EEXIST: file already exists')) @@ -16,7 +27,7 @@ describe('createUntitledMarkdownFile', () => { vi.stubGlobal('window', { api: { shell: { pathExists }, - fs: { createFile } + fs: { createFile, stat } } }) @@ -31,16 +42,18 @@ describe('createUntitledMarkdownFile', () => { expect(createFile).toHaveBeenNthCalledWith(1, { filePath: '/repo/untitled-2.md' }) expect(createFile).toHaveBeenNthCalledWith(2, { filePath: '/repo/untitled-3.md' }) + expect(pathExists).not.toHaveBeenCalled() }) it('throws a descriptive error when untitled names are exhausted', async () => { const pathExists = vi.fn(async () => true) + const stat = vi.fn().mockResolvedValue({ size: 0, isDirectory: false, mtime: 1 }) const createFile = vi.fn() vi.stubGlobal('window', { api: { shell: { pathExists }, - fs: { createFile } + fs: { createFile, stat } } }) @@ -49,16 +62,18 @@ describe('createUntitledMarkdownFile', () => { ) expect(createFile).not.toHaveBeenCalled() + expect(pathExists).not.toHaveBeenCalled() }) - it('passes connectionId to createFile and skips the local pathExists probe for SSH worktrees', async () => { + it('passes connectionId to stat and createFile for SSH worktrees', async () => { const pathExists = vi.fn(async () => false) + const stat = vi.fn().mockRejectedValue(new Error('ENOENT: no such file')) const createFile = vi.fn().mockResolvedValueOnce(undefined) vi.stubGlobal('window', { api: { shell: { pathExists }, - fs: { createFile } + fs: { createFile, stat } } }) @@ -66,13 +81,71 @@ describe('createUntitledMarkdownFile', () => { filePath: '/repo/untitled.md' }) - // Why: shell.pathExists is main-process local-only; probing it on SSH - // worktrees always reports "not found" or succeeds against the wrong - // filesystem, so the probe must be skipped when a connectionId is set. + // Why: shell.pathExists is main-process local-only; SSH worktrees must + // probe through the same filesystem API that receives the connectionId. expect(pathExists).not.toHaveBeenCalled() + expect(stat).toHaveBeenCalledWith({ + filePath: '/repo/untitled.md', + connectionId: 'conn-1' + }) expect(createFile).toHaveBeenCalledWith({ filePath: '/repo/untitled.md', connectionId: 'conn-1' }) }) + + it('creates untitled files through the selected runtime environment', async () => { + clearRuntimeCompatibilityCacheForTests() + const stat = vi.fn() + const createFile = vi.fn() + const runtimeEnvironmentCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'rpc-1', + ok: false, + error: { message: 'ENOENT: no such file' }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'rpc-2', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + const runtimeEnvironmentTransportCall = vi.fn((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + + vi.stubGlobal('window', { + api: { + shell: { pathExists: vi.fn() }, + fs: { createFile, stat }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) + + await expect( + createUntitledMarkdownFile('/remote/repo', 'wt-1', undefined, { + activeRuntimeEnvironmentId: 'env-1' + }) + ).resolves.toMatchObject({ + filePath: '/remote/repo/untitled.md', + relativePath: 'untitled.md' + }) + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'files.stat', + params: { worktree: 'wt-1', relativePath: 'untitled.md' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'files.createFile', + params: { worktree: 'wt-1', relativePath: 'untitled.md' }, + timeoutMs: 15_000 + }) + expect(stat).not.toHaveBeenCalled() + expect(createFile).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/lib/create-untitled-markdown.ts b/src/renderer/src/lib/create-untitled-markdown.ts index ee3023dd712..3d41674f160 100644 --- a/src/renderer/src/lib/create-untitled-markdown.ts +++ b/src/renderer/src/lib/create-untitled-markdown.ts @@ -1,3 +1,5 @@ +import type { GlobalSettings } from '../../../shared/types' +import { createRuntimePath, runtimePathExists } from '../runtime/runtime-file-client' import { detectLanguage } from './language-detect' import { joinPath } from './path' @@ -11,7 +13,8 @@ import { joinPath } from './path' export async function createUntitledMarkdownFile( worktreePath: string, worktreeId: string, - connectionId?: string + connectionId?: string, + settings?: Pick | null ): Promise<{ filePath: string relativePath: string @@ -30,21 +33,19 @@ export async function createUntitledMarkdownFile( // nearly the same time. Retrying EEXIST keeps "New Markdown" advancing to // the next untitled-N name instead of surfacing a spurious error toast. // - // Why (SSH): window.api.shell.pathExists is a local-only main-process probe - // and cannot see files on a remote host. For SSH worktrees we skip the probe - // and rely solely on the EEXIST retry loop; otherwise every attempt reports - // "not found" locally, then fails in the main process when createFile tries - // to authorize the remote path against local allowed roots. + // Why: existence probing must go through the same runtime/SSH-aware file + // surface as creation; the shell probe only sees the client filesystem. for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { const fileName = attempt === 1 ? `${baseName}${ext}` : `${baseName}-${attempt}${ext}` const filePath = joinPath(worktreePath, fileName) + const context = { settings, worktreeId, worktreePath, connectionId } - if (!connectionId && (await window.api.shell.pathExists(filePath))) { + if (await runtimePathExists(context, filePath)) { continue } try { - await window.api.fs.createFile({ filePath, connectionId }) + await createRuntimePath(context, filePath, 'file') return { filePath, diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.ts b/src/renderer/src/lib/ensure-hooks-confirmed.ts index 63f060b3ec6..eeb1f4a71a7 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.ts @@ -1,6 +1,7 @@ import type { AppState } from '@/store/types' import type { OrcaHooks } from '../../../shared/types' import { hashOrcaHookScript, type OrcaHookScriptKind } from './orca-hook-trust' +import { checkRuntimeHooks, readRuntimeIssueCommand } from '@/runtime/runtime-hooks-client' export type HookScriptKind = OrcaHookScriptKind @@ -31,13 +32,13 @@ export async function ensureHooksConfirmed( try { if (scriptKind === 'issueCommand') { // Local overrides are user-owned; only shared orca.yaml commands need repo trust. - const result = await window.api.hooks.readIssueCommand({ repoId }) + const result = await readRuntimeIssueCommand(state.settings, repoId) if (result.source !== 'shared') { return 'run' } scriptContent = (result.sharedContent ?? '').trim() } else { - const result = await window.api.hooks.check({ repoId }) + const result = await checkRuntimeHooks(state.settings, repoId) const yamlHooks = (result.hooks as OrcaHooks | null) ?? null scriptContent = (yamlHooks?.scripts?.[scriptKind] ?? '').trim() } diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts index f62994e49ad..bdafd7e9981 100644 --- a/src/renderer/src/lib/http-link-routing.test.ts +++ b/src/renderer/src/lib/http-link-routing.test.ts @@ -6,7 +6,9 @@ const setActiveWorktreeMock = vi.fn() const createBrowserTabMock = vi.fn() const storeState = { - settings: undefined as { openLinksInApp?: boolean } | undefined, + settings: undefined as + | { openLinksInApp?: boolean; activeRuntimeEnvironmentId?: string | null } + | undefined, setActiveWorktree: setActiveWorktreeMock, createBrowserTab: createBrowserTabMock } @@ -59,6 +61,16 @@ describe('openHttpLink', () => { expect(createBrowserTabMock).not.toHaveBeenCalled() }) + it('routes to the system browser when a remote runtime environment is active', () => { + storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: 'env-1' } + + openHttpLink('https://example.com/', { worktreeId: 'wt-1' }) + + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(createBrowserTabMock).not.toHaveBeenCalled() + expect(setActiveWorktreeMock).not.toHaveBeenCalled() + }) + it('routes to the system browser when no worktree id is provided', () => { storeState.settings = { openLinksInApp: true } diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts index 541106718d3..5204be0daec 100644 --- a/src/renderer/src/lib/http-link-routing.ts +++ b/src/renderer/src/lib/http-link-routing.ts @@ -4,7 +4,7 @@ export type OpenHttpLinkOptions = { } type StoreAccessor = () => { - settings?: { openLinksInApp?: boolean } | null + settings?: { openLinksInApp?: boolean; activeRuntimeEnvironmentId?: string | null } | null setActiveWorktree: (worktreeId: string) => void createBrowserTab: (worktreeId: string, url: string, opts: { activate: boolean }) => unknown } @@ -28,8 +28,12 @@ export function registerHttpLinkStoreAccessor(fn: StoreAccessor): void { export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void { const { worktreeId, forceSystemBrowser } = opts const state = storeAccessor?.() + const remoteRuntimeActive = Boolean(state?.settings?.activeRuntimeEnvironmentId?.trim()) const routeToOrca = - !forceSystemBrowser && Boolean(worktreeId) && state?.settings?.openLinksInApp !== false + !remoteRuntimeActive && + !forceSystemBrowser && + Boolean(worktreeId) && + state?.settings?.openLinksInApp !== false if (routeToOrca && worktreeId && state) { // Why: http clicks from inside a worktree should not push a worktree-switch diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index aacf55857db..d8a13516bb2 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -1,6 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '@/runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' const mockSpawn = vi.fn() +const mockRuntimeEnvironmentCall = vi.fn() +const mockRuntimeEnvironmentTransportCall = vi.fn() +const mockRuntimeEnvironmentSubscribe = vi.fn() const mockCreateTab = vi.fn() const mockSetTabCustomTitle = vi.fn() const mockUpdateTabPtyId = vi.fn() @@ -11,7 +19,7 @@ const mockSubscribeToPtyExit = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() const state = { - settings: { agentCmdOverrides: {} }, + settings: { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null as string | null }, repos: [{ id: 'repo-1', connectionId: null }], allWorktrees: vi.fn(() => [ { id: 'wt-1', repoId: 'repo-1', path: '/repo/worktree', displayName: 'main' } @@ -47,15 +55,39 @@ vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ describe('launchAgentBackgroundSession', () => { beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() vi.clearAllMocks() + mockRuntimeEnvironmentTransportCall.mockImplementation( + (args: RuntimeEnvironmentCallRequest) => { + return ( + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? mockRuntimeEnvironmentCall(args) + ) + } + ) + state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null } mockCreateTab.mockReturnValue({ id: 'tab-1', title: 'Terminal 1' }) mockSpawn.mockResolvedValue({ id: 'pty-1' }) + mockRuntimeEnvironmentCall.mockResolvedValue({ + ok: true, + result: { terminal: { handle: 'terminal-1', worktreeId: 'wt-1', title: null } } + }) + mockRuntimeEnvironmentSubscribe.mockImplementation(async (_args, callbacks) => { + queueMicrotask(() => callbacks.onResponse({ ok: true, result: { type: 'ready' } })) + return { unsubscribe: vi.fn(), sendBinary: vi.fn() } + }) mockSubscribeToPtyData.mockReturnValue(vi.fn()) mockSubscribeToPtyExit.mockReturnValue(vi.fn()) vi.stubGlobal('window', { api: { pty: { spawn: mockSpawn + }, + runtime: { + call: vi.fn() + }, + runtimeEnvironments: { + call: mockRuntimeEnvironmentTransportCall, + subscribe: mockRuntimeEnvironmentSubscribe } } }) @@ -175,4 +207,43 @@ describe('launchAgentBackgroundSession', () => { }) ) }) + + it('creates background sessions on the active runtime environment', async () => { + state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: 'env-1' } + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + const result = await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + + expect(mockSpawn).not.toHaveBeenCalled() + expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.create', + params: expect.objectContaining({ + worktree: 'wt-1', + command: "claude 'run the automation'", + env: expect.objectContaining({ + ORCA_PANE_KEY: 'tab-1:1', + ORCA_TAB_ID: 'tab-1', + ORCA_WORKTREE_ID: 'wt-1' + }), + focus: false + }), + timeoutMs: 15_000 + }) + expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'remote:env-1@@terminal-1') + expect(mockRegisterEagerPtyBuffer).not.toHaveBeenCalled() + expect(mockRuntimeEnvironmentSubscribe).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'terminal.multiplex', + params: {} + }), + expect.any(Object) + ) + expect(result).toMatchObject({ tabId: 'tab-1', ptyId: 'remote:env-1@@terminal-1' }) + }) }) diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index d0ae944bb18..710a0b5779d 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -14,7 +14,14 @@ import { subscribeToPtyExit } from '@/components/terminal-pane/pty-dispatcher' import { createAgentStatusOscProcessor } from '@/components/terminal-pane/agent-status-osc' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { + getRemoteRuntimeTerminalHandle, + subscribeToRuntimeTerminalData, + toRemoteRuntimePtyId +} from '@/runtime/runtime-terminal-stream' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' +import type { RuntimeTerminalCreate } from '../../../shared/runtime-types' export type LaunchAgentBackgroundSessionArgs = { agent: TuiAgent @@ -86,29 +93,49 @@ export async function launchAgentBackgroundSession( ORCA_TAB_ID: tab.id, ORCA_WORKTREE_ID: worktreeId } - let result: Awaited> + const runtimeTarget = getActiveRuntimeTarget(store.settings) + let ptyId: string try { - result = await window.api.pty.spawn({ - cols: 120, - rows: 40, - cwd: worktree.path, - command: startupPlan.launchCommand, - env: paneEnv, - connectionId: repo?.connectionId ?? null, - worktreeId, - tabId: tab.id, - leafId: 'pane:1', - telemetry: { - agent_kind: tuiAgentToAgentKind(agent), - launch_source: launchSource ?? 'unknown', - request_kind: 'new' - } - }) + if (runtimeTarget.kind === 'environment') { + // Why: runtime environments execute on the server; using local pty.spawn + // would silently run automation on the client for a remote workspace. + const created = await callRuntimeRpc<{ terminal: RuntimeTerminalCreate }>( + runtimeTarget, + 'terminal.create', + { + worktree: worktreeId, + command: startupPlan.launchCommand, + env: paneEnv, + title, + focus: false + }, + { timeoutMs: 15_000 } + ) + ptyId = toRemoteRuntimePtyId(created.terminal.handle, runtimeTarget.environmentId) + } else { + const result = await window.api.pty.spawn({ + cols: 120, + rows: 40, + cwd: worktree.path, + command: startupPlan.launchCommand, + env: paneEnv, + connectionId: repo?.connectionId ?? null, + worktreeId, + tabId: tab.id, + leafId: 'pane:1', + telemetry: { + agent_kind: tuiAgentToAgentKind(agent), + launch_source: launchSource ?? 'unknown', + request_kind: 'new' + } + }) + ptyId = result.id + } } catch (error) { store.closeTab(tab.id) throw error } - store.updateTabPtyId(tab.id, result.id) + store.updateTabPtyId(tab.id, ptyId) let exitHandled = false let unsubscribeExit = (): void => {} let unsubscribeData = (): void => {} @@ -122,19 +149,41 @@ export async function launchAgentBackgroundSession( useAppStore.getState().clearTabPtyId(tab.id, ptyId) onExit?.(ptyId, code) } - registerEagerPtyBuffer(result.id, handleExit) const processAgentStatus = createAgentStatusOscProcessor() - unsubscribeData = subscribeToPtyData(result.id, (data) => { + const handleData = (data: string): void => { const processed = processAgentStatus(data) for (const payload of processed.payloads) { useAppStore.getState().setAgentStatus(paneKey, payload, undefined) onAgentStatus?.(payload) } - }) - // Why: opening the workspace attaches a real terminal transport and disposes - // the eager exit handler. This sidecar keeps automation completion tracking - // alive regardless of whether the tab is hidden or mounted. - unsubscribeExit = subscribeToPtyExit(result.id, (code) => handleExit(result.id, code)) + } + if (runtimeTarget.kind === 'environment') { + unsubscribeData = await subscribeToRuntimeTerminalData( + store.settings, + ptyId, + `desktop:background:${tab.id}`, + handleData + ) + const terminal = getRemoteRuntimeTerminalHandle(ptyId) + if (!terminal) { + throw new Error('Runtime terminal id is invalid.') + } + void callRuntimeRpc<{ wait: { exitCode?: number | null } }>( + runtimeTarget, + 'terminal.wait', + { terminal, for: 'exit' }, + { timeoutMs: 24 * 60 * 60 * 1000 } + ) + .then((result) => handleExit(ptyId, result.wait.exitCode ?? 0)) + .catch(() => {}) + } else { + registerEagerPtyBuffer(ptyId, handleExit) + unsubscribeData = subscribeToPtyData(ptyId, handleData) + // Why: opening the workspace attaches a real terminal transport and disposes + // the eager exit handler. This sidecar keeps automation completion tracking + // alive regardless of whether the tab is hidden or mounted. + unsubscribeExit = subscribeToPtyExit(ptyId, (code) => handleExit(ptyId, code)) + } if (pasteDraftAfterLaunch !== null) { void pasteDraftWhenAgentReady({ @@ -152,5 +201,5 @@ export async function launchAgentBackgroundSession( }) } - return { tabId: tab.id, ptyId: result.id, startupPlan } + return { tabId: tab.id, ptyId, startupPlan } } diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 6a0ec8b3a5a..a297e508653 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -12,6 +12,7 @@ import { getWorkspaceSeedName } from '@/lib/new-workspace' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' +import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import { track, tuiAgentToAgentKind } from '@/lib/telemetry' import type { OrcaHooks, @@ -91,7 +92,7 @@ async function resolveSetupDecision( ): Promise<{ kind: 'decided'; decision: SetupDecision } | { kind: 'needs-modal' }> { let yamlHooks: OrcaHooks | null = null try { - const result = await window.api.hooks.check({ repoId }) + const result = await checkRuntimeHooks(useAppStore.getState().settings, repoId) yamlHooks = (result.hooks as OrcaHooks | null) ?? null } catch { yamlHooks = null diff --git a/src/renderer/src/lib/local-path-open-guard.test.ts b/src/renderer/src/lib/local-path-open-guard.test.ts new file mode 100644 index 00000000000..011757af112 --- /dev/null +++ b/src/renderer/src/lib/local-path-open-guard.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { isLocalPathOpenBlocked } from './local-path-open-guard' + +describe('isLocalPathOpenBlocked', () => { + it('allows local paths without a runtime or SSH connection', () => { + expect(isLocalPathOpenBlocked({ activeRuntimeEnvironmentId: null })).toBe(false) + }) + + it('blocks paths while a runtime environment is active', () => { + expect(isLocalPathOpenBlocked({ activeRuntimeEnvironmentId: 'env-1' })).toBe(true) + }) + + it('blocks SSH-backed paths', () => { + expect( + isLocalPathOpenBlocked({ activeRuntimeEnvironmentId: null }, { connectionId: 'ssh-1' }) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/local-path-open-guard.ts b/src/renderer/src/lib/local-path-open-guard.ts new file mode 100644 index 00000000000..3b34a7747f3 --- /dev/null +++ b/src/renderer/src/lib/local-path-open-guard.ts @@ -0,0 +1,15 @@ +import { toast } from 'sonner' +import type { GlobalSettings } from '../../../shared/types' + +export function isLocalPathOpenBlocked( + settings: Pick | null | undefined, + context?: { connectionId?: string | null } +): boolean { + return Boolean(settings?.activeRuntimeEnvironmentId?.trim() || context?.connectionId?.trim()) +} + +export function showLocalPathOpenBlockedToast(): void { + // Why: local OS reveal/open actions receive client filesystem paths. Remote + // runtime and SSH paths belong to another machine, not this client. + toast.error('Opening remote paths in the local OS is not available.') +} diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index 9327f240647..d15b28c05d6 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -1,5 +1,9 @@ import { useAppStore } from '@/store' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' +import { + inspectRuntimeTerminalProcess, + sendRuntimePtyInput +} from '@/runtime/runtime-terminal-inspection' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import { isShellProcess } from '@/lib/tui-agent-startup' import type { OrcaHooks, TaskViewPresetId } from '../../../shared/types' @@ -242,7 +246,7 @@ export async function ensureAgentStartupInTerminal(args: { // session and submitted. Wait until the agent owns the PTY before writing. if (startup.followupPrompt) { await waitForAgentForeground(ptyId, startup.expectedProcess) - window.api.pty.write(ptyId, `${startup.followupPrompt}\r`) + sendRuntimePtyInput(useAppStore.getState().settings, ptyId, `${startup.followupPrompt}\r`) } // Why: draftPrompt uses bracketed-paste so the URL lands atomically in the @@ -267,7 +271,8 @@ async function waitForAgentForeground(ptyId: string, expectedProcess: string): P await new Promise((resolve) => window.setTimeout(resolve, 150)) } try { - const foreground = (await window.api.pty.getForegroundProcess(ptyId))?.toLowerCase() ?? '' + const process = await inspectRuntimeTerminalProcess(useAppStore.getState().settings, ptyId) + const foreground = process.foregroundProcess?.toLowerCase() ?? '' const owns = foreground === expectedProcess || foreground.startsWith(`${expectedProcess}.`) || @@ -276,8 +281,7 @@ async function waitForAgentForeground(ptyId: string, expectedProcess: string): P return } if (attempt >= 4 && !isShellProcess(foreground)) { - const hasChildProcesses = await window.api.pty.hasChildProcesses(ptyId) - if (hasChildProcesses) { + if (process.hasChildProcesses) { return } } diff --git a/src/renderer/src/lib/open-project-notes-tab.ts b/src/renderer/src/lib/open-project-notes-tab.ts index f072ecd22ea..cda85c7e695 100644 --- a/src/renderer/src/lib/open-project-notes-tab.ts +++ b/src/renderer/src/lib/open-project-notes-tab.ts @@ -1,5 +1,6 @@ import { useAppStore } from '@/store' import { NOTES_ACTIVE_CHANGED_EVENT } from '@/lib/notes-events' +import { linkRuntimeProjectNote, showRuntimeProjectNote } from '@/runtime/runtime-notes-client' export function getProjectNotesEntityId(projectId: string, noteId?: string): string { if (noteId) { @@ -27,15 +28,16 @@ export async function openProjectNotesTab(worktreeId: string, noteId?: string): .find((candidate) => candidate.id === worktreeId) const repo = state.repos.find((candidate) => candidate.id === worktree?.repoId) const projectId = repo?.id ?? worktree?.repoId ?? null + const settings = state.settings if (noteId && projectId) { - await window.api.notes.link({ projectId, worktreeId, note: noteId, kind: 'active' }) + await linkRuntimeProjectNote(settings, { projectId, worktreeId, note: noteId, kind: 'active' }) } let label = 'Project Notes' if (noteId && projectId) { try { - const result = await window.api.notes.show({ projectId, worktreeId, note: noteId }) + const result = await showRuntimeProjectNote(settings, { projectId, worktreeId, note: noteId }) label = result.note.title } catch { label = 'Project Notes' diff --git a/src/renderer/src/lib/rename-file.ts b/src/renderer/src/lib/rename-file.ts index eef158e65e1..cf666e9d95a 100644 --- a/src/renderer/src/lib/rename-file.ts +++ b/src/renderer/src/lib/rename-file.ts @@ -5,6 +5,7 @@ import { basename, dirname, joinPath } from '@/lib/path' import { getConnectionId } from '@/lib/connection-context' import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' import { commitFileExplorerOp } from '@/components/right-sidebar/fileExplorerUndoRedo' +import { renameRuntimePath } from '@/runtime/runtime-file-client' /** * Electron's ipcRenderer.invoke wraps errors as: @@ -124,20 +125,26 @@ export async function renameFileOnDisk(args: RenameFileArgs): Promise { file.filePath.startsWith(`${oldPath}\\`) ) await Promise.all(filesToQuiesce.map((file) => requestEditorSaveQuiesce({ fileId: file.id }))) + const fileContext = { + settings: state.settings, + worktreeId, + worktreePath, + connectionId + } try { - await window.api.fs.rename({ oldPath, newPath, connectionId }) + await renameRuntimePath(fileContext, oldPath, newPath) remapOpenTabsForRenamedPath(oldPath, newPath, worktreePath) commitFileExplorerOp({ undo: async () => { - await window.api.fs.rename({ oldPath: newPath, newPath: oldPath, connectionId }) + await renameRuntimePath(fileContext, newPath, oldPath) if (refreshDir) { await refreshDir(parentDir) } remapOpenTabsForRenamedPath(newPath, oldPath, worktreePath) }, redo: async () => { - await window.api.fs.rename({ oldPath, newPath, connectionId }) + await renameRuntimePath(fileContext, oldPath, newPath) if (refreshDir) { await refreshDir(parentDir) } diff --git a/src/renderer/src/lib/repo-slug-index.ts b/src/renderer/src/lib/repo-slug-index.ts index 6c14526b093..09028c370b2 100644 --- a/src/renderer/src/lib/repo-slug-index.ts +++ b/src/renderer/src/lib/repo-slug-index.ts @@ -16,6 +16,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import type { Repo } from '../../../shared/types' +import type { GlobalSettings } from '../../../shared/types' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' /** Lowercased `owner/repo` → Repo. Case folded because GitHub treats slugs * case-insensitively but displays the canonical casing; the lookup side @@ -23,17 +25,29 @@ import type { Repo } from '../../../shared/types' * canonical casing depending on when the project item was indexed. */ type SlugIndex = Map -/** Module-scope cache keyed by repo.id. A Repo that has already failed +/** Module-scope cache keyed by runtime scope + repo.id. A Repo that has already failed * resolution is not retried on re-mount; the value in the map is `null` * to record the negative result so we don't keep poking `git remote` for * repos that will never match. */ const slugByRepoId = new Map() +function slugCacheKey( + repoId: string, + settings: Pick | null | undefined +): string { + const target = getActiveRuntimeTarget(settings) + return `${target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local'}:${repoId}` +} + /** Drop a repo's cached slug result. Call when a repo is removed or its * remote URL is known to have changed (e.g. after `git remote set-url`), * so the next index build re-resolves rather than serving a stale entry. */ export function clearRepoSlugCacheEntry(repoId: string): void { - slugByRepoId.delete(repoId) + for (const key of slugByRepoId.keys()) { + if (key.endsWith(`:${repoId}`)) { + slugByRepoId.delete(key) + } + } } /** Clear the entire slug cache. Useful for tests or full repo-list resets. */ @@ -41,41 +55,57 @@ export function clearRepoSlugCache(): void { slugByRepoId.clear() } -async function resolveRepoSlug(repo: Repo): Promise { - if (slugByRepoId.has(repo.id)) { - return slugByRepoId.get(repo.id) ?? null +async function resolveRepoSlug( + repo: Repo, + settings: Pick | null | undefined +): Promise { + const cacheKey = slugCacheKey(repo.id, settings) + if (slugByRepoId.has(cacheKey)) { + return slugByRepoId.get(cacheKey) ?? null } try { - const result = await window.api.gh.repoSlug({ repoPath: repo.path }) + const target = getActiveRuntimeTarget(settings) + const result = + target.kind === 'environment' + ? await callRuntimeRpc<{ owner: string; repo: string } | null>( + target, + 'github.repoSlug', + { repo: repo.id }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.repoSlug({ repoPath: repo.path }) if (!result) { - slugByRepoId.set(repo.id, null) + slugByRepoId.set(cacheKey, null) return null } const slug = `${result.owner}/${result.repo}`.toLowerCase() - slugByRepoId.set(repo.id, slug) + slugByRepoId.set(cacheKey, slug) return slug } catch { // Why: treat any IPC failure as "not resolvable" rather than propagating — // design doc §Row actions: "If gh:repoSlug fails for a repo, exclude it". - slugByRepoId.set(repo.id, null) + slugByRepoId.set(cacheKey, null) return null } } -async function buildIndex(repos: Repo[]): Promise { +async function buildIndex( + repos: Repo[], + settings: Pick | null | undefined +): Promise { // Why: evict cached entries for repos that no longer exist in state so // the cache cannot grow unbounded across long sessions where users add // and remove repos. Without this, every removed repo's id (and its // negative-cached null) lingers forever. - const liveIds = new Set(repos.map((r) => r.id)) - for (const id of slugByRepoId.keys()) { - if (!liveIds.has(id)) { - slugByRepoId.delete(id) + const liveKeys = new Set(repos.map((r) => slugCacheKey(r.id, settings))) + for (const key of slugByRepoId.keys()) { + if (!liveKeys.has(key)) { + slugByRepoId.delete(key) } } const next: SlugIndex = new Map() const results = await Promise.all( - repos.map(async (r) => ({ repo: r, slug: await resolveRepoSlug(r) })) + repos.map(async (r) => ({ repo: r, slug: await resolveRepoSlug(r, settings) })) ) for (const { repo, slug } of results) { if (slug) { @@ -90,6 +120,7 @@ async function buildIndex(repos: Repo[]): Promise { * treat it as referentially equal inside a single render cycle. */ export function useRepoSlugIndex(): (slug: string | null | undefined) => Repo | null { const repos = useAppStore((s) => s.repos) + const settings = useAppStore((s) => s.settings) const [index, setIndex] = useState(() => new Map()) // Why: track the current repos snapshot so the effect can ignore stale // resolutions when repos change mid-flight. @@ -97,13 +128,13 @@ export function useRepoSlugIndex(): (slug: string | null | undefined) => Repo | useEffect(() => { const gen = ++generationRef.current - void buildIndex(repos).then((next) => { + void buildIndex(repos, settings).then((next) => { if (gen !== generationRef.current) { return } setIndex(next) }) - }, [repos]) + }, [repos, settings]) return useMemo( () => diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index bc563959b0b..8b31ebaccce 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -111,7 +111,8 @@ export function buildEditorSessionData( relativePath: f.relativePath, worktreeId: f.worktreeId, language: f.language, - isPreview: f.isPreview || undefined + isPreview: f.isPreview || undefined, + runtimeEnvironmentId: f.runtimeEnvironmentId }) const ids = editFileIdsByWorktree[f.worktreeId] ?? (editFileIdsByWorktree[f.worktreeId] = new Set()) diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.ts b/src/renderer/src/runtime/mobile-markdown-bridge.ts index 58d11e303c5..7158c64ecc0 100644 --- a/src/renderer/src/runtime/mobile-markdown-bridge.ts +++ b/src/renderer/src/runtime/mobile-markdown-bridge.ts @@ -9,6 +9,8 @@ import { flushPendingEditorChange } from '@/components/editor/editor-pending-flu import { getConnectionId } from '@/lib/connection-context' import { useAppStore } from '@/store' import type { OpenFile } from '@/store/slices/editor' +import { readRuntimeFileContent } from './runtime-file-client' +import { settingsForRuntimeOwner } from './runtime-rpc-client' import { hashMarkdownContent, MOBILE_MARKDOWN_EDIT_MAX_BYTES, @@ -238,8 +240,12 @@ async function readCurrentContent( async function readFileContent(file: OpenFile): Promise { const connectionId = getConnectionId(file.worktreeId) ?? undefined - const result = (await window.api.fs.readFile({ + const state = useAppStore.getState() + const result = (await readRuntimeFileContent({ + settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId), filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, connectionId })) as FileContent if (result.isBinary) { diff --git a/src/renderer/src/runtime/remote-file-client.ts b/src/renderer/src/runtime/remote-file-client.ts new file mode 100644 index 00000000000..9958ac957d7 --- /dev/null +++ b/src/renderer/src/runtime/remote-file-client.ts @@ -0,0 +1,22 @@ +import type { GlobalSettings } from '../../../shared/types' +import { readRuntimeFileContent, type RuntimeReadableFileContent } from './runtime-file-client' + +export type RemoteReadableFile = { + worktreeId: string + relativePath: string + filePath?: string +} + +export type RemoteFileContent = RuntimeReadableFileContent + +export async function readFileFromActiveRuntime( + settings: Pick | null | undefined, + file: RemoteReadableFile +): Promise { + return readRuntimeFileContent({ + settings, + filePath: file.filePath ?? file.relativePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId + }) +} diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts new file mode 100644 index 00000000000..2bf32cccbd9 --- /dev/null +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -0,0 +1,379 @@ +/* eslint-disable max-lines -- Why: the remote terminal multiplexer owns one bridged subscription, stream lifecycle, binary frame parsing, and remote lock events as a single transport contract. */ +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import { unwrapRuntimeRpcResult } from './runtime-rpc-client' + +type RuntimeEnvironmentSubscriptionHandle = { + unsubscribe: () => void + sendBinary: (bytes: Uint8Array) => void +} + +type TerminalMultiplexEvent = + | { type: 'ready' } + | { type: 'subscribed'; streamId: number } + | { type: 'end'; streamId: number } + | { type: 'error'; streamId: number; message?: string } + | { + type: 'fit-override-changed' + streamId: number + mode: 'mobile-fit' | 'desktop-fit' + cols: number + rows: number + } + | { + type: 'driver-changed' + streamId: number + driver: { kind: 'idle' } | { kind: 'desktop' } | { kind: 'mobile'; clientId: string } + } + | { type: string; streamId?: number; [key: string]: unknown } + +export type RemoteRuntimeMultiplexedTerminalCallbacks = { + onData: (data: string) => void + onSnapshot: (data: string) => void + onSubscribed?: () => void + onEnd?: () => void + onError?: (message: string) => void + onFitOverrideChanged?: (event: { + mode: 'mobile-fit' | 'desktop-fit' + cols: number + rows: number + }) => void + onDriverChanged?: ( + driver: { kind: 'idle' } | { kind: 'desktop' } | { kind: 'mobile'; clientId: string } + ) => void + onTransportClose?: () => void +} + +export type RemoteRuntimeMultiplexedTerminal = { + streamId: number + sendInput: (text: string) => boolean + resize: (cols: number, rows: number) => boolean + close: () => void +} + +type RemoteRuntimeMultiplexedTerminalState = { + streamId: number + terminal: string + callbacks: RemoteRuntimeMultiplexedTerminalCallbacks + snapshotChunks: Uint8Array[] +} + +const CONTROL_STREAM_ID = 0 + +class RemoteRuntimeTerminalMultiplexer { + private readonly streams = new Map() + private subscription: RuntimeEnvironmentSubscriptionHandle | null = null + private connectPromise: Promise | null = null + private readyResolver: (() => void) | null = null + private readyRejecter: ((error: Error) => void) | null = null + private ready = false + private nextStreamId = 1 + + constructor(private readonly environmentId: string) {} + + async subscribeTerminal(args: { + terminal: string + client: { id: string; type: 'desktop' | 'mobile' } + viewport?: { cols: number; rows: number } + callbacks: RemoteRuntimeMultiplexedTerminalCallbacks + }): Promise { + const streamId = this.allocateStreamId() + const state: RemoteRuntimeMultiplexedTerminalState = { + streamId, + terminal: args.terminal, + callbacks: args.callbacks, + snapshotChunks: [] + } + this.streams.set(streamId, state) + + const stream: RemoteRuntimeMultiplexedTerminal = { + streamId, + sendInput: (text) => + this.sendFrame(streamId, TerminalStreamOpcode.Input, encodeTerminalStreamText(text)), + resize: (cols, rows) => + this.sendFrame( + streamId, + TerminalStreamOpcode.Resize, + encodeTerminalStreamJson({ cols, rows }) + ), + close: () => { + if (this.streams.get(streamId) === state) { + this.sendFrame(streamId, TerminalStreamOpcode.Unsubscribe) + this.streams.delete(streamId) + this.closeIfIdle() + } + } + } + + try { + await this.ensureConnected() + if (this.streams.get(streamId) !== state) { + return stream + } + const sent = this.sendFrame( + CONTROL_STREAM_ID, + TerminalStreamOpcode.Subscribe, + encodeTerminalStreamJson({ + streamId, + terminal: args.terminal, + client: args.client, + viewport: args.viewport + }) + ) + if (!sent) { + throw new Error('Remote terminal stream is not connected.') + } + } catch (error) { + const terminalError = error instanceof Error ? error : new Error(String(error)) + if (this.streams.get(streamId) === state) { + this.streams.delete(streamId) + this.closeIfIdle() + } + throw terminalError + } + + return stream + } + + private allocateStreamId(): number { + const start = this.nextStreamId + do { + const candidate = this.nextStreamId + this.nextStreamId = this.nextStreamId >= 0x7fffffff ? 1 : this.nextStreamId + 1 + if (!this.streams.has(candidate)) { + return candidate + } + } while (this.nextStreamId !== start) + throw new Error('No remote terminal stream ids available.') + } + + private ensureConnected(): Promise { + if (this.ready && this.subscription) { + return Promise.resolve() + } + if (this.connectPromise) { + return this.connectPromise + } + this.connectPromise = new Promise((resolve, reject) => { + this.readyResolver = resolve + this.readyRejecter = reject + void window.api.runtimeEnvironments + .subscribe( + { + selector: this.environmentId, + method: 'terminal.multiplex', + params: {}, + timeoutMs: 15_000 + }, + { + onResponse: (response) => this.handleResponse(response), + onBinary: (bytes) => this.handleBinary(bytes), + onError: (error) => this.failConnection(new Error(error.message)), + onClose: () => this.handleClose('Remote Orca runtime closed the connection.') + } + ) + .then((subscription) => { + this.subscription = subscription + this.resolveReadyIfConnected() + }) + .catch((error) => { + this.connectPromise = null + this.readyResolver = null + this.readyRejecter = null + reject(error instanceof Error ? error : new Error(String(error))) + }) + }) + return this.connectPromise + } + + private handleResponse(response: RuntimeRpcResponse): void { + let event: TerminalMultiplexEvent + try { + event = unwrapRuntimeRpcResult(response) as TerminalMultiplexEvent + } catch (error) { + this.failConnection(error instanceof Error ? error : new Error(String(error))) + return + } + + if (event.type === 'ready') { + this.ready = true + this.resolveReadyIfConnected() + return + } + + if (!('streamId' in event) || typeof event.streamId !== 'number') { + return + } + const stream = this.streams.get(event.streamId) + if (!stream) { + return + } + if (event.type === 'end') { + this.streams.delete(event.streamId) + stream.callbacks.onEnd?.() + this.closeIfIdle() + } else if (event.type === 'error') { + stream.callbacks.onError?.( + typeof event.message === 'string' ? event.message : 'Remote terminal stream failed.' + ) + } else if (event.type === 'fit-override-changed') { + if ( + (event.mode !== 'mobile-fit' && event.mode !== 'desktop-fit') || + typeof event.cols !== 'number' || + typeof event.rows !== 'number' + ) { + return + } + stream.callbacks.onFitOverrideChanged?.({ + mode: event.mode, + cols: event.cols, + rows: event.rows + }) + } else if (event.type === 'driver-changed') { + if (!isTerminalDriverState(event.driver)) { + return + } + stream.callbacks.onDriverChanged?.(event.driver) + } + } + + private handleBinary(bytes: Uint8Array): void { + const frame = decodeTerminalStreamFrame(bytes) + if (!frame) { + return + } + const stream = this.streams.get(frame.streamId) + if (!stream) { + return + } + if (frame.opcode === TerminalStreamOpcode.Output) { + stream.callbacks.onData(decodeTerminalStreamText(frame.payload)) + return + } + if (frame.opcode === TerminalStreamOpcode.SnapshotStart) { + stream.snapshotChunks = [] + return + } + if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) { + stream.snapshotChunks.push(frame.payload) + return + } + if (frame.opcode === TerminalStreamOpcode.SnapshotEnd) { + stream.callbacks.onSnapshot(decodeTerminalStreamText(concatBytes(stream.snapshotChunks))) + stream.snapshotChunks = [] + stream.callbacks.onSubscribed?.() + return + } + if (frame.opcode === TerminalStreamOpcode.Error) { + stream.callbacks.onError?.(decodeTerminalStreamText(frame.payload)) + } + } + + private sendFrame( + streamId: number, + opcode: TerminalStreamOpcode, + payload: Uint8Array = new Uint8Array() + ): boolean { + if (!this.ready || !this.subscription) { + return false + } + this.subscription.sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: 0, payload })) + return true + } + + private resolveReadyIfConnected(): void { + if (!this.ready || !this.subscription) { + return + } + this.readyResolver?.() + this.readyResolver = null + this.readyRejecter = null + } + + private failConnection(error: Error): void { + this.readyRejecter?.(error) + this.readyResolver = null + this.readyRejecter = null + for (const stream of this.streams.values()) { + stream.callbacks.onError?.(error.message) + } + this.subscription?.unsubscribe() + this.handleClose() + } + + private handleClose(message?: string): void { + const streams = Array.from(this.streams.values()) + this.ready = false + this.connectPromise = null + this.readyRejecter?.(new Error(message ?? 'Remote runtime connection closed.')) + this.readyResolver = null + this.readyRejecter = null + this.subscription = null + this.streams.clear() + for (const stream of streams) { + stream.callbacks.onTransportClose?.() + if (message) { + stream.callbacks.onError?.(message) + } + } + } + + private closeIfIdle(): void { + if (this.streams.size > 0) { + return + } + this.subscription?.unsubscribe() + this.subscription = null + this.connectPromise = null + this.ready = false + } +} + +const multiplexers = new Map() + +export function getRemoteRuntimeTerminalMultiplexer( + environmentId: string +): RemoteRuntimeTerminalMultiplexer { + let multiplexer = multiplexers.get(environmentId) + if (!multiplexer) { + multiplexer = new RemoteRuntimeTerminalMultiplexer(environmentId) + multiplexers.set(environmentId, multiplexer) + } + return multiplexer +} + +export function resetRemoteRuntimeTerminalMultiplexersForTests(): void { + multiplexers.clear() +} + +function concatBytes(chunks: Uint8Array[]): Uint8Array { + const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0) + const out = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + out.set(chunk, offset) + offset += chunk.byteLength + } + return out +} + +function isTerminalDriverState( + value: unknown +): value is { kind: 'idle' } | { kind: 'desktop' } | { kind: 'mobile'; clientId: string } { + if (!value || typeof value !== 'object' || !('kind' in value)) { + return false + } + const driver = value as { kind?: unknown; clientId?: unknown } + return ( + driver.kind === 'idle' || + driver.kind === 'desktop' || + (driver.kind === 'mobile' && typeof driver.clientId === 'string') + ) +} diff --git a/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts b/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts new file mode 100644 index 00000000000..eb1bff0c680 --- /dev/null +++ b/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts @@ -0,0 +1,37 @@ +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../shared/protocol-version' + +export type RuntimeEnvironmentCallRequest = { + method: string +} + +export function createCompatibleRuntimeStatusResponse( + runtimeId = 'remote-runtime' +): RuntimeRpcResponse { + return { + id: 'status', + ok: true, + result: { + runtimeId, + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + }, + _meta: { runtimeId } + } +} + +export function createCompatibleRuntimeStatusResponseIfNeeded( + args: RuntimeEnvironmentCallRequest, + runtimeId?: string +): RuntimeRpcResponse | null { + return args.method === 'status.get' ? createCompatibleRuntimeStatusResponse(runtimeId) : null +} diff --git a/src/renderer/src/runtime/runtime-file-client.test.ts b/src/renderer/src/runtime/runtime-file-client.test.ts new file mode 100644 index 00000000000..582d4af028b --- /dev/null +++ b/src/renderer/src/runtime/runtime-file-client.test.ts @@ -0,0 +1,1186 @@ +/* eslint-disable max-lines -- Why: the runtime file client mirrors the file +preload API plus remote fallbacks; keeping route coverage together makes local +versus environment behavior easy to audit. */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + copyRuntimePath, + createRuntimePath, + deleteRuntimePath, + getRuntimeFileReadScope, + importExternalPathsToRuntime, + listRuntimeFiles, + listRuntimeMarkdownDocuments, + readRuntimeDirectory, + readRuntimeFileContent, + readRuntimeFilePreview, + renameRuntimePath, + searchRuntimeFiles, + statRuntimePath, + subscribeRuntimeFileChanges, + type RuntimeReadableFileContent +} from './runtime-file-client' +import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../shared/protocol-version' + +const fsReadFile = vi.fn() +const fsOnChanged = vi.fn() +const fsCopy = vi.fn() +const fsCreateDir = vi.fn() +const fsCreateFile = vi.fn() +const fsRename = vi.fn() +const fsDeletePath = vi.fn() +const fsStat = vi.fn() +const fsImportExternalPaths = vi.fn() +const fsStageExternalPathsForRuntimeUpload = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() +const runtimeEnvironmentSubscribe = vi.fn() +const runtimeCall = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + fsReadFile.mockReset() + fsOnChanged.mockReset() + fsCopy.mockReset() + fsCreateDir.mockReset() + fsCreateFile.mockReset() + fsRename.mockReset() + fsDeletePath.mockReset() + fsStat.mockReset() + fsImportExternalPaths.mockReset() + fsStageExternalPathsForRuntimeUpload.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentSubscribe.mockReset() + runtimeCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: { method: string }) => { + if (args.method === 'status.get') { + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + fs: { + readFile: fsReadFile, + onFsChanged: fsOnChanged, + copy: fsCopy, + createDir: fsCreateDir, + createFile: fsCreateFile, + rename: fsRename, + deletePath: fsDeletePath, + stat: fsStat, + importExternalPaths: fsImportExternalPaths, + stageExternalPathsForRuntimeUpload: fsStageExternalPathsForRuntimeUpload + }, + runtime: { call: runtimeCall }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall, + subscribe: runtimeEnvironmentSubscribe + } + } + }) +}) + +describe('runtime file client', () => { + it('uses local filesystem reads when no remote runtime is active', async () => { + const localResult: RuntimeReadableFileContent = { content: 'hello', isBinary: false } + fsReadFile.mockResolvedValue(localResult) + + await expect( + readRuntimeFileContent({ + settings: { activeRuntimeEnvironmentId: null }, + filePath: '/repo/readme.md', + relativePath: 'readme.md', + worktreeId: 'wt-1', + connectionId: 'ssh-1' + }) + ).resolves.toBe(localResult) + + expect(fsReadFile).toHaveBeenCalledWith({ filePath: '/repo/readme.md', connectionId: 'ssh-1' }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('routes worktree-relative text reads through the selected runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { + worktree: 'wt-1', + relativePath: 'src/index.ts', + content: 'export {}\n', + truncated: false, + byteLength: 10 + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + readRuntimeFileContent({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + filePath: '/remote/repo/src/index.ts', + relativePath: 'src/index.ts', + worktreeId: 'wt-1' + }) + ).resolves.toEqual({ content: 'export {}\n', isBinary: false }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.read', + params: { worktree: 'wt-1', relativePath: 'src/index.ts' }, + timeoutMs: 15_000 + }) + expect(fsReadFile).not.toHaveBeenCalled() + }) + + it('keeps external absolute-path files on the local filesystem path', async () => { + const localResult: RuntimeReadableFileContent = { content: 'scratch', isBinary: false } + fsReadFile.mockResolvedValue(localResult) + + await expect( + readRuntimeFileContent({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + filePath: '/Users/me/scratch.md', + relativePath: '/Users/me/scratch.md' + }) + ).resolves.toBe(localResult) + + expect(fsReadFile).toHaveBeenCalledWith({ + filePath: '/Users/me/scratch.md', + connectionId: undefined + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('rejects remote-owned text reads that are not worktree-relative', async () => { + await expect( + readRuntimeFileContent({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + filePath: '/tmp/scratch.md', + relativePath: '/tmp/scratch.md', + worktreeId: 'wt-1' + }) + ).rejects.toThrow('Remote file is outside the owning runtime worktree') + + await expect( + readRuntimeFileContent({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + filePath: '/remote/repo/unknown.md', + worktreeId: 'wt-1' + }) + ).rejects.toThrow('Remote file is outside the owning runtime worktree') + expect(fsReadFile).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('rejects truncated remote reads instead of returning partial editable content', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { + worktree: 'wt-1', + relativePath: 'large.log', + content: 'partial', + truncated: true, + byteLength: 524_288 + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + readRuntimeFileContent({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + filePath: '/remote/repo/large.log', + relativePath: 'large.log', + worktreeId: 'wt-1' + }) + ).rejects.toThrow('Remote file is too large to open in the editor') + }) + + it('uses the active runtime id as the dedupe scope', () => { + expect(getRuntimeFileReadScope({ activeRuntimeEnvironmentId: 'env-1' }, 'ssh-1')).toBe( + 'runtime:env-1' + ) + expect(getRuntimeFileReadScope({ activeRuntimeEnvironmentId: null }, 'ssh-1')).toBe('ssh-1') + }) + + it('routes directory reads through the selected runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: [{ name: 'src', isDirectory: true }], + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + readRuntimeDirectory( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + '/remote/repo/src' + ) + ).resolves.toEqual([{ name: 'src', isDirectory: true }]) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.readDir', + params: { worktree: 'wt-1', relativePath: 'src' }, + timeoutMs: 15_000 + }) + }) + + it('routes Windows drive paths case-insensitively through the selected runtime', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: [], + _meta: { runtimeId: 'remote-runtime' } + }) + + await readRuntimeDirectory( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: 'C:\\Repo' + }, + 'c:\\repo\\Src' + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.readDir', + params: { worktree: 'wt-1', relativePath: 'Src' }, + timeoutMs: 15_000 + }) + }) + + it('routes forward-slash UNC paths through the selected runtime', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: [], + _meta: { runtimeId: 'remote-runtime' } + }) + + await readRuntimeDirectory( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '//Server/Share/Repo' + }, + '//server/share/repo/src' + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.readDir', + params: { worktree: 'wt-1', relativePath: 'src' }, + timeoutMs: 15_000 + }) + }) + + it('routes preview reads through the selected runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { content: 'base64', isBinary: true, isImage: true, mimeType: 'image/png' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + readRuntimeFilePreview( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + '/remote/repo/images/logo.png' + ) + ).resolves.toEqual({ + content: 'base64', + isBinary: true, + isImage: true, + mimeType: 'image/png' + }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.readPreview', + params: { worktree: 'wt-1', relativePath: 'images/logo.png' }, + timeoutMs: 15_000 + }) + }) + + it('does not fall back to client-local preview reads for remote-owned files outside the worktree', async () => { + await expect( + readRuntimeFilePreview( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + '/tmp/logo.png' + ) + ).rejects.toThrow('outside the owning runtime worktree') + + expect(fsReadFile).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('routes root directory reads with an empty relative path', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: [], + _meta: { runtimeId: 'remote-runtime' } + }) + + await readRuntimeDirectory( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + '/remote/repo' + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.readDir', + params: { worktree: 'wt-1', relativePath: '' }, + timeoutMs: 15_000 + }) + }) + + it('does not fall back to client-local directory reads for remote-owned paths outside the worktree', async () => { + await expect( + readRuntimeDirectory( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo', + connectionId: 'ssh-1' + }, + '/tmp' + ) + ).rejects.toThrow('outside the owning runtime worktree') + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('routes create, rename, copy, and delete mutations through the selected runtime', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + const context = { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + } + + await createRuntimePath(context, '/remote/repo/src/new.ts', 'file') + await renameRuntimePath(context, '/remote/repo/src/new.ts', '/remote/repo/src/renamed.ts') + await copyRuntimePath( + context, + '/remote/repo/src/renamed.ts', + '/remote/repo/src/renamed copy.ts' + ) + await deleteRuntimePath(context, '/remote/repo/src/renamed.ts', false) + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'files.createFile', + params: { worktree: 'wt-1', relativePath: 'src/new.ts' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'files.rename', + params: { + worktree: 'wt-1', + oldRelativePath: 'src/new.ts', + newRelativePath: 'src/renamed.ts' + }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { + selector: 'env-1', + method: 'files.copy', + params: { + worktree: 'wt-1', + sourceRelativePath: 'src/renamed.ts', + destinationRelativePath: 'src/renamed copy.ts' + }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, { + selector: 'env-1', + method: 'files.delete', + params: { worktree: 'wt-1', relativePath: 'src/renamed.ts', recursive: false }, + timeoutMs: 15_000 + }) + }) + + it('does not fall back to client-local mutations for remote-owned paths outside the worktree', async () => { + const context = { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + } + + await expect(createRuntimePath(context, '/tmp/new.ts', 'file')).rejects.toThrow( + 'outside the owning runtime worktree' + ) + await expect( + renameRuntimePath(context, '/remote/repo/src/new.ts', '/tmp/renamed.ts') + ).rejects.toThrow('outside the owning runtime worktree') + await expect( + copyRuntimePath(context, '/remote/repo/src/new.ts', '/tmp/copied.ts') + ).rejects.toThrow('outside the owning runtime worktree') + await expect(deleteRuntimePath(context, '/tmp/new.ts')).rejects.toThrow( + 'outside the owning runtime worktree' + ) + + expect(fsCreateFile).not.toHaveBeenCalled() + expect(fsRename).not.toHaveBeenCalled() + expect(fsCopy).not.toHaveBeenCalled() + expect(fsDeletePath).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('does not fall back to client-local mutations when a remote Windows path escapes the worktree', async () => { + const context = { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: 'C:\\repo' + } + + await expect(createRuntimePath(context, 'D:\\repo\\new.ts', 'file')).rejects.toThrow( + 'outside the owning runtime worktree' + ) + await expect( + createRuntimePath(context, '\\\\server\\share\\repo\\new.ts', 'file') + ).rejects.toThrow('outside the owning runtime worktree') + + expect(fsCreateFile).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('keeps copy operations on local filesystem IPC when no runtime is active', async () => { + await copyRuntimePath( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + '/repo/a.md', + '/repo/a copy.md' + ) + + expect(fsCopy).toHaveBeenCalledWith({ + sourcePath: '/repo/a.md', + destinationPath: '/repo/a copy.md', + connectionId: undefined + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('preserves the SSH connection for copy operations when no runtime is active', async () => { + await copyRuntimePath( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1' + }, + '/repo/a.md', + '/repo/a copy.md' + ) + + expect(fsCopy).toHaveBeenCalledWith({ + sourcePath: '/repo/a.md', + destinationPath: '/repo/a copy.md', + connectionId: 'ssh-1' + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('uploads staged local drops into the selected runtime environment', async () => { + fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ + sources: [ + { + sourcePath: '/Users/me/assets', + status: 'staged', + name: 'assets', + kind: 'directory', + entries: [ + { relativePath: '', kind: 'directory' }, + { relativePath: 'logo.png', kind: 'file', contentBase64: 'cG5n' } + ] + } + ] + }) + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'stat-destination-miss', + ok: false, + error: { code: 'not_found', message: 'not found' }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'create-destination-dir', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'stat-miss', + ok: false, + error: { code: 'not_found', message: 'not found' }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'create-dir', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'write-file', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'commit-upload', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'delete-temp', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + importExternalPathsToRuntime( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + ['/Users/me/assets'], + '/remote/repo/uploads' + ) + ).resolves.toEqual({ + results: [ + { + sourcePath: '/Users/me/assets', + status: 'imported', + destPath: '/remote/repo/uploads/assets', + kind: 'directory', + renamed: false + } + ] + }) + + expect(fsStageExternalPathsForRuntimeUpload).toHaveBeenCalledWith({ + sourcePaths: ['/Users/me/assets'] + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'files.stat', + params: { worktree: 'wt-1', relativePath: 'uploads' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'files.createDir', + params: { worktree: 'wt-1', relativePath: 'uploads' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { + selector: 'env-1', + method: 'files.stat', + params: { worktree: 'wt-1', relativePath: 'uploads/assets' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, { + selector: 'env-1', + method: 'files.createDirNoClobber', + params: { worktree: 'wt-1', relativePath: 'uploads/assets' }, + timeoutMs: 15_000 + }) + const smallWriteCall = runtimeEnvironmentCall.mock.calls[4]?.[0] as { + params: { relativePath: string } + } + expect(smallWriteCall.params.relativePath).toMatch( + /^uploads\/assets\/\.logo\.png\.orca-upload-/ + ) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, { + selector: 'env-1', + method: 'files.writeBase64', + params: { + worktree: 'wt-1', + relativePath: smallWriteCall.params.relativePath, + contentBase64: 'cG5n' + }, + timeoutMs: 30_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, { + selector: 'env-1', + method: 'files.commitUpload', + params: { + worktree: 'wt-1', + tempRelativePath: smallWriteCall.params.relativePath, + finalRelativePath: 'uploads/assets/logo.png' + }, + timeoutMs: 30_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(7, { + selector: 'env-1', + method: 'files.delete', + params: { + worktree: 'wt-1', + relativePath: smallWriteCall.params.relativePath, + recursive: false + }, + timeoutMs: 15_000 + }) + expect(fsImportExternalPaths).not.toHaveBeenCalled() + }) + + it('chunks large staged runtime uploads below the WebSocket frame budget', async () => { + const firstChunk = 'A'.repeat(512 * 1024) + const secondChunk = 'BBBBBBBB' + fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ + sources: [ + { + sourcePath: '/Users/me/large.bin', + status: 'staged', + name: 'large.bin', + kind: 'file', + entries: [ + { relativePath: '', kind: 'file', contentBase64: `${firstChunk}${secondChunk}` } + ] + } + ] + }) + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'stat-destination-miss', + ok: false, + error: { code: 'not_found', message: 'not found' }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'create-destination-dir', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'stat-miss', + ok: false, + error: { code: 'not_found', message: 'not found' }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'write-chunk-1', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'write-chunk-2', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'commit-upload', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'delete-temp', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + importExternalPathsToRuntime( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + ['/Users/me/large.bin'], + '/remote/repo/uploads' + ) + ).resolves.toEqual({ + results: [ + { + sourcePath: '/Users/me/large.bin', + status: 'imported', + destPath: '/remote/repo/uploads/large.bin', + kind: 'file', + renamed: false + } + ] + }) + + const chunkWriteCall = runtimeEnvironmentCall.mock.calls[3]?.[0] as { + params: { relativePath: string } + } + expect(chunkWriteCall.params.relativePath).toMatch(/^uploads\/\.large\.bin\.orca-upload-/) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, { + selector: 'env-1', + method: 'files.writeBase64Chunk', + params: { + worktree: 'wt-1', + relativePath: chunkWriteCall.params.relativePath, + contentBase64: firstChunk, + append: false + }, + timeoutMs: 30_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, { + selector: 'env-1', + method: 'files.writeBase64Chunk', + params: { + worktree: 'wt-1', + relativePath: chunkWriteCall.params.relativePath, + contentBase64: secondChunk, + append: true + }, + timeoutMs: 30_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, { + selector: 'env-1', + method: 'files.commitUpload', + params: { + worktree: 'wt-1', + tempRelativePath: chunkWriteCall.params.relativePath, + finalRelativePath: 'uploads/large.bin' + }, + timeoutMs: 30_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(7, { + selector: 'env-1', + method: 'files.delete', + params: { + worktree: 'wt-1', + relativePath: chunkWriteCall.params.relativePath, + recursive: false + }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.writeBase64' }) + ) + }) + + it('cleans up staged runtime upload temp files when a later chunk fails', async () => { + const firstChunk = 'A'.repeat(512 * 1024) + const secondChunk = 'BBBBBBBB' + fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ + sources: [ + { + sourcePath: '/Users/me/large.bin', + status: 'staged', + name: 'large.bin', + kind: 'file', + entries: [ + { relativePath: '', kind: 'file', contentBase64: `${firstChunk}${secondChunk}` } + ] + } + ] + }) + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'stat-destination-miss', + ok: false, + error: { code: 'not_found', message: 'not found' }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'create-destination-dir', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'stat-miss', + ok: false, + error: { code: 'not_found', message: 'not found' }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'write-chunk-1', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'write-chunk-2', + ok: false, + error: { code: 'write_failed', message: 'disk full' }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'delete-temp', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + importExternalPathsToRuntime( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + ['/Users/me/large.bin'], + '/remote/repo/uploads' + ) + ).resolves.toMatchObject({ + results: [{ status: 'failed', reason: 'disk full' }] + }) + + const chunkCall = runtimeEnvironmentCall.mock.calls[3]?.[0] as + | { params: { relativePath: string } } + | undefined + if (!chunkCall) { + throw new Error('missing first chunk call') + } + const tempRelativePath = chunkCall.params.relativePath + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.commitUpload' }) + ) + expect(runtimeEnvironmentCall).toHaveBeenLastCalledWith({ + selector: 'env-1', + method: 'files.delete', + params: { worktree: 'wt-1', relativePath: tempRelativePath, recursive: false }, + timeoutMs: 15_000 + }) + }) + + it('keeps local external imports on filesystem IPC when no runtime is active', async () => { + fsImportExternalPaths.mockResolvedValue({ + results: [ + { + sourcePath: '/Users/me/readme.md', + status: 'imported', + destPath: '/repo/readme.md', + kind: 'file', + renamed: false + } + ] + }) + + await importExternalPathsToRuntime( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1' + }, + ['/Users/me/readme.md'], + '/repo', + { ensureDestinationDir: true } + ) + + expect(fsImportExternalPaths).toHaveBeenCalledWith({ + sourcePaths: ['/Users/me/readme.md'], + destDir: '/repo', + connectionId: 'ssh-1', + ensureDir: true + }) + expect(fsStageExternalPathsForRuntimeUpload).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('routes text search through the selected runtime without sending client root paths', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { files: [], totalMatches: 0, truncated: false }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + searchRuntimeFiles( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + { + query: 'needle', + rootPath: '/remote/repo', + caseSensitive: true, + maxResults: 50 + } + ) + ).resolves.toEqual({ files: [], totalMatches: 0, truncated: false }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.search', + params: { worktree: 'wt-1', query: 'needle', caseSensitive: true, maxResults: 50 }, + timeoutMs: 15_000 + }) + }) + + it('routes quick-open file listing through the selected runtime', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: ['src/index.ts'], + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + listRuntimeFiles( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + { + rootPath: '/remote/repo', + excludePaths: ['/remote/repo-other'] + } + ) + ).resolves.toEqual(['src/index.ts']) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.listAll', + params: { worktree: 'wt-1', excludePaths: ['/remote/repo-other'] }, + timeoutMs: 15_000 + }) + }) + + it('routes markdown document listing and stat through the selected runtime', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: [{ relativePath: 'readme.md' }], + _meta: { runtimeId: 'remote-runtime' } + }) + const context = { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + } + + await listRuntimeMarkdownDocuments(context, '/remote/repo') + await statRuntimePath(context, '/remote/repo/readme.md') + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'files.listMarkdownDocuments', + params: { worktree: 'wt-1' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'files.stat', + params: { worktree: 'wt-1', relativePath: 'readme.md' }, + timeoutMs: 15_000 + }) + }) + + it('does not fall back to client-local stat for remote-owned paths outside the worktree', async () => { + await expect( + statRuntimePath( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + '/tmp/readme.md' + ) + ).rejects.toThrow('outside the owning runtime worktree') + + await expect( + statRuntimePath( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: 'C:\\repo' + }, + '\\\\server\\share\\repo\\readme.md' + ) + ).rejects.toThrow('outside the owning runtime worktree') + + expect(fsStat).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('uses the local fs changed stream when no runtime environment is active', async () => { + const unsubscribe = vi.fn() + const onPayload = vi.fn() + fsOnChanged.mockReturnValue(unsubscribe) + + await expect( + subscribeRuntimeFileChanges( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + onPayload + ) + ).resolves.toBe(unsubscribe) + + expect(fsOnChanged).toHaveBeenCalledWith(onPayload) + expect(runtimeEnvironmentSubscribe).not.toHaveBeenCalled() + }) + + it('maps runtime file watch events back to fs changed payloads', async () => { + const onPayload = vi.fn() + const unsubscribe = vi.fn() + let onResponse: ((response: unknown) => void) | undefined + runtimeEnvironmentSubscribe.mockImplementation((_args, callbacks) => { + onResponse = callbacks.onResponse + return Promise.resolve({ unsubscribe, sendBinary: vi.fn() }) + }) + + const stop = await subscribeRuntimeFileChanges( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + onPayload + ) + + expect(runtimeEnvironmentSubscribe).toHaveBeenCalledWith( + { + selector: 'env-1', + method: 'files.watch', + params: { worktree: 'wt-1' }, + timeoutMs: 15_000 + }, + expect.any(Object) + ) + + onResponse?.({ + id: 'rpc-1', + ok: true, + result: { + type: 'changed', + worktree: 'wt-1', + events: [{ kind: 'update', absolutePath: '/remote/repo/readme.md' }] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + expect(onPayload).toHaveBeenCalledWith({ + worktreePath: '/remote/repo', + events: [{ kind: 'update', absolutePath: '/remote/repo/readme.md' }] + }) + + stop() + expect(unsubscribe).toHaveBeenCalled() + }) + + it('shares one remote file watch subscription across listeners for the same worktree', async () => { + const firstPayload = vi.fn() + const secondPayload = vi.fn() + const unsubscribe = vi.fn() + let onResponse: ((response: unknown) => void) | undefined + runtimeEnvironmentCall.mockResolvedValue({ + id: 'unwatch', + ok: true, + result: { unsubscribed: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + runtimeEnvironmentSubscribe.mockImplementation((_args, callbacks) => { + onResponse = callbacks.onResponse + return Promise.resolve({ unsubscribe, sendBinary: vi.fn() }) + }) + + const firstStop = await subscribeRuntimeFileChanges( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + firstPayload + ) + const secondStop = await subscribeRuntimeFileChanges( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + secondPayload + ) + + expect(runtimeEnvironmentSubscribe).toHaveBeenCalledTimes(1) + onResponse?.({ + id: 'ready', + ok: true, + result: { type: 'ready', subscriptionId: 'files-watch-1' }, + _meta: { runtimeId: 'remote-runtime' } + }) + onResponse?.({ + id: 'changed', + ok: true, + result: { + type: 'changed', + worktree: 'wt-1', + events: [{ kind: 'update', absolutePath: '/remote/repo/readme.md' }] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + expect(firstPayload).toHaveBeenCalledWith({ + worktreePath: '/remote/repo', + events: [{ kind: 'update', absolutePath: '/remote/repo/readme.md' }] + }) + expect(secondPayload).toHaveBeenCalledWith({ + worktreePath: '/remote/repo', + events: [{ kind: 'update', absolutePath: '/remote/repo/readme.md' }] + }) + + firstStop() + expect(unsubscribe).not.toHaveBeenCalled() + + secondStop() + expect(unsubscribe).toHaveBeenCalledTimes(1) + await vi.waitFor(() => + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.unwatch', + params: { subscriptionId: 'files-watch-1' }, + timeoutMs: 5_000 + }) + ) + }) +}) diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts new file mode 100644 index 00000000000..233f641c61b --- /dev/null +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -0,0 +1,860 @@ +/* eslint-disable max-lines -- Why: this client intentionally centralizes the +file preload API plus remote runtime fallbacks so call sites cannot drift on +local-vs-environment routing rules. */ +import type { + DirEntry, + FsChangedPayload, + GlobalSettings, + MarkdownDocument, + SearchOptions, + SearchResult +} from '../../../shared/types' +import type { RuntimeFilePreviewResult, RuntimeFileReadResult } from '../../../shared/runtime-types' +import { + callRuntimeRpc, + getActiveRuntimeTarget, + unwrapRuntimeRpcResult +} from './runtime-rpc-client' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { basename, joinPath, normalizeRelativePath } from '@/lib/path' +import { + isWindowsAbsolutePathLike, + relativePathInsideRoot +} from '../../../shared/cross-platform-path' + +export type RuntimeReadableFileContent = { + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string +} + +export type RuntimeFileReadArgs = { + settings: Pick | null | undefined + filePath: string + relativePath?: string + worktreeId?: string + connectionId?: string +} + +export type RuntimeFileOperationArgs = { + settings: Pick | null | undefined + worktreeId: string | null | undefined + worktreePath: string | null | undefined + connectionId?: string +} + +type StagedRuntimeImportSource = + | { + sourcePath: string + status: 'staged' + name: string + kind: 'file' | 'directory' + entries: StagedRuntimeImportEntry[] + } + | { + sourcePath: string + status: 'skipped' + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + } + | { sourcePath: string; status: 'failed'; reason: string } + +type StagedRuntimeImportEntry = + | { relativePath: string; kind: 'directory' } + | { relativePath: string; kind: 'file'; contentBase64: string } + +type RuntimeImportResult = + | { + sourcePath: string + status: 'imported' + destPath: string + kind: 'file' | 'directory' + renamed: boolean + } + | { + sourcePath: string + status: 'skipped' + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + } + | { + sourcePath: string + status: 'failed' + reason: string + } + +type RuntimeFileWatchEvent = + | { type: 'ready'; subscriptionId: string } + | { type: 'changed'; worktree: string; events: FsChangedPayload['events'] } + | { type: 'end' } + +const REMOTE_UPLOAD_BASE64_CHUNK_CHARS = 512 * 1024 + +type RuntimeFileWatchListener = { + onPayload: (payload: FsChangedPayload) => void + onError?: (error: Error) => void +} + +type SharedRuntimeFileWatch = { + target: { kind: 'environment'; environmentId: string } + worktreeId: string + listeners: Set + start: Promise + unsubscribe: (() => void) | null + remoteSubscriptionId: string | null + closed: boolean +} + +const sharedRuntimeFileWatches = new Map() + +function getSharedRuntimeFileWatchKey( + environmentId: string, + worktreeId: string, + worktreePath: string +): string { + return `${environmentId}\0${worktreeId}\0${worktreePath}` +} + +export function getRuntimeFileReadScope( + settings: Pick | null | undefined, + connectionId: string | undefined +): string | undefined { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' ? `runtime:${target.environmentId}` : connectionId +} + +export async function readRuntimeFileContent({ + settings, + filePath, + relativePath, + worktreeId, + connectionId +}: RuntimeFileReadArgs): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind !== 'environment') { + return window.api.fs.readFile({ filePath, connectionId }) + } + if (!worktreeId) { + return window.api.fs.readFile({ filePath, connectionId }) + } + if (!canReadRelativeRuntimeFile(relativePath)) { + throw new Error('Remote file is outside the owning runtime worktree') + } + + const result = await callRuntimeRpc( + target, + 'files.read', + { worktree: worktreeId, relativePath }, + { timeoutMs: 15_000 } + ) + if (result.truncated) { + // Why: the runtime file RPC is preview-sized today; treating a truncated + // payload as editable content would make saves overwrite the rest of the file. + throw new Error(`Remote file is too large to open in the editor (${result.byteLength} bytes)`) + } + return { content: result.content, isBinary: false } +} + +export async function readRuntimeFilePreview( + context: RuntimeFileOperationArgs, + filePath: string +): Promise { + const remoteArgs = getRemoteFileArgs(context, filePath) + if (!remoteArgs) { + if (hasRemoteRuntimeOwner(context)) { + throw new Error('Remote file is outside the owning runtime worktree') + } + return window.api.fs.readFile({ filePath, connectionId: context.connectionId }) + } + return callRuntimeRpc( + remoteArgs.target, + 'files.readPreview', + { worktree: remoteArgs.worktreeId, relativePath: remoteArgs.relativePath }, + { timeoutMs: 15_000 } + ) +} + +export async function readRuntimeDirectory( + context: RuntimeFileOperationArgs, + dirPath: string +): Promise { + const remoteArgs = getRemoteFileArgs(context, dirPath) + if (!remoteArgs) { + assertLocalFilesystemFallbackAllowed(context) + return window.api.fs.readDir({ dirPath, connectionId: context.connectionId }) + } + return callRuntimeRpc( + remoteArgs.target, + 'files.readDir', + { worktree: remoteArgs.worktreeId, relativePath: remoteArgs.relativePath }, + { timeoutMs: 15_000 } + ) +} + +export async function writeRuntimeFile( + context: RuntimeFileOperationArgs, + filePath: string, + content: string +): Promise { + const remoteArgs = getRemoteFileArgs(context, filePath) + if (!remoteArgs) { + assertLocalFilesystemFallbackAllowed(context) + await window.api.fs.writeFile({ filePath, content, connectionId: context.connectionId }) + return + } + await callRuntimeRpc( + remoteArgs.target, + 'files.write', + { worktree: remoteArgs.worktreeId, relativePath: remoteArgs.relativePath, content }, + { timeoutMs: 15_000 } + ) +} + +export async function createRuntimePath( + context: RuntimeFileOperationArgs, + path: string, + kind: 'file' | 'directory' +): Promise { + const remoteArgs = getRemoteFileArgs(context, path) + if (!remoteArgs) { + assertLocalFilesystemFallbackAllowed(context) + await (kind === 'directory' + ? window.api.fs.createDir({ dirPath: path, connectionId: context.connectionId }) + : window.api.fs.createFile({ filePath: path, connectionId: context.connectionId })) + return + } + await callRuntimeRpc( + remoteArgs.target, + kind === 'directory' ? 'files.createDir' : 'files.createFile', + { worktree: remoteArgs.worktreeId, relativePath: remoteArgs.relativePath }, + { timeoutMs: 15_000 } + ) +} + +export async function renameRuntimePath( + context: RuntimeFileOperationArgs, + oldPath: string, + newPath: string +): Promise { + const oldRemoteArgs = getRemoteFileArgs(context, oldPath) + const newRelativePath = getRelativePathInsideWorktree(context.worktreePath, newPath) + if (!oldRemoteArgs || newRelativePath === null) { + assertLocalFilesystemFallbackAllowed(context) + await window.api.fs.rename({ oldPath, newPath, connectionId: context.connectionId }) + return + } + await callRuntimeRpc( + oldRemoteArgs.target, + 'files.rename', + { + worktree: oldRemoteArgs.worktreeId, + oldRelativePath: oldRemoteArgs.relativePath, + newRelativePath + }, + { timeoutMs: 15_000 } + ) +} + +export async function copyRuntimePath( + context: RuntimeFileOperationArgs, + sourcePath: string, + destinationPath: string +): Promise { + const sourceArgs = getRemoteFileArgs(context, sourcePath) + const destinationArgs = getRemoteFileArgs(context, destinationPath) + if (!sourceArgs || !destinationArgs) { + assertLocalFilesystemFallbackAllowed(context) + await window.api.fs.copy({ + sourcePath, + destinationPath, + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc( + sourceArgs.target, + 'files.copy', + { + worktree: sourceArgs.worktreeId, + sourceRelativePath: sourceArgs.relativePath, + destinationRelativePath: destinationArgs.relativePath + }, + { timeoutMs: 15_000 } + ) +} + +export async function deleteRuntimePath( + context: RuntimeFileOperationArgs, + targetPath: string, + recursive?: boolean +): Promise { + const remoteArgs = getRemoteFileArgs(context, targetPath) + if (!remoteArgs) { + assertLocalFilesystemFallbackAllowed(context) + await window.api.fs.deletePath({ + targetPath, + connectionId: context.connectionId, + recursive + }) + return + } + await callRuntimeRpc( + remoteArgs.target, + 'files.delete', + { worktree: remoteArgs.worktreeId, relativePath: remoteArgs.relativePath, recursive }, + { timeoutMs: 15_000 } + ) +} + +export async function deleteRuntimeRelativePath( + context: RuntimeFileOperationArgs, + relativePath: string, + recursive?: boolean +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if ( + target.kind !== 'environment' || + !context.worktreeId || + !canReadRelativeRuntimeFile(relativePath) + ) { + return false + } + await callRuntimeRpc( + target, + 'files.delete', + { worktree: context.worktreeId, relativePath: normalizeRelativePath(relativePath), recursive }, + { timeoutMs: 15_000 } + ) + return true +} + +export async function importExternalPathsToRuntime( + context: RuntimeFileOperationArgs, + sourcePaths: string[], + destinationDir: string, + options?: { ensureDestinationDir?: boolean } +): Promise<{ results: RuntimeImportResult[] }> { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind !== 'environment' || !context.worktreeId || !context.worktreePath) { + return window.api.fs.importExternalPaths({ + sourcePaths, + destDir: destinationDir, + connectionId: context.connectionId, + ensureDir: options?.ensureDestinationDir + }) + } + + const destinationArgs = getRemoteFileArgs(context, destinationDir) + if (!destinationArgs) { + throw new Error('Destination is outside the active runtime worktree') + } + + const staged = await window.api.fs.stageExternalPathsForRuntimeUpload({ sourcePaths }) + const results: RuntimeImportResult[] = [] + const reservedNames = new Set() + + await ensureRuntimeDirectory(context, destinationDir) + + for (const source of staged.sources as StagedRuntimeImportSource[]) { + if (source.status !== 'staged') { + results.push(source) + continue + } + try { + const finalName = await deconflictRuntimeImportName( + context, + destinationDir, + source.name, + reservedNames + ) + const destPath = joinPath(destinationDir, finalName) + const destRelativePath = joinRuntimeRelativePath(destinationArgs.relativePath, finalName) + for (const entry of source.entries) { + const entryRelativePath = joinRuntimeRelativePath(destRelativePath, entry.relativePath) + if (entry.kind === 'directory') { + await callRuntimeRpc( + target, + 'files.createDirNoClobber', + { worktree: context.worktreeId, relativePath: entryRelativePath }, + { timeoutMs: 15_000 } + ) + continue + } + await uploadRuntimeFileWithoutClobber( + target, + context.worktreeId, + entryRelativePath, + entry.contentBase64 + ) + } + reservedNames.add(finalName) + results.push({ + sourcePath: source.sourcePath, + status: 'imported', + destPath, + kind: source.kind, + renamed: finalName !== source.name + }) + } catch (error) { + results.push({ + sourcePath: source.sourcePath, + status: 'failed', + reason: error instanceof Error ? error.message : String(error) + }) + } + } + + return { results } +} + +async function uploadRuntimeFileWithoutClobber( + target: { kind: 'environment'; environmentId: string }, + worktreeId: string, + relativePath: string, + contentBase64: string +): Promise { + const tempRelativePath = makeRuntimeUploadTempPath(relativePath) + try { + await writeRuntimeBase64File(target, worktreeId, tempRelativePath, contentBase64) + await callRuntimeRpc( + target, + 'files.commitUpload', + { + worktree: worktreeId, + tempRelativePath, + finalRelativePath: relativePath + }, + { timeoutMs: 30_000 } + ) + } finally { + await callRuntimeRpc( + target, + 'files.delete', + { worktree: worktreeId, relativePath: tempRelativePath, recursive: false }, + { timeoutMs: 15_000 } + ).catch(() => {}) + } +} + +async function writeRuntimeBase64File( + target: { kind: 'environment'; environmentId: string }, + worktreeId: string, + relativePath: string, + contentBase64: string +): Promise { + if (contentBase64.length <= REMOTE_UPLOAD_BASE64_CHUNK_CHARS) { + await callRuntimeRpc( + target, + 'files.writeBase64', + { worktree: worktreeId, relativePath, contentBase64 }, + { timeoutMs: 30_000 } + ) + return + } + + for (let offset = 0; offset < contentBase64.length; offset += REMOTE_UPLOAD_BASE64_CHUNK_CHARS) { + await callRuntimeRpc( + target, + 'files.writeBase64Chunk', + { + worktree: worktreeId, + relativePath, + contentBase64: contentBase64.slice(offset, offset + REMOTE_UPLOAD_BASE64_CHUNK_CHARS), + append: offset > 0 + }, + { timeoutMs: 30_000 } + ) + } +} + +function makeRuntimeUploadTempPath(relativePath: string): string { + const normalized = normalizeRelativePath(relativePath) + const slashIndex = normalized.lastIndexOf('/') + const dir = slashIndex === -1 ? '' : normalized.slice(0, slashIndex + 1) + const leaf = slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1) + const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + return `${dir}.${leaf}.orca-upload-${nonce}` +} + +async function ensureRuntimeDirectory( + context: RuntimeFileOperationArgs, + destinationDir: string +): Promise { + const destinationArgs = getRemoteFileArgs(context, destinationDir) + if (!destinationArgs) { + return + } + const parts = normalizeRelativePath(destinationArgs.relativePath) + .split('/') + .filter((part) => part.length > 0) + let current = '' + for (const part of parts) { + current = joinRuntimeRelativePath(current, part) + const absolutePath = joinPath(context.worktreePath ?? '', current) + if (await runtimePathExists(context, absolutePath)) { + continue + } + await callRuntimeRpc( + destinationArgs.target, + 'files.createDir', + { worktree: destinationArgs.worktreeId, relativePath: current }, + { timeoutMs: 15_000 } + ) + } +} + +export async function searchRuntimeFiles( + context: RuntimeFileOperationArgs, + options: SearchOptions +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind !== 'environment' || !context.worktreeId) { + return window.api.fs.search({ + ...options, + connectionId: context.connectionId + }) + } + const { rootPath: _rootPath, ...runtimeOptions } = options + return callRuntimeRpc( + target, + 'files.search', + { worktree: context.worktreeId, ...runtimeOptions }, + { timeoutMs: 15_000 } + ) +} + +export async function listRuntimeFiles( + context: RuntimeFileOperationArgs, + args: { rootPath: string; excludePaths?: string[] } +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind !== 'environment' || !context.worktreeId) { + return window.api.fs.listFiles({ + rootPath: args.rootPath, + connectionId: context.connectionId, + excludePaths: args.excludePaths + }) + } + return callRuntimeRpc( + target, + 'files.listAll', + { worktree: context.worktreeId, excludePaths: args.excludePaths }, + { timeoutMs: 15_000 } + ) +} + +export async function listRuntimeMarkdownDocuments( + context: RuntimeFileOperationArgs, + rootPath: string +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind !== 'environment' || !context.worktreeId) { + return window.api.fs.listMarkdownDocuments({ + rootPath, + connectionId: context.connectionId + }) + } + return callRuntimeRpc( + target, + 'files.listMarkdownDocuments', + { worktree: context.worktreeId }, + { timeoutMs: 15_000 } + ) +} + +export async function statRuntimePath( + context: RuntimeFileOperationArgs, + absolutePath: string +): Promise<{ size: number; isDirectory: boolean; mtime: number }> { + const remoteArgs = getRemoteFileArgs(context, absolutePath) + if (!remoteArgs) { + assertLocalFilesystemFallbackAllowed(context) + return window.api.fs.stat({ + filePath: absolutePath, + connectionId: context.connectionId + }) + } + return callRuntimeRpc<{ size: number; isDirectory: boolean; mtime: number }>( + remoteArgs.target, + 'files.stat', + { worktree: remoteArgs.worktreeId, relativePath: remoteArgs.relativePath }, + { timeoutMs: 15_000 } + ) +} + +export async function subscribeRuntimeFileChanges( + context: RuntimeFileOperationArgs, + onPayload: (payload: FsChangedPayload) => void, + onError?: (error: Error) => void +): Promise<() => void> { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind !== 'environment' || !context.worktreeId || !context.worktreePath) { + return window.api.fs.onFsChanged(onPayload) + } + + const listener: RuntimeFileWatchListener = { onPayload, onError } + const key = getSharedRuntimeFileWatchKey( + target.environmentId, + context.worktreeId, + context.worktreePath + ) + let shared = sharedRuntimeFileWatches.get(key) + if (!shared) { + shared = createSharedRuntimeFileWatch(key, target, context.worktreeId, context.worktreePath) + sharedRuntimeFileWatches.set(key, shared) + } + shared.listeners.add(listener) + try { + await shared.start + } catch (err) { + shared.listeners.delete(listener) + throw err + } + + return () => { + const current = sharedRuntimeFileWatches.get(key) + if (!current) { + return + } + current.listeners.delete(listener) + if (current.listeners.size === 0) { + closeSharedRuntimeFileWatch(key, current) + } + } +} + +function createSharedRuntimeFileWatch( + key: string, + target: { kind: 'environment'; environmentId: string }, + worktreeId: string, + worktreePath: string +): SharedRuntimeFileWatch { + const shared: SharedRuntimeFileWatch = { + target, + worktreeId, + listeners: new Set(), + start: Promise.resolve(), + unsubscribe: null, + remoteSubscriptionId: null, + closed: false + } + // Why: editor reloads and the Explorer can watch the same remote worktree. + // Keep one runtime WebSocket/server watcher and fan out events in renderer. + shared.start = window.api.runtimeEnvironments + .subscribe( + { + selector: target.environmentId, + method: 'files.watch', + params: { worktree: worktreeId }, + timeoutMs: 15_000 + }, + { + onResponse: (response) => { + handleSharedRuntimeFileWatchResponse(shared, worktreePath, response) + }, + onError: (error) => { + notifySharedRuntimeFileWatchError(shared, new Error(error.message)) + }, + onClose: () => { + if (sharedRuntimeFileWatches.get(key) === shared) { + sharedRuntimeFileWatches.delete(key) + } + shared.closed = true + shared.unsubscribe = null + } + } + ) + .then((subscription) => { + if (shared.closed || sharedRuntimeFileWatches.get(key) !== shared) { + subscription.unsubscribe() + unwatchSharedRuntimeFileWatch(shared) + return + } + shared.unsubscribe = subscription.unsubscribe + }) + .catch((err) => { + if (sharedRuntimeFileWatches.get(key) === shared) { + sharedRuntimeFileWatches.delete(key) + } + shared.closed = true + notifySharedRuntimeFileWatchError(shared, err instanceof Error ? err : new Error(String(err))) + throw err + }) + return shared +} + +function handleSharedRuntimeFileWatchResponse( + shared: SharedRuntimeFileWatch, + worktreePath: string, + response: unknown +): void { + try { + const event = unwrapRuntimeRpcResult( + response as RuntimeRpcResponse + ) + if (event.type === 'ready') { + shared.remoteSubscriptionId = event.subscriptionId + } else if (event.type === 'changed') { + for (const listener of Array.from(shared.listeners)) { + listener.onPayload({ worktreePath, events: event.events }) + } + } + } catch (err) { + notifySharedRuntimeFileWatchError(shared, err instanceof Error ? err : new Error(String(err))) + } +} + +function notifySharedRuntimeFileWatchError(shared: SharedRuntimeFileWatch, error: Error): void { + for (const listener of Array.from(shared.listeners)) { + listener.onError?.(error) + } +} + +function closeSharedRuntimeFileWatch(key: string, shared: SharedRuntimeFileWatch): void { + if (shared.closed) { + return + } + shared.closed = true + sharedRuntimeFileWatches.delete(key) + shared.unsubscribe?.() + shared.unsubscribe = null + unwatchSharedRuntimeFileWatch(shared) +} + +function unwatchSharedRuntimeFileWatch(shared: SharedRuntimeFileWatch): void { + if (!shared.remoteSubscriptionId) { + return + } + void callRuntimeRpc( + shared.target, + 'files.unwatch', + { subscriptionId: shared.remoteSubscriptionId }, + { timeoutMs: 5_000 } + ).catch(() => {}) +} + +export async function runtimePathExists( + context: RuntimeFileOperationArgs, + absolutePath: string +): Promise { + try { + await statRuntimePath(context, absolutePath) + return true + } catch (err) { + const message = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase() + if ( + message.includes('enoent') || + message.includes('not found') || + message.includes('no such file') + ) { + return false + } + throw err + } +} + +export function isRemoteRuntimeFileOperation( + context: RuntimeFileOperationArgs, + path: string +): boolean { + return getRemoteFileArgs(context, path) !== null +} + +function canReadRelativeRuntimeFile(relativePath: string | undefined): relativePath is string { + return Boolean(relativePath && relativePath.trim() && !isAbsolutePathLike(relativePath)) +} + +function isAbsolutePathLike(value: string): boolean { + return value.startsWith('/') || isWindowsAbsolutePathLike(value) +} + +function getRemoteFileArgs( + context: RuntimeFileOperationArgs, + absolutePath: string +): { + target: ReturnType & { kind: 'environment' } + worktreeId: string + relativePath: string +} | null { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind !== 'environment' || !context.worktreeId) { + return null + } + const relativePath = getRelativePathInsideWorktree(context.worktreePath, absolutePath) + if (relativePath === null) { + return null + } + return { target, worktreeId: context.worktreeId, relativePath } +} + +function hasRemoteRuntimeOwner(context: RuntimeFileOperationArgs): boolean { + return ( + getActiveRuntimeTarget(context.settings).kind === 'environment' && Boolean(context.worktreeId) + ) +} + +function assertLocalFilesystemFallbackAllowed(context: RuntimeFileOperationArgs): void { + if (hasRemoteRuntimeOwner(context)) { + throw new Error('Remote file is outside the owning runtime worktree') + } +} + +function getRelativePathInsideWorktree( + worktreePath: string | null | undefined, + absolutePath: string +): string | null { + if (!worktreePath) { + return null + } + return relativePathInsideRoot(worktreePath, absolutePath) +} + +async function deconflictRuntimeImportName( + context: RuntimeFileOperationArgs, + destinationDir: string, + originalName: string, + reservedNames: Set +): Promise { + if ( + !(await runtimePathExists(context, joinPath(destinationDir, originalName))) && + !reservedNames.has(originalName) + ) { + return originalName + } + + const dotIndex = originalName.lastIndexOf('.') + const hasMeaningfulExt = dotIndex > 0 + const stem = hasMeaningfulExt ? originalName.slice(0, dotIndex) : originalName + const ext = hasMeaningfulExt ? originalName.slice(dotIndex) : '' + let candidate = `${stem} copy${ext}` + if ( + !(await runtimePathExists(context, joinPath(destinationDir, candidate))) && + !reservedNames.has(candidate) + ) { + return candidate + } + + let counter = 2 + while (counter < 10000) { + candidate = `${stem} copy ${counter}${ext}` + if ( + !(await runtimePathExists(context, joinPath(destinationDir, candidate))) && + !reservedNames.has(candidate) + ) { + return candidate + } + counter += 1 + } + throw new Error(`Could not generate a unique name for '${basename(originalName)}'`) +} + +function joinRuntimeRelativePath(basePath: string, relativePath: string): string { + const normalizedBase = normalizeRelativePath(basePath) + const normalizedRelative = normalizeRelativePath(relativePath) + if (!normalizedBase) { + return normalizedRelative + } + if (!normalizedRelative) { + return normalizedBase + } + return `${normalizedBase}/${normalizedRelative}` +} diff --git a/src/renderer/src/runtime/runtime-git-client.test.ts b/src/renderer/src/runtime/runtime-git-client.test.ts new file mode 100644 index 00000000000..19888c32ae8 --- /dev/null +++ b/src/renderer/src/runtime/runtime-git-client.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + bulkStageRuntimeGitPaths, + commitRuntimeGit, + getRuntimeGitDiff, + getRuntimeGitStatus, + pushRuntimeGit +} from './runtime-git-client' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from './runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' + +const gitStatus = vi.fn() +const gitDiff = vi.fn() +const gitBulkStage = vi.fn() +const gitCommit = vi.fn() +const gitPush = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() +const runtimeCall = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + gitStatus.mockReset() + gitDiff.mockReset() + gitBulkStage.mockReset() + gitCommit.mockReset() + gitPush.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + git: { + status: gitStatus, + diff: gitDiff, + bulkStage: gitBulkStage, + commit: gitCommit, + push: gitPush + }, + runtime: { call: runtimeCall }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) +}) + +describe('runtime git client', () => { + it('uses local git IPC when no remote runtime is active', async () => { + gitStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + + await getRuntimeGitStatus({ + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1' + }) + + expect(gitStatus).toHaveBeenCalledWith({ worktreePath: '/repo', connectionId: 'ssh-1' }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('routes status and diffs through the active runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { entries: [], conflictOperation: 'unknown' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await getRuntimeGitStatus({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + await getRuntimeGitDiff( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { filePath: 'src/a.ts', staged: false, compareAgainstHead: true } + ) + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'git.status', + params: { worktree: 'wt-1' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'git.diff', + params: { + worktree: 'wt-1', + filePath: 'src/a.ts', + staged: false, + compareAgainstHead: true + }, + timeoutMs: 15_000 + }) + }) + + it('routes bulk stage and remote operations through the active runtime', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { success: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + const context = { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + } + + await bulkStageRuntimeGitPaths(context, ['a.ts', 'b.ts']) + await commitRuntimeGit(context, 'feat: test') + await pushRuntimeGit(context, { publish: true, pushTarget: { remote: 'origin' } as never }) + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'git.bulkStage', + params: { worktree: 'wt-1', filePaths: ['a.ts', 'b.ts'] }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'git.commit', + params: { worktree: 'wt-1', message: 'feat: test' }, + timeoutMs: 30_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { + selector: 'env-1', + method: 'git.push', + params: { worktree: 'wt-1', publish: true, pushTarget: { remote: 'origin' } }, + timeoutMs: 30_000 + }) + }) +}) diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts new file mode 100644 index 00000000000..759edbd624c --- /dev/null +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -0,0 +1,340 @@ +/* eslint-disable max-lines -- Why: this module mirrors the git preload API with +runtime-aware routing so source-control callers have one typed boundary instead +of reimplementing local-vs-environment branching per operation. */ +import type { + GitBranchCompareResult, + GitConflictOperation, + GitDiffResult, + GitPushTarget, + GitStatusResult, + GitUpstreamStatus, + GlobalSettings +} from '../../../shared/types' +import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' + +export type RuntimeGitContext = { + settings: Pick | null | undefined + worktreeId: string | null | undefined + worktreePath: string + connectionId?: string +} + +export function getRuntimeGitScope( + settings: Pick | null | undefined, + connectionId: string | undefined +): string | undefined { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' ? `runtime:${target.environmentId}` : connectionId +} + +export async function getRuntimeGitStatus(context: RuntimeGitContext): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.status({ + worktreePath: context.worktreePath, + connectionId: context.connectionId + }) + } + return callRuntimeRpc( + target, + 'git.status', + { worktree: context.worktreeId }, + { timeoutMs: 15_000 } + ) +} + +export async function getRuntimeGitConflictOperation( + context: RuntimeGitContext +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.conflictOperation({ + worktreePath: context.worktreePath, + connectionId: context.connectionId + }) + } + return callRuntimeRpc( + target, + 'git.conflictOperation', + { worktree: context.worktreeId }, + { timeoutMs: 15_000 } + ) +} + +export async function getRuntimeGitDiff( + context: RuntimeGitContext, + args: { filePath: string; staged: boolean; compareAgainstHead?: boolean } +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.diff({ + worktreePath: context.worktreePath, + filePath: args.filePath, + staged: args.staged, + compareAgainstHead: args.compareAgainstHead, + connectionId: context.connectionId + }) + } + return callRuntimeRpc( + target, + 'git.diff', + { worktree: context.worktreeId, ...args }, + { timeoutMs: 15_000 } + ) +} + +export async function getRuntimeGitBranchCompare( + context: RuntimeGitContext, + baseRef: string +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.branchCompare({ + worktreePath: context.worktreePath, + baseRef, + connectionId: context.connectionId + }) + } + return callRuntimeRpc( + target, + 'git.branchCompare', + { worktree: context.worktreeId, baseRef }, + { timeoutMs: 15_000 } + ) +} + +export async function getRuntimeGitUpstreamStatus( + context: RuntimeGitContext +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.upstreamStatus({ + worktreePath: context.worktreePath, + connectionId: context.connectionId + }) + } + return callRuntimeRpc( + target, + 'git.upstreamStatus', + { worktree: context.worktreeId }, + { timeoutMs: 15_000 } + ) +} + +export async function fetchRuntimeGit(context: RuntimeGitContext): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.fetch({ + worktreePath: context.worktreePath, + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc(target, 'git.fetch', { worktree: context.worktreeId }, { timeoutMs: 30_000 }) +} + +export async function pullRuntimeGit(context: RuntimeGitContext): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.pull({ + worktreePath: context.worktreePath, + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc(target, 'git.pull', { worktree: context.worktreeId }, { timeoutMs: 30_000 }) +} + +export async function pushRuntimeGit( + context: RuntimeGitContext, + args: { publish?: boolean; pushTarget?: GitPushTarget } = {} +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.push({ + worktreePath: context.worktreePath, + publish: args.publish, + pushTarget: args.pushTarget, + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc( + target, + 'git.push', + { worktree: context.worktreeId, publish: args.publish, pushTarget: args.pushTarget }, + { timeoutMs: 30_000 } + ) +} + +export async function getRuntimeGitBranchDiff( + context: RuntimeGitContext, + args: { + compare: { baseRef: string; baseOid: string; headOid: string; mergeBase: string } + filePath: string + oldPath?: string + } +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.branchDiff({ + worktreePath: context.worktreePath, + compare: args.compare, + filePath: args.filePath, + oldPath: args.oldPath, + connectionId: context.connectionId + }) + } + return callRuntimeRpc( + target, + 'git.branchDiff', + { worktree: context.worktreeId, ...args }, + { timeoutMs: 15_000 } + ) +} + +export async function commitRuntimeGit( + context: RuntimeGitContext, + message: string +): Promise<{ success: boolean; error?: string }> { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.commit({ + worktreePath: context.worktreePath, + message, + connectionId: context.connectionId + }) + } + return callRuntimeRpc<{ success: boolean; error?: string }>( + target, + 'git.commit', + { worktree: context.worktreeId, message }, + { timeoutMs: 30_000 } + ) +} + +export async function stageRuntimeGitPath( + context: RuntimeGitContext, + filePath: string +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.stage({ + worktreePath: context.worktreePath, + filePath, + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc( + target, + 'git.stage', + { worktree: context.worktreeId, filePath }, + { timeoutMs: 15_000 } + ) +} + +export async function bulkStageRuntimeGitPaths( + context: RuntimeGitContext, + filePaths: string[] +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.bulkStage({ + worktreePath: context.worktreePath, + filePaths, + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc( + target, + 'git.bulkStage', + { worktree: context.worktreeId, filePaths }, + { timeoutMs: 15_000 } + ) +} + +export async function unstageRuntimeGitPath( + context: RuntimeGitContext, + filePath: string +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.unstage({ + worktreePath: context.worktreePath, + filePath, + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc( + target, + 'git.unstage', + { worktree: context.worktreeId, filePath }, + { timeoutMs: 15_000 } + ) +} + +export async function bulkUnstageRuntimeGitPaths( + context: RuntimeGitContext, + filePaths: string[] +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.bulkUnstage({ + worktreePath: context.worktreePath, + filePaths, + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc( + target, + 'git.bulkUnstage', + { worktree: context.worktreeId, filePaths }, + { timeoutMs: 15_000 } + ) +} + +export async function discardRuntimeGitPath( + context: RuntimeGitContext, + filePath: string +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.discard({ + worktreePath: context.worktreePath, + filePath, + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc( + target, + 'git.discard', + { worktree: context.worktreeId, filePath }, + { timeoutMs: 15_000 } + ) +} + +export async function getRuntimeGitRemoteFileUrl( + context: RuntimeGitContext, + args: { relativePath: string; line: number } +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.remoteFileUrl({ + worktreePath: context.worktreePath, + relativePath: args.relativePath, + line: args.line, + connectionId: context.connectionId + }) + } + return callRuntimeRpc( + target, + 'git.remoteFileUrl', + { worktree: context.worktreeId, relativePath: args.relativePath, line: args.line }, + { timeoutMs: 15_000 } + ) +} diff --git a/src/renderer/src/runtime/runtime-hooks-client.test.ts b/src/renderer/src/runtime/runtime-hooks-client.test.ts new file mode 100644 index 00000000000..53c9d6891a1 --- /dev/null +++ b/src/renderer/src/runtime/runtime-hooks-client.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + checkRuntimeHooks, + readRuntimeIssueCommand, + writeRuntimeIssueCommand +} from './runtime-hooks-client' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from './runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' + +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() +const hooksCheck = vi.fn() +const hooksReadIssueCommand = vi.fn() +const hooksWriteIssueCommand = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + hooksCheck.mockReset() + hooksReadIssueCommand.mockReset() + hooksWriteIssueCommand.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { call: runtimeEnvironmentTransportCall }, + hooks: { + check: hooksCheck, + readIssueCommand: hooksReadIssueCommand, + writeIssueCommand: hooksWriteIssueCommand + } + } + }) +}) + +describe('runtime hooks client', () => { + it('uses local hook IPC when no runtime environment is active', async () => { + hooksCheck.mockResolvedValue({ hasHooks: false, hooks: null, mayNeedUpdate: false }) + hooksReadIssueCommand.mockResolvedValue({ + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: '', + source: 'none' + }) + + await checkRuntimeHooks({ activeRuntimeEnvironmentId: null }, 'repo-1') + await readRuntimeIssueCommand({ activeRuntimeEnvironmentId: null }, 'repo-1') + await writeRuntimeIssueCommand({ activeRuntimeEnvironmentId: null }, 'repo-1', 'Fix it') + + expect(hooksCheck).toHaveBeenCalledWith({ repoId: 'repo-1' }) + expect(hooksReadIssueCommand).toHaveBeenCalledWith({ repoId: 'repo-1' }) + expect(hooksWriteIssueCommand).toHaveBeenCalledWith({ repoId: 'repo-1', content: 'Fix it' }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('routes hook operations through the active runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'runtime-1' } + }) + + await checkRuntimeHooks({ activeRuntimeEnvironmentId: 'env-1' }, 'repo-1') + await readRuntimeIssueCommand({ activeRuntimeEnvironmentId: 'env-1' }, 'repo-1') + await writeRuntimeIssueCommand({ activeRuntimeEnvironmentId: 'env-1' }, 'repo-1', 'Fix it') + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'repo.hooksCheck', + params: { repo: 'repo-1' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'repo.issueCommandRead', + params: { repo: 'repo-1' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { + selector: 'env-1', + method: 'repo.issueCommandWrite', + params: { repo: 'repo-1', content: 'Fix it' }, + timeoutMs: 15_000 + }) + expect(hooksCheck).not.toHaveBeenCalled() + expect(hooksReadIssueCommand).not.toHaveBeenCalled() + expect(hooksWriteIssueCommand).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/runtime-hooks-client.ts b/src/renderer/src/runtime/runtime-hooks-client.ts new file mode 100644 index 00000000000..3179de7eef0 --- /dev/null +++ b/src/renderer/src/runtime/runtime-hooks-client.ts @@ -0,0 +1,66 @@ +import type { GlobalSettings, OrcaHooks } from '../../../shared/types' +import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' + +export type HookCheckResult = { + hasHooks: boolean + hooks: OrcaHooks | null + mayNeedUpdate: boolean +} + +export type IssueCommandReadResult = { + localContent: string | null + sharedContent: string | null + effectiveContent: string | null + localFilePath: string + source: 'local' | 'shared' | 'none' +} + +export async function checkRuntimeHooks( + settings: Pick | null | undefined, + repoId: string +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind !== 'environment') { + return window.api.hooks.check({ repoId }) + } + return callRuntimeRpc( + target, + 'repo.hooksCheck', + { repo: repoId }, + { timeoutMs: 15_000 } + ) +} + +export async function readRuntimeIssueCommand( + settings: Pick | null | undefined, + repoId: string +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind !== 'environment') { + return window.api.hooks.readIssueCommand({ repoId }) + } + return callRuntimeRpc( + target, + 'repo.issueCommandRead', + { repo: repoId }, + { timeoutMs: 15_000 } + ) +} + +export async function writeRuntimeIssueCommand( + settings: Pick | null | undefined, + repoId: string, + content: string +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind !== 'environment') { + await window.api.hooks.writeIssueCommand({ repoId, content }) + return + } + await callRuntimeRpc( + target, + 'repo.issueCommandWrite', + { repo: repoId, content }, + { timeoutMs: 15_000 } + ) +} diff --git a/src/renderer/src/runtime/runtime-linear-client.test.ts b/src/renderer/src/runtime/runtime-linear-client.test.ts new file mode 100644 index 00000000000..a552db06e19 --- /dev/null +++ b/src/renderer/src/runtime/runtime-linear-client.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + linearCreateIssue, + linearListTeams, + linearSearchIssues, + linearStatus, + linearUpdateIssue +} from './runtime-linear-client' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from './runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' + +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() +const linearStatusLocal = vi.fn() +const linearSearchIssuesLocal = vi.fn() +const linearCreateIssueLocal = vi.fn() +const linearUpdateIssueLocal = vi.fn() +const linearListTeamsLocal = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + linearStatusLocal.mockReset() + linearSearchIssuesLocal.mockReset() + linearCreateIssueLocal.mockReset() + linearUpdateIssueLocal.mockReset() + linearListTeamsLocal.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { call: runtimeEnvironmentTransportCall }, + linear: { + status: linearStatusLocal, + searchIssues: linearSearchIssuesLocal, + createIssue: linearCreateIssueLocal, + updateIssue: linearUpdateIssueLocal, + listTeams: linearListTeamsLocal + } + } + }) +}) + +describe('runtime linear client', () => { + it('uses local Linear IPC when no runtime environment is active', async () => { + linearStatusLocal.mockResolvedValue({ connected: false, viewer: null }) + linearSearchIssuesLocal.mockResolvedValue([{ id: 'issue-1' }]) + + await expect(linearStatus({ activeRuntimeEnvironmentId: null })).resolves.toEqual({ + connected: false, + viewer: null + }) + await expect( + linearSearchIssues({ activeRuntimeEnvironmentId: null }, 'bug', 10) + ).resolves.toEqual([{ id: 'issue-1' }]) + + expect(linearStatusLocal).toHaveBeenCalled() + expect(linearSearchIssuesLocal).toHaveBeenCalledWith({ query: 'bug', limit: 10 }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('routes Linear reads through the selected runtime environment', async () => { + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'rpc-status', + ok: true, + result: { connected: true, viewer: null }, + _meta: { runtimeId: 'runtime-1' } + }) + .mockResolvedValueOnce({ + id: 'rpc-search', + ok: true, + result: [{ id: 'issue-1' }], + _meta: { runtimeId: 'runtime-1' } + }) + + await linearStatus({ activeRuntimeEnvironmentId: 'env-1' }) + await linearSearchIssues({ activeRuntimeEnvironmentId: 'env-1' }, 'bug', 10) + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'linear.status', + params: undefined, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'linear.searchIssues', + params: { query: 'bug', limit: 10 }, + timeoutMs: 30_000 + }) + expect(linearStatusLocal).not.toHaveBeenCalled() + expect(linearSearchIssuesLocal).not.toHaveBeenCalled() + }) + + it('routes Linear mutations and metadata through the selected runtime environment', async () => { + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'rpc-create', + ok: true, + result: { ok: true, id: 'issue-1', identifier: 'ENG-1', url: 'https://linear.app/ENG-1' }, + _meta: { runtimeId: 'runtime-1' } + }) + .mockResolvedValueOnce({ + id: 'rpc-update', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'runtime-1' } + }) + .mockResolvedValueOnce({ + id: 'rpc-teams', + ok: true, + result: [{ id: 'team-1' }], + _meta: { runtimeId: 'runtime-1' } + }) + + await linearCreateIssue( + { activeRuntimeEnvironmentId: 'env-1' }, + { teamId: 'team-1', title: 'Fix bug' } + ) + await linearUpdateIssue({ activeRuntimeEnvironmentId: 'env-1' }, 'issue-1', { priority: 2 }) + await linearListTeams({ activeRuntimeEnvironmentId: 'env-1' }) + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'linear.createIssue', + params: { teamId: 'team-1', title: 'Fix bug' }, + timeoutMs: 30_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'linear.updateIssue', + params: { id: 'issue-1', updates: { priority: 2 } }, + timeoutMs: 30_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { + selector: 'env-1', + method: 'linear.listTeams', + params: undefined, + timeoutMs: 30_000 + }) + }) +}) diff --git a/src/renderer/src/runtime/runtime-linear-client.ts b/src/renderer/src/runtime/runtime-linear-client.ts new file mode 100644 index 00000000000..7821a46d8aa --- /dev/null +++ b/src/renderer/src/runtime/runtime-linear-client.ts @@ -0,0 +1,222 @@ +import type { + GlobalSettings, + LinearComment, + LinearConnectionStatus, + LinearIssue, + LinearIssueUpdate, + LinearLabel, + LinearMember, + LinearTeam, + LinearViewer, + LinearWorkflowState +} from '../../../shared/types' +import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' + +export type RuntimeLinearSettings = + | Pick + | null + | undefined + +export type LinearIssueFilter = 'assigned' | 'created' | 'all' | 'completed' +export type LinearConnectResult = { ok: true; viewer: LinearViewer } | { ok: false; error: string } +export type LinearCreateIssueResult = + | { ok: true; id: string; identifier: string; url: string } + | { ok: false; error: string } +export type LinearMutationResult = { ok: true } | { ok: false; error: string } +export type LinearCommentResult = { ok: true; id: string } | { ok: false; error: string } + +export async function linearStatus( + settings: RuntimeLinearSettings +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc(target, 'linear.status', undefined, { + timeoutMs: 15_000 + }) + : window.api.linear.status() +} + +export async function linearTestConnection( + settings: RuntimeLinearSettings +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc(target, 'linear.testConnection', undefined, { + timeoutMs: 30_000 + }) + : window.api.linear.testConnection() +} + +export async function linearConnect( + settings: RuntimeLinearSettings, + apiKey: string +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.connect', + { apiKey }, + { timeoutMs: 30_000 } + ) + : window.api.linear.connect({ apiKey }) +} + +export async function linearDisconnect(settings: RuntimeLinearSettings): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'environment') { + await callRuntimeRpc<{ ok: true }>(target, 'linear.disconnect', undefined, { + timeoutMs: 15_000 + }) + return + } + await window.api.linear.disconnect() +} + +export async function linearSearchIssues( + settings: RuntimeLinearSettings, + query: string, + limit?: number +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.searchIssues', + { query, limit }, + { timeoutMs: 30_000 } + ) + : window.api.linear.searchIssues({ query, limit }) +} + +export async function linearListIssues( + settings: RuntimeLinearSettings, + filter?: LinearIssueFilter, + limit?: number +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.listIssues', + { filter, limit }, + { timeoutMs: 30_000 } + ) + : window.api.linear.listIssues({ filter, limit }) +} + +export async function linearCreateIssue( + settings: RuntimeLinearSettings, + args: { teamId: string; title: string; description?: string } +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc(target, 'linear.createIssue', args, { + timeoutMs: 30_000 + }) + : window.api.linear.createIssue(args) +} + +export async function linearGetIssue( + settings: RuntimeLinearSettings, + id: string +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc(target, 'linear.getIssue', { id }, { timeoutMs: 30_000 }) + : window.api.linear.getIssue({ id }) +} + +export async function linearUpdateIssue( + settings: RuntimeLinearSettings, + id: string, + updates: LinearIssueUpdate +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.updateIssue', + { id, updates }, + { timeoutMs: 30_000 } + ) + : window.api.linear.updateIssue({ id, updates }) +} + +export async function linearAddIssueComment( + settings: RuntimeLinearSettings, + issueId: string, + body: string +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.addIssueComment', + { issueId, body }, + { timeoutMs: 30_000 } + ) + : window.api.linear.addIssueComment({ issueId, body }) +} + +export async function linearIssueComments( + settings: RuntimeLinearSettings, + issueId: string +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.issueComments', + { issueId }, + { timeoutMs: 30_000 } + ) + : window.api.linear.issueComments({ issueId }) +} + +export async function linearListTeams(settings: RuntimeLinearSettings): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc(target, 'linear.listTeams', undefined, { timeoutMs: 30_000 }) + : window.api.linear.listTeams() +} + +export async function linearTeamStates( + settings: RuntimeLinearSettings, + teamId: string +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.teamStates', + { teamId }, + { timeoutMs: 30_000 } + ) + : window.api.linear.teamStates({ teamId }) +} + +export async function linearTeamLabels( + settings: RuntimeLinearSettings, + teamId: string +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc(target, 'linear.teamLabels', { teamId }, { timeoutMs: 30_000 }) + : window.api.linear.teamLabels({ teamId }) +} + +export async function linearTeamMembers( + settings: RuntimeLinearSettings, + teamId: string +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.teamMembers', + { teamId }, + { timeoutMs: 30_000 } + ) + : window.api.linear.teamMembers({ teamId }) +} diff --git a/src/renderer/src/runtime/runtime-notes-client.test.ts b/src/renderer/src/runtime/runtime-notes-client.test.ts new file mode 100644 index 00000000000..ee15a592106 --- /dev/null +++ b/src/renderer/src/runtime/runtime-notes-client.test.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createRuntimeProjectNote, + deleteRuntimeProjectNote, + linkRuntimeProjectNote, + listRuntimeProjectNotes, + renameRuntimeProjectNote, + resolveRuntimeNotesPanelState, + saveRuntimeProjectNote, + showRuntimeProjectNote +} from './runtime-notes-client' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from './runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' + +const notesList = vi.fn() +const notesShow = vi.fn() +const notesCreate = vi.fn() +const notesSave = vi.fn() +const notesRename = vi.fn() +const notesDelete = vi.fn() +const notesAppend = vi.fn() +const notesSearch = vi.fn() +const notesLink = vi.fn() +const notesPanelState = vi.fn() +const runtimeCall = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + notesList.mockReset() + notesShow.mockReset() + notesCreate.mockReset() + notesSave.mockReset() + notesRename.mockReset() + notesDelete.mockReset() + notesAppend.mockReset() + notesSearch.mockReset() + notesLink.mockReset() + notesPanelState.mockReset() + runtimeCall.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + notes: { + list: notesList, + show: notesShow, + create: notesCreate, + save: notesSave, + rename: notesRename, + delete: notesDelete, + append: notesAppend, + search: notesSearch, + link: notesLink, + panelState: notesPanelState + }, + runtime: { call: runtimeCall }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) +}) + +describe('runtime notes client', () => { + it('uses local notes IPC when no remote runtime is active', async () => { + notesList.mockResolvedValue({ notes: [], totalCount: 0, truncated: false }) + + await listRuntimeProjectNotes( + { activeRuntimeEnvironmentId: null }, + { projectId: 'repo-1', worktreeId: 'wt-1', limit: 100 } + ) + + expect(notesList).toHaveBeenCalledWith({ + projectId: 'repo-1', + worktreeId: 'wt-1', + limit: 100 + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('routes note reads through the active runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { notes: [], totalCount: 0, truncated: false }, + _meta: { runtimeId: 'remote-runtime' } + }) + const settings = { activeRuntimeEnvironmentId: 'env-1' } + + await listRuntimeProjectNotes(settings, { + projectId: 'repo-1', + worktreeId: 'wt-1', + limit: 100 + }) + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-2', + ok: true, + result: { note: { id: 'note-1' }, linkKind: null }, + _meta: { runtimeId: 'remote-runtime' } + }) + await showRuntimeProjectNote(settings, { + projectId: 'repo-1', + worktreeId: 'wt-1', + note: 'note-1' + }) + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'note.list', + params: { worktree: 'wt-1', limit: 100 }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'note.show', + params: { worktree: 'wt-1', note: 'note-1' }, + timeoutMs: 15_000 + }) + }) + + it('routes note mutations through the active runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { note: { id: 'note-1' }, linkKind: 'active' }, + _meta: { runtimeId: 'remote-runtime' } + }) + const settings = { activeRuntimeEnvironmentId: 'env-1' } + const base = { projectId: 'repo-1', worktreeId: 'wt-1' } + + await createRuntimeProjectNote(settings, { ...base, title: 'Plan', bodyMarkdown: 'body' }) + await saveRuntimeProjectNote(settings, { + ...base, + note: 'note-1', + title: 'Plan', + bodyMarkdown: 'updated', + revision: 3, + makeActive: true + }) + await renameRuntimeProjectNote(settings, { ...base, note: 'note-1', title: 'Renamed' }) + await deleteRuntimeProjectNote(settings, { ...base, note: 'note-1' }) + await linkRuntimeProjectNote(settings, { ...base, note: 'note-1', kind: 'active' }) + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'note.create', + params: { + worktree: 'wt-1', + title: 'Plan', + bodyMarkdown: 'body', + makeActive: undefined, + createdBySessionId: undefined + }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'note.save', + params: { + worktree: 'wt-1', + note: 'note-1', + title: 'Plan', + bodyMarkdown: 'updated', + revision: 3, + makeActive: true, + updatedBySessionId: undefined + }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { + selector: 'env-1', + method: 'note.rename', + params: { + worktree: 'wt-1', + note: 'note-1', + title: 'Renamed', + updatedBySessionId: undefined + }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, { + selector: 'env-1', + method: 'note.delete', + params: { worktree: 'wt-1', note: 'note-1' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, { + selector: 'env-1', + method: 'note.link', + params: { worktree: 'wt-1', note: 'note-1', kind: 'active' }, + timeoutMs: 15_000 + }) + }) + + it('routes panel state through the active runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { state: 'emptyDraft', projectId: 'repo-1', worktreeId: 'wt-1' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await resolveRuntimeNotesPanelState( + { activeRuntimeEnvironmentId: 'env-1' }, + { projectId: 'repo-1', worktreeId: 'wt-1' } + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'note.panelState', + params: { worktree: 'wt-1' }, + timeoutMs: 15_000 + }) + }) + + it('returns noProject for remote panel state without a project/worktree', async () => { + await expect( + resolveRuntimeNotesPanelState({ activeRuntimeEnvironmentId: 'env-1' }, {}) + ).resolves.toEqual({ state: 'noProject' }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/runtime-notes-client.ts b/src/renderer/src/runtime/runtime-notes-client.ts new file mode 100644 index 00000000000..669d9d8681c --- /dev/null +++ b/src/renderer/src/runtime/runtime-notes-client.ts @@ -0,0 +1,229 @@ +import type { GlobalSettings } from '../../../shared/types' +import type { + NoteAppendArgs, + NoteCreateArgs, + NoteDeleteArgs, + NoteDeleteResult, + NoteLink, + NoteLinkArgs, + NoteListArgs, + NoteListResult, + NoteMutationResult, + NoteRenameArgs, + NoteSaveArgs, + NoteSearchArgs, + NoteShowArgs, + NoteShowResult, + NotesPanelOpenState, + NotesPanelStateArgs +} from '../../../shared/notes-types' +import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' + +type RuntimeNotesSettings = Pick | null | undefined + +function requireWorktreeId(worktreeId: string | null | undefined): string { + if (!worktreeId?.trim()) { + throw new Error('Project notes require an active worktree on remote runtime servers.') + } + return worktreeId +} + +function noteTarget(settings: RuntimeNotesSettings): ReturnType { + return getActiveRuntimeTarget(settings) +} + +export async function listRuntimeProjectNotes( + settings: RuntimeNotesSettings, + args: NoteListArgs +): Promise { + const target = noteTarget(settings) + if (target.kind === 'local') { + return window.api.notes.list(args) + } + return callRuntimeRpc( + target, + 'note.list', + { worktree: requireWorktreeId(args.worktreeId), limit: args.limit }, + { timeoutMs: 15_000 } + ) +} + +export async function showRuntimeProjectNote( + settings: RuntimeNotesSettings, + args: NoteShowArgs +): Promise { + const target = noteTarget(settings) + if (target.kind === 'local') { + return window.api.notes.show(args) + } + return callRuntimeRpc( + target, + 'note.show', + { worktree: requireWorktreeId(args.worktreeId), note: args.note }, + { timeoutMs: 15_000 } + ) +} + +export async function createRuntimeProjectNote( + settings: RuntimeNotesSettings, + args: NoteCreateArgs +): Promise { + const target = noteTarget(settings) + if (target.kind === 'local') { + return window.api.notes.create(args) + } + return callRuntimeRpc( + target, + 'note.create', + { + worktree: requireWorktreeId(args.worktreeId), + title: args.title, + bodyMarkdown: args.bodyMarkdown, + makeActive: args.makeActive, + createdBySessionId: args.createdBySessionId + }, + { timeoutMs: 15_000 } + ) +} + +export async function saveRuntimeProjectNote( + settings: RuntimeNotesSettings, + args: NoteSaveArgs +): Promise { + const target = noteTarget(settings) + if (target.kind === 'local') { + return window.api.notes.save(args) + } + return callRuntimeRpc( + target, + 'note.save', + { + worktree: requireWorktreeId(args.worktreeId), + note: args.note, + title: args.title, + bodyMarkdown: args.bodyMarkdown, + revision: args.revision, + makeActive: args.makeActive, + updatedBySessionId: args.updatedBySessionId + }, + { timeoutMs: 15_000 } + ) +} + +export async function renameRuntimeProjectNote( + settings: RuntimeNotesSettings, + args: NoteRenameArgs +): Promise { + const target = noteTarget(settings) + if (target.kind === 'local') { + return window.api.notes.rename(args) + } + return callRuntimeRpc( + target, + 'note.rename', + { + worktree: requireWorktreeId(args.worktreeId), + note: args.note, + title: args.title, + updatedBySessionId: args.updatedBySessionId + }, + { timeoutMs: 15_000 } + ) +} + +export async function deleteRuntimeProjectNote( + settings: RuntimeNotesSettings, + args: NoteDeleteArgs +): Promise { + const target = noteTarget(settings) + if (target.kind === 'local') { + return window.api.notes.delete(args) + } + return callRuntimeRpc( + target, + 'note.delete', + { worktree: requireWorktreeId(args.worktreeId), note: args.note }, + { timeoutMs: 15_000 } + ) +} + +export async function appendRuntimeProjectNote( + settings: RuntimeNotesSettings, + args: NoteAppendArgs +): Promise { + const target = noteTarget(settings) + if (target.kind === 'local') { + return window.api.notes.append(args) + } + return callRuntimeRpc( + target, + 'note.append', + { + worktree: requireWorktreeId(args.worktreeId), + note: args.note, + bodyMarkdown: args.bodyMarkdown, + makeActive: args.makeActive, + updatedBySessionId: args.updatedBySessionId + }, + { timeoutMs: 15_000 } + ) +} + +export async function searchRuntimeProjectNotes( + settings: RuntimeNotesSettings, + args: NoteSearchArgs +): Promise { + const target = noteTarget(settings) + if (target.kind === 'local') { + return window.api.notes.search(args) + } + return callRuntimeRpc( + target, + 'note.search', + { + worktree: requireWorktreeId(args.worktreeId), + query: args.query, + limit: args.limit + }, + { timeoutMs: 15_000 } + ) +} + +export async function linkRuntimeProjectNote( + settings: RuntimeNotesSettings, + args: NoteLinkArgs +): Promise { + const target = noteTarget(settings) + if (target.kind === 'local') { + return window.api.notes.link(args) + } + return callRuntimeRpc( + target, + 'note.link', + { + worktree: requireWorktreeId(args.worktreeId), + note: args.note, + kind: args.kind + }, + { timeoutMs: 15_000 } + ) +} + +export async function resolveRuntimeNotesPanelState( + settings: RuntimeNotesSettings, + args: NotesPanelStateArgs +): Promise { + const target = noteTarget(settings) + if (target.kind === 'local') { + return window.api.notes.panelState(args) + } + if (!args.projectId && !args.worktreeId) { + return { state: 'noProject' } + } + return callRuntimeRpc( + target, + 'note.panelState', + { worktree: requireWorktreeId(args.worktreeId) }, + { timeoutMs: 15_000 } + ) +} diff --git a/src/renderer/src/runtime/runtime-protocol-compat.ts b/src/renderer/src/runtime/runtime-protocol-compat.ts new file mode 100644 index 00000000000..3459c01b834 --- /dev/null +++ b/src/renderer/src/runtime/runtime-protocol-compat.ts @@ -0,0 +1,19 @@ +import { describeRuntimeCompatBlock, evaluateRuntimeCompat } from '../../../shared/protocol-compat' +import { + MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../shared/protocol-version' +import type { RuntimeStatus } from '../../../shared/runtime-types' + +export function assertRuntimeStatusCompatible(status: RuntimeStatus): void { + const verdict = evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion, + serverMinCompatibleClientProtocolVersion: + status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion + }) + if (verdict.kind === 'blocked') { + throw new Error(describeRuntimeCompatBlock(verdict)) + } +} diff --git a/src/renderer/src/runtime/runtime-repo-client.ts b/src/renderer/src/runtime/runtime-repo-client.ts new file mode 100644 index 00000000000..bdd596c5da7 --- /dev/null +++ b/src/renderer/src/runtime/runtime-repo-client.ts @@ -0,0 +1,42 @@ +import type { GlobalSettings } from '../../../shared/types' +import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' + +export type RuntimeRepoBaseRefDefault = { + defaultBaseRef: string | null + remoteCount: number +} + +export async function getRuntimeRepoBaseRefDefault( + settings: Pick | null | undefined, + repoId: string +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind !== 'environment') { + return window.api.repos.getBaseRefDefault({ repoId }) + } + return callRuntimeRpc( + target, + 'repo.baseRefDefault', + { repo: repoId }, + { timeoutMs: 15_000 } + ) +} + +export async function searchRuntimeRepoBaseRefs( + settings: Pick | null | undefined, + repoId: string, + query: string, + limit: number +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind !== 'environment') { + return window.api.repos.searchBaseRefs({ repoId, query, limit }) + } + const result = await callRuntimeRpc<{ refs: string[]; truncated: boolean }>( + target, + 'repo.searchRefs', + { repo: repoId, query, limit }, + { timeoutMs: 15_000 } + ) + return result.refs +} diff --git a/src/renderer/src/runtime/runtime-rpc-client.test.ts b/src/renderer/src/runtime/runtime-rpc-client.test.ts new file mode 100644 index 00000000000..21eec50c6c0 --- /dev/null +++ b/src/renderer/src/runtime/runtime-rpc-client.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + callRuntimeRpc, + clearRuntimeCompatibilityCacheForTests, + getActiveRuntimeTarget, + RuntimeRpcCallError, + unwrapRuntimeRpcResult +} from './runtime-rpc-client' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../shared/protocol-version' + +const runtimeCall = vi.fn() +const runtimeEnvironmentCall = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + runtimeCall.mockReset() + runtimeEnvironmentCall.mockReset() + vi.stubGlobal('window', { + api: { + runtime: { call: runtimeCall }, + runtimeEnvironments: { call: runtimeEnvironmentCall } + } + }) +}) + +describe('runtime RPC client routing', () => { + it('uses the local runtime when no active environment is selected', () => { + expect(getActiveRuntimeTarget(null)).toEqual({ kind: 'local' }) + expect(getActiveRuntimeTarget({ activeRuntimeEnvironmentId: null })).toEqual({ kind: 'local' }) + expect(getActiveRuntimeTarget({ activeRuntimeEnvironmentId: ' ' })).toEqual({ kind: 'local' }) + }) + + it('uses the active saved environment when one is selected', () => { + expect(getActiveRuntimeTarget({ activeRuntimeEnvironmentId: 'env-1' })).toEqual({ + kind: 'environment', + environmentId: 'env-1' + }) + }) + + it('routes local runtime calls through window.api.runtime.call', async () => { + runtimeCall.mockResolvedValue({ + id: 'local', + ok: true, + result: [{ id: 'repo-1' }], + _meta: { runtimeId: 'local-runtime' } + }) + + await expect(callRuntimeRpc({ kind: 'local' }, 'repo.list')).resolves.toEqual([ + { id: 'repo-1' } + ]) + expect(runtimeCall).toHaveBeenCalledWith({ method: 'repo.list', params: undefined }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('routes remote runtime calls through window.api.runtimeEnvironments.call', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'remote', + ok: true, + result: { graphStatus: 'ready' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + callRuntimeRpc({ kind: 'environment', environmentId: 'env-1' }, 'status.get', undefined, { + timeoutMs: 50 + }) + ).resolves.toEqual({ graphStatus: 'ready' }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'status.get', + params: undefined, + timeoutMs: 50 + }) + expect(runtimeCall).not.toHaveBeenCalled() + }) + + it('preflights remote runtime compatibility before non-status calls', async () => { + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + const result = + method === 'status.get' + ? { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } + : { repos: [{ id: 'repo-1' }] } + return Promise.resolve({ + id: method, + ok: true, + result, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + await expect( + callRuntimeRpc({ kind: 'environment', environmentId: 'env-compat' }, 'repo.list') + ).resolves.toEqual({ repos: [{ id: 'repo-1' }] }) + + expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([ + 'status.get', + 'repo.list' + ]) + }) + + it('caches successful remote compatibility checks per environment', async () => { + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + const result = + method === 'status.get' + ? { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } + : { ok: true } + return Promise.resolve({ + id: method, + ok: true, + result, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + await callRuntimeRpc({ kind: 'environment', environmentId: 'env-cache' }, 'repo.list') + await callRuntimeRpc({ kind: 'environment', environmentId: 'env-cache' }, 'worktree.list') + + expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([ + 'status.get', + 'repo.list', + 'worktree.list' + ]) + }) + + it('throws structured runtime RPC failures', () => { + const failure = { + id: 'rpc-1', + ok: false as const, + error: { code: 'method_not_found', message: 'Unknown method: nope' }, + _meta: { runtimeId: 'runtime-1' } + } + + expect(() => unwrapRuntimeRpcResult(failure)).toThrow(RuntimeRpcCallError) + try { + unwrapRuntimeRpcResult(failure) + } catch (error) { + expect(error).toBeInstanceOf(RuntimeRpcCallError) + expect((error as RuntimeRpcCallError).code).toBe('method_not_found') + expect((error as RuntimeRpcCallError).response).toBe(failure) + } + }) +}) diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts new file mode 100644 index 00000000000..38540f694a4 --- /dev/null +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -0,0 +1,108 @@ +import type { GlobalSettings } from '../../../shared/types' +import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import { assertRuntimeStatusCompatible } from './runtime-protocol-compat' + +export type RuntimeClientTarget = { kind: 'local' } | { kind: 'environment'; environmentId: string } + +const compatibleRuntimeEnvironments = new Map>() + +export class RuntimeRpcCallError extends Error { + readonly code: string + readonly response: RuntimeRpcFailure + + constructor(response: RuntimeRpcFailure) { + super(response.error.message) + this.name = 'RuntimeRpcCallError' + this.code = response.error.code + this.response = response + } +} + +export function getActiveRuntimeTarget( + settings: Pick | null | undefined +): RuntimeClientTarget { + const environmentId = settings?.activeRuntimeEnvironmentId?.trim() + if (!environmentId) { + return { kind: 'local' } + } + return { kind: 'environment', environmentId } +} + +export function settingsForRuntimeOwner( + settings: Pick | null | undefined, + runtimeEnvironmentId: string | null | undefined +): Pick | null | undefined { + const ownerId = runtimeEnvironmentId?.trim() + return ownerId ? { activeRuntimeEnvironmentId: ownerId } : settings +} + +export async function callRuntimeRpc( + target: RuntimeClientTarget, + method: string, + params?: unknown, + options: { timeoutMs?: number } = {} +): Promise { + if (target.kind === 'environment' && method !== 'status.get') { + await ensureRuntimeEnvironmentCompatible(target.environmentId, options.timeoutMs) + } + const response = + target.kind === 'local' + ? await window.api.runtime.call({ method, params }) + : await window.api.runtimeEnvironments.call({ + selector: target.environmentId, + method, + params, + timeoutMs: options.timeoutMs + }) + return unwrapRuntimeRpcResult(response as RuntimeRpcResponse) +} + +async function ensureRuntimeEnvironmentCompatible( + environmentId: string, + timeoutMs?: number +): Promise { + const cached = compatibleRuntimeEnvironments.get(environmentId) + if (cached) { + await cached + return + } + const check = (async () => { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'status.get', + timeoutMs + }) + const status = unwrapRuntimeRpcResult( + response as RuntimeRpcResponse + ) + assertRuntimeStatusCompatible(status) + })() + compatibleRuntimeEnvironments.set(environmentId, check) + try { + await check + } catch (error) { + compatibleRuntimeEnvironments.delete(environmentId) + throw error + } +} + +export function clearRuntimeCompatibilityCache(environmentId?: string | null): void { + const trimmed = environmentId?.trim() + if (trimmed) { + compatibleRuntimeEnvironments.delete(trimmed) + return + } + compatibleRuntimeEnvironments.clear() +} + +export function clearRuntimeCompatibilityCacheForTests(): void { + clearRuntimeCompatibilityCache() +} + +export function unwrapRuntimeRpcResult(response: RuntimeRpcResponse): TResult { + if (response.ok === false) { + throw new RuntimeRpcCallError(response) + } + return response.result +} diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts new file mode 100644 index 00000000000..222cb9e308b --- /dev/null +++ b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { inspectRuntimeTerminalProcess, sendRuntimePtyInput } from './runtime-terminal-inspection' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from './runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' + +describe('runtime terminal owner routing', () => { + const runtimeCall = vi.fn() + const runtimeTransportCall = vi.fn() + const localWrite = vi.fn() + const localForeground = vi.fn() + const localHasChildren = vi.fn() + + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + vi.clearAllMocks() + runtimeCall.mockResolvedValue({ + ok: true, + result: { process: { foregroundProcess: 'bash', hasChildProcesses: true } }, + _meta: { runtimeId: 'runtime-1' } + }) + runtimeTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeCall(args) + }) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { call: runtimeTransportCall }, + pty: { + write: localWrite, + getForegroundProcess: localForeground, + hasChildProcesses: localHasChildren + } + } + }) + }) + + it('sends input through the PTY owning environment instead of the active one', async () => { + expect( + sendRuntimePtyInput({ activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', 'x') + ).toBe(true) + + await vi.waitFor(() => { + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.send', + params: { terminal: 'terminal-1', text: 'x' }, + timeoutMs: 15_000 + }) + }) + expect(localWrite).not.toHaveBeenCalled() + }) + + it('inspects the PTY owning environment instead of the active one', async () => { + await expect( + inspectRuntimeTerminalProcess( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1' + ) + ).resolves.toEqual({ foregroundProcess: 'bash', hasChildProcesses: true }) + + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.inspectProcess', + params: { terminal: 'terminal-1' }, + timeoutMs: 15_000 + }) + expect(localForeground).not.toHaveBeenCalled() + expect(localHasChildren).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.ts b/src/renderer/src/runtime/runtime-terminal-inspection.ts new file mode 100644 index 00000000000..9c5c1f2f73f --- /dev/null +++ b/src/renderer/src/runtime/runtime-terminal-inspection.ts @@ -0,0 +1,62 @@ +import type { GlobalSettings } from '../../../shared/types' +import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { + getRemoteRuntimePtyEnvironmentId, + getRemoteRuntimeTerminalHandle +} from './runtime-terminal-stream' + +export type RuntimeTerminalProcessInspection = { + foregroundProcess: string | null + hasChildProcesses: boolean +} + +const REMOTE_PTY_ID_PREFIX = 'remote:' + +export function isRemoteRuntimePtyId(ptyId: string): boolean { + return ptyId.startsWith(REMOTE_PTY_ID_PREFIX) +} + +export async function inspectRuntimeTerminalProcess( + settings: Pick | null | undefined, + ptyId: string +): Promise { + const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) + const target = ownerEnvironmentId + ? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const) + : getActiveRuntimeTarget(settings) + const terminal = getRemoteRuntimeTerminalHandle(ptyId) + if (target.kind !== 'environment' || !terminal) { + const [foregroundProcess, hasChildProcesses] = await Promise.all([ + window.api.pty.getForegroundProcess(ptyId), + window.api.pty.hasChildProcesses(ptyId) + ]) + return { foregroundProcess, hasChildProcesses } + } + + const result = await callRuntimeRpc<{ process: RuntimeTerminalProcessInspection }>( + target, + 'terminal.inspectProcess', + { terminal }, + { timeoutMs: 15_000 } + ) + return result.process +} + +export function sendRuntimePtyInput( + settings: Pick | null | undefined, + ptyId: string, + data: string +): boolean { + const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) + const target = ownerEnvironmentId + ? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const) + : getActiveRuntimeTarget(settings) + const terminal = getRemoteRuntimeTerminalHandle(ptyId) + if (target.kind !== 'environment' || !terminal) { + window.api.pty.write(ptyId, data) + return true + } + + void callRuntimeRpc(target, 'terminal.send', { terminal, text: data }, { timeoutMs: 15_000 }) + return true +} diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts new file mode 100644 index 00000000000..d08b413af74 --- /dev/null +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + encodeTerminalStreamFrame, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import { resetRemoteRuntimeTerminalMultiplexersForTests } from './remote-runtime-terminal-multiplexer' +import { + getRemoteRuntimePtyEnvironmentId, + getRemoteRuntimeTerminalHandle, + subscribeToRuntimeTerminalData, + toRemoteRuntimePtyId +} from './runtime-terminal-stream' + +describe('remote runtime terminal ids', () => { + it('encodes and decodes the owning runtime environment', () => { + const ptyId = toRemoteRuntimePtyId('terminal:one', 'env-1') + + expect(ptyId).toBe('remote:env-1@@terminal%3Aone') + expect(getRemoteRuntimePtyEnvironmentId(ptyId)).toBe('env-1') + expect(getRemoteRuntimeTerminalHandle(ptyId)).toBe('terminal:one') + }) + + it('keeps legacy remote ids readable', () => { + expect(getRemoteRuntimePtyEnvironmentId('remote:terminal-1')).toBeNull() + expect(getRemoteRuntimeTerminalHandle('remote:terminal-1')).toBe('terminal-1') + }) +}) + +describe('remote runtime terminal data subscriptions', () => { + const runtimeSubscribe = vi.fn() + const sendBinary = vi.fn() + const unsubscribe = vi.fn() + let callbacks: { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + } | null = null + + beforeEach(() => { + vi.clearAllMocks() + resetRemoteRuntimeTerminalMultiplexersForTests() + callbacks = null + runtimeSubscribe.mockImplementation(async (_args: unknown, nextCallbacks: typeof callbacks) => { + callbacks = nextCallbacks + queueMicrotask(() => + callbacks?.onResponse({ + ok: true, + result: { type: 'ready' } + }) + ) + return { unsubscribe, sendBinary } + }) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + subscribe: runtimeSubscribe + } + } + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('uses the shared terminal multiplexer for sidecar data watchers', async () => { + const watcher = vi.fn() + + const dispose = await subscribeToRuntimeTerminalData( + { activeRuntimeEnvironmentId: 'env-fallback' }, + 'remote:env-1@@terminal-1', + 'watcher-1', + watcher + ) + + expect(runtimeSubscribe).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'terminal.multiplex' + }), + expect.any(Object) + ) + await vi.waitFor(() => expect(sendBinary).toHaveBeenCalled()) + const subscribeFrame = decodeTerminalStreamFrame(sendBinary.mock.calls[0][0]) + expect(subscribeFrame?.opcode).toBe(TerminalStreamOpcode.Subscribe) + const subscribePayload = + subscribeFrame && decodeTerminalStreamJson<{ streamId: number }>(subscribeFrame.payload) + expect(subscribePayload?.streamId).toEqual(expect.any(Number)) + + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId: subscribePayload!.streamId, + seq: 1, + payload: encodeTerminalStreamText('live') + }) + ) + + expect(watcher).toHaveBeenCalledWith('live') + dispose() + expect(unsubscribe).toHaveBeenCalled() + }) + + it('rejects remote terminal subscriptions when the multiplex connection fails', async () => { + runtimeSubscribe.mockRejectedValueOnce(new Error('offline')) + + await expect( + subscribeToRuntimeTerminalData( + { activeRuntimeEnvironmentId: 'env-fallback' }, + 'remote:env-1@@terminal-1', + 'watcher-1', + vi.fn() + ) + ).rejects.toThrow('offline') + + expect(sendBinary).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/runtime-terminal-stream.ts b/src/renderer/src/runtime/runtime-terminal-stream.ts new file mode 100644 index 00000000000..d5e747e3344 --- /dev/null +++ b/src/renderer/src/runtime/runtime-terminal-stream.ts @@ -0,0 +1,163 @@ +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import type { GlobalSettings } from '../../../shared/types' +import { RuntimeRpcCallError, getActiveRuntimeTarget } from './runtime-rpc-client' +import { getRemoteRuntimeTerminalMultiplexer } from './remote-runtime-terminal-multiplexer' + +const REMOTE_PTY_ID_PREFIX = 'remote:' +const REMOTE_PTY_OWNER_SEPARATOR = '@@' + +export type RemoteRuntimePtyIdParts = { + environmentId: string | null + handle: string +} + +export type RuntimeTerminalSubscribeEvent = + | { + type: 'scrollback' | 'subscribed' + streamId?: number | null + lines?: string[] + truncated?: boolean + serialized?: string + cols?: number + rows?: number + } + | { type: 'data'; chunk: string } + | { type: 'end' } + | { type: string; [key: string]: unknown } + +export function toRemoteRuntimePtyId(handle: string, environmentId?: string | null): string { + const owner = environmentId?.trim() + if (!owner) { + return `${REMOTE_PTY_ID_PREFIX}${handle}` + } + return `${REMOTE_PTY_ID_PREFIX}${encodeURIComponent(owner)}${REMOTE_PTY_OWNER_SEPARATOR}${encodeURIComponent(handle)}` +} + +export function parseRemoteRuntimePtyId(ptyId: string): RemoteRuntimePtyIdParts | null { + if (!ptyId.startsWith(REMOTE_PTY_ID_PREFIX)) { + return null + } + const rest = ptyId.slice(REMOTE_PTY_ID_PREFIX.length) + const separatorIndex = rest.indexOf(REMOTE_PTY_OWNER_SEPARATOR) + if (separatorIndex === -1) { + return { environmentId: null, handle: rest } + } + return { + environmentId: decodeURIComponent(rest.slice(0, separatorIndex)), + handle: decodeURIComponent(rest.slice(separatorIndex + REMOTE_PTY_OWNER_SEPARATOR.length)) + } +} + +export function getRemoteRuntimeTerminalHandle(ptyId: string): string | null { + return parseRemoteRuntimePtyId(ptyId)?.handle ?? null +} + +export function getRemoteRuntimePtyEnvironmentId(ptyId: string): string | null { + return parseRemoteRuntimePtyId(ptyId)?.environmentId ?? null +} + +export function isRuntimeTerminalScrollbackEvent( + event: RuntimeTerminalSubscribeEvent +): event is Extract { + return event.type === 'scrollback' || event.type === 'subscribed' +} + +export function isRuntimeTerminalDataEvent( + event: RuntimeTerminalSubscribeEvent +): event is Extract { + return event.type === 'data' && typeof (event as { chunk?: unknown }).chunk === 'string' +} + +export function runtimeTerminalErrorMessage(error: unknown): string { + if (error instanceof RuntimeRpcCallError) { + return error.message + } + return error instanceof Error ? error.message : String(error) +} + +export function readRuntimeTerminalScrollback(event: { + serialized?: string + lines?: string[] +}): string | null { + if (event.serialized) { + return event.serialized + } + if (event.lines && event.lines.length > 0) { + return `${event.lines.join('\r\n')}\r\n` + } + return null +} + +function concatBytes(chunks: Uint8Array[]): Uint8Array { + const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0) + const out = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + out.set(chunk, offset) + offset += chunk.byteLength + } + return out +} + +export function createRuntimeTerminalBinaryReader(callbacks: { + onData: (data: string) => void + onSnapshot: (data: string) => void + onEnd?: () => void +}): (bytes: Uint8Array) => void { + let snapshotChunks: Uint8Array[] = [] + + return (bytes) => { + const frame = decodeTerminalStreamFrame(bytes) + if (!frame) { + return + } + if (frame.opcode === TerminalStreamOpcode.Output) { + callbacks.onData(decodeTerminalStreamText(frame.payload)) + return + } + if (frame.opcode === TerminalStreamOpcode.SnapshotStart) { + snapshotChunks = [] + return + } + if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) { + snapshotChunks.push(frame.payload) + return + } + if (frame.opcode === TerminalStreamOpcode.SnapshotEnd) { + callbacks.onSnapshot(decodeTerminalStreamText(concatBytes(snapshotChunks))) + snapshotChunks = [] + callbacks.onEnd?.() + } + } +} + +export async function subscribeToRuntimeTerminalData( + settings: Pick | null | undefined, + ptyId: string, + clientId: string, + watcher: (data: string) => void +): Promise<() => void> { + const terminal = getRemoteRuntimeTerminalHandle(ptyId) + const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) + const target = ownerEnvironmentId + ? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const) + : getActiveRuntimeTarget(settings) + if (target.kind !== 'environment' || !terminal) { + return () => {} + } + + const stream = await getRemoteRuntimeTerminalMultiplexer(target.environmentId).subscribeTerminal({ + terminal, + client: { id: clientId, type: 'desktop' }, + callbacks: { + onData: watcher, + onSnapshot: watcher + } + }) + + return () => stream.close() +} diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts index f29297d19a7..3b22bb062c7 100644 --- a/src/renderer/src/store/slices/browser.test.ts +++ b/src/renderer/src/store/slices/browser.test.ts @@ -1,867 +1,415 @@ -/* eslint-disable max-lines -- - * Why: this slice test keeps closely related browser-slice scenarios - * (create/close, reopen, hydrate, shutdown) in one file so the shared - * webview-registry mock setup stays consistent across behaviors. - */ +/* eslint-disable max-lines -- Why: browser slice behavior shares one mocked store harness; splitting only the tests would duplicate more setup than it saves. */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import { createBrowserSlice } from './browser' +import type { AppState } from '../types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' -vi.mock('../../components/browser-pane/webview-registry', () => ({ - destroyPersistentWebview: vi.fn() -})) +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() -import { createTestStore, makeTabGroup, makeWorktree, seedStore } from './store-test-helpers' -import { destroyPersistentWebview } from '../../components/browser-pane/webview-registry' +const mockApi = { + browser: { + sessionListProfiles: vi.fn().mockResolvedValue([]), + sessionCreateProfile: vi.fn().mockResolvedValue(null), + sessionDeleteProfile: vi.fn().mockResolvedValue(false), + sessionImportCookies: vi.fn().mockResolvedValue({ ok: false, reason: 'canceled' }), + sessionDetectBrowsers: vi.fn().mockResolvedValue([]), + sessionImportFromBrowser: vi.fn().mockResolvedValue({ ok: false, reason: 'canceled' }), + sessionClearDefaultCookies: vi.fn().mockResolvedValue(false), + notifyActiveTabChanged: vi.fn().mockResolvedValue(undefined) + }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall + } +} -describe('browser slice', () => { - it('places a new tab in the target group when targetGroupId is provided', () => { - const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId: worktreeId, - activeTabType: 'terminal', - worktreesByRepo: { - repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/tmp/wt-1' })] - }, - groupsByWorktree: { - [worktreeId]: [ - makeTabGroup({ id: 'terminal-group', worktreeId, activeTabId: null, tabOrder: [] }), - makeTabGroup({ id: 'browser-group', worktreeId, activeTabId: null, tabOrder: [] }) - ] - }, - activeGroupIdByWorktree: { [worktreeId]: 'terminal-group' }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} - }) +// @ts-expect-error test window mock +globalThis.window = { api: mockApi } - const created = store.getState().createBrowserTab(worktreeId, 'https://example.com', { - title: 'Example', - targetGroupId: 'browser-group' - }) +function createTestStore() { + return create()( + (...a) => + ({ + settings: { activeRuntimeEnvironmentId: null } as AppState['settings'], + unifiedTabsByWorktree: {}, + tabBarOrderByWorktree: {}, + tabsByWorktree: {}, + openFiles: [], + activeTabType: 'terminal', + activeTabTypeByWorktree: {}, + worktreesByRepo: {}, + createUnifiedTab: vi.fn(), + closeUnifiedTab: vi.fn(), + activateTab: vi.fn(), + setTabLabel: vi.fn(), + ...createBrowserSlice(...a) + }) as unknown as AppState + ) +} - const unifiedTab = (store.getState().unifiedTabsByWorktree[worktreeId] ?? []).find( - (t) => t.contentType === 'browser' && t.entityId === created.id - ) - expect(unifiedTab?.groupId).toBe('browser-group') - }) +function settingsWithRuntime(id: string): AppState['settings'] { + return { activeRuntimeEnvironmentId: id } as AppState['settings'] +} - it('falls back to active group when targetGroupId is not provided', () => { - const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId: worktreeId, - activeTabType: 'terminal', - worktreesByRepo: { - repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/tmp/wt-1' })] - }, - groupsByWorktree: { - [worktreeId]: [ - makeTabGroup({ id: 'terminal-group', worktreeId, activeTabId: null, tabOrder: [] }), - makeTabGroup({ id: 'browser-group', worktreeId, activeTabId: null, tabOrder: [] }) - ] - }, - activeGroupIdByWorktree: { [worktreeId]: 'terminal-group' }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} - }) - - const created = store.getState().createBrowserTab(worktreeId, 'https://example.com', { - title: 'Example' - }) - - const unifiedTab = (store.getState().unifiedTabsByWorktree[worktreeId] ?? []).find( - (t) => t.contentType === 'browser' && t.entityId === created.id - ) - expect(unifiedTab?.groupId).toBe('terminal-group') - }) - - it('reopens the most recently closed browser tab in the same worktree', () => { - const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId: worktreeId, - activeTabType: 'browser', - worktreesByRepo: { - repo1: [ - makeWorktree({ - id: worktreeId, - repoId: 'repo1', - path: '/tmp/wt-1' - }) - ] - }, - groupsByWorktree: { - [worktreeId]: [ - makeTabGroup({ - id: 'group-1', - worktreeId, - activeTabId: null, - tabOrder: [] - }) - ] - }, - activeGroupIdByWorktree: { - [worktreeId]: 'group-1' - }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} - }) - - const created = store.getState().createBrowserTab(worktreeId, 'https://example.com/docs', { - title: 'Docs' - }) - store.getState().closeBrowserTab(created.id) - - expect(store.getState().browserTabsByWorktree[worktreeId]).toBeUndefined() - expect(store.getState().recentlyClosedBrowserTabsByWorktree[worktreeId]).toHaveLength(1) - - const reopened = store.getState().reopenClosedBrowserTab(worktreeId) - - expect(reopened).not.toBeNull() - expect(reopened?.id).not.toBe(created.id) - expect(reopened?.url).toBe('https://example.com/docs') - expect(reopened?.title).toBe('Docs') - expect(store.getState().browserTabsByWorktree[worktreeId]).toHaveLength(1) - expect(store.getState().recentlyClosedBrowserTabsByWorktree[worktreeId]).toHaveLength(0) - }) - - it('reopens a multi-page workspace without duplicating the active URL (page order ≠ active first)', () => { - const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId: worktreeId, - activeTabType: 'browser', - worktreesByRepo: { - repo1: [ - makeWorktree({ - id: worktreeId, - repoId: 'repo1', - path: '/tmp/wt-1' - }) - ] - }, - groupsByWorktree: { - [worktreeId]: [ - makeTabGroup({ - id: 'group-1', - worktreeId, - activeTabId: null, - tabOrder: [] - }) - ] - }, - activeGroupIdByWorktree: { - [worktreeId]: 'group-1' - }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} - }) - - const ws = store.getState().createBrowserTab(worktreeId, 'https://example.com/a', { - title: 'A' - }) - store - .getState() - .createBrowserPage(ws.id, 'https://example.com/b', { title: 'B', activate: true }) - const beforeClose = store.getState().browserPagesByWorkspace[ws.id] ?? [] - expect(beforeClose).toHaveLength(2) - expect(store.getState().browserTabsByWorktree[worktreeId]?.[0]?.url).toBe( - 'https://example.com/b' - ) - - store.getState().closeBrowserTab(ws.id) - const reopened = store.getState().reopenClosedBrowserTab(worktreeId) - expect(reopened).not.toBeNull() - const pages = store.getState().browserPagesByWorkspace[reopened!.id] ?? [] - expect(pages).toHaveLength(2) - const urls = new Set(pages.map((p) => p.url)) - expect(urls.has('https://example.com/a')).toBe(true) - expect(urls.has('https://example.com/b')).toBe(true) - expect(store.getState().browserTabsByWorktree[worktreeId]?.[0]?.url).toBe( - 'https://example.com/b' - ) - }) - - it('sets pending address-bar focus when focusAddressBar is true even for non-blank URLs', () => { - const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId: worktreeId, - activeTabType: 'terminal', - worktreesByRepo: { - repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/tmp/wt-1' })] - }, - groupsByWorktree: { - [worktreeId]: [makeTabGroup({ id: 'group-1', worktreeId, activeTabId: null, tabOrder: [] })] - }, - activeGroupIdByWorktree: { [worktreeId]: 'group-1' }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} - }) - - const created = store.getState().createBrowserTab(worktreeId, 'https://example.com/home', { - title: 'Home', - focusAddressBar: true - }) - - const pageId = store.getState().browserPagesByWorkspace[created.id]?.[0]?.id - expect(pageId).toBeDefined() - expect(store.getState().pendingAddressBarFocusByTabId[created.id]).toBe(true) - expect(store.getState().pendingAddressBarFocusByPageId[pageId!]).toBe(true) - }) - - it('does not set pending address-bar focus for non-blank URLs when focusAddressBar is not set', () => { - const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId: worktreeId, - activeTabType: 'terminal', - worktreesByRepo: { - repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/tmp/wt-1' })] - }, - groupsByWorktree: { - [worktreeId]: [makeTabGroup({ id: 'group-1', worktreeId, activeTabId: null, tabOrder: [] })] - }, - activeGroupIdByWorktree: { [worktreeId]: 'group-1' }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} - }) - - const created = store.getState().createBrowserTab(worktreeId, 'https://example.com/home', { - title: 'Home' - }) - - expect(store.getState().pendingAddressBarFocusByTabId[created.id]).toBeUndefined() - }) -}) - -describe('hydrateBrowserSession', () => { +describe('createBrowserSlice runtime guard', () => { beforeEach(() => { - vi.mocked(destroyPersistentWebview).mockClear() + vi.clearAllMocks() + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', ok: true, result: {} }) }) - // Why: design §3. hydrateBrowserSession drops workspaces whose worktree no - // longer exists. Reducers are pure; destroy calls must bracket the set() - // to mirror closeBrowserTab's contract and keep future callers (that might - // run hydrate after webviews are live) safe without surgery. - it('destroys webviews for dropped workspaces before committing state', () => { + it('fetches browser profiles from the active runtime environment', async () => { const store = createTestStore() - const survivingWorktreeId = 'repo1::/tmp/wt-1' - seedStore(store, { - worktreesByRepo: { - repo1: [makeWorktree({ id: survivingWorktreeId, repoId: 'repo1', path: '/tmp/wt-1' })] - }, - // Why: destroyWorkspaceWebviews reads pre-hydrate browserPagesByWorkspace - // to resolve page ids. If the store is empty at hydrate time, the helper - // falls back to the legacy workspace-id destroy (design §2 fallback). To - // prove the page-id branch runs when pages exist in-store, seed them - // first. Today's boot-only caller hits the fallback because hydrate is - // the first thing to populate the map — the assertion here covers a - // future re-hydrate after webviews are already live. - browserPagesByWorkspace: { - 'workspace-drop': [ + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: { + profiles: [ { - id: 'page-drop-a', - workspaceId: 'workspace-drop', - worktreeId: 'repo1::/tmp/wt-gone', - url: 'about:blank', - title: 'a', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - }, - { - id: 'page-drop-b', - workspaceId: 'workspace-drop', - worktreeId: 'repo1::/tmp/wt-gone', - url: 'about:blank', - title: 'b', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 + id: 'default', + scope: 'default', + partition: 'persist:orca-default', + label: 'Default', + source: null } ] - } as never + }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ + settings: settingsWithRuntime('env-1'), + browserSessionProfiles: [] }) - store.getState().hydrateBrowserSession({ - browserTabsByWorktree: { - [survivingWorktreeId]: [ - { - id: 'workspace-keep', - worktreeId: survivingWorktreeId, - label: 'keep', - sessionProfileId: null, - pageIds: ['page-keep'], - activePageId: 'page-keep', - url: 'about:blank', - title: 'keep', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ], - 'repo1::/tmp/wt-gone': [ - { - id: 'workspace-drop', - worktreeId: 'repo1::/tmp/wt-gone', - label: 'drop', - sessionProfileId: null, - pageIds: ['page-drop-a', 'page-drop-b'], - activePageId: 'page-drop-a', - url: 'about:blank', - title: 'drop', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ] - }, - browserPagesByWorkspace: { - 'workspace-drop': [ - { - id: 'page-drop-a', - workspaceId: 'workspace-drop', - worktreeId: 'repo1::/tmp/wt-gone', - url: 'about:blank', - title: 'a', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - }, - { - id: 'page-drop-b', - workspaceId: 'workspace-drop', - worktreeId: 'repo1::/tmp/wt-gone', - url: 'about:blank', - title: 'b', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ], - 'workspace-keep': [ - { - id: 'page-keep', - workspaceId: 'workspace-keep', - worktreeId: survivingWorktreeId, - url: 'about:blank', - title: 'keep', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ] - } - } as never) + await store.getState().fetchBrowserSessionProfiles() - expect(destroyPersistentWebview).toHaveBeenCalledWith('page-drop-a') - expect(destroyPersistentWebview).toHaveBeenCalledWith('page-drop-b') - expect(destroyPersistentWebview).not.toHaveBeenCalledWith('page-keep') - expect(store.getState().browserTabsByWorktree['repo1::/tmp/wt-gone']).toBeUndefined() - expect(store.getState().browserPagesByWorkspace['workspace-drop']).toBeUndefined() - expect(store.getState().browserTabsByWorktree[survivingWorktreeId]).toHaveLength(1) - }) - - it('redacts Kagi session tokens from hydrated browser history', () => { - const store = createTestStore() - - store.getState().hydrateBrowserSession({ - browserUrlHistory: [ - { - url: 'https://kagi.com/search?token=secret&q=hello+world', - normalizedUrl: 'https://kagi.com/search?token=secret&q=hello+world', - title: 'Kagi Search', - lastVisitedAt: 1, - visitCount: 1 - } - ] - } as never) - - expect(store.getState().browserUrlHistory).toEqual([ + expect(mockApi.browser.sessionListProfiles).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'browser.profileList', + params: undefined, + timeoutMs: 15_000 + }) + expect(store.getState().browserSessionProfiles).toEqual([ { - url: 'https://kagi.com/search?q=hello+world', - normalizedUrl: 'https://kagi.com/search?q=hello+world', - title: 'Kagi Search', - lastVisitedAt: 1, - visitCount: 1 + id: 'default', + scope: 'default', + partition: 'persist:orca-default', + label: 'Default', + source: null } ]) }) -}) -// Why: setBrowserPageUrl is the single sink for URL updates from did-navigate, -// the agent-browser CDP nav-update IPC, and direct address-bar submits. Pin -// redaction at this boundary so a Kagi bearer token cannot reach the -// persisted BrowserPage.url field via any caller that forgot to redact. -describe('setBrowserPageUrl redaction', () => { - it('redacts Kagi session tokens before storing the page URL', () => { + it('does not import local browser cookies while a runtime environment is active', async () => { const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId: worktreeId, - activeTabType: 'browser', - worktreesByRepo: { - repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/tmp/wt-1' })] - }, - groupsByWorktree: { - [worktreeId]: [makeTabGroup({ id: 'group-1', worktreeId, activeTabId: null, tabOrder: [] })] - }, - activeGroupIdByWorktree: { [worktreeId]: 'group-1' }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} + store.setState({ settings: settingsWithRuntime('env-1') }) + + const result = await store.getState().importCookiesToProfile('default') + + expect(mockApi.browser.sessionImportCookies).not.toHaveBeenCalled() + expect(result.ok).toBe(false) + expect(store.getState().browserSessionImportState).toMatchObject({ + profileId: 'default', + status: 'error' }) - - const ws = store.getState().createBrowserTab(worktreeId, 'about:blank', { title: 'New Tab' }) - const pageId = store.getState().browserPagesByWorkspace[ws.id]?.[0]?.id - expect(pageId).toBeDefined() - - store - .getState() - .setBrowserPageUrl(pageId!, 'https://kagi.com/search?token=secret&q=hello+world') - - const page = store.getState().browserPagesByWorkspace[ws.id]?.[0] - expect(page?.url).toBe('https://kagi.com/search?q=hello+world') - expect(store.getState().browserTabsByWorktree[worktreeId]?.[0]?.url).toBe( - 'https://kagi.com/search?q=hello+world' - ) - }) -}) - -// Why: the leak this PR fixes lives inside the thunk itself. removeWorktree -// and runSleepWorktree tests stub this thunk, so destroy-call regressions in -// its body would be invisible there. These tests pin the thunk directly. -describe('shutdownWorktreeBrowsers', () => { - beforeEach(() => { - vi.mocked(destroyPersistentWebview).mockClear() }) - function seedWorktree(store: ReturnType, worktreeId: string): void { - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId: worktreeId, - activeTabType: 'browser', - worktreesByRepo: { - repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/tmp/wt-1' })] - }, - groupsByWorktree: { - [worktreeId]: [makeTabGroup({ id: 'group-1', worktreeId, activeTabId: null, tabOrder: [] })] - }, - activeGroupIdByWorktree: { [worktreeId]: 'group-1' }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} - }) - } - - it('destroys every page in every workspace of the target worktree', async () => { + it('uses local browser IPC when no runtime environment is active', async () => { const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedWorktree(store, worktreeId) + mockApi.browser.sessionListProfiles.mockResolvedValueOnce([ + { + id: 'default', + scope: 'default', + partition: 'persist:orca-default', + label: 'Default', + source: null + } + ]) - const ws1 = store.getState().createBrowserTab(worktreeId, 'https://example.com/a', { - title: 'A' - }) - const ws2 = store.getState().createBrowserTab(worktreeId, 'https://example.com/b', { - title: 'B' - }) - const ws1PageIds = (store.getState().browserPagesByWorkspace[ws1.id] ?? []).map((p) => p.id) - const ws2PageIds = (store.getState().browserPagesByWorkspace[ws2.id] ?? []).map((p) => p.id) - expect(ws1PageIds).toHaveLength(1) - expect(ws2PageIds).toHaveLength(1) + await store.getState().fetchBrowserSessionProfiles() - await store.getState().shutdownWorktreeBrowsers(worktreeId) - - // Why: this is the regression guard. If the thunk ever stops calling - // destroyWorkspaceWebviews (or reverts to the old workspace-id keying), - // this assertion fails loudly. - for (const pageId of [...ws1PageIds, ...ws2PageIds]) { - expect(destroyPersistentWebview).toHaveBeenCalledWith(pageId) - } - expect(store.getState().browserTabsByWorktree[worktreeId]).toBeUndefined() - expect(store.getState().browserPagesByWorkspace[ws1.id]).toBeUndefined() - expect(store.getState().browserPagesByWorkspace[ws2.id]).toBeUndefined() + expect(mockApi.browser.sessionListProfiles).toHaveBeenCalledTimes(1) + expect(store.getState().browserSessionProfiles).toEqual([ + { + id: 'default', + scope: 'default', + partition: 'persist:orca-default', + label: 'Default', + source: null + } + ]) }) - it('destroys every page id of a multi-page workspace', async () => { + it('does not notify the local browser manager when selecting tabs under runtime', () => { const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedWorktree(store, worktreeId) - - const ws = store.getState().createBrowserTab(worktreeId, 'https://example.com/a', { - title: 'A' + store.setState({ + settings: settingsWithRuntime('env-1'), + unifiedTabsByWorktree: {}, + browserTabsByWorktree: { + 'wt-1': [ + { + id: 'workspace-1', + worktreeId: 'wt-1', + sessionProfileId: null, + activePageId: 'page-1', + pageIds: ['page-1'], + url: 'about:blank', + title: 'New Tab', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + } }) - store - .getState() - .createBrowserPage(ws.id, 'https://example.com/b', { title: 'B', activate: false }) - store - .getState() - .createBrowserPage(ws.id, 'https://example.com/c', { title: 'C', activate: false }) - const pageIds = (store.getState().browserPagesByWorkspace[ws.id] ?? []).map((p) => p.id) - expect(pageIds).toHaveLength(3) - await store.getState().shutdownWorktreeBrowsers(worktreeId) + store.getState().setActiveBrowserTab('workspace-1') - for (const pageId of pageIds) { - expect(destroyPersistentWebview).toHaveBeenCalledWith(pageId) - } + expect(mockApi.browser.notifyActiveTabChanged).not.toHaveBeenCalled() }) - it('leaves other worktrees untouched', async () => { + it('closes the mapped remote tab when closing a browser page in the active runtime', async () => { const store = createTestStore() - const targetWorktreeId = 'repo1::/tmp/wt-1' - const otherWorktreeId = 'repo1::/tmp/wt-2' - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId: targetWorktreeId, - activeTabType: 'browser', - worktreesByRepo: { - repo1: [ - makeWorktree({ id: targetWorktreeId, repoId: 'repo1', path: '/tmp/wt-1' }), - makeWorktree({ id: otherWorktreeId, repoId: 'repo1', path: '/tmp/wt-2' }) + store.setState({ + settings: settingsWithRuntime('env-1'), + browserTabsByWorktree: { + 'wt-1': [ + { + id: 'workspace-1', + worktreeId: 'wt-1', + sessionProfileId: null, + activePageId: 'page-1', + pageIds: ['page-1'], + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } ] }, - groupsByWorktree: { - [targetWorktreeId]: [ - makeTabGroup({ - id: 'group-1', - worktreeId: targetWorktreeId, - activeTabId: null, - tabOrder: [] - }) - ], - [otherWorktreeId]: [ - makeTabGroup({ - id: 'group-2', - worktreeId: otherWorktreeId, - activeTabId: null, - tabOrder: [] - }) + browserPagesByWorkspace: { + 'workspace-1': [ + { + id: 'page-1', + workspaceId: 'workspace-1', + worktreeId: 'wt-1', + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } ] }, - activeGroupIdByWorktree: { - [targetWorktreeId]: 'group-1', - [otherWorktreeId]: 'group-2' - }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} + remoteBrowserPageHandlesByPageId: { + 'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } + } }) - const targetWs = store.getState().createBrowserTab(targetWorktreeId, 'https://example.com/a', { - title: 'A' - }) - const otherWs = store.getState().createBrowserTab(otherWorktreeId, 'https://example.com/b', { - title: 'B' - }) - const otherPageIds = (store.getState().browserPagesByWorkspace[otherWs.id] ?? []).map( - (p) => p.id - ) + store.getState().closeBrowserPage('page-1') - await store.getState().shutdownWorktreeBrowsers(targetWorktreeId) - - expect(store.getState().browserTabsByWorktree[targetWorktreeId]).toBeUndefined() - expect(store.getState().browserPagesByWorkspace[targetWs.id]).toBeUndefined() - expect(store.getState().browserTabsByWorktree[otherWorktreeId]).toHaveLength(1) - expect(store.getState().browserPagesByWorkspace[otherWs.id]).toHaveLength(1) - for (const pageId of otherPageIds) { - expect(destroyPersistentWebview).not.toHaveBeenCalledWith(pageId) - } - }) - - it('clears activeBrowserTabId and activeTabType when the target is the active worktree', async () => { - const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedWorktree(store, worktreeId) - - const ws = store.getState().createBrowserTab(worktreeId, 'https://example.com/a', { - title: 'A', - activate: true - }) - expect(store.getState().activeBrowserTabId).toBe(ws.id) - - await store.getState().shutdownWorktreeBrowsers(worktreeId) - - expect(store.getState().activeBrowserTabId).toBeNull() - expect(store.getState().activeTabType).toBe('terminal') - expect(store.getState().activeBrowserTabIdByWorktree[worktreeId]).toBeUndefined() - }) - - it('leaves global active browser alone when shutting down a background worktree', async () => { - const store = createTestStore() - const activeWorktreeId = 'repo1::/tmp/wt-1' - const backgroundWorktreeId = 'repo1::/tmp/wt-2' - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId, - activeTabType: 'browser', - worktreesByRepo: { - repo1: [ - makeWorktree({ id: activeWorktreeId, repoId: 'repo1', path: '/tmp/wt-1' }), - makeWorktree({ id: backgroundWorktreeId, repoId: 'repo1', path: '/tmp/wt-2' }) - ] - }, - groupsByWorktree: { - [activeWorktreeId]: [ - makeTabGroup({ - id: 'group-1', - worktreeId: activeWorktreeId, - activeTabId: null, - tabOrder: [] - }) - ], - [backgroundWorktreeId]: [ - makeTabGroup({ - id: 'group-2', - worktreeId: backgroundWorktreeId, - activeTabId: null, - tabOrder: [] - }) - ] - }, - activeGroupIdByWorktree: { - [activeWorktreeId]: 'group-1', - [backgroundWorktreeId]: 'group-2' - }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} - }) - - const activeWs = store - .getState() - .createBrowserTab(activeWorktreeId, 'https://example.com/active', { - title: 'Active', - activate: true + await vi.waitFor(() => { + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'browser.tabClose', + params: { worktree: 'id:wt-1', page: 'remote-page-1' }, + timeoutMs: 15_000 }) - store.getState().createBrowserTab(backgroundWorktreeId, 'https://example.com/bg', { - title: 'Background', - // Why: createBrowserTab defaults to activate=true which would replace - // activeBrowserTabId globally. This test needs the original active tab - // to stay active so we can prove shutdown of the background worktree - // doesn't disturb it. - activate: false }) - expect(store.getState().activeBrowserTabId).toBe(activeWs.id) - - await store.getState().shutdownWorktreeBrowsers(backgroundWorktreeId) - - // Why: §1 shouldResetGlobalBrowser is gated on activeWorktreeId===target. - // Shutting down a background worktree must not disturb what the user is - // currently looking at. - expect(store.getState().activeBrowserTabId).toBe(activeWs.id) - expect(store.getState().activeTabType).toBe('browser') + expect(store.getState().remoteBrowserPageHandlesByPageId['page-1']).toBeUndefined() }) - it('is a no-op when the worktree has no browser tabs', async () => { + it('closes mapped remote tabs when closing a browser workspace in the active runtime', async () => { const store = createTestStore() - const worktreeId = 'repo1::/tmp/wt-1' - seedWorktree(store, worktreeId) - // Override: this test needs activeTabType to start as terminal so we can - // prove shutdown doesn't flip it. - store.setState({ activeTabType: 'terminal' }) - - await store.getState().shutdownWorktreeBrowsers(worktreeId) - - expect(destroyPersistentWebview).not.toHaveBeenCalled() - expect(store.getState().activeTabType).toBe('terminal') - }) -}) - -// Why: focusBrowserTabInWorktree is the renderer side of `tab switch --focus`. -// The defining invariant is no cross-worktree screen theft: when multiple -// agents drive browsers in parallel worktrees, an agent focusing a tab in -// worktree X must never yank the user's view away from worktree Y. -describe('focusBrowserTabInWorktree', () => { - function seedTwoWorktrees( - store: ReturnType, - activeWt: string, - otherWt: string - ): void { - seedStore(store, { - activeRepoId: 'repo1', - activeWorktreeId: activeWt, - activeTabType: 'terminal', - worktreesByRepo: { - repo1: [ - makeWorktree({ id: activeWt, repoId: 'repo1', path: '/tmp/wt-active' }), - makeWorktree({ id: otherWt, repoId: 'repo1', path: '/tmp/wt-other' }) + store.setState({ + settings: settingsWithRuntime('env-1'), + browserTabsByWorktree: { + 'wt-1': [ + { + id: 'workspace-1', + worktreeId: 'wt-1', + sessionProfileId: null, + activePageId: 'page-1', + pageIds: ['page-1'], + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } ] }, - groupsByWorktree: { - [activeWt]: [ - makeTabGroup({ id: 'g-active', worktreeId: activeWt, activeTabId: null, tabOrder: [] }) - ], - [otherWt]: [ - makeTabGroup({ id: 'g-other', worktreeId: otherWt, activeTabId: null, tabOrder: [] }) + activeBrowserTabId: 'workspace-1', + activeBrowserTabIdByWorktree: { 'wt-1': 'workspace-1' }, + browserPagesByWorkspace: { + 'workspace-1': [ + { + id: 'page-1', + workspaceId: 'workspace-1', + worktreeId: 'wt-1', + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } ] }, - activeGroupIdByWorktree: { [activeWt]: 'g-active', [otherWt]: 'g-other' }, - activeTabTypeByWorktree: { [activeWt]: 'terminal', [otherWt]: 'terminal' }, - browserTabsByWorktree: {}, - unifiedTabsByWorktree: {} + remoteBrowserPageHandlesByPageId: { + 'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } + } }) - } - it('does not yank activeWorktreeId when focusing a tab in a non-active worktree', () => { - const store = createTestStore() - const activeWt = 'repo1::/tmp/wt-active' - const otherWt = 'repo1::/tmp/wt-other' - seedTwoWorktrees(store, activeWt, otherWt) + store.getState().closeBrowserTab('workspace-1') - const ws = store.getState().createBrowserTab(otherWt, 'https://example.com/a', { title: 'A' }) - const pageId = (store.getState().browserPagesByWorkspace[ws.id] ?? [])[0]?.id - expect(pageId).toBeDefined() - // createBrowserTab on a non-active worktree leaves activeWorktreeId alone; - // re-pin it here in case any future change to that path regresses it. - store.setState({ activeWorktreeId: activeWt, activeTabType: 'terminal' }) - - store.getState().focusBrowserTabInWorktree(otherWt, pageId!, { surfacePane: true }) - - expect(store.getState().activeWorktreeId).toBe(activeWt) - expect(store.getState().activeTabType).toBe('terminal') + await vi.waitFor(() => { + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'browser.tabClose', + params: { worktree: 'id:wt-1', page: 'remote-page-1' }, + timeoutMs: 15_000 + }) + }) + expect(store.getState().remoteBrowserPageHandlesByPageId['page-1']).toBeUndefined() }) - it('updates per-worktree active tab and tab type for the targeted worktree even when not active', () => { + it('closes mapped remote pages in their owning environment after switching local', async () => { const store = createTestStore() - const activeWt = 'repo1::/tmp/wt-active' - const otherWt = 'repo1::/tmp/wt-other' - seedTwoWorktrees(store, activeWt, otherWt) + store.setState({ + browserTabsByWorktree: { + 'wt-1': [ + { + id: 'workspace-1', + worktreeId: 'wt-1', + sessionProfileId: null, + activePageId: 'page-1', + pageIds: ['page-1'], + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + browserPagesByWorkspace: { + 'workspace-1': [ + { + id: 'page-1', + workspaceId: 'workspace-1', + worktreeId: 'wt-1', + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + remoteBrowserPageHandlesByPageId: { + 'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } + } + }) - const ws = store.getState().createBrowserTab(otherWt, 'https://example.com/a', { title: 'A' }) - const pageId = (store.getState().browserPagesByWorkspace[ws.id] ?? [])[0]?.id - store.setState({ activeWorktreeId: activeWt, activeTabType: 'terminal' }) + store.getState().closeBrowserPage('page-1') - store.getState().focusBrowserTabInWorktree(otherWt, pageId!, { surfacePane: true }) - - // Per-worktree slots: pre-staged for whenever the user next visits otherWt. - expect(store.getState().activeBrowserTabIdByWorktree[otherWt]).toBe(ws.id) - expect(store.getState().activeTabTypeByWorktree[otherWt]).toBe('browser') + await vi.waitFor(() => { + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'browser.tabClose', + params: { worktree: 'id:wt-1', page: 'remote-page-1' }, + timeoutMs: 15_000 + }) + }) }) - it('flips global activeBrowserTabId and activeTabType when targeting the active worktree with surfacePane', () => { + it('closes mapped remote tabs in their owning environment after switching environments', async () => { const store = createTestStore() - const activeWt = 'repo1::/tmp/wt-active' - const otherWt = 'repo1::/tmp/wt-other' - seedTwoWorktrees(store, activeWt, otherWt) + store.setState({ + settings: settingsWithRuntime('env-2'), + browserTabsByWorktree: { + 'wt-1': [ + { + id: 'workspace-1', + worktreeId: 'wt-1', + sessionProfileId: null, + activePageId: 'page-1', + pageIds: ['page-1'], + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + browserPagesByWorkspace: { + 'workspace-1': [ + { + id: 'page-1', + workspaceId: 'workspace-1', + worktreeId: 'wt-1', + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + remoteBrowserPageHandlesByPageId: { + 'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } + } + }) - const ws = store.getState().createBrowserTab(activeWt, 'https://example.com/a', { title: 'A' }) - const pageId = (store.getState().browserPagesByWorkspace[ws.id] ?? [])[0]?.id - // Reset to terminal so we can prove --focus surfaces the pane. - store.setState((s) => ({ - activeTabType: 'terminal', - activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [activeWt]: 'terminal' } - })) + store.getState().closeBrowserTab('workspace-1') - store.getState().focusBrowserTabInWorktree(activeWt, pageId!, { surfacePane: true }) - - expect(store.getState().activeBrowserTabId).toBe(ws.id) - expect(store.getState().activeTabType).toBe('browser') - }) - - it('without surfacePane, leaves activeTabType alone even on the active worktree', () => { - const store = createTestStore() - const activeWt = 'repo1::/tmp/wt-active' - const otherWt = 'repo1::/tmp/wt-other' - seedTwoWorktrees(store, activeWt, otherWt) - - const ws = store.getState().createBrowserTab(activeWt, 'https://example.com/a', { title: 'A' }) - const pageId = (store.getState().browserPagesByWorkspace[ws.id] ?? [])[0]?.id - // Reset both globals and per-worktree slot so we can prove surfacePane=false - // does NOT flip the user back into the browser pane (createBrowserTab - // itself flips both because it activates the new tab). - store.setState((s) => ({ - activeTabType: 'terminal', - activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [activeWt]: 'terminal' } - })) - - store.getState().focusBrowserTabInWorktree(activeWt, pageId!, { surfacePane: false }) - - // Per-worktree active tab still pre-staged, but the user is still on terminal. - expect(store.getState().activeBrowserTabIdByWorktree[activeWt]).toBe(ws.id) - expect(store.getState().activeTabType).toBe('terminal') - expect(store.getState().activeTabTypeByWorktree[activeWt]).toBe('terminal') - }) - - it('finds the owning workspace via pageIds for multi-page workspaces', () => { - const store = createTestStore() - const activeWt = 'repo1::/tmp/wt-active' - const otherWt = 'repo1::/tmp/wt-other' - seedTwoWorktrees(store, activeWt, otherWt) - - const ws = store.getState().createBrowserTab(otherWt, 'https://example.com/a', { title: 'A' }) - const pageB = store - .getState() - .createBrowserPage(ws.id, 'https://example.com/b', { title: 'B', activate: false }) - expect(pageB).not.toBeNull() - - store.getState().focusBrowserTabInWorktree(otherWt, pageB!.id, { surfacePane: true }) - - // The workspace's activePageId must follow the focused page id, not its - // first page. Renderer otherwise shows the wrong tab on next visit. - const updatedWs = store.getState().browserTabsByWorktree[otherWt]?.find((t) => t.id === ws.id) - expect(updatedWs?.activePageId).toBe(pageB!.id) - expect(store.getState().activeBrowserTabIdByWorktree[otherWt]).toBe(ws.id) - }) - - it('is a no-op when the page id is not present in the targeted worktree', () => { - const store = createTestStore() - const activeWt = 'repo1::/tmp/wt-active' - const otherWt = 'repo1::/tmp/wt-other' - seedTwoWorktrees(store, activeWt, otherWt) - - const before = store.getState() - before.focusBrowserTabInWorktree(otherWt, 'page-that-does-not-exist', { surfacePane: true }) - const after = store.getState() - - // Nothing globally or per-worktree should have moved. Best-effort: the - // page may have closed between the bridge switching and the IPC arriving. - expect(after.activeWorktreeId).toBe(before.activeWorktreeId) - expect(after.activeTabType).toBe(before.activeTabType) - expect(after.activeBrowserTabIdByWorktree[otherWt]).toBeUndefined() - }) - - it('surfaces the pane by default when no options are passed (active worktree)', () => { - const store = createTestStore() - const activeWt = 'repo1::/tmp/wt-active' - const otherWt = 'repo1::/tmp/wt-other' - seedTwoWorktrees(store, activeWt, otherWt) - - const ws = store.getState().createBrowserTab(activeWt, 'https://example.com/a', { title: 'A' }) - const pageId = (store.getState().browserPagesByWorkspace[ws.id] ?? [])[0]?.id - store.setState((s) => ({ - activeTabType: 'terminal', - activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [activeWt]: 'terminal' } - })) - - store.getState().focusBrowserTabInWorktree(activeWt, pageId!) - - expect(store.getState().activeBrowserTabId).toBe(ws.id) - expect(store.getState().activeTabType).toBe('browser') - expect(store.getState().activeTabTypeByWorktree[activeWt]).toBe('browser') + await vi.waitFor(() => { + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'browser.tabClose', + params: { worktree: 'id:wt-1', page: 'remote-page-1' }, + timeoutMs: 15_000 + }) + }) }) }) diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index d20473c299b..e326b761568 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -21,6 +21,19 @@ import { } from '../../../../shared/workspace-session-browser-history' import { pickNeighbor } from './tab-group-state' import { destroyWorkspaceWebviews } from './browser-webview-cleanup' +import { + callRuntimeRpc, + getActiveRuntimeTarget, + type RuntimeClientTarget +} from '@/runtime/runtime-rpc-client' +import type { + BrowserDetectProfilesResult, + BrowserProfileClearDefaultCookiesResult, + BrowserProfileCreateResult, + BrowserProfileDeleteResult, + BrowserProfileImportFromBrowserResult, + BrowserProfileListResult +} from '../../../../shared/runtime-types' type CreateBrowserTabOptions = { activate?: boolean @@ -57,9 +70,15 @@ type ClosedBrowserWorkspaceSnapshot = { pages: BrowserPage[] } +export type RemoteBrowserPageHandle = { + environmentId: string + remotePageId: string +} + export type BrowserSlice = { browserTabsByWorktree: Record browserPagesByWorkspace: Record + remoteBrowserPageHandlesByPageId: Record activeBrowserTabId: string | null activeBrowserTabIdByWorktree: Record recentlyClosedBrowserTabsByWorktree: Record @@ -101,6 +120,11 @@ export type BrowserSlice = { updateBrowserPageState: (pageId: string, updates: BrowserTabPageState) => void setBrowserTabUrl: (pageId: string, url: string) => void setBrowserPageUrl: (pageId: string, url: string) => void + setRemoteBrowserPageHandle: (pageId: string, handle: RemoteBrowserPageHandle) => void + removeRemoteBrowserPageHandle: ( + pageId: string, + remotePageId?: string + ) => RemoteBrowserPageHandle | null setBrowserPageViewportPreset: ( pageId: string, viewportPresetId: BrowserViewportPresetId | null @@ -172,6 +196,23 @@ function normalizeBrowserTitle(title: string | null | undefined, url: string): s return title } +function isRuntimeEnvironmentActive(state: AppState): boolean { + return Boolean(state.settings?.activeRuntimeEnvironmentId?.trim()) +} + +function closeRemoteBrowserPageInOwningEnvironment( + worktreeId: string, + handle: RemoteBrowserPageHandle +): void { + const target: RuntimeClientTarget = { kind: 'environment', environmentId: handle.environmentId } + void callRuntimeRpc( + target, + 'browser.tabClose', + { worktree: `id:${worktreeId}`, page: handle.remotePageId }, + { timeoutMs: 15_000 } + ).catch(() => {}) +} + function buildBrowserPage( workspaceId: string, worktreeId: string, @@ -297,6 +338,7 @@ function findPage( export const createBrowserSlice: StateCreator = (set, get) => ({ browserTabsByWorktree: {}, browserPagesByWorkspace: {}, + remoteBrowserPageHandlesByPageId: {}, activeBrowserTabId: null, activeBrowserTabIdByWorktree: {}, recentlyClosedBrowserTabsByWorktree: {}, @@ -414,6 +456,7 @@ export const createBrowserSlice: StateCreator = }, closeBrowserTab: (tabId) => { + let remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] set((s) => { let owningWorktreeId: string | null = null let closedWorkspace: BrowserWorkspace | null = null @@ -436,6 +479,16 @@ export const createBrowserSlice: StateCreator = const closedPages = s.browserPagesByWorkspace[tabId] ?? [] const nextBrowserPagesByWorkspace = { ...s.browserPagesByWorkspace } delete nextBrowserPagesByWorkspace[tabId] + remotePagesToClose = closedPages.flatMap((page) => { + const handle = s.remoteBrowserPageHandlesByPageId[page.id] + return handle ? [{ worktreeId: page.worktreeId, handle }] : [] + }) + const nextRemoteBrowserPageHandlesByPageId = { + ...s.remoteBrowserPageHandlesByPageId + } + for (const page of closedPages) { + delete nextRemoteBrowserPageHandlesByPageId[page.id] + } const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree } const remainingBrowserTabs = nextBrowserTabsByWorktree[owningWorktreeId] ?? [] @@ -506,10 +559,15 @@ export const createBrowserSlice: StateCreator = pendingAddressBarFocusByTabId: nextPendingAddressBarFocusByTabId, activeTabTypeByWorktree: nextActiveTabTypeByWorktree, recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree, - recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace + recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace, + remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId } }) + for (const remotePage of remotePagesToClose) { + closeRemoteBrowserPageInOwningEnvironment(remotePage.worktreeId, remotePage.handle) + } + for (const tabs of Object.values(get().unifiedTabsByWorktree)) { const workspaceItem = tabs.find( (entry) => entry.contentType === 'browser' && entry.entityId === tabId @@ -643,7 +701,12 @@ export const createBrowserSlice: StateCreator = // registerGuest uses page IDs (not workspace IDs), so we resolve the active // page within the workspace to find the correct browserPageId. const workspace = findWorkspace(get().browserTabsByWorktree, tabId) - if (workspace?.activePageId && typeof window !== 'undefined' && window.api?.browser) { + if ( + workspace?.activePageId && + !isRuntimeEnvironmentActive(get()) && + typeof window !== 'undefined' && + window.api?.browser + ) { window.api.browser .notifyActiveTabChanged({ browserPageId: workspace.activePageId }) .catch(() => {}) @@ -723,6 +786,8 @@ export const createBrowserSlice: StateCreator = }, closeBrowserPage: (pageId) => { + let closedWorkspaceIdForLabel: string | null = null + const remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] set((s) => { const page = findPage(s.browserPagesByWorkspace, pageId) if (!page) { @@ -732,6 +797,7 @@ export const createBrowserSlice: StateCreator = if (!workspace) { return s } + closedWorkspaceIdForLabel = page.workspaceId const currentPages = s.browserPagesByWorkspace[workspace.id] ?? [] const nextPages = currentPages.filter((entry) => entry.id !== pageId) const closedIdx = currentPages.findIndex((entry) => entry.id === pageId) @@ -747,6 +813,14 @@ export const createBrowserSlice: StateCreator = }, nextPages ) + const remoteHandle = s.remoteBrowserPageHandlesByPageId[pageId] + if (remoteHandle) { + remotePagesToClose.push({ worktreeId: page.worktreeId, handle: remoteHandle }) + } + const nextRemoteBrowserPageHandlesByPageId = { + ...s.remoteBrowserPageHandlesByPageId + } + delete nextRemoteBrowserPageHandlesByPageId[pageId] return { browserPagesByWorkspace: { @@ -777,18 +851,23 @@ export const createBrowserSlice: StateCreator = Object.entries(s.pendingAddressBarFocusByTabId).filter( ([pendingPageId]) => pendingPageId !== pageId ) - ) + ), + remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId } }) - const page = findPage(get().browserPagesByWorkspace, pageId) - if (!page) { + for (const remotePage of remotePagesToClose) { + closeRemoteBrowserPageInOwningEnvironment(remotePage.worktreeId, remotePage.handle) + } + + const closedWorkspaceId = closedWorkspaceIdForLabel + if (!closedWorkspaceId) { return } - const workspace = findWorkspace(get().browserTabsByWorktree, page.workspaceId) + const workspace = findWorkspace(get().browserTabsByWorktree, closedWorkspaceId) const item = Object.values(get().unifiedTabsByWorktree) .flat() - .find((entry) => entry.contentType === 'browser' && entry.entityId === page.workspaceId) + .find((entry) => entry.contentType === 'browser' && entry.entityId === closedWorkspaceId) if (item && workspace) { get().setTabLabel(item.id, workspace.title) } @@ -852,7 +931,11 @@ export const createBrowserSlice: StateCreator = // Why: switching the active page within a workspace changes which guest // webContents the CDP bridge should target for agent commands. - if (typeof window !== 'undefined' && window.api?.browser) { + if ( + !isRuntimeEnvironmentActive(get()) && + typeof window !== 'undefined' && + window.api?.browser + ) { window.api.browser.notifyActiveTabChanged({ browserPageId: pageId }).catch(() => {}) } @@ -925,7 +1008,11 @@ export const createBrowserSlice: StateCreator = // Why: notify the CDP bridge which guest webContents is now active so // subsequent agent commands target the correct page. Mirrors the // notifyActiveTabChanged calls in setActiveBrowserTab/setActiveBrowserPage. - if (typeof window !== 'undefined' && window.api?.browser) { + if ( + !isRuntimeEnvironmentActive(get()) && + typeof window !== 'undefined' && + window.api?.browser + ) { window.api.browser.notifyActiveTabChanged({ browserPageId }).catch(() => {}) } @@ -1057,6 +1144,32 @@ export const createBrowserSlice: StateCreator = } }), + setRemoteBrowserPageHandle: (pageId, handle) => { + set((s) => ({ + remoteBrowserPageHandlesByPageId: { + ...s.remoteBrowserPageHandlesByPageId, + [pageId]: handle + } + })) + }, + + removeRemoteBrowserPageHandle: (pageId, remotePageId) => { + let removedHandle: RemoteBrowserPageHandle | null = null + set((s) => { + const current = s.remoteBrowserPageHandlesByPageId[pageId] + if (!current || (remotePageId && current.remotePageId !== remotePageId)) { + return s + } + removedHandle = current + const nextRemoteBrowserPageHandlesByPageId = { + ...s.remoteBrowserPageHandlesByPageId + } + delete nextRemoteBrowserPageHandlesByPageId[pageId] + return { remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId } + }) + return removedHandle + }, + // viewportPresetId is a per-page setting on BrowserPage and is intentionally not // mirrored onto BrowserWorkspace: the outer tab strip doesn't surface the preset, // so there's no UI consumer at the workspace layer. Keeping it page-local avoids @@ -1241,6 +1354,7 @@ export const createBrowserSlice: StateCreator = activeBrowserTabId, activeTabTypeByWorktree: nextActiveTabTypeByWorktree, activeTabType, + remoteBrowserPageHandlesByPageId: {}, browserUrlHistory: normalizeBrowserHistoryEntries(session.browserUrlHistory ?? []) } }) @@ -1281,6 +1395,20 @@ export const createBrowserSlice: StateCreator = }, fetchBrowserSessionProfiles: async () => { + if (isRuntimeEnvironmentActive(get())) { + try { + const result = await callRuntimeRpc( + getActiveRuntimeTarget(get().settings), + 'browser.profileList', + undefined, + { timeoutMs: 15_000 } + ) + set({ browserSessionProfiles: result.profiles }) + } catch { + set({ browserSessionProfiles: [] }) + } + return + } try { const profiles = (await window.api.browser.sessionListProfiles()) as BrowserSessionProfile[] set({ browserSessionProfiles: profiles }) @@ -1290,6 +1418,25 @@ export const createBrowserSlice: StateCreator = }, createBrowserSessionProfile: async (scope, label) => { + if (isRuntimeEnvironmentActive(get())) { + try { + const result = await callRuntimeRpc( + getActiveRuntimeTarget(get().settings), + 'browser.profileCreate', + { scope, label }, + { timeoutMs: 15_000 } + ) + const profile = result.profile + if (profile) { + set((s) => ({ + browserSessionProfiles: [...s.browserSessionProfiles, profile] + })) + } + return profile + } catch { + return null + } + } try { const profile = (await window.api.browser.sessionCreateProfile({ scope, @@ -1307,6 +1454,27 @@ export const createBrowserSlice: StateCreator = }, deleteBrowserSessionProfile: async (profileId) => { + if (isRuntimeEnvironmentActive(get())) { + try { + const result = await callRuntimeRpc( + getActiveRuntimeTarget(get().settings), + 'browser.profileDelete', + { profileId }, + { timeoutMs: 15_000 } + ) + if (result.deleted) { + set((s) => ({ + browserSessionProfiles: s.browserSessionProfiles.filter((p) => p.id !== profileId), + ...(s.defaultBrowserSessionProfileId === profileId + ? { defaultBrowserSessionProfileId: null } + : {}) + })) + } + return result.deleted + } catch { + return false + } + } try { const ok = await window.api.browser.sessionDeleteProfile({ profileId }) if (ok) { @@ -1324,6 +1492,18 @@ export const createBrowserSlice: StateCreator = }, importCookiesToProfile: async (profileId) => { + if (isRuntimeEnvironmentActive(get())) { + const reason = 'Manual cookie file import is unavailable while a remote runtime is active.' + set({ + browserSessionImportState: { + profileId, + status: 'error', + summary: null, + error: reason + } + }) + return { ok: false as const, reason } + } set({ browserSessionImportState: { profileId, @@ -1381,6 +1561,20 @@ export const createBrowserSlice: StateCreator = detectedBrowsersLoaded: false, fetchDetectedBrowsers: async () => { + if (isRuntimeEnvironmentActive(get())) { + try { + const result = await callRuntimeRpc( + getActiveRuntimeTarget(get().settings), + 'browser.profileDetectBrowsers', + undefined, + { timeoutMs: 15_000 } + ) + set({ detectedBrowsers: result.browsers, detectedBrowsersLoaded: true }) + } catch { + set({ detectedBrowsers: [], detectedBrowsersLoaded: true }) + } + return + } if (get().detectedBrowsersLoaded) { return } @@ -1399,6 +1593,58 @@ export const createBrowserSlice: StateCreator = }, importCookiesFromBrowser: async (profileId, browserFamily, browserProfile?) => { + if (isRuntimeEnvironmentActive(get())) { + set({ + browserSessionImportState: { + profileId, + status: 'importing', + summary: null, + error: null + } + }) + try { + const result = await callRuntimeRpc( + getActiveRuntimeTarget(get().settings), + 'browser.profileImportFromBrowser', + { profileId, browserFamily, browserProfile }, + { timeoutMs: 30_000 } + ) + if (result.ok) { + set({ + browserSessionImportState: { + profileId, + status: 'success', + summary: result.summary, + error: null + } + }) + await get() + .fetchBrowserSessionProfiles() + .catch(() => {}) + } else { + set({ + browserSessionImportState: { + profileId, + status: 'error', + summary: null, + error: result.reason + } + }) + } + return result + } catch (err) { + const reason = String((err as Error)?.message ?? err) + set({ + browserSessionImportState: { + profileId, + status: 'error', + summary: null, + error: reason + } + }) + return { ok: false as const, reason } + } + } set({ browserSessionImportState: { profileId, @@ -1451,6 +1697,22 @@ export const createBrowserSlice: StateCreator = }, clearDefaultSessionCookies: async () => { + if (isRuntimeEnvironmentActive(get())) { + try { + const result = await callRuntimeRpc( + getActiveRuntimeTarget(get().settings), + 'browser.profileClearDefaultCookies', + undefined, + { timeoutMs: 15_000 } + ) + if (result.cleared) { + await get().fetchBrowserSessionProfiles() + } + return result.cleared + } catch { + return false + } + } try { const ok = await window.api.browser.sessionClearDefaultCookies() if (ok) { diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index 827d9e3831e..22ba47359e3 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -2,6 +2,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' import type { DiffComment, Worktree } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' // Mock sonner (imported transitively by other slices) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) @@ -14,6 +19,13 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { }) const updateMeta = vi.fn().mockResolvedValue({}) +const runtimeEnvironmentCall = vi.fn().mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } +}) +const runtimeEnvironmentTransportCall = vi.fn() const mockApi = { worktrees: { list: vi.fn().mockResolvedValue([]), @@ -21,6 +33,7 @@ const mockApi = { remove: vi.fn().mockResolvedValue(undefined), updateMeta }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall }, repos: { list: vi.fn().mockResolvedValue([]), add: vi.fn().mockResolvedValue({}), @@ -161,7 +174,18 @@ function seed(store: ReturnType, comments: DiffComment[] describe('updateDiffComment', () => { beforeEach(() => { vi.clearAllMocks() + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) updateMeta.mockResolvedValue({}) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) }) it('updates the body, trims it, and persists', async () => { @@ -189,6 +213,38 @@ describe('updateDiffComment', () => { expect(updateMeta).toHaveBeenCalledTimes(1) }) + it('persists through the selected runtime environment', async () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never + }) + seed(store, [ + { + id: 'c1', + worktreeId: WT, + filePath: 'src/foo.ts', + lineNumber: 10, + body: 'old body', + createdAt: 1000, + side: 'modified' + } + ]) + + const ok = await store.getState().updateDiffComment(WT, 'c1', 'remote body') + + expect(ok).toBe(true) + expect(updateMeta).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'worktree.set', + params: { + worktree: WT, + diffComments: [expect.objectContaining({ id: 'c1', body: 'remote body' })] + }, + timeoutMs: 15_000 + }) + }) + it('rejects an empty body without persisting', async () => { const store = createTestStore() seed(store, [ diff --git a/src/renderer/src/store/slices/diffComments.ts b/src/renderer/src/store/slices/diffComments.ts index c9a8493cd90..7b776d2aa23 100644 --- a/src/renderer/src/store/slices/diffComments.ts +++ b/src/renderer/src/store/slices/diffComments.ts @@ -2,6 +2,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { DiffComment, Worktree } from '../../../../shared/types' import { findWorktreeById, getRepoIdFromWorktreeId } from './worktree-helpers' +import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' export type DiffCommentsSlice = { getDiffComments: (worktreeId: string | null | undefined) => DiffComment[] @@ -22,11 +23,25 @@ function generateId(): string { // the sentinel from being corrupted globally. const EMPTY_COMMENTS: readonly DiffComment[] = Object.freeze([]) -async function persist(worktreeId: string, diffComments: DiffComment[]): Promise { - await window.api.worktrees.updateMeta({ - worktreeId, - updates: { diffComments } - }) +async function persist( + settings: AppState['settings'], + worktreeId: string, + diffComments: DiffComment[] +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'local') { + await window.api.worktrees.updateMeta({ + worktreeId, + updates: { diffComments } + }) + return + } + await callRuntimeRpc( + target, + 'worktree.set', + { worktree: worktreeId, diffComments }, + { timeoutMs: 15_000 } + ) } // Why: IPC writes from `persist` are not ordered with respect to each other. @@ -55,7 +70,7 @@ function enqueuePersist(worktreeId: string, get: () => AppState): Promise const repoList = get().worktreesByRepo[repoId] const target = repoList?.find((w) => w.id === worktreeId) const latest = target?.diffComments ?? [] - await persist(worktreeId, latest) + await persist(get().settings, worktreeId, latest) } const next = prior.then(run, run) persistQueueByWorktree.set(worktreeId, next) diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index b7a3a50e6e1..875946f1d4a 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -4,6 +4,11 @@ import { createStore, type StoreApi } from 'zustand/vanilla' import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { createEditorSlice } from './editor' import type { AppState } from '../types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' const { toastErrorMock } = vi.hoisted(() => ({ toastErrorMock: vi.fn() @@ -107,6 +112,203 @@ describe('createEditorSlice openDiff', () => { }) }) +describe('createEditorSlice untitled cleanup routing', () => { + const runtimeEnvironmentCallMock = vi.fn() + const runtimeEnvironmentTransportCallMock = vi.fn() + const localDeletePathMock = vi.fn() + + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCallMock.mockReset() + runtimeEnvironmentTransportCallMock.mockReset() + localDeletePathMock.mockReset() + runtimeEnvironmentCallMock.mockResolvedValue({ ok: true, result: { deleted: true } }) + runtimeEnvironmentTransportCallMock.mockImplementation( + (args: RuntimeEnvironmentCallRequest) => + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCallMock(args) + ) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { call: runtimeEnvironmentTransportCallMock }, + fs: { deletePath: localDeletePathMock } + } + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + function seedRemoteWorktree(store: StoreApi): void { + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [ + { + id: 'repo1', + path: '/remote/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 0 + } + ], + worktreesByRepo: { + repo1: [ + { + id: 'wt-1', + repoId: 'repo1', + path: '/remote/wt', + branch: 'refs/heads/main', + head: 'abc', + isBare: false, + isMainWorktree: false, + displayName: 'main', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } + ] + } + } as Partial) + } + + it('closeFile deletes untouched remote untitled files through runtime file RPC', async () => { + const store = createEditorStore() + seedRemoteWorktree(store) + store.getState().openFile({ + filePath: '/remote/wt/untitled.md', + relativePath: 'untitled.md', + worktreeId: 'wt-1', + language: 'markdown', + isUntitled: true, + mode: 'edit' + }) + + store.getState().closeFile('/remote/wt/untitled.md') + + await vi.waitFor(() => { + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.delete', + params: { worktree: 'wt-1', relativePath: 'untitled.md', recursive: undefined }, + timeoutMs: 15_000 + }) + }) + expect(localDeletePathMock).not.toHaveBeenCalled() + }) + + it('closeAllFiles deletes untouched remote untitled files through runtime file RPC', async () => { + const store = createEditorStore() + seedRemoteWorktree(store) + store.getState().openFile({ + filePath: '/remote/wt/untitled.md', + relativePath: 'untitled.md', + worktreeId: 'wt-1', + language: 'markdown', + isUntitled: true, + mode: 'edit' + }) + + store.getState().closeAllFiles() + + await vi.waitFor(() => { + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.delete', + params: { worktree: 'wt-1', relativePath: 'untitled.md', recursive: undefined }, + timeoutMs: 15_000 + }) + }) + expect(localDeletePathMock).not.toHaveBeenCalled() + }) + + it('closeFile uses relative remote delete when worktree metadata is missing', async () => { + const store = createEditorStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [], + worktreesByRepo: {} + } as Partial) + store.getState().openFile({ + filePath: '/remote/wt/untitled.md', + relativePath: 'untitled.md', + worktreeId: 'wt-1', + language: 'markdown', + isUntitled: true, + mode: 'edit' + }) + + store.getState().closeFile('/remote/wt/untitled.md') + + await vi.waitFor(() => { + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.delete', + params: { worktree: 'wt-1', relativePath: 'untitled.md', recursive: undefined }, + timeoutMs: 15_000 + }) + }) + expect(localDeletePathMock).not.toHaveBeenCalled() + }) + + it('closeFile deletes untouched remote untitled files in their owning runtime after switching local', async () => { + const store = createEditorStore() + seedRemoteWorktree(store) + store.getState().openFile({ + filePath: '/remote/wt/untitled.md', + relativePath: 'untitled.md', + worktreeId: 'wt-1', + language: 'markdown', + isUntitled: true, + mode: 'edit' + }) + store.setState({ settings: { activeRuntimeEnvironmentId: null } as never }) + + store.getState().closeFile('/remote/wt/untitled.md') + + await vi.waitFor(() => { + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.delete', + params: { worktree: 'wt-1', relativePath: 'untitled.md', recursive: undefined }, + timeoutMs: 15_000 + }) + }) + expect(localDeletePathMock).not.toHaveBeenCalled() + }) + + it('closeFile deletes untouched remote untitled files in their owning runtime after switching environments', async () => { + const store = createEditorStore() + seedRemoteWorktree(store) + store.getState().openFile({ + filePath: '/remote/wt/untitled.md', + relativePath: 'untitled.md', + worktreeId: 'wt-1', + language: 'markdown', + isUntitled: true, + mode: 'edit' + }) + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-2' } as never }) + + store.getState().closeFile('/remote/wt/untitled.md') + + await vi.waitFor(() => { + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.delete', + params: { worktree: 'wt-1', relativePath: 'untitled.md', recursive: undefined }, + timeoutMs: 15_000 + }) + }) + expect(localDeletePathMock).not.toHaveBeenCalled() + }) +}) + describe('createEditorSlice markdown view state', () => { it('updates stale language metadata when reopening an existing file', () => { const store = createEditorStore() @@ -1047,13 +1249,30 @@ describe('createEditorSlice activateMarkdownLink', () => { const openFileUriMock = vi.fn() const pathExistsMock = vi.fn() const authorizeExternalPathMock = vi.fn() + const runtimeEnvironmentCallMock = vi.fn() + const runtimeEnvironmentTransportCallMock = vi.fn() beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() toastErrorMock.mockReset() openUrlMock.mockReset() openFileUriMock.mockReset() pathExistsMock.mockReset() + pathExistsMock.mockResolvedValue(true) authorizeExternalPathMock.mockReset() + runtimeEnvironmentCallMock.mockReset() + runtimeEnvironmentTransportCallMock.mockReset() + runtimeEnvironmentCallMock.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { size: 1, isDirectory: false, mtime: 1 }, + _meta: { runtimeId: 'runtime-source' } + }) + runtimeEnvironmentTransportCallMock.mockImplementation( + (args: RuntimeEnvironmentCallRequest) => + createCompatibleRuntimeStatusResponseIfNeeded(args, 'runtime-source') ?? + runtimeEnvironmentCallMock(args) + ) openHttpLinkMock.mockReset() // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(globalThis as any).window = (globalThis as any).window ?? {} @@ -1065,7 +1284,17 @@ describe('createEditorSlice activateMarkdownLink', () => { pathExists: pathExistsMock }, fs: { - authorizeExternalPath: authorizeExternalPathMock + authorizeExternalPath: authorizeExternalPathMock, + stat: vi.fn(async ({ filePath }: { filePath: string }) => { + const exists = await pathExistsMock(filePath) + if (!exists) { + throw new Error('File not found') + } + return { size: 1, isDirectory: false, mtime: 1 } + }) + }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCallMock } } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1100,6 +1329,70 @@ describe('createEditorSlice activateMarkdownLink', () => { expect(openUrlMock).not.toHaveBeenCalled() }) + it('opens remote-owned markdown links through the source file runtime owner', async () => { + const store = createEditorStore() + pathExistsMock.mockResolvedValue(true) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-active' } as AppState['settings'] + }) + store.getState().openFile({ + filePath: '/repo/docs/note.md', + relativePath: 'docs/note.md', + worktreeId: 'wt-1', + runtimeEnvironmentId: 'env-source', + language: 'markdown', + mode: 'edit' + }) + + await store.getState().activateMarkdownLink('./guide.md', { + sourceFilePath: '/repo/docs/note.md', + worktreeId: 'wt-1', + worktreeRoot: '/repo' + }) + + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ + selector: 'env-source', + method: 'files.stat', + params: { worktree: 'wt-1', relativePath: 'docs/guide.md' }, + timeoutMs: 15_000 + }) + expect(store.getState().openFiles).toEqual([ + expect.objectContaining({ + filePath: '/repo/docs/note.md', + runtimeEnvironmentId: 'env-source' + }), + expect.objectContaining({ + filePath: '/repo/docs/guide.md', + runtimeEnvironmentId: 'env-source', + mode: 'edit', + isPreview: true + }) + ]) + }) + + it('can open a file without adopting the currently active runtime owner', () => { + const store = createEditorStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-active' } as AppState['settings'] + }) + + store.getState().openFile( + { + filePath: '/remote/.orca/drops/log.txt', + relativePath: '.orca/drops/log.txt', + worktreeId: 'wt-1', + language: 'text', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + + expect(store.getState().openFiles[0]).toMatchObject({ + filePath: '/remote/.orca/drops/log.txt' + }) + expect(store.getState().openFiles[0]?.runtimeEnvironmentId).toBeUndefined() + }) + it('toasts when the markdown target is missing', async () => { const store = createEditorStore() pathExistsMock.mockResolvedValue(false) diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 04f77a127cc..4faba8b6fbc 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -23,6 +23,19 @@ import type { } from '../../../../shared/types' import { stripCredentialsFromMessage } from '../../../../shared/git-remote-error' import type { RemoteOpKind } from '@/components/right-sidebar/source-control-primary-action' +import { + fetchRuntimeGit, + getRuntimeGitUpstreamStatus, + pullRuntimeGit, + pushRuntimeGit +} from '@/runtime/runtime-git-client' +import { + deleteRuntimePath, + deleteRuntimeRelativePath, + runtimePathExists +} from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { findWorktreeById, getRepoIdFromWorktreeId } from './worktree-helpers' export type DiffSource = | 'unstaged' @@ -91,6 +104,9 @@ export type OpenFile = { worktreeId: string language: string isDirty: boolean + // Why: remote untitled cleanup must target the environment that created the + // file, even if the user switches to Local or another runtime before closing. + runtimeEnvironmentId?: string /** Why: markdown preview tabs are separate editor tabs that mirror a source * markdown file's live draft. Storing the source file ID lets the preview * follow unsaved edits from the normal editor without becoming editable @@ -199,7 +215,12 @@ export type EditorSlice = { setActiveTabType: (type: WorkspaceVisibleTabType) => void openFile: ( file: Omit, - options?: { preview?: boolean; targetGroupId?: string; recordReplacedPreview?: boolean } + options?: { + preview?: boolean + targetGroupId?: string + recordReplacedPreview?: boolean + suppressActiveRuntimeFallback?: boolean + } ) => void // Why: dispatcher for markdown link activation. Lives on the slice because it // sequences openFile, setMarkdownViewMode, and setPendingEditorReveal around @@ -207,10 +228,18 @@ export type EditorSlice = { // docs/markdown-internal-link-opening-design.md. activateMarkdownLink: ( rawHref: string | undefined, - ctx: { sourceFilePath: string; worktreeId: string; worktreeRoot: string | null } + ctx: { + sourceFilePath: string + worktreeId: string + worktreeRoot: string | null + runtimeEnvironmentId?: string | null + } ) => Promise openMarkdownPreview: ( - file: Pick, + file: Pick< + OpenFile, + 'filePath' | 'relativePath' | 'worktreeId' | 'language' | 'runtimeEnvironmentId' + >, options?: { anchor?: string | null; targetGroupId?: string } ) => void pinFile: (fileId: string, tabId?: string) => void @@ -494,6 +523,31 @@ function resolveRemoteOperationErrorMessage( return error.message } +function deleteUntouchedUntitledFile(state: AppState, file: OpenFile): void { + const worktree = findWorktreeById(state.worktreesByRepo, file.worktreeId) + const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(file.worktreeId) + const repo = state.repos.find((candidate) => candidate.id === repoId) + const owningRuntimeEnvironmentId = file.runtimeEnvironmentId?.trim() + // Why: untitled placeholders may live on a remote runtime or SSH target. + // Route through the runtime-aware client instead of assuming client-local FS. + const context = { + settings: owningRuntimeEnvironmentId + ? { activeRuntimeEnvironmentId: owningRuntimeEnvironmentId } + : state.settings, + worktreeId: file.worktreeId, + worktreePath: worktree?.path ?? null, + connectionId: repo?.connectionId ?? undefined + } + void deleteRuntimeRelativePath(context, file.relativePath) + .then((deletedRemotely) => { + if (!deletedRemotely && !owningRuntimeEnvironmentId) { + return deleteRuntimePath(context, file.filePath) + } + return undefined + }) + .catch(() => {}) +} + export const createEditorSlice: StateCreator = (set, get) => ({ editorDrafts: {}, setEditorDraft: (fileId, content) => @@ -606,6 +660,11 @@ export const createEditorSlice: StateCreator = (s const id = file.filePath const existing = s.openFiles.find((f) => f.id === id) const worktreeId = file.worktreeId + const runtimeEnvironmentId = + file.runtimeEnvironmentId ?? + (options?.suppressActiveRuntimeFallback + ? undefined + : (s.settings?.activeRuntimeEnvironmentId?.trim() ?? undefined)) const isPreview = options?.preview ?? false const recordReplacedPreview = options?.recordReplacedPreview ?? false // Why: resolve the target group up-front so preview replacement can be @@ -647,7 +706,8 @@ export const createEditorSlice: StateCreator = (s existing.isPreview !== updatedPreview || existing.language !== file.language || existing.relativePath !== file.relativePath || - existing.worktreeId !== file.worktreeId + existing.worktreeId !== file.worktreeId || + existing.runtimeEnvironmentId !== runtimeEnvironmentId if (!needsExistingUpdate) { return activeResult } @@ -659,6 +719,7 @@ export const createEditorSlice: StateCreator = (s relativePath: file.relativePath, worktreeId: file.worktreeId, language: file.language, + runtimeEnvironmentId, mode: file.mode, diffSource: file.diffSource, branchCompare: file.branchCompare, @@ -729,7 +790,9 @@ export const createEditorSlice: StateCreator = (s ) // Replace in-place to preserve tab position newFiles = s.openFiles.map((f, i) => - i === existingPreviewIdx ? { ...file, id, isDirty: false, isPreview: true } : f + i === existingPreviewIdx + ? { ...file, id, isDirty: false, isPreview: true, runtimeEnvironmentId } + : f ) // Swap the old preview ID for the new one in the stored tab bar order const prevOrder = s.tabBarOrderByWorktree?.[worktreeId] @@ -799,7 +862,13 @@ export const createEditorSlice: StateCreator = (s return { openFiles: [ ...newFiles, - { ...file, id, isDirty: false, isPreview: isPreview || undefined } + { + ...file, + id, + isDirty: false, + isPreview: isPreview || undefined, + runtimeEnvironmentId + } ], ...tabBarUpdate, ...activeResult @@ -826,6 +895,8 @@ export const createEditorSlice: StateCreator = (s set((s) => { const existing = s.openFiles.find((openFile) => openFile.id === id) const worktreeId = file.worktreeId + const runtimeEnvironmentId = + file.runtimeEnvironmentId ?? s.settings?.activeRuntimeEnvironmentId?.trim() ?? undefined const activeResult = { activeFileId: id, activeTabType: 'editor' as const, @@ -851,6 +922,7 @@ export const createEditorSlice: StateCreator = (s relativePath: file.relativePath, worktreeId: file.worktreeId, language: file.language, + runtimeEnvironmentId, markdownPreviewSourceFileId: file.filePath, markdownPreviewAnchor: anchor, mode: 'markdown-preview' as const @@ -869,6 +941,7 @@ export const createEditorSlice: StateCreator = (s worktreeId: file.worktreeId, language: file.language, isDirty: false, + runtimeEnvironmentId, markdownPreviewSourceFileId: file.filePath, markdownPreviewAnchor: anchor, mode: 'markdown-preview' @@ -1075,7 +1148,7 @@ export const createEditorSlice: StateCreator = (s // tab without typing anything, the file is just clutter. Fire-and-forget // delete; failure (e.g. already removed externally) is harmless. if (shouldDeleteFromDisk && preClose && typeof window !== 'undefined') { - void window.api?.fs?.deletePath({ targetPath: preClose.filePath })?.catch(() => {}) + deleteUntouchedUntitledFile(get(), preClose) } // Why: the unified tab model drives visual tab-bar order and next-active @@ -1242,8 +1315,9 @@ export const createEditorSlice: StateCreator = (s } }) if (typeof window !== 'undefined') { + const postCloseState = get() for (const f of untitledToDelete) { - void window.api?.fs?.deletePath({ targetPath: f.filePath })?.catch(() => {}) + deleteUntouchedUntitledFile(postCloseState, f) } } for (const itemId of closingItemIds) { @@ -1904,7 +1978,9 @@ export const createEditorSlice: StateCreator = (s }), fetchUpstreamStatus: async (worktreeId, worktreePath, connectionId) => { try { - const status = await window.api.git.upstreamStatus({ + const status = await getRuntimeGitUpstreamStatus({ + settings: get().settings, + worktreeId, worktreePath, connectionId }) @@ -1931,7 +2007,10 @@ export const createEditorSlice: StateCreator = (s // store as soon as the IPC resolves. get().beginRemoteOperation(publish ? 'publish' : 'push') try { - await window.api.git.push({ worktreePath, publish, connectionId, pushTarget }) + await pushRuntimeGit( + { settings: get().settings, worktreeId, worktreePath, connectionId }, + { publish, pushTarget } + ) } catch (error) { toast.error(resolveRemoteOperationErrorMessage(error, { publish, isPush: true })) throw error @@ -1943,7 +2022,7 @@ export const createEditorSlice: StateCreator = (s pullBranch: async (worktreeId, worktreePath, connectionId) => { get().beginRemoteOperation('pull') try { - await window.api.git.pull({ worktreePath, connectionId }) + await pullRuntimeGit({ settings: get().settings, worktreeId, worktreePath, connectionId }) } catch (error) { toast.error(resolveRemoteOperationErrorMessage(error)) throw error @@ -1963,23 +2042,17 @@ export const createEditorSlice: StateCreator = (s // outer catch must then skip toasting to avoid a double-toast. let pushStageToastShown = false try { - await window.api.git.fetch({ worktreePath, connectionId }) - await window.api.git.pull({ worktreePath, connectionId }) + const context = { settings: get().settings, worktreeId, worktreePath, connectionId } + await fetchRuntimeGit(context) + await pullRuntimeGit(context) // Why: push only if the pull left local commits that aren't on the // remote. After a merge pull the ahead count can be >0 (local commits + // the new merge commit) or 0 (pure fast-forward), and we avoid a // no-op push round-trip in the fast-forward case. - const upstreamStatus = await window.api.git.upstreamStatus({ - worktreePath, - connectionId - }) + const upstreamStatus = await getRuntimeGitUpstreamStatus(context) if (upstreamStatus.ahead > 0) { try { - await window.api.git.push({ - worktreePath, - connectionId, - pushTarget - }) + await pushRuntimeGit(context, { pushTarget }) } catch (error) { // Why: format under the user-facing operation (sync) rather than // the inner step (push) — the user clicked Sync and shouldn't see @@ -2010,7 +2083,7 @@ export const createEditorSlice: StateCreator = (s // ahead/behind counts on the upstream-status payload. get().beginRemoteOperation('fetch') try { - await window.api.git.fetch({ worktreePath, connectionId }) + await fetchRuntimeGit({ settings: get().settings, worktreeId, worktreePath, connectionId }) } catch (error) { toast.error(resolveRemoteOperationErrorMessage(error)) throw error @@ -2142,6 +2215,11 @@ export const createEditorSlice: StateCreator = (s setPendingEditorReveal: (reveal) => set({ pendingEditorReveal: reveal }), activateMarkdownLink: async (rawHref, ctx) => { + const sourceRuntimeEnvironmentId = + ctx.runtimeEnvironmentId ?? + get().openFiles.find((file) => file.filePath === ctx.sourceFilePath)?.runtimeEnvironmentId ?? + null + const sourceSettings = settingsForRuntimeOwner(get().settings, sourceRuntimeEnvironmentId) const target = resolveMarkdownLinkTarget(rawHref, ctx.sourceFilePath, ctx.worktreeRoot) if (!target) { return @@ -2155,6 +2233,13 @@ export const createEditorSlice: StateCreator = (s } if (target.kind === 'file') { if (target.relativePath === undefined) { + if (sourceSettings?.activeRuntimeEnvironmentId?.trim()) { + // Why: a file:// link outside the worktree is a client-local escape + // hatch. Remote runtime editors must not authorize/open client paths + // as though the server could read them. + toast.error('External local file links are not available for remote runtime files yet.') + return + } // Why: terminal file links already authorize clicked external paths // before opening them in Orca. Markdown file:// links need the same // user-gesture authorization so /tmp screenshots can use ImageViewer. @@ -2166,6 +2251,7 @@ export const createEditorSlice: StateCreator = (s filePath: target.absolutePath, relativePath: target.relativePath ?? target.absolutePath, worktreeId: ctx.worktreeId, + runtimeEnvironmentId: sourceRuntimeEnvironmentId ?? undefined, language: detectLanguage(target.absolutePath), mode: 'edit' }, @@ -2180,14 +2266,25 @@ export const createEditorSlice: StateCreator = (s // target.kind === 'markdown' const { absolutePath, relativePath, line, column } = target - const exists = await window.api.shell.pathExists(absolutePath) + const exists = await runtimePathExists( + { + settings: sourceSettings, + worktreeId: ctx.worktreeId, + worktreePath: ctx.worktreeRoot + }, + absolutePath + ) if (!exists) { toast.error(`File not found: ${relativePath}`) return } const state = get() - const existing = state.openFiles.find((f) => f.filePath === absolutePath) + const existing = state.openFiles.find( + (f) => + f.filePath === absolutePath && + (f.runtimeEnvironmentId ?? null) === sourceRuntimeEnvironmentId + ) const fileId = existing?.id ?? absolutePath // Why: pendingEditorReveal is consumed by MonacoEditor on mount. If the @@ -2205,6 +2302,7 @@ export const createEditorSlice: StateCreator = (s filePath: absolutePath, relativePath, worktreeId: ctx.worktreeId, + runtimeEnvironmentId: sourceRuntimeEnvironmentId ?? undefined, language: 'markdown', mode: 'edit' }, @@ -2272,6 +2370,7 @@ export const createEditorSlice: StateCreator = (s language: detectLanguage(pf.relativePath || pf.filePath), isDirty: false, isPreview: pf.isPreview, + runtimeEnvironmentId: pf.runtimeEnvironmentId, mode: 'edit' }) } diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index 3229a588db2..6fbae94e642 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -6,13 +6,25 @@ import { create } from 'zustand' import { createGitHubSlice } from './github' import type { AppState } from '../types' import type { PRInfo } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() const mockApi = { gh: { prForBranch: vi.fn().mockResolvedValue(null), issue: vi.fn().mockResolvedValue(null), prChecks: vi.fn().mockResolvedValue([]), - listWorkItems: vi.fn() + listWorkItems: vi.fn(), + getProjectViewTable: vi.fn() + }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall }, cache: { getGitHub: vi.fn().mockResolvedValue(null), @@ -23,6 +35,15 @@ const mockApi = { // @ts-expect-error test window mock globalThis.window = { api: mockApi } +function resetRemoteRuntimeMocks() { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) +} + function createTestStore() { return create()( (...a) => @@ -49,6 +70,7 @@ function makePR(overrides: Partial = {}): PRInfo { describe('createGitHubSlice.fetchPRChecks', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() mockApi.gh.prChecks.mockResolvedValue([]) }) @@ -230,6 +252,7 @@ describe('createGitHubSlice.fetchPRChecks', () => { describe('createGitHubSlice.fetchPRForBranch', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() mockApi.gh.prForBranch.mockResolvedValue(null) }) @@ -265,6 +288,13 @@ describe('createGitHubSlice.fetchPRForBranch', () => { describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { items: [], sources: { issues: null, prs: null, upstreamCandidate: null } }, + _meta: { runtimeId: 'remote-runtime' } + }) }) it('stores resolved sources on the cache entry for the indicator to read', async () => { @@ -342,6 +372,104 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { const after = store.getState().getWorkItemsSourcesAndError('/repo', 24, '') expect(after.error).toBeNull() }) + + it('routes work item fetches through the active runtime environment', async () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + repos: [ + { + id: 'repo-id', + path: '/server/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + ] + } as Partial) + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: { + items: [{ type: 'issue', number: 7, title: 'Server issue', url: 'https://example.test/7' }], + sources: { issues: { owner: 'up', repo: 'r' }, prs: { owner: 'up', repo: 'r' } } + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await store.getState().fetchWorkItems('repo-id', '/server/repo', 24, '') + + expect(mockApi.gh.listWorkItems).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.listWorkItems', + params: { repo: 'repo-id', limit: 24, query: undefined }, + timeoutMs: 30_000 + }) + expect(store.getState().workItemsCache['/server/repo::24::'].data?.[0]).toMatchObject({ + repoId: 'repo-id', + number: 7 + }) + }) + + it('routes project table fetches through the active runtime environment', async () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } + } as Partial) + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: { + ok: true, + data: { + project: { + id: 'project-1', + owner: 'acme', + ownerType: 'organization', + number: 1, + title: 'Roadmap', + url: 'https://github.com/orgs/acme/projects/1' + }, + selectedView: { + id: 'view-1', + number: 1, + name: 'Table', + layout: 'TABLE_LAYOUT', + filter: '', + fields: [], + groupByFields: [], + sortByFields: [] + }, + rows: [], + totalCount: 0, + parentFieldDropped: false + } + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + const result = await store.getState().fetchProjectViewTable({ + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1' + }) + + expect(result.ok).toBe(true) + expect(mockApi.gh.getProjectViewTable).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.project.viewTable', + params: { + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1' + }, + timeoutMs: 60_000 + }) + }) }) describe('IssueSourceIndicator suppression', () => { diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index e77901aba9f..4f007e839f1 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -11,6 +11,7 @@ import type { IssueInfo, PRCheckDetail, PRComment, + Repo, Worktree, GitHubWorkItem } from '../../../../shared/types' @@ -25,6 +26,7 @@ import type { } from '../../../../shared/github-project-types' import { sortWorkItemsByUpdatedAt, PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items' import { syncPRChecksStatus } from './github-checks' +import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' // ─── ProjectV2 cache types ──────────────────────────────────────────── // Why: declared separately from CacheEntry (not a generified E parameter) @@ -77,6 +79,18 @@ function queryOverrideKeyPart(queryOverride: string | undefined): string { return `:q=${queryOverride}` } +function getRuntimeRepoTarget( + state: AppState, + repoPath: string +): { target: { kind: 'environment'; environmentId: string }; repo: Repo } | null { + const target = getActiveRuntimeTarget(state.settings) + if (target.kind !== 'environment') { + return null + } + const repo = state.repos.find((candidate) => candidate.path === repoPath) + return repo ? { target, repo } : null +} + export function projectViewCacheKey( ownerType: GetProjectViewTableArgs['ownerType'], owner: string, @@ -616,7 +630,16 @@ export const createGitHubSlice: StateCreator = (s const request = (async (): Promise => { await acquireWorkItemSlot() try { - const envelope = await window.api.gh.getProjectViewTable(args) + const target = getActiveRuntimeTarget(get().settings) + const envelope = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.viewTable', + args, + { timeoutMs: 60_000 } + ) + : await window.api.gh.getProjectViewTable(args) if (envelope.ok) { const table = envelope.data const key = projectViewCacheKey( @@ -703,12 +726,26 @@ export const createGitHubSlice: StateCreator = (s } applyRowPatch(set, cacheKey, rowId, optimisticRow) - const result = await window.api.gh.updateProjectItemField({ - projectId: table.project.id, - itemId: rowId, - fieldId, - value - }) + const target = getActiveRuntimeTarget(get().settings) + const result = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.updateItemField', + { + projectId: table.project.id, + itemId: rowId, + fieldId, + value + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updateProjectItemField({ + projectId: table.project.id, + itemId: rowId, + fieldId, + value + }) if (!result.ok) { rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) } @@ -741,11 +778,24 @@ export const createGitHubSlice: StateCreator = (s } applyRowPatch(set, cacheKey, rowId, optimisticRow) - const result = await window.api.gh.clearProjectItemField({ - projectId: table.project.id, - itemId: rowId, - fieldId - }) + const target = getActiveRuntimeTarget(get().settings) + const result = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.clearItemField', + { + projectId: table.project.id, + itemId: rowId, + fieldId + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.clearProjectItemField({ + projectId: table.project.id, + itemId: rowId, + fieldId + }) if (!result.ok) { rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) } @@ -820,11 +870,12 @@ export const createGitHubSlice: StateCreator = (s // PRs goes through updatePullRequestBySlug; for issues through // updateIssueBySlug. We dispatch both as needed. let envelope: GitHubProjectMutationResult = { ok: true } + const target = getActiveRuntimeTarget(get().settings) if ( previousRow.itemType === 'PULL_REQUEST' && (updates.title !== undefined || updates.body !== undefined) ) { - const prRes = await window.api.gh.updatePullRequestBySlug({ + const args = { owner, repo, number, @@ -832,7 +883,16 @@ export const createGitHubSlice: StateCreator = (s ...(updates.title !== undefined ? { title: updates.title } : {}), ...(updates.body !== undefined ? { body: updates.body } : {}) } - }) + } + const prRes = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.updatePullRequestBySlug', + args, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updatePullRequestBySlug(args) if (!prRes.ok) { envelope = prRes } @@ -846,7 +906,7 @@ export const createGitHubSlice: StateCreator = (s (previousRow.itemType === 'ISSUE' && (updates.title !== undefined || updates.body !== undefined))) ) { - const issueRes = await window.api.gh.updateIssueBySlug({ + const args = { owner, repo, number, @@ -858,7 +918,16 @@ export const createGitHubSlice: StateCreator = (s ...(updates.addAssignees ? { addAssignees: updates.addAssignees } : {}), ...(updates.removeAssignees ? { removeAssignees: updates.removeAssignees } : {}) } - }) + } + const issueRes = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.updateIssueBySlug', + args, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updateIssueBySlug(args) if (!issueRes.ok) { envelope = issueRes } @@ -899,12 +968,22 @@ export const createGitHubSlice: StateCreator = (s content: { ...previousRow.content, issueType } } applyRowPatch(set, cacheKey, rowId, optimistic) - const res = await window.api.gh.updateIssueTypeBySlug({ + const target = getActiveRuntimeTarget(get().settings) + const args = { owner, repo, number, issueTypeId: issueType?.id ?? null - }) + } + const res = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.updateIssueTypeBySlug', + args, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updateIssueTypeBySlug(args) if (!res.ok) { rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) } @@ -1008,11 +1087,19 @@ export const createGitHubSlice: StateCreator = (s const request = (async () => { await acquireWorkItemSlot() try { - const envelope = await window.api.gh.listWorkItems({ - repoPath, - limit, - query: query || undefined - }) + const runtimeRepo = getRuntimeRepoTarget(get(), repoPath) + const envelope = runtimeRepo + ? await callRuntimeRpc>>( + runtimeRepo.target, + 'github.listWorkItems', + { repo: runtimeRepo.repo.id, limit, query: query || undefined }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.listWorkItems({ + repoPath, + limit, + query: query || undefined + }) // Why: stamp repoId at the renderer fetch boundary so every downstream // consumer (cross-repo merge, row rendering, drawer) can rely on the // field being present. Main doesn't know Orca's Repo.id. @@ -1103,12 +1190,25 @@ export const createGitHubSlice: StateCreator = (s repos.map(async (r) => { await acquireWorkItemSlot() try { - const envelope = await window.api.gh.listWorkItems({ - repoPath: r.path, - limit: perRepoLimit, - query: query || undefined, - before - }) + const runtimeRepo = getRuntimeRepoTarget(get(), r.path) + const envelope = runtimeRepo + ? await callRuntimeRpc>>( + runtimeRepo.target, + 'github.listWorkItems', + { + repo: runtimeRepo.repo.id, + limit: perRepoLimit, + query: query || undefined, + before + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.listWorkItems({ + repoPath: r.path, + limit: perRepoLimit, + query: query || undefined, + before + }) // Why: page-N partial failures don't participate in the cache's per-repo // error banner (which is keyed on the initial-fetch cache entry). Log the // classified issues-side error so pagination failures are at least @@ -1140,10 +1240,18 @@ export const createGitHubSlice: StateCreator = (s const counts = await Promise.all( repos.map(async (r) => { try { - return await window.api.gh.countWorkItems({ - repoPath: r.path, - query: query || undefined - }) + const runtimeRepo = getRuntimeRepoTarget(get(), r.path) + return runtimeRepo + ? await callRuntimeRpc( + runtimeRepo.target, + 'github.countWorkItems', + { repo: runtimeRepo.repo.id, query: query || undefined }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.countWorkItems({ + repoPath: r.path, + query: query || undefined + }) } catch { return 0 } @@ -1201,7 +1309,15 @@ export const createGitHubSlice: StateCreator = (s const linkedPRNumber = options?.linkedPRNumber ?? null const request = (async () => { try { - const pr = await window.api.gh.prForBranch({ repoPath, branch, linkedPRNumber }) + const runtimeRepo = getRuntimeRepoTarget(get(), repoPath) + const pr = runtimeRepo + ? await callRuntimeRpc( + runtimeRepo.target, + 'github.prForBranch', + { repo: runtimeRepo.repo.id, branch, linkedPRNumber }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.prForBranch({ repoPath, branch, linkedPRNumber }) if (prRequestGenerations.get(cacheKey) === generation) { set((s) => ({ prCache: { ...s.prCache, [cacheKey]: { data: pr, fetchedAt: Date.now() } } @@ -1248,7 +1364,15 @@ export const createGitHubSlice: StateCreator = (s const request = (async () => { try { - const issue = await window.api.gh.issue({ repoPath, number }) + const runtimeRepo = getRuntimeRepoTarget(get(), repoPath) + const issue = runtimeRepo + ? await callRuntimeRpc( + runtimeRepo.target, + 'github.issue', + { repo: runtimeRepo.repo.id, number }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.issue({ repoPath, number }) set((s) => ({ issueCache: { ...s.issueCache, [cacheKey]: { data: issue, fetchedAt: Date.now() } } })) @@ -1290,12 +1414,20 @@ export const createGitHubSlice: StateCreator = (s const request = (async () => { try { - const checks = (await window.api.gh.prChecks({ - repoPath, - prNumber, - headSha, - noCache: options?.force - })) as PRCheckDetail[] + const runtimeRepo = getRuntimeRepoTarget(get(), repoPath) + const checks = runtimeRepo + ? await callRuntimeRpc( + runtimeRepo.target, + 'github.prChecks', + { repo: runtimeRepo.repo.id, prNumber, headSha, noCache: options?.force }, + { timeoutMs: 30_000 } + ) + : ((await window.api.gh.prChecks({ + repoPath, + prNumber, + headSha, + noCache: options?.force + })) as PRCheckDetail[]) set((s) => { const nextState: Partial = { checksCache: { ...s.checksCache, [cacheKey]: { data: checks, fetchedAt: Date.now() } } @@ -1336,11 +1468,19 @@ export const createGitHubSlice: StateCreator = (s const request = (async () => { try { - const comments = (await window.api.gh.prComments({ - repoPath, - prNumber, - noCache: options?.force - })) as PRComment[] + const runtimeRepo = getRuntimeRepoTarget(get(), repoPath) + const comments = runtimeRepo + ? await callRuntimeRpc( + runtimeRepo.target, + 'github.prComments', + { repo: runtimeRepo.repo.id, prNumber, noCache: options?.force }, + { timeoutMs: 30_000 } + ) + : ((await window.api.gh.prComments({ + repoPath, + prNumber, + noCache: options?.force + })) as PRComment[]) set((s) => ({ commentsCache: { ...s.commentsCache, @@ -1378,7 +1518,15 @@ export const createGitHubSlice: StateCreator = (s })) } - const ok = await window.api.gh.resolveReviewThread({ repoPath, threadId, resolve }) + const runtimeRepo = getRuntimeRepoTarget(get(), repoPath) + const ok = runtimeRepo + ? await callRuntimeRpc( + runtimeRepo.target, + 'github.resolveReviewThread', + { repo: runtimeRepo.repo.id, threadId, resolve }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.resolveReviewThread({ repoPath, threadId, resolve }) if (!ok && prev) { // Revert optimistic update on failure set((s) => ({ @@ -1529,10 +1677,11 @@ export const createGitHubSlice: StateCreator = (s // `repos:changed` broadcast → other windows re-fetch. The store layer // normalizes `'auto'` to `undefined` so the persisted record drops // the key entirely (see main/persistence.ts#updateRepo). - await window.api.repos.update({ - repoId, - updates: { issueSourcePreference: preference === 'auto' ? undefined : preference } - }) + const updates = { issueSourcePreference: preference === 'auto' ? undefined : preference } + const target = getActiveRuntimeTarget(get().settings) + await (target.kind === 'local' + ? window.api.repos.update({ repoId, updates }) + : callRuntimeRpc(target, 'repo.update', { repo: repoId, updates }, { timeoutMs: 15_000 })) } catch (err) { console.error('Failed to persist issue-source preference:', err) // Why: surface the persist failure so the user understands why the diff --git a/src/renderer/src/store/slices/hosted-review.test.ts b/src/renderer/src/store/slices/hosted-review.test.ts index 08c217e3cba..3f90fb4bfbb 100644 --- a/src/renderer/src/store/slices/hosted-review.test.ts +++ b/src/renderer/src/store/slices/hosted-review.test.ts @@ -4,6 +4,20 @@ import type { AppState } from '../types' import { createHostedReviewSlice } from './hosted-review' import type { HostedReviewInfo } from '../../../../shared/hosted-review' +const runtimeRpc = vi.hoisted(() => ({ + callRuntimeRpc: vi.fn() +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: runtimeRpc.callRuntimeRpc, + getActiveRuntimeTarget: ( + settings: { activeRuntimeEnvironmentId?: string | null } | null | undefined + ) => { + const environmentId = settings?.activeRuntimeEnvironmentId?.trim() + return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } + } +})) + const mockApi = { hostedReview: { forBranch: vi.fn() @@ -12,9 +26,12 @@ const mockApi = { globalThis.window = { api: mockApi } as never -function makeStore() { - return create>()((...args) => - createHostedReviewSlice(...(args as Parameters)) +function makeStore(settings: AppState['settings'] = null) { + return create>()( + (...args) => ({ + settings, + ...createHostedReviewSlice(...(args as Parameters)) + }) ) } @@ -32,6 +49,7 @@ const review: HostedReviewInfo = { describe('hosted review slice', () => { beforeEach(() => { mockApi.hostedReview.forBranch.mockReset() + runtimeRpc.callRuntimeRpc.mockReset() }) it('fetches and caches branch review status through the common IPC surface', async () => { @@ -56,4 +74,31 @@ describe('hosted review slice', () => { linkedBitbucketPR: null }) }) + + it('routes active runtime review lookups through runtime RPC', async () => { + runtimeRpc.callRuntimeRpc.mockResolvedValueOnce(review) + const store = makeStore({ + activeRuntimeEnvironmentId: 'env-win' + } as AppState['settings']) + + await expect( + store.getState().fetchHostedReviewForBranch('C:\\repo', 'feature/windows', { + linkedGitHubPR: 12 + }) + ).resolves.toEqual(review) + + expect(mockApi.hostedReview.forBranch).not.toHaveBeenCalled() + expect(runtimeRpc.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-win' }, + 'hostedReview.forBranch', + { + repo: 'C:\\repo', + branch: 'feature/windows', + linkedGitHubPR: 12, + linkedGitLabMR: null, + linkedBitbucketPR: null + }, + { timeoutMs: 30_000 } + ) + }) }) diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index 0abbc0733ac..39b545f62e2 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -1,5 +1,7 @@ import type { StateCreator } from 'zustand' import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { GlobalSettings } from '../../../../shared/types' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { AppState } from '../types' type CacheEntry = { data: T | null; fetchedAt: number } @@ -17,6 +19,16 @@ function isFresh(entry: CacheEntry | undefined): entry is CacheEntry { return entry !== undefined && Date.now() - entry.fetchedAt < CACHE_TTL_MS } +export function getHostedReviewCacheKey( + repoPath: string, + branch: string, + settings?: Pick | null +): string { + const target = getActiveRuntimeTarget(settings) + const scope = target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local' + return `${scope}::${repoPath}::${branch}` +} + export type HostedReviewSlice = { hostedReviewCache: Record> fetchHostedReviewForBranch: ( @@ -41,7 +53,9 @@ export const createHostedReviewSlice: StateCreator => { - const cacheKey = `${repoPath}::${branch}` + const settings = get().settings + const target = getActiveRuntimeTarget(settings) + const cacheKey = getHostedReviewCacheKey(repoPath, branch, settings) const cached = get().hostedReviewCache[cacheKey] const linkedRefetch = cached?.data === null && @@ -62,13 +76,25 @@ export const createHostedReviewSlice: StateCreator { try { - const review = await window.api.hostedReview.forBranch({ - repoPath, + const args = { branch, linkedGitHubPR: options?.linkedGitHubPR ?? null, linkedGitLabMR: options?.linkedGitLabMR ?? null, linkedBitbucketPR: options?.linkedBitbucketPR ?? null - }) + } + const review = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'hostedReview.forBranch', + { repo: repoPath, ...args }, + // Why: remote dev boxes can be slower at `git`/`gh` lookups + // than local desktop repos, especially on Windows filesystem + // paths. The main-process queue caps concurrency, so a longer + // timeout no longer risks a background socket stampede. + { timeoutMs: 30_000 } + ) + : await window.api.hostedReview.forBranch({ repoPath, ...args }) if (requestGenerations.get(cacheKey) === generation) { set((state) => ({ hostedReviewCache: { diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index 34098ee337c..084c8e29cf9 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -3,6 +3,15 @@ import type { AppState } from '../types' import type { LinearViewer, LinearConnectionStatus, LinearIssue } from '../../../../shared/types' import type { CacheEntry } from './github' import { clearLinearMetadataCache } from '../../hooks/useIssueMetadata' +import { + linearConnect, + linearDisconnect, + linearGetIssue, + linearListIssues, + linearSearchIssues, + linearStatus, + linearTestConnection +} from '@/runtime/runtime-linear-client' const CACHE_TTL = 60_000 // 60s — same as GitHub work-items TTL const MAX_CACHE_ENTRIES = 500 @@ -67,7 +76,7 @@ export const createLinearSlice: StateCreator = (s checkLinearConnection: async () => { try { - const status = (await window.api.linear.status()) as LinearConnectionStatus + const status = (await linearStatus(get().settings)) as LinearConnectionStatus const prev = get().linearStatus if (prev.connected !== status.connected || prev.viewer?.email !== status.viewer?.email) { set({ linearStatus: status, linearStatusChecked: true }) @@ -85,7 +94,7 @@ export const createLinearSlice: StateCreator = (s testLinearConnection: async () => { try { - const result = (await window.api.linear.testConnection()) as + const result = (await linearTestConnection(get().settings)) as | { ok: true; viewer: LinearViewer } | { ok: false; error: string } if (result.ok) { @@ -110,7 +119,7 @@ export const createLinearSlice: StateCreator = (s connectLinear: async (apiKey: string) => { try { - const result = await window.api.linear.connect({ apiKey }) + const result = await linearConnect(get().settings, apiKey) if (result.ok) { set({ linearStatus: { @@ -127,7 +136,7 @@ export const createLinearSlice: StateCreator = (s }, disconnectLinear: async () => { - await window.api.linear.disconnect() + await linearDisconnect(get().settings) inflightIssueRequests.clear() inflightSearchRequests.clear() inflightListRequests.clear() @@ -150,8 +159,7 @@ export const createLinearSlice: StateCreator = (s return inflight } - const promise = window.api.linear - .getIssue({ id }) + const promise = linearGetIssue(get().settings, id) .then((issue) => { const data = issue as LinearIssue | null set((s) => ({ @@ -189,8 +197,7 @@ export const createLinearSlice: StateCreator = (s return inflight } - const promise = window.api.linear - .searchIssues({ query, limit }) + const promise = linearSearchIssues(get().settings, query, limit) .then((issues) => { const data = issues as LinearIssue[] set((s) => ({ @@ -228,8 +235,7 @@ export const createLinearSlice: StateCreator = (s return inflight } - const promise = window.api.linear - .listIssues({ filter, limit }) + const promise = linearListIssues(get().settings, filter, limit) .then((issues) => { const data = issues as LinearIssue[] set((s) => ({ diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts new file mode 100644 index 00000000000..ea268eb5b0b --- /dev/null +++ b/src/renderer/src/store/slices/repos.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { createTestStore, makeWorktree } from './store-test-helpers' +import type { Repo } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +const localRepo: Repo = { + id: 'local-repo', + path: '/local', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1 +} + +const remoteRepo: Repo = { + id: 'remote-repo', + path: '/remote', + displayName: 'Remote', + badgeColor: '#111', + addedAt: 2 +} + +const reposList = vi.fn() +const reposAdd = vi.fn() +const reposPickFolder = vi.fn() +const reposRemove = vi.fn() +const reposUpdate = vi.fn() +const reposReorder = vi.fn() +const ptyKill = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposList.mockReset() + reposAdd.mockReset() + reposPickFolder.mockReset() + reposRemove.mockReset() + reposUpdate.mockReset() + reposReorder.mockReset() + ptyKill.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: { + list: reposList, + add: reposAdd, + pickFolder: reposPickFolder, + remove: reposRemove, + update: reposUpdate, + reorder: reposReorder + }, + pty: { kill: ptyKill }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) +}) + +describe('repo slice runtime routing', () => { + it('fetches repos from local IPC when no remote environment is active', async () => { + reposList.mockResolvedValue([localRepo]) + const store = createTestStore() + + await store.getState().fetchRepos() + + expect(store.getState().repos).toEqual([localRepo]) + expect(reposList).toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('fetches repos from the active remote runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + activeRepoId: 'stale-repo', + filterRepoIds: ['remote-repo', 'stale-repo'] + }) + + await store.getState().fetchRepos() + + expect(store.getState().repos).toEqual([remoteRepo]) + expect(store.getState().activeRepoId).toBeNull() + expect(store.getState().filterRepoIds).toEqual(['remote-repo']) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.list', + params: undefined, + timeoutMs: 15_000 + }) + expect(reposList).not.toHaveBeenCalled() + }) + + it('updates repos through the active remote runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-2', + ok: true, + result: { repo: { ...remoteRepo, displayName: 'Renamed' } }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [remoteRepo] + }) + + await store.getState().updateRepo(remoteRepo.id, { displayName: 'Renamed' }) + + expect(store.getState().repos[0]?.displayName).toBe('Renamed') + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.update', + params: { repo: remoteRepo.id, updates: { displayName: 'Renamed' } }, + timeoutMs: 15_000 + }) + expect(reposUpdate).not.toHaveBeenCalled() + }) + + it('adds explicit server paths through the active remote runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-add', + ok: true, + result: { repo: remoteRepo }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never + }) + + await expect(store.getState().addRepoPath('/srv/project', 'folder')).resolves.toEqual( + remoteRepo + ) + + expect(store.getState().repos).toEqual([remoteRepo]) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.add', + params: { path: '/srv/project', kind: 'folder' }, + timeoutMs: 15_000 + }) + expect(reposAdd).not.toHaveBeenCalled() + expect(reposPickFolder).not.toHaveBeenCalled() + }) + + it('does not open the client folder picker when a remote runtime environment is active', async () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never + }) + + await expect(store.getState().addRepo()).resolves.toBeNull() + + expect(reposPickFolder).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('removes repos through the active remote runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-3', + ok: true, + result: { removed: true }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [remoteRepo], + activeRepoId: remoteRepo.id + }) + + await store.getState().removeRepo(remoteRepo.id) + + expect(store.getState().repos).toEqual([]) + expect(store.getState().activeRepoId).toBeNull() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.rm', + params: { repo: remoteRepo.id }, + timeoutMs: 15_000 + }) + expect(reposRemove).not.toHaveBeenCalled() + }) + + it('stops remote runtime terminals instead of killing remote ids through local pty IPC', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-remote', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + const worktreeId = `${remoteRepo.id}::/remote/wt` + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [remoteRepo], + worktreesByRepo: { + [remoteRepo.id]: [makeWorktree({ id: worktreeId, repoId: remoteRepo.id })] + }, + tabsByWorktree: { + [worktreeId]: [{ id: 'tab-1', worktreeId } as never] + }, + ptyIdsByTabId: { + 'tab-1': ['remote:term-1', 'pty-local-stale'] + } + }) + + await store.getState().removeRepo(remoteRepo.id) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.stop', + params: { worktree: worktreeId }, + timeoutMs: 15_000 + }) + expect(ptyKill).toHaveBeenCalledWith('pty-local-stale') + expect(ptyKill).not.toHaveBeenCalledWith('remote:term-1') + }) + + it('reorders repos through the active remote runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-4', + ok: true, + result: { status: 'applied' }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [localRepo, remoteRepo] + }) + + await store.getState().reorderRepos([remoteRepo.id, localRepo.id]) + + expect(store.getState().repos.map((repo) => repo.id)).toEqual([remoteRepo.id, localRepo.id]) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.reorder', + params: { orderedIds: [remoteRepo.id, localRepo.id] }, + timeoutMs: 15_000 + }) + expect(reposReorder).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 78977a17b7d..0cc50b4bc84 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -1,9 +1,14 @@ +/* eslint-disable max-lines -- Why: repo slice owns local/runtime routing, +add/remove/reorder side effects, and cross-slice teardown. Splitting it during +the client-server refactor would obscure the invariants this file is currently +auditing and preserving. */ import type { StateCreator } from 'zustand' import { toast } from 'sonner' import type { AppState } from '../types' import type { Repo } from '../../../../shared/types' import { isGitRepoKind } from '../../../../shared/repo-kind' import { getRepoIdFromWorktreeId } from './worktree-helpers' +import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' const ERROR_TOAST_DURATION = 60_000 @@ -12,6 +17,7 @@ export type RepoSlice = { activeRepoId: string | null fetchRepos: () => Promise addRepo: () => Promise + addRepoPath: (path: string, kind?: 'git' | 'folder') => Promise addNonGitFolder: (path: string) => Promise removeRepo: (repoId: string) => Promise updateRepo: ( @@ -39,7 +45,21 @@ export const createRepoSlice: StateCreator = (set, fetchRepos: async () => { try { - const repos = await window.api.repos.list() + const target = getActiveRuntimeTarget(get().settings) + const repos = + target.kind === 'local' + ? ((await window.api.repos.list()) as Repo[]) + : ( + await callRuntimeRpc<{ repos: Repo[] }>( + target, + 'repo.list', + undefined, + // Why: remote environment fetches cross the network; keep the + // boot-time repo hydration bounded instead of inheriting an + // unbounded renderer promise. + { timeoutMs: 15_000 } + ) + ).repos set((s) => { const validRepoIds = new Set(repos.map((repo) => repo.id)) return { @@ -53,22 +73,30 @@ export const createRepoSlice: StateCreator = (set, } }, - addRepo: async () => { + addRepoPath: async (path, kind = 'git') => { try { - const path = await window.api.repos.pickFolder() - if (!path) { - return null - } + const target = getActiveRuntimeTarget(get().settings) let repo: Repo try { - const result = await window.api.repos.add({ path }) - if ('error' in result) { - throw new Error(result.error) + if (target.kind === 'local') { + const result = await window.api.repos.add({ path, kind }) + if ('error' in result) { + throw new Error(result.error) + } + repo = result.repo + } else { + repo = ( + await callRuntimeRpc<{ repo: Repo }>( + target, + 'repo.add', + { path, kind }, + { timeoutMs: 15_000 } + ) + ).repo } - repo = result.repo } catch (err) { const message = err instanceof Error ? err.message : String(err) - if (!message.includes('Not a valid git repository')) { + if (kind !== 'git' || !message.includes('Not a valid git repository')) { throw err } // Why: folder mode is a capability downgrade, not a silent fallback. @@ -109,27 +137,26 @@ export const createRepoSlice: StateCreator = (set, } }, + addRepo: async () => { + const target = getActiveRuntimeTarget(get().settings) + if (target.kind !== 'local') { + // Why: OS folder pickers return client-local paths. Remote environments + // need an explicit server path, which the Add Project dialog handles. + toast.error('Use a server path to add projects from a remote runtime.') + return null + } + const path = await window.api.repos.pickFolder() + if (!path) { + return null + } + return get().addRepoPath(path) + }, + addNonGitFolder: async (path) => { try { - const result = await window.api.repos.add({ path, kind: 'folder' }) - if ('error' in result) { - throw new Error(result.error) - } - const repo = result.repo - const alreadyAdded = get().repos.some((r) => r.id === repo.id) - if (alreadyAdded) { - get().clearOrcaHookTrustForRepo(repo.id) - } - set((s) => { - if (s.repos.some((r) => r.id === repo.id)) { - return s - } - return { repos: [...s.repos, repo] } - }) - if (alreadyAdded) { - toast.info('Project already added', { description: repo.displayName }) - } else { - toast.success('Folder added', { description: repo.displayName }) + const repo = await get().addRepoPath(path, 'folder') + if (!repo) { + return null } // Why: without focusing the new folder, the UI looks unchanged after // the dialog closes and users think nothing happened. Fetch the @@ -154,7 +181,10 @@ export const createRepoSlice: StateCreator = (set, removeRepo: async (repoId) => { try { - await window.api.repos.remove({ repoId }) + const target = getActiveRuntimeTarget(get().settings) + await (target.kind === 'local' + ? window.api.repos.remove({ repoId }) + : callRuntimeRpc(target, 'repo.rm', { repo: repoId }, { timeoutMs: 15_000 })) get().clearOrcaHookTrustForRepo(repoId) @@ -162,13 +192,22 @@ export const createRepoSlice: StateCreator = (set, const worktreeIds = (get().worktreesByRepo[repoId] ?? []).map((w) => w.id) const killedTabIds = new Set() const killedPtyIds = new Set() + if (target.kind === 'environment') { + await Promise.allSettled( + worktreeIds.map((worktreeId) => + callRuntimeRpc(target, 'terminal.stop', { worktree: worktreeId }, { timeoutMs: 15_000 }) + ) + ) + } for (const wId of worktreeIds) { const tabs = get().tabsByWorktree[wId] ?? [] for (const tab of tabs) { killedTabIds.add(tab.id) for (const ptyId of get().ptyIdsByTabId[tab.id] ?? []) { killedPtyIds.add(ptyId) - window.api.pty.kill(ptyId) + if (!ptyId.startsWith('remote:')) { + window.api.pty.kill(ptyId) + } } } } @@ -259,7 +298,10 @@ export const createRepoSlice: StateCreator = (set, updateRepo: async (repoId, updates) => { try { - await window.api.repos.update({ repoId, updates }) + const target = getActiveRuntimeTarget(get().settings) + await (target.kind === 'local' + ? window.api.repos.update({ repoId, updates }) + : callRuntimeRpc(target, 'repo.update', { repo: repoId, updates }, { timeoutMs: 15_000 })) set((s) => ({ repos: s.repos.map((r) => (r.id === repoId ? { ...r, ...updates } : r)) })) @@ -288,7 +330,16 @@ export const createRepoSlice: StateCreator = (set, } set({ repos: next }) try { - const result = await window.api.repos.reorder({ orderedIds }) + const target = getActiveRuntimeTarget(get().settings) + const result = + target.kind === 'local' + ? await window.api.repos.reorder({ orderedIds }) + : await callRuntimeRpc<{ status: 'applied' | 'rejected' }>( + target, + 'repo.reorder', + { orderedIds }, + { timeoutMs: 15_000 } + ) if (result.status === 'rejected') { await get().fetchRepos() } diff --git a/src/renderer/src/store/slices/settings.test.ts b/src/renderer/src/store/slices/settings.test.ts new file mode 100644 index 00000000000..9d0bc5e5476 --- /dev/null +++ b/src/renderer/src/store/slices/settings.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { createTestStore, makeWorktree } from './store-test-helpers' +import type { AppState } from '../types' +import { toast } from 'sonner' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../../shared/protocol-version' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) + } +}) + +const runtimeEnvironmentCall = vi.fn() +const settingsSet = vi.fn().mockResolvedValue(undefined) + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + vi.clearAllMocks() + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + const result = + method === 'status.get' + ? { + runtimeId: 'runtime-2', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } + : method === 'repo.list' + ? { + repos: [ + { + id: 'repo-env-2', + path: '/env-2/repo', + displayName: 'Env 2', + badgeColor: 'blue', + addedAt: 1 + } + ] + } + : method === 'worktree.list' + ? { + worktrees: [ + makeWorktree({ + id: 'repo-env-2::/env-2/repo', + repoId: 'repo-env-2', + path: '/env-2/repo' + }) + ], + totalCount: 1, + truncated: false + } + : method === 'browser.profile.list' + ? { profiles: [] } + : {} + return Promise.resolve({ id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-2' } }) + }) + vi.stubGlobal('window', { + api: { + settings: { set: settingsSet }, + runtimeEnvironments: { call: runtimeEnvironmentCall } + } + }) +}) + +describe('createSettingsSlice runtime switching', () => { + it('clears stale runtime-owned state before loading the selected environment', async () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + repos: [{ id: 'repo-env-1', path: '/env-1/repo', displayName: 'Env 1' } as never], + worktreesByRepo: { + 'repo-env-1': [makeWorktree({ id: 'repo-env-1::/env-1/repo', repoId: 'repo-env-1' })] + }, + activeWorktreeId: 'repo-env-1::/env-1/repo', + openFiles: [{ id: '/env-1/repo/a.md', worktreeId: 'repo-env-1::/env-1/repo' } as never], + ptyIdsByTabId: { tab1: ['remote:env-1@@terminal-a'] }, + terminalLayoutsByTabId: { + tab1: { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'remote:legacy-terminal' } + } + }, + browserTabsByWorktree: { 'repo-env-1::/env-1/repo': [{ id: 'browser-env-1' }] as never }, + browserPagesByWorkspace: { + 'browser-env-1': [{ id: 'page-env-1', worktreeId: 'repo-env-1::/env-1/repo' }] as never + }, + remoteBrowserPageHandlesByPageId: { + 'page-env-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } + }, + editorDrafts: { '/env-1/repo/stale.md': 'stale' }, + markdownViewMode: { '/env-1/repo/stale.md': 'rich' }, + editorViewMode: { '/env-1/repo/stale.md': 'changes' }, + editorCursorLine: { '/env-1/repo/stale.md': 4 }, + prCache: { '/env-1/repo::main': { data: null, fetchedAt: Date.now() } }, + linearIssueCache: { 'LIN-1': { data: { id: 'LIN-1' } as never, fetchedAt: Date.now() } } + }) + + await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(true) + + expect(settingsSet).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: 'env-2' }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-2', method: 'status.get' }) + ) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-2', method: 'repo.list' }) + ) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'terminal.close', + params: { terminal: 'terminal-a' } + }) + ) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'terminal.close', + params: { terminal: 'legacy-terminal' } + }) + ) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'browser.tabClose', + params: { worktree: 'id:repo-env-1::/env-1/repo', page: 'remote-page-1' } + }) + ) + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['repo-env-2']) + expect(store.getState().worktreesByRepo['repo-env-2']?.map((worktree) => worktree.id)).toEqual([ + 'repo-env-2::/env-2/repo' + ]) + expect(store.getState().activeWorktreeId).toBeNull() + expect(store.getState().openFiles).toEqual([]) + expect(store.getState().editorDrafts).toEqual({}) + expect(store.getState().markdownViewMode).toEqual({}) + expect(store.getState().editorViewMode).toEqual({}) + expect(store.getState().editorCursorLine).toEqual({}) + expect(store.getState().ptyIdsByTabId).toEqual({}) + expect(store.getState().browserTabsByWorktree).toEqual({}) + expect(store.getState().prCache).toEqual({}) + expect(store.getState().linearIssueCache).toEqual({}) + }) + + it('refuses to switch environments while editor tabs have unsaved state', async () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + openFiles: [ + { + id: '/env-1/repo/dirty.md', + worktreeId: 'repo-env-1::/env-1/repo', + isDirty: true + } as never + ], + editorDrafts: { '/env-1/repo/dirty.md': 'draft' } + }) + + await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(false) + + expect(settingsSet).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(store.getState().settings?.activeRuntimeEnvironmentId).toBe('env-1') + expect(store.getState().openFiles).toHaveLength(1) + expect(store.getState().editorDrafts).toEqual({ '/env-1/repo/dirty.md': 'draft' }) + expect(toast.error).toHaveBeenCalledWith( + 'Save or close unsaved editor tabs before switching servers.' + ) + }) + + it('keeps the current environment when the selected remote server is unreachable', async () => { + runtimeEnvironmentCall.mockRejectedValueOnce( + new Error('Remote Orca runtime closed the connection.') + ) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + repos: [{ id: 'repo-env-1', path: '/env-1/repo', displayName: 'Env 1' } as never], + openFiles: [], + ptyIdsByTabId: { tab1: ['remote:env-1@@terminal-a'] } + }) + + await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(false) + + expect(settingsSet).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-2', method: 'status.get' }) + ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-1', method: 'terminal.close' }) + ) + expect(store.getState().settings?.activeRuntimeEnvironmentId).toBe('env-1') + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['repo-env-1']) + expect(store.getState().ptyIdsByTabId).toEqual({ tab1: ['remote:env-1@@terminal-a'] }) + expect(toast.error).toHaveBeenCalledWith('Failed to switch servers', { + description: 'Remote Orca runtime closed the connection.' + }) + }) + + it('keeps the current environment when the selected server is protocol-incompatible', async () => { + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + const result = + method === 'status.get' + ? { + runtimeId: 'runtime-old', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION - 1, + minCompatibleRuntimeClientVersion: 0 + } + : {} + return Promise.resolve({ + id: 'rpc-1', + ok: true, + result, + _meta: { runtimeId: 'runtime-old' } + }) + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + repos: [{ id: 'repo-env-1', path: '/env-1/repo', displayName: 'Env 1' } as never], + openFiles: [] + }) + + await expect(store.getState().switchRuntimeEnvironment('env-old')).resolves.toBe(false) + + expect(settingsSet).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-old', method: 'status.get' }) + ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-old', method: 'repo.list' }) + ) + expect(toast.error).toHaveBeenCalledWith('Failed to switch servers', { + description: expect.stringContaining('server is too old') + }) + }) +}) diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 8307e594667..eb11cbf64f6 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -1,6 +1,12 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { GlobalSettings } from '../../../../shared/types' +import { toast } from 'sonner' +import { callRuntimeRpc, clearRuntimeCompatibilityCache } from '@/runtime/runtime-rpc-client' +import { + getRemoteRuntimePtyEnvironmentId, + getRemoteRuntimeTerminalHandle +} from '@/runtime/runtime-terminal-stream' import { normalizeTerminalQuickCommands } from '../../../../shared/terminal-quick-commands' export type SettingsSlice = { @@ -9,9 +15,199 @@ export type SettingsSlice = { setSettingsSearchQuery: (q: string) => void fetchSettings: () => Promise updateSettings: (updates: Partial) => Promise + switchRuntimeEnvironment: (environmentId: string | null) => Promise } -export const createSettingsSlice: StateCreator = (set) => ({ +function normalizeRuntimeEnvironmentId(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +function runtimeScopedStateReset(): Partial { + return { + repos: [], + activeRepoId: null, + sparsePresetsByRepo: {}, + sparsePresetsLoadingByRepo: {}, + sparsePresetsLoadStatusByRepo: {}, + sparsePresetsErrorByRepo: {}, + worktreesByRepo: {}, + activeWorktreeId: null, + deleteStateByWorktreeId: {}, + baseStatusByWorktreeId: {}, + remoteBranchConflictByWorktreeId: {}, + sortEpoch: 0, + everActivatedWorktreeIds: new Set(), + lastVisitedAtByWorktreeId: {}, + hasHydratedWorktreePurge: false, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + layoutByWorktree: {}, + tabsByWorktree: {}, + activeTabId: null, + activeTabIdByWorktree: {}, + ptyIdsByTabId: {}, + runtimePaneTitlesByTabId: {}, + unreadTerminalTabs: {}, + suppressedPtyExitIds: {}, + pendingCodexPaneRestartIds: {}, + codexRestartNoticeByPtyId: {}, + expandedPaneByTabId: {}, + canExpandPaneByTabId: {}, + terminalLayoutsByTabId: {}, + pendingStartupByTabId: {}, + pendingSetupSplitByTabId: {}, + pendingIssueCommandSplitByTabId: {}, + tabBarOrderByWorktree: {}, + pendingReconnectWorktreeIds: [], + pendingReconnectTabByWorktree: {}, + pendingReconnectPtyIdByTabId: {}, + lastKnownRelayPtyIdByTabId: {}, + pendingSnapshotByPtyId: {}, + pendingColdRestoreByPtyId: {}, + deferredSshReconnectTargets: [], + deferredSshSessionIdsByTabId: {}, + cacheTimerByKey: {}, + expandedDirs: {}, + pendingExplorerReveal: null, + openFiles: [], + editorDrafts: {}, + markdownViewMode: {}, + editorViewMode: {}, + editorCursorLine: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + activeTabTypeByWorktree: {}, + activeTabType: 'terminal', + recentlyClosedEditorTabsByWorktree: {}, + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + remoteBrowserPageHandlesByPageId: {}, + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + recentlyClosedBrowserTabsByWorktree: {}, + recentlyClosedBrowserPagesByWorkspace: {}, + pendingAddressBarFocusByTabId: {}, + pendingAddressBarFocusByPageId: {}, + browserSessionProfiles: [], + browserSessionImportState: null, + defaultBrowserSessionProfileId: null, + detectedBrowsers: [], + detectedBrowsersLoaded: false, + prCache: {}, + issueCache: {}, + checksCache: {}, + commentsCache: {}, + workItemsCache: {}, + workItemsInvalidationNonce: 0, + projectViewCache: {}, + linearStatus: { connected: false, viewer: null }, + linearStatusChecked: false, + linearIssueCache: {}, + linearSearchCache: {} + } +} + +function hasUnsavedEditorState(state: AppState): boolean { + return state.openFiles.some((file) => file.isDirty || state.editorDrafts[file.id] !== undefined) +} + +async function closeRemoteBrowserPagesBeforeRuntimeSwitch(state: AppState): Promise { + const worktreeIdByPageId = new Map() + for (const pages of Object.values(state.browserPagesByWorkspace)) { + for (const page of pages) { + worktreeIdByPageId.set(page.id, page.worktreeId) + } + } + await Promise.allSettled( + Object.entries(state.remoteBrowserPageHandlesByPageId).map(([pageId, handle]) => { + const worktreeId = worktreeIdByPageId.get(pageId) + if (!worktreeId) { + return Promise.resolve() + } + return callRuntimeRpc( + { kind: 'environment', environmentId: handle.environmentId }, + 'browser.tabClose', + { worktree: `id:${worktreeId}`, page: handle.remotePageId }, + { timeoutMs: 15_000 } + ) + }) + ) +} + +function collectRemoteTerminalHandlesForRuntimeSwitch( + state: AppState, + fallbackEnvironmentId: string | null +): Map> { + const handlesByEnvironmentId = new Map>() + const collect = (ptyId: string | null | undefined): void => { + if (!ptyId) { + return + } + const handle = getRemoteRuntimeTerminalHandle(ptyId) + if (!handle) { + return + } + const environmentId = getRemoteRuntimePtyEnvironmentId(ptyId) ?? fallbackEnvironmentId + if (!environmentId) { + return + } + const handles = handlesByEnvironmentId.get(environmentId) ?? new Set() + handles.add(handle) + handlesByEnvironmentId.set(environmentId, handles) + } + + for (const ptyIds of Object.values(state.ptyIdsByTabId)) { + for (const ptyId of ptyIds) { + collect(ptyId) + } + } + for (const tabs of Object.values(state.tabsByWorktree)) { + for (const tab of tabs) { + collect(tab.ptyId) + } + } + for (const layout of Object.values(state.terminalLayoutsByTabId)) { + for (const ptyId of Object.values(layout.ptyIdsByLeafId ?? {})) { + collect(ptyId) + } + } + return handlesByEnvironmentId +} + +async function closeRemoteTerminalsBeforeRuntimeSwitch( + state: AppState, + fallbackEnvironmentId: string | null +): Promise { + const handlesByEnvironmentId = collectRemoteTerminalHandlesForRuntimeSwitch( + state, + fallbackEnvironmentId + ) + await Promise.allSettled( + Array.from(handlesByEnvironmentId.entries()).flatMap(([environmentId, handles]) => + Array.from(handles).map((terminal) => + callRuntimeRpc( + { kind: 'environment', environmentId }, + 'terminal.close', + { terminal }, + { timeoutMs: 15_000 } + ) + ) + ) + ) +} + +async function verifyRuntimeEnvironmentReachable(environmentId: string | null): Promise { + if (!environmentId) { + return + } + await callRuntimeRpc({ kind: 'environment', environmentId }, 'repo.list', undefined, { + timeoutMs: 15_000 + }) +} + +export const createSettingsSlice: StateCreator = (set, get) => ({ settings: null, settingsSearchQuery: '', setSettingsSearchQuery: (q) => set({ settingsSearchQuery: q }), @@ -72,5 +268,43 @@ export const createSettingsSlice: StateCreator } catch (err) { console.error('Failed to update settings:', err) } + }, + + switchRuntimeEnvironment: async (environmentId) => { + const nextId = normalizeRuntimeEnvironmentId(environmentId) + const previousId = normalizeRuntimeEnvironmentId(get().settings?.activeRuntimeEnvironmentId) + if (previousId === nextId) { + return true + } + if (hasUnsavedEditorState(get())) { + toast.error('Save or close unsaved editor tabs before switching servers.') + return false + } + try { + clearRuntimeCompatibilityCache(nextId) + await verifyRuntimeEnvironmentReachable(nextId) + // Why: remote browser tabs live on their owning server. Close them before + // clearing browser maps so the old server does not retain orphan pages. + await closeRemoteTerminalsBeforeRuntimeSwitch(get(), previousId) + await closeRemoteBrowserPagesBeforeRuntimeSwitch(get()) + await window.api.settings.set({ activeRuntimeEnvironmentId: nextId }) + set((s) => ({ + ...runtimeScopedStateReset(), + settings: s.settings ? { ...s.settings, activeRuntimeEnvironmentId: nextId } : null + })) + // Why: server-owned state is cleared before refetch so old worktree, + // terminal, browser, and issue IDs cannot be used against the new server + // while the new environment is loading. + await get().fetchRepos() + await get().fetchAllWorktrees() + await get().fetchBrowserSessionProfiles() + return true + } catch (err) { + console.error('Failed to switch runtime environment:', err) + toast.error('Failed to switch servers', { + description: err instanceof Error ? err.message : String(err) + }) + return false + } } }) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 662ed2e87ba..e526fd41885 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -28,6 +28,7 @@ import { unregisterPtyDataHandlers } from '@/components/terminal-pane/pty-transport' import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' function getNextTerminalOrdinal(tabs: TerminalTab[]): number { const usedOrdinals = new Set() @@ -1252,8 +1253,20 @@ export const createTerminalSlice: StateCreator return } + const target = getActiveRuntimeTarget(get().settings) + if (target.kind === 'environment') { + await callRuntimeRpc( + target, + 'terminal.stop', + { worktree: worktreeId }, + { timeoutMs: 15_000 } + ).catch(() => null) + } + await Promise.allSettled( - ptyIds.map((ptyId) => window.api.pty.kill(ptyId, { keepHistory: keepIdentifiers })) + ptyIds + .filter((ptyId) => !ptyId.startsWith('remote:')) + .map((ptyId) => window.api.pty.kill(ptyId, { keepHistory: keepIdentifiers })) ) }, diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 8fce0f6cb15..f9de8d4140b 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -259,6 +259,7 @@ export type UISlice = { | 'accounts' | 'voice' | 'experimental' + | 'servers' | 'mobile' | 'ssh' repoId: string | null diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index d884ff07460..b5517e65649 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -6,6 +6,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' import type { Worktree } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() const mockApi = { worktrees: { @@ -19,6 +27,9 @@ const mockApi = { }, hooks: { check: vi.fn().mockResolvedValue({ hasHooks: false, hooks: null, mayNeedUpdate: false }) + }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall } } @@ -27,6 +38,15 @@ globalThis.window = { api: mockApi } import { createWorktreeSlice } from './worktrees' +function resetRemoteRuntimeMocks() { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) +} + function createTestStore() { return create()( (...a) => @@ -98,6 +118,7 @@ function makeWorktree(overrides: Partial & { id: string; repoId: strin describe('fetchWorktrees', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() }) it('does not notify subscribers when the fetched payload is unchanged', async () => { @@ -192,11 +213,40 @@ describe('fetchWorktrees', () => { expect(store.getState().worktreesByRepo.repo1).toEqual([]) expect(store.getState().sortEpoch).toBe(8) }) + + it('fetches worktrees from the active remote runtime environment', async () => { + const store = createTestStore() + const remote = makeWorktree({ + id: 'repo1::/remote/wt1', + repoId: 'repo1', + path: '/remote/wt1', + branch: 'refs/heads/remote' + }) + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { worktrees: [remote], totalCount: 1, truncated: false }, + _meta: { runtimeId: 'runtime-remote' } + }) + + await store.getState().fetchWorktrees('repo1') + + expect(store.getState().worktreesByRepo.repo1).toEqual([remote]) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'worktree.list', + params: { repo: 'repo1' }, + timeoutMs: 15_000 + }) + expect(mockApi.worktrees.list).not.toHaveBeenCalled() + }) }) describe('updateWorktreeGitIdentity', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() }) it('updates branch identity from git status without fetching worktrees', () => { @@ -228,6 +278,7 @@ describe('updateWorktreeGitIdentity', () => { describe('createWorktree base status merge', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() }) it('passes linked work item and creation agent metadata through the create IPC payload', async () => { @@ -311,6 +362,7 @@ describe('createWorktree base status merge', () => { describe('removeWorktree state cleanup', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() }) it('cleans up editorDrafts for files in the removed worktree', async () => { @@ -609,6 +661,120 @@ describe('removeWorktree state cleanup', () => { }) }) +describe('worktree remote runtime mutations', () => { + beforeEach(() => { + vi.clearAllMocks() + resetRemoteRuntimeMocks() + }) + + it('creates worktrees through the active remote runtime environment', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo1::/path/feature', + repoId: 'repo1', + path: '/path/feature' + }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-create', + ok: true, + result: { worktree: wt }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: { repo1: [] } + } as Partial) + + const result = await store + .getState() + .createWorktree( + 'repo1', + 'feature', + 'origin/main', + 'skip', + { directories: ['src'], presetId: 'preset-1' }, + 'sidebar', + 'Feature title', + 123, + 456, + { remoteName: 'fork', branchName: 'feature' } + ) + + expect(result).toEqual({ worktree: wt }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'worktree.create', + params: { + repo: 'repo1', + name: 'feature', + baseBranch: 'origin/main', + setupDecision: 'skip', + sparseCheckout: { directories: ['src'], presetId: 'preset-1' }, + displayName: 'Feature title', + linkedIssue: 123, + linkedPR: 456, + pushTarget: { remoteName: 'fork', branchName: 'feature' } + }, + timeoutMs: 10 * 60_000 + }) + expect(mockApi.worktrees.create).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo.repo1).toEqual([wt]) + }) + + it('removes worktrees through the active remote runtime environment', async () => { + const store = createTestStore() + const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-rm', + ok: true, + result: { removed: true }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: { repo1: [wt] } + } as Partial) + + const result = await store.getState().removeWorktree(wt.id) + + expect(result).toEqual({ ok: true }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'worktree.rm', + params: { worktree: wt.id, force: undefined, runHooks: true }, + timeoutMs: 60_000 + }) + expect(mockApi.worktrees.remove).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo.repo1).toEqual([]) + }) + + it('persists worktree metadata through the active remote runtime environment', async () => { + const store = createTestStore() + const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-set', + ok: true, + result: { worktree: { ...wt, comment: 'remote note' } }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: { repo1: [wt] } + } as Partial) + + await store.getState().updateWorktreeMeta(wt.id, { comment: 'remote note' }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'worktree.set', + params: expect.objectContaining({ worktree: wt.id, comment: 'remote note' }), + timeoutMs: 15_000 + }) + expect(mockApi.worktrees.updateMeta).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo.repo1[0]?.comment).toBe('remote note') + }) +}) + // Why: ghostty "show until interact" model — BEL must raise the sidebar dot // even on the active worktree, and only clearWorktreeUnread (called from the // terminal pane on keystroke / pointerdown) dismisses it. Pins both halves @@ -616,6 +782,7 @@ describe('removeWorktree state cleanup', () => { describe('worktree unread (show-until-interact)', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() }) it('markWorktreeUnread sets isUnread even when the worktree is active', async () => { @@ -685,6 +852,7 @@ describe('worktree unread (show-until-interact)', () => { describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() }) const repoA = { @@ -823,6 +991,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { describe('purgeWorktreeTerminalState direct (design §4.4)', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() }) it('wipes tab-id-keyed maps (terminalLayoutsByTabId, ptyIdsByTabId) and clears actives', () => { diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index d804e49c475..9d738dea8c6 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -10,6 +10,7 @@ import { } from './worktree-helpers' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { tabHasLivePty } from '@/lib/tab-has-live-pty' +import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers' function arraysShallowEqual(a: string[] | undefined, b: string[] | undefined): boolean { @@ -68,6 +69,43 @@ function toVisibleTabType(contentType: string): WorkspaceVisibleTabType { : 'editor' } +async function listWorktreesForRepo( + settings: AppState['settings'], + repoId: string +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'local') { + return window.api.worktrees.list({ repoId }) + } + const result = await callRuntimeRpc<{ worktrees: Worktree[] }>( + target, + 'worktree.list', + { repo: repoId }, + // Why: remote environment hydration crosses the network. Bound the call + // so startup can recover instead of leaving the renderer waiting forever. + { timeoutMs: 15_000 } + ) + return result.worktrees +} + +async function persistWorktreeMeta( + settings: AppState['settings'], + worktreeId: string, + updates: Partial +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'local') { + await window.api.worktrees.updateMeta({ worktreeId, updates }) + return + } + await callRuntimeRpc( + target, + 'worktree.set', + { worktree: worktreeId, ...updates }, + { timeoutMs: 15_000 } + ) +} + export const createWorktreeSlice: StateCreator = (set, get) => ({ worktreesByRepo: {}, activeWorktreeId: null, @@ -81,7 +119,7 @@ export const createWorktreeSlice: StateCreator fetchWorktrees: async (repoId) => { try { - const worktrees = await window.api.worktrees.list({ repoId }) + const worktrees = await listWorktreesForRepo(get().settings, repoId) const current = get().worktreesByRepo[repoId] if (areWorktreesEqual(current, worktrees)) { return @@ -135,7 +173,7 @@ export const createWorktreeSlice: StateCreator const results = await Promise.all( repos.map(async (r) => { try { - const list = await window.api.worktrees.list({ repoId: r.id }) + const list = await listWorktreesForRepo(get().settings, r.id) const current = get().worktreesByRepo[r.id] if ( !areWorktreesEqual(current, list) && @@ -252,7 +290,7 @@ export const createWorktreeSlice: StateCreator for (let attempt = 0; attempt < 25; attempt += 1) { const candidateName = nextCandidateName(name, attempt) try { - const result = await window.api.worktrees.create({ + const createArgs = { repoId, name: candidateName, baseBranch, @@ -264,7 +302,28 @@ export const createWorktreeSlice: StateCreator ...(linkedPR !== undefined ? { linkedPR } : {}), ...(pushTarget ? { pushTarget } : {}), ...(createdWithAgent ? { createdWithAgent } : {}) - }) + } + const target = getActiveRuntimeTarget(get().settings) + const result = + target.kind === 'local' + ? await window.api.worktrees.create(createArgs) + : await callRuntimeRpc>>( + target, + 'worktree.create', + { + repo: repoId, + name: candidateName, + baseBranch, + setupDecision, + sparseCheckout, + ...(displayName ? { displayName } : {}), + ...(linkedIssue !== undefined ? { linkedIssue } : {}), + ...(linkedPR !== undefined ? { linkedPR } : {}), + ...(pushTarget ? { pushTarget } : {}), + ...(createdWithAgent ? { createdWithAgent } : {}) + }, + { timeoutMs: 10 * 60_000 } + ) // Why: a file watcher (worktrees.onChanged) can fire between the // backend creating the worktree and this callback running, causing // fetchWorktrees to add the worktree first. Appending unconditionally @@ -338,7 +397,15 @@ export const createWorktreeSlice: StateCreator // can intercept them. await get().shutdownWorktreeBrowsers(worktreeId) await get().shutdownWorktreeTerminals(worktreeId) - await window.api.worktrees.remove({ worktreeId, force, skipArchive }) + const target = getActiveRuntimeTarget(get().settings) + await (target.kind === 'local' + ? window.api.worktrees.remove({ worktreeId, force, skipArchive }) + : callRuntimeRpc( + target, + 'worktree.rm', + { worktree: worktreeId, force, runHooks: !skipArchive }, + { timeoutMs: 60_000 } + )) const tabs = get().tabsByWorktree[worktreeId] ?? [] const tabIds = new Set(tabs.map((t) => t.id)) @@ -557,7 +624,7 @@ export const createWorktreeSlice: StateCreator }) try { - await window.api.worktrees.updateMeta({ worktreeId, updates: enriched }) + await persistWorktreeMeta(get().settings, worktreeId, enriched) } catch (err) { console.error('Failed to update worktree meta:', err) void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) @@ -591,12 +658,13 @@ export const createWorktreeSlice: StateCreator return } - void window.api.worktrees - .updateMeta({ worktreeId, updates: { isUnread: true, lastActivityAt: now } }) - .catch((err) => { - console.error('Failed to persist unread worktree state:', err) - void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) - }) + void persistWorktreeMeta(get().settings, worktreeId, { + isUnread: true, + lastActivityAt: now + }).catch((err) => { + console.error('Failed to persist unread worktree state:', err) + void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) + }) }, clearWorktreeUnread: (worktreeId) => { @@ -622,12 +690,10 @@ export const createWorktreeSlice: StateCreator return } - void window.api.worktrees - .updateMeta({ worktreeId, updates: { isUnread: false } }) - .catch((err) => { - console.error('Failed to persist cleared unread worktree state:', err) - void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) - }) + void persistWorktreeMeta(get().settings, worktreeId, { isUnread: false }).catch((err) => { + console.error('Failed to persist cleared unread worktree state:', err) + void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) + }) }, bumpWorktreeActivity: (worktreeId) => { @@ -655,12 +721,10 @@ export const createWorktreeSlice: StateCreator } }) - void window.api.worktrees - .updateMeta({ worktreeId, updates: { lastActivityAt: now } }) - .catch((err) => { - console.error('Failed to persist worktree activity timestamp:', err) - void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) - }) + void persistWorktreeMeta(get().settings, worktreeId, { lastActivityAt: now }).catch((err) => { + console.error('Failed to persist worktree activity timestamp:', err) + void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) + }) }, markWorktreeVisited: (worktreeId, visitedAt) => { @@ -947,11 +1011,11 @@ export const createWorktreeSlice: StateCreator } if (shouldClearUnread) { - const updates: Parameters[0]['updates'] = { + const updates: Partial = { isUnread: false } - void window.api.worktrees.updateMeta({ worktreeId, updates }).catch((err) => { + void persistWorktreeMeta(get().settings, worktreeId, updates).catch((err) => { console.error('Failed to persist worktree activation state:', err) void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) }) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 68560894056..6f6f33fa764 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -251,6 +251,9 @@ export function getDefaultSettings(homedir: string): GlobalSettings { experimentalPet: false, experimentalActivity: true, experimentalWorktreeSymlinks: false, + // Why: local desktop remains the default server until the user explicitly + // selects a saved runtime environment. + activeRuntimeEnvironmentId: null, // Why: hydrate an empty default so the renderer's optional-chained reads // (`settings?.githubProjects?.activeProject`) land on a stable shape // instead of `undefined`. Upgraded profiles inherit this via the diff --git a/src/shared/cross-platform-path.test.ts b/src/shared/cross-platform-path.test.ts new file mode 100644 index 00000000000..ea805b7a788 --- /dev/null +++ b/src/shared/cross-platform-path.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { isPathInsideOrEqual, relativePathInsideRoot } from './cross-platform-path' + +describe('cross-platform path containment', () => { + it('keeps POSIX sibling prefixes outside the root', () => { + expect(isPathInsideOrEqual('/repo/app', '/repo/app')).toBe(true) + expect(isPathInsideOrEqual('/repo/app', '/repo/app/src/index.ts')).toBe(true) + expect(isPathInsideOrEqual('/repo/app', '/repo/application/src/index.ts')).toBe(false) + expect(relativePathInsideRoot('/repo/app/', '/repo/app/src/index.ts')).toBe('src/index.ts') + }) + + it('handles Windows drive roots and sibling drives case-insensitively', () => { + expect(isPathInsideOrEqual('C:\\Repo', 'c:\\repo\\src\\index.ts')).toBe(true) + expect(relativePathInsideRoot('C:\\Repo', 'c:\\repo\\src\\index.ts')).toBe('src/index.ts') + expect(isPathInsideOrEqual('C:\\Repo', 'D:\\Repo\\src\\index.ts')).toBe(false) + expect(relativePathInsideRoot('C:\\', 'c:\\repo\\src\\index.ts')).toBe('repo/src/index.ts') + }) + + it('handles UNC roots, trailing slashes, mixed separators, and case', () => { + expect(isPathInsideOrEqual('\\\\Server\\Share\\Repo\\', '//server/share/repo/src')).toBe(true) + expect(relativePathInsideRoot('\\\\Server\\Share\\Repo\\', '//server/share/repo/src')).toBe( + 'src' + ) + expect(isPathInsideOrEqual('\\\\Server\\Share\\Repo', '\\\\server\\share\\repo2')).toBe(false) + }) +}) diff --git a/src/shared/cross-platform-path.ts b/src/shared/cross-platform-path.ts new file mode 100644 index 00000000000..d2645a34239 --- /dev/null +++ b/src/shared/cross-platform-path.ts @@ -0,0 +1,57 @@ +export function isWindowsAbsolutePathLike(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('//') +} + +export function normalizeRuntimePathSeparators(value: string): string { + const normalized = value.replace(/\\/g, '/').replace(/\/+/g, '/') + if (value.startsWith('\\\\') || value.startsWith('//')) { + return `//${normalized.replace(/^\/+/, '')}` + } + return normalized +} + +export function normalizeRuntimePathForComparison(value: string): string { + const normalized = trimRuntimePathTrailingSlash(normalizeRuntimePathSeparators(value)) + return isWindowsAbsolutePathLike(value) ? normalized.toLowerCase() : normalized +} + +export function isPathInsideOrEqual(rootPath: string, candidatePath: string): boolean { + const root = normalizeRuntimePathForComparison(rootPath) + const candidate = normalizeRuntimePathForComparison(candidatePath) + if (candidate === root) { + return true + } + const rootWithBoundary = + root === '/' || /^[a-z]:\/$/i.test(root) ? root : `${root.replace(/\/+$/, '')}/` + return candidate.startsWith(rootWithBoundary) +} + +export function relativePathInsideRoot(rootPath: string, candidatePath: string): string | null { + const normalizedRoot = trimRuntimePathTrailingSlash(normalizeRuntimePathSeparators(rootPath)) + const normalizedCandidate = trimRuntimePathTrailingSlash( + normalizeRuntimePathSeparators(candidatePath) + ) + const comparisonRoot = isWindowsAbsolutePathLike(rootPath) + ? normalizedRoot.toLowerCase() + : normalizedRoot + const comparisonCandidate = isWindowsAbsolutePathLike(rootPath) + ? normalizedCandidate.toLowerCase() + : normalizedCandidate + + if (comparisonCandidate === comparisonRoot) { + return '' + } + const isRoot = comparisonRoot === '/' || /^[a-z]:\/$/i.test(comparisonRoot) + const comparisonPrefix = isRoot ? comparisonRoot : `${comparisonRoot}/` + if (!comparisonCandidate.startsWith(comparisonPrefix)) { + return null + } + return normalizedCandidate.slice(comparisonPrefix.length) +} + +function trimRuntimePathTrailingSlash(value: string): string { + if (value === '/' || /^[A-Za-z]:\/$/.test(value)) { + return value + } + return value.replace(/\/+$/, '') +} diff --git a/src/shared/e2ee-crypto.ts b/src/shared/e2ee-crypto.ts new file mode 100644 index 00000000000..3d5bcdb5317 --- /dev/null +++ b/src/shared/e2ee-crypto.ts @@ -0,0 +1,65 @@ +// Why: Orca's remote runtime transports share one NaCl box format across +// desktop, CLI, and mobile pairing. Keeping the Node-compatible primitives in +// shared code prevents the CLI from importing main-process modules. +import nacl from 'tweetnacl' + +export function generateKeyPair(): nacl.BoxKeyPair { + return nacl.box.keyPair() +} + +export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array { + return nacl.box.before(peerPublicKey, ourSecretKey) +} + +export function publicKeyFromBase64(b64: string): Uint8Array { + const key = Uint8Array.from(Buffer.from(b64, 'base64')) + if (key.length !== 32) { + throw new Error(`Invalid public key: expected 32 bytes, got ${key.length}`) + } + return key +} + +export function publicKeyToBase64(key: Uint8Array): string { + return Buffer.from(key).toString('base64') +} + +export function encrypt(plaintext: string, sharedKey: Uint8Array): string { + const messageBytes = new TextEncoder().encode(plaintext) + return Buffer.from(encryptBytes(messageBytes, sharedKey)).toString('base64') +} + +export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null { + const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64')) + const plaintext = decryptBytes(bundle, sharedKey) + return plaintext ? new TextDecoder().decode(plaintext) : null +} + +export function encryptBytes( + plaintext: Uint8Array, + sharedKey: Uint8Array +): Uint8Array { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const ciphertext = nacl.box.after(plaintext, nonce, sharedKey) + + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + + return bundle +} + +export function decryptBytes(bundle: Uint8Array, sharedKey: Uint8Array): Uint8Array | null { + if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) { + return null + } + + const nonce = bundle.slice(0, nacl.box.nonceLength) + const ciphertext = bundle.slice(nacl.box.nonceLength) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + + if (!plaintext) { + return null + } + + return plaintext +} diff --git a/src/shared/protocol-compat.test.ts b/src/shared/protocol-compat.test.ts index 22d5d4f5fa2..79b70c2d09a 100644 --- a/src/shared/protocol-compat.test.ts +++ b/src/shared/protocol-compat.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from 'vitest' -import { evaluateCompat } from './protocol-compat' -import { DESKTOP_PROTOCOL_VERSION, MIN_COMPATIBLE_MOBILE_VERSION } from './protocol-version' +import { + describeRuntimeCompatBlock, + evaluateCompat, + evaluateRuntimeCompat +} from './protocol-compat' +import { + DESKTOP_PROTOCOL_VERSION, + MIN_COMPATIBLE_MOBILE_VERSION, + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + RUNTIME_PROTOCOL_VERSION +} from './protocol-version' const MOBILE_V = 1 @@ -122,3 +132,74 @@ describe('evaluateCompat', () => { }) }) }) + +describe('evaluateRuntimeCompat', () => { + it('keeps the current client and current server self-compatible', () => { + const verdict = evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + serverProtocolVersion: RUNTIME_PROTOCOL_VERSION, + serverMinCompatibleClientProtocolVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + }) + + expect(verdict).toMatchObject({ kind: 'ok' }) + }) + + it('allows client and server app versions to skew when protocol ranges overlap', () => { + const verdict = evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + serverProtocolVersion: RUNTIME_PROTOCOL_VERSION + 3, + serverMinCompatibleClientProtocolVersion: RUNTIME_PROTOCOL_VERSION - 1 + }) + + expect(verdict).toMatchObject({ kind: 'ok' }) + }) + + it('blocks when the server requires a newer client protocol', () => { + const verdict = evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + serverProtocolVersion: RUNTIME_PROTOCOL_VERSION + 1, + serverMinCompatibleClientProtocolVersion: RUNTIME_PROTOCOL_VERSION + 1 + }) + + expect(verdict).toMatchObject({ + kind: 'blocked', + reason: 'client-too-old', + requiredClientProtocolVersion: RUNTIME_PROTOCOL_VERSION + 1 + }) + expect(describeRuntimeCompatBlock(verdict)).toContain('client is too old') + }) + + it('blocks when the server protocol is below the client minimum', () => { + const verdict = evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: RUNTIME_PROTOCOL_VERSION, + serverProtocolVersion: RUNTIME_PROTOCOL_VERSION - 1, + serverMinCompatibleClientProtocolVersion: 0 + }) + + expect(verdict).toMatchObject({ + kind: 'blocked', + reason: 'server-too-old', + requiredServerProtocolVersion: RUNTIME_PROTOCOL_VERSION + }) + expect(describeRuntimeCompatBlock(verdict)).toContain('server is too old') + }) + + it('treats missing server fields as protocol 0', () => { + const verdict = evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: 1, + serverProtocolVersion: undefined, + serverMinCompatibleClientProtocolVersion: undefined + }) + + expect(verdict).toMatchObject({ + kind: 'blocked', + reason: 'server-too-old', + serverProtocolVersion: 0 + }) + }) +}) diff --git a/src/shared/protocol-compat.ts b/src/shared/protocol-compat.ts index 72477cfb482..82f9566a9a0 100644 --- a/src/shared/protocol-compat.ts +++ b/src/shared/protocol-compat.ts @@ -1,8 +1,67 @@ -// Why: pure compat evaluator shared between desktop tests and mobile -// runtime. Mobile imports a thin wrapper (`mobile/src/transport/protocol-compat.ts`) -// that injects the mobile-side constants; desktop tests import this -// directly so the function is covered by the root vitest suite. -// All four numbers are passed in to keep the function dependency-free. +// Why: pure compat evaluators shared between desktop tests, renderer runtime +// switching, and the mobile mirror. All version numbers are passed in to keep +// the logic dependency-free and easy to duplicate in Expo. + +export type RuntimeCompatVerdict = + | { + kind: 'ok' + clientProtocolVersion: number + serverProtocolVersion: number + } + | { + kind: 'blocked' + reason: 'client-too-old' | 'server-too-old' + clientProtocolVersion: number + serverProtocolVersion: number + requiredClientProtocolVersion?: number + requiredServerProtocolVersion?: number + } + +export function evaluateRuntimeCompat(input: { + clientProtocolVersion: number + minCompatibleServerProtocolVersion: number + serverProtocolVersion: number | undefined + serverMinCompatibleClientProtocolVersion: number | undefined +}): RuntimeCompatVerdict { + // Why: absent fields are protocol 0. New clients can give old servers a + // clear "update server" error instead of attempting partially-supported RPCs. + const serverProtocolVersion = input.serverProtocolVersion ?? 0 + const requiredClientProtocolVersion = input.serverMinCompatibleClientProtocolVersion ?? 0 + + if (input.clientProtocolVersion < requiredClientProtocolVersion) { + return { + kind: 'blocked', + reason: 'client-too-old', + clientProtocolVersion: input.clientProtocolVersion, + serverProtocolVersion, + requiredClientProtocolVersion + } + } + if (serverProtocolVersion < input.minCompatibleServerProtocolVersion) { + return { + kind: 'blocked', + reason: 'server-too-old', + clientProtocolVersion: input.clientProtocolVersion, + serverProtocolVersion, + requiredServerProtocolVersion: input.minCompatibleServerProtocolVersion + } + } + return { + kind: 'ok', + clientProtocolVersion: input.clientProtocolVersion, + serverProtocolVersion + } +} + +export function describeRuntimeCompatBlock(verdict: RuntimeCompatVerdict): string { + if (verdict.kind === 'ok') { + return 'Runtime client and server are compatible.' + } + if (verdict.reason === 'client-too-old') { + return `This Orca client is too old for the selected server. Update Orca on this machine. Client protocol ${verdict.clientProtocolVersion}, server requires client protocol ${verdict.requiredClientProtocolVersion}.` + } + return `The selected Orca server is too old for this client. Update Orca on the server. Server protocol ${verdict.serverProtocolVersion}, client requires server protocol ${verdict.requiredServerProtocolVersion}.` +} export type CompatVerdict = | { kind: 'ok' } diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 63aebbdc312..355c7e63666 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -1,23 +1,36 @@ -// Why: declares the desktop's mobile-pairing protocol version so mobile -// builds can detect declared-incompatible combos and hard-block at pair -// time. Today's values are wide-open (mobile=any, desktop=any), so -// nothing actually blocks; the wire format is ready for the day we -// ship a genuinely-breaking change. +// Why: declares the Orca runtime RPC compatibility contract. Desktop, +// headless server, CLI, and mobile builds may drift in app version, but +// they must agree on this protocol range before runtime RPCs are allowed. // -// Bump DESKTOP_PROTOCOL_VERSION when: -// - You remove an RPC method or required parameter that mobile uses. +// Bump RUNTIME_PROTOCOL_VERSION when: +// - You remove an RPC method or required parameter that clients use. // - You change the meaning (units, nullability) of an existing field -// mobile reads. -// - You change encryption, framing, or the auth handshake. +// clients read. +// - You change encrypted framing, terminal stream framing, or auth. // Do NOT bump for: // - Adding new RPC methods. // - Adding new optional fields on existing methods. -// - Adding new event types in `terminal.subscribe`. +// - Adding new ignorable event types. // -// Bump MIN_COMPATIBLE_MOBILE_VERSION when desktop ships a change that -// requires a minimum mobile version to function safely. This is the -// "kill switch": desktop can refuse old mobile builds without needing -// a desktop release of mobile. +// Bump MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION when a runtime server must +// refuse older clients. Bump MIN_COMPATIBLE_RUNTIME_SERVER_VERSION when +// this client build requires a newer server. Exact app-version equality is +// never required; these numbers define the supported compatibility window. -export const DESKTOP_PROTOCOL_VERSION = 2 -export const MIN_COMPATIBLE_MOBILE_VERSION = 2 +export const RUNTIME_PROTOCOL_VERSION = 2 +export const MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION = 2 +export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = 2 + +export const RUNTIME_CAPABILITIES = [ + 'runtime.status.compat.v1', + 'runtime.environments.v1', + 'terminal.binary-stream.v1', + 'terminal.multiplex.v1' +] as const + +export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {}) + +// COMPAT(mobileProtocolAliases): added 2026-05-15 for mobile builds that +// still read desktop/mobile names; remove once mobile reads runtime names. +export const DESKTOP_PROTOCOL_VERSION = RUNTIME_PROTOCOL_VERSION +export const MIN_COMPATIBLE_MOBILE_VERSION = MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION diff --git a/src/shared/remote-runtime-client.test.ts b/src/shared/remote-runtime-client.test.ts new file mode 100644 index 00000000000..4d7e2e9526d --- /dev/null +++ b/src/shared/remote-runtime-client.test.ts @@ -0,0 +1,140 @@ +import type { AddressInfo } from 'net' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WebSocketServer, type WebSocket } from 'ws' +import { encodePairingOffer, parsePairingCode, type PairingOffer } from './pairing' +import { + decrypt, + decryptBytes, + deriveSharedKey, + encrypt, + generateKeyPair, + publicKeyFromBase64, + publicKeyToBase64 +} from './e2ee-crypto' +import { subscribeRemoteRuntimeRequest } from './remote-runtime-client' + +const servers: WebSocketServer[] = [] + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + for (const client of server.clients) { + client.close() + } + server.close(() => resolve()) + }) + ) + ) +}) + +describe('subscribeRemoteRuntimeRequest', () => { + it('sends encrypted binary frames on an established subscription socket', async () => { + const server = await createSubscriptionServer() + const onResponse = vi.fn() + const onError = vi.fn() + + const subscription = await subscribeRemoteRuntimeRequest( + server.pairing, + 'terminal.subscribe', + { terminal: 't1' }, + 1000, + { + onResponse, + onError + } + ) + + await vi.waitFor(() => + expect(onResponse).toHaveBeenCalledWith( + expect.objectContaining({ ok: true, result: { type: 'subscribed' } }) + ) + ) + const bytes = new Uint8Array([1, 2, 3]) + expect(subscription.sendBinary(bytes)).toBe(true) + await expect(server.nextBinary).resolves.toEqual(bytes) + expect(onError).not.toHaveBeenCalled() + subscription.close() + }) +}) + +async function createSubscriptionServer(): Promise<{ + pairing: PairingOffer + nextBinary: Promise +}> { + const serverKeyPair = generateKeyPair() + let resolveBinary: (bytes: Uint8Array) => void = () => {} + const nextBinary = new Promise((resolve) => { + resolveBinary = resolve + }) + const wss = new WebSocketServer({ port: 0 }) + servers.push(wss) + + wss.on('connection', (ws) => { + let sharedKey: Uint8Array | null = null + let authenticated = false + + ws.on('message', (data, isBinary) => { + if (isBinary) { + if (!sharedKey) { + return + } + const plaintext = decryptBytes(new Uint8Array(data as Buffer), sharedKey) + if (plaintext) { + resolveBinary(plaintext) + } + return + } + + const frame = data.toString() + if (!sharedKey) { + const hello = JSON.parse(frame) as { publicKeyB64: string } + sharedKey = deriveSharedKey( + serverKeyPair.secretKey, + publicKeyFromBase64(hello.publicKeyB64) + ) + ws.send(JSON.stringify({ type: 'e2ee_ready' })) + return + } + + const plaintext = decrypt(frame, sharedKey) + if (!plaintext) { + return + } + if (!authenticated) { + authenticated = true + sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' }) + return + } + + const request = JSON.parse(plaintext) as { id: string } + sendEncrypted(ws, sharedKey, { + id: request.id, + ok: true, + streaming: true, + result: { type: 'subscribed' }, + _meta: { runtimeId: 'runtime-test' } + }) + }) + }) + + await new Promise((resolve) => wss.once('listening', resolve)) + const address = wss.address() as AddressInfo + const pairing = parsePairingCode( + encodePairingOffer({ + v: 2, + endpoint: `ws://127.0.0.1:${address.port}`, + deviceToken: 'device-token', + publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey) + }) + ) + if (!pairing) { + throw new Error('Failed to create test pairing') + } + return { pairing, nextBinary } +} + +function sendEncrypted(ws: WebSocket, sharedKey: Uint8Array, message: unknown): void { + ws.send(encrypt(JSON.stringify(message), sharedKey)) +} diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts new file mode 100644 index 00000000000..23589deb894 --- /dev/null +++ b/src/shared/remote-runtime-client.ts @@ -0,0 +1,552 @@ +/* oxlint-disable max-lines -- Why: one-shot and streaming remote clients share the + * same E2EE handshake and response validation state; keep them together until + * the terminal transport is fully migrated and a stable shared connection + * abstraction emerges. */ +import { randomUUID } from 'crypto' +import WebSocket from 'ws' +import type { PairingOffer } from './pairing' +import { + decrypt, + decryptBytes, + deriveSharedKey, + encrypt, + encryptBytes, + generateKeyPair, + publicKeyFromBase64, + publicKeyToBase64 +} from './e2ee-crypto' +import { RuntimeRpcEnvelopeSchema, type RuntimeRpcResponse } from './runtime-rpc-envelope' + +type HandshakeState = 'awaiting_ready' | 'awaiting_authenticated' | 'ready' + +export class RemoteRuntimeClientError extends Error { + readonly code: string + + constructor(code: string, message: string) { + super(message) + this.name = 'RemoteRuntimeClientError' + this.code = code + } +} + +export type RemoteRuntimeSubscription = { + requestId: string + close: () => void + sendBinary: (bytes: Uint8Array) => boolean +} + +export type RemoteRuntimeSubscriptionCallbacks = { + onResponse: (response: RuntimeRpcResponse) => void + onBinary?: (bytes: Uint8Array) => void + onError: (error: RemoteRuntimeClientError) => void + onClose?: () => void +} + +export async function sendRemoteRuntimeRequest( + pairing: PairingOffer, + method: string, + params: unknown, + timeoutMs: number +): Promise> { + return await new Promise((resolve, reject) => { + const requestId = randomUUID() + const keyPair = generateKeyPair() + const serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64) + const sharedKey = deriveSharedKey(keyPair.secretKey, serverPublicKey) + let state: HandshakeState = 'awaiting_ready' + let settled = false + let ws: WebSocket | null = null + + const timeout = setTimeout(() => { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'runtime_timeout', + 'Timed out waiting for the remote Orca runtime to respond.' + ) + }) + }, timeoutMs) + + const finish = ( + result: { ok: true; response: RuntimeRpcResponse } | { ok: false; error: Error } + ): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + try { + ws?.close() + } catch { + // ignore best-effort close + } + if (result.ok === false) { + reject(result.error) + } else { + resolve(result.response) + } + } + + try { + ws = new WebSocket(pairing.endpoint) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_argument', + `Invalid remote endpoint: ${message}` + ) + }) + return + } + + ws.once('open', () => { + ws?.send( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(keyPair.publicKey) + }) + ) + }) + + ws.once('error', () => { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'remote_runtime_unavailable', + 'Could not connect to the remote Orca runtime.' + ) + }) + }) + + ws.on('close', () => { + if (!settled) { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'remote_runtime_unavailable', + 'Remote Orca runtime closed the connection.' + ) + }) + } + }) + + ws.on('message', (data, isBinary) => { + if (settled) { + return + } + if (isBinary) { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an unexpected binary frame.' + ) + }) + return + } + + const frame = data.toString() + if (state === 'awaiting_ready') { + handleReadyFrame(frame) + return + } + + const plaintext = decrypt(frame, sharedKey) + if (plaintext === null) { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an undecryptable frame.' + ) + }) + return + } + + if (state === 'awaiting_authenticated') { + handleAuthenticatedFrame(plaintext) + return + } + + handleRpcFrame(plaintext) + }) + + function handleReadyFrame(frame: string): void { + let ready: unknown + try { + ready = JSON.parse(frame) + } catch { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an invalid E2EE handshake frame.' + ) + }) + return + } + if ( + typeof ready !== 'object' || + ready === null || + (ready as { type?: unknown }).type !== 'e2ee_ready' + ) { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an unexpected E2EE handshake frame.' + ) + }) + return + } + state = 'awaiting_authenticated' + ws?.send( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: pairing.deviceToken }), sharedKey) + ) + } + + function handleAuthenticatedFrame(plaintext: string): void { + let authenticated: unknown + try { + authenticated = JSON.parse(plaintext) + } catch { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an invalid E2EE auth frame.' + ) + }) + return + } + const type = (authenticated as { type?: unknown }).type + if (type !== 'e2ee_authenticated') { + const code = + typeof authenticated === 'object' && + authenticated !== null && + (authenticated as { error?: { code?: unknown } }).error?.code === 'unauthorized' + ? 'unauthorized' + : 'invalid_runtime_response' + finish({ + ok: false, + error: new RemoteRuntimeClientError( + code, + 'Remote Orca runtime rejected the pairing token.' + ) + }) + return + } + state = 'ready' + ws?.send( + encrypt( + JSON.stringify({ + id: requestId, + deviceToken: pairing.deviceToken, + method, + params + }), + sharedKey + ) + ) + } + + function handleRpcFrame(plaintext: string): void { + let raw: unknown + try { + raw = JSON.parse(plaintext) + } catch { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an invalid response frame.' + ) + }) + return + } + const parsed = RuntimeRpcEnvelopeSchema.safeParse(raw) + if (!parsed.success || '_keepalive' in parsed.data) { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an invalid response frame.' + ) + }) + return + } + const response = parsed.data as RuntimeRpcResponse + if (response.id !== requestId) { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned a mismatched response id.' + ) + }) + return + } + finish({ ok: true, response }) + } + }) +} + +export async function subscribeRemoteRuntimeRequest( + pairing: PairingOffer, + method: string, + params: unknown, + timeoutMs: number, + callbacks: RemoteRuntimeSubscriptionCallbacks +): Promise { + return await new Promise((resolve, reject) => { + const requestId = randomUUID() + const keyPair = generateKeyPair() + const serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64) + const sharedKey = deriveSharedKey(keyPair.secretKey, serverPublicKey) + let state: HandshakeState = 'awaiting_ready' + let settled = false + let ws: WebSocket | null = null + + const timeout = setTimeout(() => { + fail( + new RemoteRuntimeClientError( + 'runtime_timeout', + 'Timed out waiting for the remote Orca runtime subscription to start.' + ) + ) + }, timeoutMs) + + const close = (): void => { + try { + ws?.close() + } catch { + // ignore best-effort close + } + } + + const sendBinary = (bytes: Uint8Array): boolean => { + if (state !== 'ready' || !ws || ws.readyState !== WebSocket.OPEN) { + return false + } + ws.send(Buffer.from(encryptBytes(bytes, sharedKey)), { binary: true }) + return true + } + + const succeed = (): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + resolve({ requestId, close, sendBinary }) + } + + const fail = (error: RemoteRuntimeClientError): void => { + if (!settled) { + settled = true + clearTimeout(timeout) + close() + reject(error) + return + } + callbacks.onError(error) + } + + try { + ws = new WebSocket(pairing.endpoint) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + fail(new RemoteRuntimeClientError('invalid_argument', `Invalid remote endpoint: ${message}`)) + return + } + + ws.once('open', () => { + ws?.send( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(keyPair.publicKey) + }) + ) + }) + + ws.once('error', () => { + fail( + new RemoteRuntimeClientError( + 'remote_runtime_unavailable', + 'Could not connect to the remote Orca runtime.' + ) + ) + }) + + ws.on('close', () => { + clearTimeout(timeout) + if (!settled) { + reject( + new RemoteRuntimeClientError( + 'remote_runtime_unavailable', + 'Remote Orca runtime closed the connection.' + ) + ) + return + } + callbacks.onClose?.() + }) + + ws.on('message', (data, isBinary) => { + if (isBinary) { + handleBinaryFrame(new Uint8Array(data as Buffer)) + return + } + + const frame = data.toString() + if (state === 'awaiting_ready') { + handleReadyFrame(frame) + return + } + + const plaintext = decrypt(frame, sharedKey) + if (plaintext === null) { + fail( + new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an undecryptable frame.' + ) + ) + return + } + + if (state === 'awaiting_authenticated') { + handleAuthenticatedFrame(plaintext) + return + } + + handleRpcFrame(plaintext) + }) + + function handleReadyFrame(frame: string): void { + let ready: unknown + try { + ready = JSON.parse(frame) + } catch { + fail( + new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an invalid E2EE handshake frame.' + ) + ) + return + } + if ( + typeof ready !== 'object' || + ready === null || + (ready as { type?: unknown }).type !== 'e2ee_ready' + ) { + fail( + new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an unexpected E2EE handshake frame.' + ) + ) + return + } + state = 'awaiting_authenticated' + ws?.send( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: pairing.deviceToken }), sharedKey) + ) + } + + function handleAuthenticatedFrame(plaintext: string): void { + let authenticated: unknown + try { + authenticated = JSON.parse(plaintext) + } catch { + fail( + new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an invalid E2EE auth frame.' + ) + ) + return + } + const type = (authenticated as { type?: unknown }).type + if (type !== 'e2ee_authenticated') { + const code = + typeof authenticated === 'object' && + authenticated !== null && + (authenticated as { error?: { code?: unknown } }).error?.code === 'unauthorized' + ? 'unauthorized' + : 'invalid_runtime_response' + fail(new RemoteRuntimeClientError(code, 'Remote Orca runtime rejected the pairing token.')) + return + } + state = 'ready' + ws?.send( + encrypt( + JSON.stringify({ + id: requestId, + deviceToken: pairing.deviceToken, + method, + params + }), + sharedKey + ) + ) + succeed() + } + + function handleRpcFrame(plaintext: string): void { + let raw: unknown + try { + raw = JSON.parse(plaintext) + } catch { + fail( + new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an invalid response frame.' + ) + ) + return + } + const parsed = RuntimeRpcEnvelopeSchema.safeParse(raw) + if (!parsed.success || '_keepalive' in parsed.data) { + return + } + const response = parsed.data as RuntimeRpcResponse + if (response.id !== requestId) { + fail( + new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned a mismatched response id.' + ) + ) + return + } + callbacks.onResponse(response) + } + + function handleBinaryFrame(frame: Uint8Array): void { + if (state !== 'ready') { + fail( + new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned binary data before authentication.' + ) + ) + return + } + const plaintext = decryptBytes(frame, sharedKey) + if (plaintext === null) { + fail( + new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an undecryptable binary frame.' + ) + ) + return + } + callbacks.onBinary?.(plaintext) + } + }) +} diff --git a/src/shared/remote-runtime-request-connection-stale.test.ts b/src/shared/remote-runtime-request-connection-stale.test.ts new file mode 100644 index 00000000000..39afac27b6e --- /dev/null +++ b/src/shared/remote-runtime-request-connection-stale.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import type { PairingOffer } from './pairing' +import { decrypt, encrypt } from './e2ee-crypto' +import type { RemoteRuntimeWebSocketCallbacks } from './remote-runtime-request-websocket' + +const opens: FakeOpenedSocket[] = [] + +vi.mock('./remote-runtime-request-websocket', () => ({ + openRemoteRuntimeWebSocket: ( + _pairing: PairingOffer, + callbacks: RemoteRuntimeWebSocketCallbacks + ) => { + const socket = createFakeOpenedSocket(callbacks) + opens.push(socket) + return { ok: true, socket: { ws: socket.ws, sharedKey: socket.sharedKey } } + } +})) + +type FakeOpenedSocket = { + ws: WebSocket + sharedKey: Uint8Array + sent: string[] + callbacks: RemoteRuntimeWebSocketCallbacks +} + +function createFakeOpenedSocket(callbacks: RemoteRuntimeWebSocketCallbacks): FakeOpenedSocket { + const sent: string[] = [] + const ws = { + readyState: WebSocket.OPEN, + send: (frame: string) => { + sent.push(frame) + }, + close: vi.fn() + } as unknown as WebSocket + return { + ws, + sharedKey: new Uint8Array(32).fill(opens.length + 1), + sent, + callbacks + } +} + +function authenticate(socket: FakeOpenedSocket): void { + socket.callbacks.onTextFrame(socket.ws, JSON.stringify({ type: 'e2ee_ready' })) + socket.callbacks.onTextFrame( + socket.ws, + encrypt(JSON.stringify({ type: 'e2ee_authenticated' }), socket.sharedKey) + ) +} + +function latestRequestId(socket: FakeOpenedSocket): string { + const plaintext = decrypt(socket.sent.at(-1) ?? '', socket.sharedKey) + if (plaintext === null) { + throw new Error('missing encrypted request') + } + return (JSON.parse(plaintext) as { id: string }).id +} + +describe('RemoteRuntimeRequestConnection stale socket callbacks', () => { + beforeEach(() => { + opens.splice(0) + }) + + it('ignores stale socket errors and text frames after a replacement socket opens', async () => { + vi.useFakeTimers() + try { + const { RemoteRuntimeRequestConnection } = + await import('./remote-runtime-request-connection.js') + const connection = new RemoteRuntimeRequestConnection({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'device-token', + publicKeyB64: Buffer.from(new Uint8Array(32).fill(9)).toString('base64') + }) + + const first = connection.request('slow.method', undefined, 10) + authenticate(opens[0]!) + const firstRejected = expect(first).rejects.toThrow('Timed out') + await vi.advanceTimersByTimeAsync(11) + await firstRejected + + const second = connection.request('status.get', undefined, 1000) + authenticate(opens[1]!) + await vi.waitFor(() => expect(opens[1]!.sent.length).toBeGreaterThan(1)) + + opens[0]!.callbacks.onError(opens[0]!.ws, new Error('stale socket error') as never) + opens[0]!.callbacks.onTextFrame( + opens[0]!.ws, + encrypt(JSON.stringify({ id: 'stale', ok: true, result: {} }), opens[0]!.sharedKey) + ) + + const requestId = latestRequestId(opens[1]!) + opens[1]!.callbacks.onTextFrame( + opens[1]!.ws, + encrypt( + JSON.stringify({ + id: requestId, + ok: true, + result: { state: 'ok' }, + _meta: { runtimeId: 'runtime-2' } + }), + opens[1]!.sharedKey + ) + ) + + await expect(second).resolves.toMatchObject({ + ok: true, + result: { state: 'ok' } + }) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/shared/remote-runtime-request-connection.test.ts b/src/shared/remote-runtime-request-connection.test.ts new file mode 100644 index 00000000000..3a9d7f1b191 --- /dev/null +++ b/src/shared/remote-runtime-request-connection.test.ts @@ -0,0 +1,142 @@ +import type { AddressInfo } from 'net' +import { afterEach, describe, expect, it } from 'vitest' +import { WebSocketServer, type WebSocket } from 'ws' +import { encodePairingOffer, parsePairingCode, type PairingOffer } from './pairing' +import { + decrypt, + deriveSharedKey, + encrypt, + generateKeyPair, + publicKeyFromBase64, + publicKeyToBase64 +} from './e2ee-crypto' +import { RemoteRuntimeRequestConnection } from './remote-runtime-request-connection' + +type TestServer = { + wss: WebSocketServer + pairing: PairingOffer + requests: unknown[] + connectionCount: () => number +} + +const servers: WebSocketServer[] = [] + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + for (const client of server.clients) { + client.close() + } + server.close(() => resolve()) + }) + ) + ) +}) + +describe('RemoteRuntimeRequestConnection', () => { + it('reuses one encrypted WebSocket for multiple one-shot RPCs', async () => { + const server = await createServer() + const connection = new RemoteRuntimeRequestConnection(server.pairing) + + const first = await connection.request('status.get', undefined, 1000) + const second = await connection.request('terminal.send', { terminal: 't1', text: 'ab' }, 1000) + + expect(first).toMatchObject({ + ok: true, + result: { method: 'status.get' }, + _meta: { runtimeId: 'runtime-test' } + }) + expect(second).toMatchObject({ + ok: true, + result: { method: 'terminal.send' }, + _meta: { runtimeId: 'runtime-test' } + }) + expect(server.connectionCount()).toBe(1) + expect(server.requests).toMatchObject([ + { method: 'status.get' }, + { method: 'terminal.send', params: { terminal: 't1', text: 'ab' } } + ]) + + connection.close() + }) +}) + +async function createServer(): Promise { + const serverKeyPair = generateKeyPair() + const requests: unknown[] = [] + let connectionCount = 0 + const wss = new WebSocketServer({ port: 0 }) + servers.push(wss) + + wss.on('connection', (ws) => { + connectionCount += 1 + let sharedKey: Uint8Array | null = null + let authenticated = false + + ws.on('message', (data, isBinary) => { + if (isBinary) { + return + } + const frame = data.toString() + if (!sharedKey) { + const hello = JSON.parse(frame) as { type: string; publicKeyB64: string } + const clientPublicKey = publicKeyFromBase64(hello.publicKeyB64) + sharedKey = deriveSharedKey(serverKeyPair.secretKey, clientPublicKey) + ws.send(JSON.stringify({ type: 'e2ee_ready' })) + return + } + + const plaintext = decrypt(frame, sharedKey) + if (plaintext === null) { + return + } + if (!authenticated) { + const auth = JSON.parse(plaintext) as { type: string; deviceToken: string } + expect(auth).toEqual({ type: 'e2ee_auth', deviceToken: 'device-token' }) + authenticated = true + sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' }) + return + } + + const request = JSON.parse(plaintext) as { + id: string + method: string + params?: unknown + } + requests.push(request) + sendEncrypted(ws, sharedKey, { + id: request.id, + ok: true, + result: { method: request.method }, + _meta: { runtimeId: 'runtime-test' } + }) + }) + }) + + await new Promise((resolve) => wss.once('listening', resolve)) + const address = wss.address() as AddressInfo + const pairing = parsePairingCode( + encodePairingOffer({ + v: 2, + endpoint: `ws://127.0.0.1:${address.port}`, + deviceToken: 'device-token', + publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey) + }) + ) + if (!pairing) { + throw new Error('Failed to create test pairing') + } + + return { + wss, + pairing, + requests, + connectionCount: () => connectionCount + } +} + +function sendEncrypted(ws: WebSocket, sharedKey: Uint8Array, message: unknown): void { + ws.send(encrypt(JSON.stringify(message), sharedKey)) +} diff --git a/src/shared/remote-runtime-request-connection.ts b/src/shared/remote-runtime-request-connection.ts new file mode 100644 index 00000000000..17f6605ab87 --- /dev/null +++ b/src/shared/remote-runtime-request-connection.ts @@ -0,0 +1,292 @@ +import { randomUUID } from 'crypto' +import WebSocket from 'ws' +import type { PairingOffer } from './pairing' +import { decrypt, encrypt } from './e2ee-crypto' +import type { RuntimeRpcResponse } from './runtime-rpc-envelope' +import { RemoteRuntimeClientError } from './remote-runtime-client' +import { + invalidRemoteRuntimeResponseError, + parseAuthenticatedFrame, + parseReadyFrame, + parseRemoteRuntimeRpcFrame, + remoteRuntimeTimeoutError, + remoteRuntimeUnavailableError +} from './remote-runtime-request-frames' +import { openRemoteRuntimeWebSocket } from './remote-runtime-request-websocket' + +type ConnectionState = 'closed' | 'awaiting_ready' | 'awaiting_authenticated' | 'ready' + +type PendingRequest = { + resolve: (response: RuntimeRpcResponse) => void + reject: (error: Error) => void + timeout: ReturnType +} + +type ReadyWaiter = { + resolve: () => void + reject: (error: Error) => void +} + +const IDLE_CLOSE_MS = 60_000 + +export class RemoteRuntimeRequestConnection { + private readonly pairing: PairingOffer + private state: ConnectionState = 'closed' + private ws: WebSocket | null = null + private sharedKey: Uint8Array | null = null + private readonly pendingRequests = new Map>() + private readonly readyWaiters: ReadyWaiter[] = [] + private idleCloseTimer: ReturnType | null = null + + constructor(pairing: PairingOffer) { + this.pairing = pairing + } + + request( + method: string, + params: unknown, + timeoutMs: number + ): Promise> { + this.clearIdleCloseTimer() + const requestId = randomUUID() + return new Promise>((resolve, reject) => { + const timeout = setTimeout(() => { + const pending = this.pendingRequests.get(requestId) + if (!pending) { + return + } + this.pendingRequests.delete(requestId) + const error = remoteRuntimeTimeoutError() + pending.reject(error) + this.close(error) + }, timeoutMs) + this.pendingRequests.set(requestId, { + resolve: resolve as (response: RuntimeRpcResponse) => void, + reject, + timeout + }) + + void this.ensureReady().then( + () => this.sendRequest(requestId, method, params), + (error) => this.rejectPendingRequest(requestId, toClientError(error)) + ) + }) + } + + close(error?: Error): void { + const ws = this.ws + this.ws = null + this.sharedKey = null + this.state = 'closed' + this.clearIdleCloseTimer() + + const closeError = error ?? remoteRuntimeUnavailableError() + this.rejectReadyWaiters(closeError) + for (const [requestId, pending] of this.pendingRequests) { + clearTimeout(pending.timeout) + this.pendingRequests.delete(requestId) + pending.reject(closeError) + } + + try { + ws?.close() + } catch { + // Best-effort shutdown for a cached remote control connection. + } + } + + private ensureReady(): Promise { + const ws = this.ws + if (this.state === 'ready' && ws?.readyState === WebSocket.OPEN && this.sharedKey) { + return Promise.resolve() + } + + const promise = new Promise((resolve, reject) => { + this.readyWaiters.push({ resolve, reject }) + }) + + if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) { + this.open() + } + + return promise + } + + private open(): void { + const opened = openRemoteRuntimeWebSocket(this.pairing, { + onClose: (ws) => { + if (this.ws === ws) { + this.close() + } + }, + onError: (ws, error) => { + if (this.ws === ws) { + this.close(error) + } + }, + onTextFrame: (ws, frame) => { + if (this.ws === ws) { + this.handleTextFrame(frame) + } + } + }) + if (!opened.ok) { + this.close(opened.error) + return + } + this.ws = opened.socket.ws + this.sharedKey = opened.socket.sharedKey + this.state = 'awaiting_ready' + } + + private handleTextFrame(frame: string): void { + if (this.state === 'awaiting_ready') { + this.handleReadyFrame(frame) + return + } + + const sharedKey = this.sharedKey + if (!sharedKey) { + return + } + const plaintext = decrypt(frame, sharedKey) + if (plaintext === null) { + this.close( + invalidRemoteRuntimeResponseError('Remote Orca runtime returned an undecryptable frame.') + ) + return + } + + if (this.state === 'awaiting_authenticated') { + this.handleAuthenticatedFrame(plaintext) + return + } + + this.handleRpcFrame(plaintext) + } + + private handleReadyFrame(frame: string): void { + const error = parseReadyFrame(frame) + if (error) { + this.close(error) + return + } + this.state = 'awaiting_authenticated' + const sharedKey = this.sharedKey + if (!sharedKey) { + return + } + this.ws?.send( + encrypt( + JSON.stringify({ type: 'e2ee_auth', deviceToken: this.pairing.deviceToken }), + sharedKey + ) + ) + } + + private handleAuthenticatedFrame(plaintext: string): void { + const error = parseAuthenticatedFrame(plaintext) + if (error) { + this.close(error) + return + } + this.state = 'ready' + this.resolveReadyWaiters() + this.scheduleIdleCloseIfUnused() + } + + private handleRpcFrame(plaintext: string): void { + const parsed = parseRemoteRuntimeRpcFrame(plaintext) + if (parsed.type === 'keepalive') { + return + } + if (parsed.type === 'error') { + this.close(parsed.error) + return + } + + const response = parsed.response + const pending = this.pendingRequests.get(response.id) + if (!pending) { + return + } + this.pendingRequests.delete(response.id) + clearTimeout(pending.timeout) + pending.resolve(response) + this.scheduleIdleCloseIfUnused() + } + + private sendRequest(requestId: string, method: string, params: unknown): void { + const pending = this.pendingRequests.get(requestId) + const ws = this.ws + const sharedKey = this.sharedKey + if (!pending) { + return + } + if (this.state !== 'ready' || !ws || ws.readyState !== WebSocket.OPEN || !sharedKey) { + this.rejectPendingRequest(requestId, remoteRuntimeUnavailableError()) + return + } + ws.send( + encrypt( + JSON.stringify({ + id: requestId, + deviceToken: this.pairing.deviceToken, + method, + params + }), + sharedKey + ) + ) + } + + private rejectPendingRequest(requestId: string, error: Error): void { + const pending = this.pendingRequests.get(requestId) + if (!pending) { + return + } + this.pendingRequests.delete(requestId) + clearTimeout(pending.timeout) + pending.reject(error) + this.scheduleIdleCloseIfUnused() + } + + private resolveReadyWaiters(): void { + const waiters = this.readyWaiters.splice(0) + for (const waiter of waiters) { + waiter.resolve() + } + } + + private rejectReadyWaiters(error: Error): void { + const waiters = this.readyWaiters.splice(0) + for (const waiter of waiters) { + waiter.reject(error) + } + } + + private scheduleIdleCloseIfUnused(): void { + if (this.pendingRequests.size > 0 || this.readyWaiters.length > 0 || this.state !== 'ready') { + return + } + this.clearIdleCloseTimer() + this.idleCloseTimer = setTimeout(() => this.close(), IDLE_CLOSE_MS) + if (typeof this.idleCloseTimer.unref === 'function') { + this.idleCloseTimer.unref() + } + } + + private clearIdleCloseTimer(): void { + if (this.idleCloseTimer) { + clearTimeout(this.idleCloseTimer) + this.idleCloseTimer = null + } + } +} + +function toClientError(error: unknown): Error { + if (error instanceof Error) { + return error + } + return new RemoteRuntimeClientError('runtime_error', String(error)) +} diff --git a/src/shared/remote-runtime-request-frames.ts b/src/shared/remote-runtime-request-frames.ts new file mode 100644 index 00000000000..b48212d0f5b --- /dev/null +++ b/src/shared/remote-runtime-request-frames.ts @@ -0,0 +1,98 @@ +import { + RuntimeRpcEnvelopeSchema, + type RuntimeRpcResponse, + isKeepaliveFrame +} from './runtime-rpc-envelope' +import { RemoteRuntimeClientError } from './remote-runtime-client' + +export type ParsedRemoteRuntimeFrame = + | { type: 'keepalive' } + | { type: 'response'; response: RuntimeRpcResponse } + | { type: 'error'; error: RemoteRuntimeClientError } + +export function remoteRuntimeUnavailableError( + message = 'Remote Orca runtime closed the connection.' +): RemoteRuntimeClientError { + return new RemoteRuntimeClientError('remote_runtime_unavailable', message) +} + +export function remoteRuntimeTimeoutError(): RemoteRuntimeClientError { + return new RemoteRuntimeClientError( + 'runtime_timeout', + 'Timed out waiting for the remote Orca runtime to respond.' + ) +} + +export function invalidRemoteRuntimeResponseError(message: string): RemoteRuntimeClientError { + return new RemoteRuntimeClientError('invalid_runtime_response', message) +} + +export function parseReadyFrame(frame: string): RemoteRuntimeClientError | null { + let ready: unknown + try { + ready = JSON.parse(frame) + } catch { + return invalidRemoteRuntimeResponseError( + 'Remote Orca runtime returned an invalid E2EE handshake frame.' + ) + } + if ( + typeof ready !== 'object' || + ready === null || + (ready as { type?: unknown }).type !== 'e2ee_ready' + ) { + return invalidRemoteRuntimeResponseError( + 'Remote Orca runtime returned an unexpected E2EE handshake frame.' + ) + } + return null +} + +export function parseAuthenticatedFrame(plaintext: string): RemoteRuntimeClientError | null { + let authenticated: unknown + try { + authenticated = JSON.parse(plaintext) + } catch { + return invalidRemoteRuntimeResponseError( + 'Remote Orca runtime returned an invalid E2EE auth frame.' + ) + } + const type = (authenticated as { type?: unknown }).type + if (type === 'e2ee_authenticated') { + return null + } + const code = + typeof authenticated === 'object' && + authenticated !== null && + (authenticated as { error?: { code?: unknown } }).error?.code === 'unauthorized' + ? 'unauthorized' + : 'invalid_runtime_response' + return new RemoteRuntimeClientError(code, 'Remote Orca runtime rejected the pairing token.') +} + +export function parseRemoteRuntimeRpcFrame(plaintext: string): ParsedRemoteRuntimeFrame { + let raw: unknown + try { + raw = JSON.parse(plaintext) + } catch { + return { + type: 'error', + error: invalidRemoteRuntimeResponseError( + 'Remote Orca runtime returned an invalid response frame.' + ) + } + } + if (isKeepaliveFrame(raw)) { + return { type: 'keepalive' } + } + const parsed = RuntimeRpcEnvelopeSchema.safeParse(raw) + if (!parsed.success || '_keepalive' in parsed.data) { + return { + type: 'error', + error: invalidRemoteRuntimeResponseError( + 'Remote Orca runtime returned an invalid response frame.' + ) + } + } + return { type: 'response', response: parsed.data as RuntimeRpcResponse } +} diff --git a/src/shared/remote-runtime-request-websocket.ts b/src/shared/remote-runtime-request-websocket.ts new file mode 100644 index 00000000000..569ef9e2759 --- /dev/null +++ b/src/shared/remote-runtime-request-websocket.ts @@ -0,0 +1,96 @@ +import WebSocket from 'ws' +import type { PairingOffer } from './pairing' +import { + deriveSharedKey, + generateKeyPair, + publicKeyFromBase64, + publicKeyToBase64 +} from './e2ee-crypto' +import { RemoteRuntimeClientError } from './remote-runtime-client' +import { + invalidRemoteRuntimeResponseError, + remoteRuntimeUnavailableError +} from './remote-runtime-request-frames' + +export type RemoteRuntimeWebSocket = { + ws: WebSocket + sharedKey: Uint8Array +} + +export type RemoteRuntimeWebSocketCallbacks = { + onClose: (ws: WebSocket) => void + onError: (ws: WebSocket, error: RemoteRuntimeClientError) => void + onTextFrame: (ws: WebSocket, frame: string) => void +} + +export function openRemoteRuntimeWebSocket( + pairing: PairingOffer, + callbacks: RemoteRuntimeWebSocketCallbacks +): { ok: true; socket: RemoteRuntimeWebSocket } | { ok: false; error: RemoteRuntimeClientError } { + const opened = createSocket(pairing) + if (!opened.ok) { + return opened + } + const { ws, keyPair } = opened + const serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64) + const sharedKey = deriveSharedKey(keyPair.secretKey, serverPublicKey) + + ws.once('open', () => { + ws.send( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(keyPair.publicKey) + }) + ) + }) + ws.on('error', () => { + callbacks.onError( + ws, + remoteRuntimeUnavailableError('Could not connect to the remote Orca runtime.') + ) + }) + ws.on('close', () => callbacks.onClose(ws)) + ws.on('message', (data, isBinary) => { + if (isBinary) { + callbacks.onError( + ws, + invalidRemoteRuntimeResponseError( + 'Remote Orca runtime returned an unexpected binary frame.' + ) + ) + return + } + callbacks.onTextFrame(ws, data.toString()) + }) + return { ok: true, socket: { ws, sharedKey } } +} + +function createSocket( + pairing: PairingOffer +): + | { ok: true; ws: WebSocket; keyPair: ReturnType } + | { ok: false; error: RemoteRuntimeClientError } { + let keyPair: ReturnType + try { + keyPair = generateKeyPair() + publicKeyFromBase64(pairing.publicKeyB64) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_argument', + `Invalid remote pairing key: ${message}` + ) + } + } + try { + return { ok: true, ws: new WebSocket(pairing.endpoint), keyPair } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + ok: false, + error: new RemoteRuntimeClientError('invalid_argument', `Invalid remote endpoint: ${message}`) + } + } +} diff --git a/src/shared/runtime-environment-store.test.ts b/src/shared/runtime-environment-store.test.ts new file mode 100644 index 00000000000..649775d11b7 --- /dev/null +++ b/src/shared/runtime-environment-store.test.ts @@ -0,0 +1,47 @@ +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { encodePairingOffer } from './pairing' +import { + RuntimeEnvironmentStoreError, + addEnvironmentFromPairingCode, + listEnvironments +} from './runtime-environment-store' + +function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string { + return encodePairingOffer({ + v: 2, + endpoint, + deviceToken: 'device-token', + publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64') + }) +} + +describe('runtime environment store', () => { + const tempDirs: string[] = [] + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('rejects duplicate server names instead of silently replacing the saved server', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-store-')) + tempDirs.push(userDataPath) + + const first = addEnvironmentFromPairingCode(userDataPath, { + name: 'dev box', + pairingCode: pairingCode('ws://127.0.0.1:6768') + }) + + expect(() => + addEnvironmentFromPairingCode(userDataPath, { + name: 'dev box', + pairingCode: pairingCode('ws://192.0.2.10:6768') + }) + ).toThrow(RuntimeEnvironmentStoreError) + expect(listEnvironments(userDataPath)).toEqual([first]) + }) +}) diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts new file mode 100644 index 00000000000..2682ffb74dd --- /dev/null +++ b/src/shared/runtime-environment-store.ts @@ -0,0 +1,166 @@ +import { randomUUID } from 'crypto' +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' +import { parsePairingCode, type PairingOffer } from './pairing' +import { hardenExistingSecureFile, writeSecureJsonFile } from './secure-file' +import { + createEnvironmentFromPairingOffer, + getPreferredPairingOffer, + KnownRuntimeEnvironmentSchema, + RuntimeEnvironmentStoreSchema, + type KnownRuntimeEnvironment, + type RuntimeEnvironmentStore +} from './runtime-environments' + +const ENVIRONMENTS_FILE = 'orca-environments.json' + +export type RuntimeEnvironmentStoreErrorCode = 'invalid_argument' | 'runtime_error' + +export class RuntimeEnvironmentStoreError extends Error { + readonly code: RuntimeEnvironmentStoreErrorCode + + constructor(code: RuntimeEnvironmentStoreErrorCode, message: string) { + super(message) + this.name = 'RuntimeEnvironmentStoreError' + this.code = code + } +} + +export function getEnvironmentStorePath(userDataPath: string): string { + return join(userDataPath, ENVIRONMENTS_FILE) +} + +export function listEnvironments(userDataPath: string): KnownRuntimeEnvironment[] { + return readEnvironmentStore(userDataPath).environments +} + +export function addEnvironmentFromPairingCode( + userDataPath: string, + args: { name: string; pairingCode: string; now?: number } +): KnownRuntimeEnvironment { + const offer = parsePairingCode(args.pairingCode) + if (!offer) { + throw new RuntimeEnvironmentStoreError( + 'invalid_argument', + 'Invalid pairing code. Expected an orca://pair#... URL or bare pairing payload.' + ) + } + const store = readEnvironmentStore(userDataPath) + const now = args.now ?? Date.now() + const existing = store.environments.find((entry) => entry.name === args.name) + if (existing) { + throw new RuntimeEnvironmentStoreError( + 'invalid_argument', + `A server named "${args.name}" already exists.` + ) + } + const environment = createEnvironmentFromPairingOffer({ + id: randomUUID(), + name: args.name, + now, + offer, + runtimeId: null + }) + const next = { + version: 1 as const, + environments: [ + ...store.environments.filter((entry) => entry.id !== environment.id), + environment + ].sort((a, b) => a.name.localeCompare(b.name)) + } + writeEnvironmentStore(userDataPath, next) + return environment +} + +export function removeEnvironment(userDataPath: string, selector: string): KnownRuntimeEnvironment { + const store = readEnvironmentStore(userDataPath) + const environment = resolveEnvironmentFromStore(store, selector) + writeEnvironmentStore(userDataPath, { + version: 1, + environments: store.environments.filter((entry) => entry.id !== environment.id) + }) + return environment +} + +export function resolveEnvironment( + userDataPath: string, + selector: string +): KnownRuntimeEnvironment { + return resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), selector) +} + +export function resolveEnvironmentPairingOffer( + userDataPath: string, + selector: string +): PairingOffer { + return getPreferredPairingOffer(resolveEnvironment(userDataPath, selector)) +} + +export function markEnvironmentUsed( + userDataPath: string, + selector: string, + args: { runtimeId?: string | null; now?: number } = {} +): void { + const store = readEnvironmentStore(userDataPath) + const environment = resolveEnvironmentFromStore(store, selector) + const now = args.now ?? Date.now() + const next = store.environments.map((entry) => + entry.id === environment.id + ? { + ...entry, + runtimeId: args.runtimeId ?? entry.runtimeId, + lastUsedAt: now, + updatedAt: now + } + : entry + ) + writeEnvironmentStore(userDataPath, { version: 1, environments: next }) +} + +function resolveEnvironmentFromStore( + store: RuntimeEnvironmentStore, + selector: string +): KnownRuntimeEnvironment { + const byId = store.environments.find((entry) => entry.id === selector) + if (byId) { + return byId + } + const matches = store.environments.filter((entry) => entry.name === selector) + if (matches.length === 1) { + return matches[0]! + } + if (matches.length > 1) { + throw new RuntimeEnvironmentStoreError( + 'invalid_argument', + `Environment name "${selector}" is ambiguous; use the environment id.` + ) + } + throw new RuntimeEnvironmentStoreError('invalid_argument', `Unknown environment: ${selector}`) +} + +function readEnvironmentStore(userDataPath: string): RuntimeEnvironmentStore { + const path = getEnvironmentStorePath(userDataPath) + if (!existsSync(path)) { + return { version: 1, environments: [] } + } + try { + hardenExistingSecureFile(path) + const parsed = RuntimeEnvironmentStoreSchema.parse(JSON.parse(readFileSync(path, 'utf8'))) + return { + version: 1, + environments: parsed.environments + .map((entry) => KnownRuntimeEnvironmentSchema.parse(entry)) + .sort((a, b) => a.name.localeCompare(b.name)) + } + } catch { + throw new RuntimeEnvironmentStoreError( + 'runtime_error', + `Could not read Orca environments at ${path}; the file is invalid.` + ) + } +} + +function writeEnvironmentStore(userDataPath: string, store: RuntimeEnvironmentStore): void { + const path = getEnvironmentStorePath(userDataPath) + writeSecureJsonFile(path, RuntimeEnvironmentStoreSchema.parse(store)) +} diff --git a/src/shared/runtime-environments.ts b/src/shared/runtime-environments.ts new file mode 100644 index 00000000000..4f315b42dba --- /dev/null +++ b/src/shared/runtime-environments.ts @@ -0,0 +1,99 @@ +import { z } from 'zod' +import { PAIRING_OFFER_VERSION, type PairingOffer } from './pairing' + +export const RuntimeAccessEndpointSchema = z.object({ + id: z.string().min(1), + kind: z.literal('websocket'), + label: z.string().min(1), + endpoint: z.string().min(1), + deviceToken: z.string().min(1), + publicKeyB64: z.string().min(1) +}) + +export type RuntimeAccessEndpoint = z.infer + +export const PublicRuntimeAccessEndpointSchema = RuntimeAccessEndpointSchema.omit({ + deviceToken: true, + publicKeyB64: true +}) + +export type PublicRuntimeAccessEndpoint = z.infer + +export const KnownRuntimeEnvironmentSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + createdAt: z.number().finite(), + updatedAt: z.number().finite(), + lastUsedAt: z.number().finite().nullable(), + runtimeId: z.string().min(1).nullable(), + endpoints: z.array(RuntimeAccessEndpointSchema).min(1), + preferredEndpointId: z.string().min(1) +}) + +export type KnownRuntimeEnvironment = z.infer + +export type PublicKnownRuntimeEnvironment = Omit & { + endpoints: PublicRuntimeAccessEndpoint[] +} + +export function redactRuntimeEnvironment( + environment: KnownRuntimeEnvironment +): PublicKnownRuntimeEnvironment { + return { + ...environment, + endpoints: environment.endpoints.map( + ({ deviceToken: _deviceToken, publicKeyB64: _key, ...rest }) => rest + ) + } +} + +export const RuntimeEnvironmentStoreSchema = z.object({ + version: z.literal(1), + environments: z.array(KnownRuntimeEnvironmentSchema) +}) + +export type RuntimeEnvironmentStore = z.infer + +export function createEnvironmentFromPairingOffer(args: { + id: string + name: string + now: number + offer: PairingOffer + runtimeId?: string | null +}): KnownRuntimeEnvironment { + const endpointId = `ws-${args.id}` + return KnownRuntimeEnvironmentSchema.parse({ + id: args.id, + name: args.name, + createdAt: args.now, + updatedAt: args.now, + lastUsedAt: null, + runtimeId: args.runtimeId ?? null, + endpoints: [ + { + id: endpointId, + kind: 'websocket', + label: 'WebSocket', + endpoint: args.offer.endpoint, + deviceToken: args.offer.deviceToken, + publicKeyB64: args.offer.publicKeyB64 + } + ], + preferredEndpointId: endpointId + }) +} + +export function getPreferredPairingOffer(environment: KnownRuntimeEnvironment): PairingOffer { + const endpoint = + environment.endpoints.find((entry) => entry.id === environment.preferredEndpointId) ?? + environment.endpoints[0] + if (!endpoint) { + throw new Error(`Environment ${environment.name} has no access endpoints`) + } + return { + v: PAIRING_OFFER_VERSION, + endpoint: endpoint.endpoint, + deviceToken: endpoint.deviceToken, + publicKeyB64: endpoint.publicKeyB64 + } +} diff --git a/src/shared/runtime-rpc-envelope.ts b/src/shared/runtime-rpc-envelope.ts new file mode 100644 index 00000000000..c72811c18a3 --- /dev/null +++ b/src/shared/runtime-rpc-envelope.ts @@ -0,0 +1,73 @@ +// Why: runtime clients can be CLI, desktop, or future non-Electron shells. +// Keeping the envelope contract here avoids making those clients import each +// other just to validate the shared RPC frame shape. +import { z } from 'zod' + +const MetaSuccess = z.object({ + runtimeId: z.string() +}) + +const MetaFailure = z + .object({ + runtimeId: z.union([z.string(), z.null()]) + }) + .optional() + +const Success = z.object({ + id: z.string(), + ok: z.literal(true), + result: z.unknown(), + _meta: MetaSuccess +}) + +const Failure = z.object({ + id: z.string(), + ok: z.literal(false), + error: z.object({ + code: z.string(), + message: z.string(), + data: z.unknown().optional() + }), + _meta: MetaFailure +}) + +const Keepalive = z.object({ + _keepalive: z.literal(true) +}) + +export const RuntimeRpcEnvelopeSchema = z.union([Success, Failure, Keepalive]) + +export type RuntimeRpcSuccess = { + id: string + ok: true + result: TResult + _meta: { + runtimeId: string + } +} + +export type RuntimeRpcFailure = { + id: string + ok: false + error: { + code: string + message: string + data?: unknown + } + _meta?: { + runtimeId: string | null + } +} + +export type RuntimeRpcResponse = RuntimeRpcSuccess | RuntimeRpcFailure + +export type RuntimeRpcKeepaliveFrame = z.infer + +export function isKeepaliveFrame(frame: unknown): frame is RuntimeRpcKeepaliveFrame { + return ( + typeof frame === 'object' && + frame !== null && + '_keepalive' in frame && + (frame as { _keepalive: unknown })._keepalive === true + ) +} diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 97070dfe649..586252341e3 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -1,10 +1,18 @@ /* eslint-disable max-lines -- Why: shared type definitions for all runtime RPC methods live in one file for discoverability and import simplicity. */ import type { TerminalPaneLayoutNode } from './types' -import type { BrowserSessionProfile, GitWorktreeInfo, Repo } from './types' +import type { + BrowserCookieImportResult, + BrowserSessionProfile, + BrowserSessionProfileSource, + GitWorktreeInfo, + Repo, + Worktree +} from './types' import type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult } from './mobile-markdown-document' +import type { RuntimeCapability } from './protocol-version' export type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult } @@ -17,9 +25,13 @@ export type RuntimeStatus = { authoritativeWindowId: number | null liveTabCount: number liveLeafCount: number - // Why: optional so mobile builds can read both new and pre-PR desktops. - // Absence is treated as 0 by mobile's compat evaluator. See - // src/shared/protocol-version.ts for bump discipline. + // Why: optional so clients can read both new and pre-contract runtimes. + // Absence is treated as protocol 0 by the compat evaluator. + runtimeProtocolVersion?: number + minCompatibleRuntimeClientVersion?: number + capabilities?: RuntimeCapability[] + // COMPAT(runtimeStatusMobileAliases): added 2026-05-15 for mobile builds + // that still read these names; new desktop/CLI code uses the fields above. protocolVersion?: number minCompatibleMobileVersion?: number } @@ -189,6 +201,13 @@ export type RuntimeFileReadResult = { byteLength: number } +export type RuntimeFilePreviewResult = { + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string +} + export type RuntimeTerminalSummary = { handle: string worktreeId: string @@ -292,15 +311,8 @@ export type RuntimeWorktreePsSummary = { export type RuntimeWorktreeStatus = 'active' | 'working' | 'permission' | 'done' | 'inactive' -export type RuntimeWorktreeRecord = { - id: string - repoId: string - path: string - branch: string - linkedIssue: number | null +export type RuntimeWorktreeRecord = Worktree & { git: GitWorktreeInfo - displayName: string - comment: string } export type RuntimeWorktreePsResult = { @@ -446,6 +458,28 @@ export type BrowserProfileDeleteResult = { profileId: string } +export type BrowserDetectedProfileInfo = { + name: string + directory: string +} + +export type BrowserDetectedInfo = { + family: BrowserSessionProfileSource['browserFamily'] + label: string + profiles: BrowserDetectedProfileInfo[] + selectedProfile: string +} + +export type BrowserDetectProfilesResult = { + browsers: BrowserDetectedInfo[] +} + +export type BrowserProfileImportFromBrowserResult = BrowserCookieImportResult + +export type BrowserProfileClearDefaultCookiesResult = { + cleared: boolean +} + export type BrowserHoverResult = { hovered: string } diff --git a/src/shared/secure-file.ts b/src/shared/secure-file.ts new file mode 100644 index 00000000000..3d662a0806b --- /dev/null +++ b/src/shared/secure-file.ts @@ -0,0 +1,109 @@ +import { execFileSync } from 'child_process' +import { randomBytes } from 'crypto' +import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'fs' +import { dirname } from 'path' + +let cachedWindowsUserSid: string | null | undefined + +export function writeSecureJsonFile(targetPath: string, value: unknown): void { + writeSecureFile(targetPath, JSON.stringify(value, null, 2)) +} + +export function writeSecureFile(targetPath: string, contents: string): void { + const dir = dirname(targetPath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }) + } + hardenSecurePath(dir, { isDirectory: true, platform: process.platform }) + + const tmpFile = `${targetPath}.${process.pid}.${Date.now()}.${randomBytes(4).toString('hex')}.tmp` + try { + writeFileSync(tmpFile, contents, { + encoding: 'utf-8', + mode: 0o600 + }) + hardenSecurePath(tmpFile, { isDirectory: false, platform: process.platform }) + renameSync(tmpFile, targetPath) + // Why: these files carry runtime auth/device credentials; the published + // path must remain current-user only after the atomic rename. + hardenSecurePath(targetPath, { isDirectory: false, platform: process.platform }) + } catch (error) { + rmSync(tmpFile, { force: true }) + throw error + } +} + +export function hardenExistingSecureFile(targetPath: string): void { + const dir = dirname(targetPath) + if (existsSync(dir)) { + hardenSecurePath(dir, { isDirectory: true, platform: process.platform }) + } + if (existsSync(targetPath)) { + hardenSecurePath(targetPath, { isDirectory: false, platform: process.platform }) + } +} + +export function hardenSecurePath( + targetPath: string, + options: { + isDirectory: boolean + platform: NodeJS.Platform + } +): void { + if (options.platform === 'win32') { + bestEffortRestrictWindowsPath(targetPath) + return + } + chmodSync(targetPath, options.isDirectory ? 0o700 : 0o600) +} + +function bestEffortRestrictWindowsPath(targetPath: string): void { + const currentUserSid = getCurrentWindowsUserSid() + if (!currentUserSid) { + return + } + try { + execFileSync( + 'icacls', + [ + targetPath, + '/inheritance:r', + '/grant:r', + `*${currentUserSid}:(F)`, + '*S-1-5-18:(F)', + '*S-1-5-32-544:(F)' + ], + { + stdio: 'ignore', + windowsHide: true, + timeout: 5000 + } + ) + } catch { + // Why: credential-file hardening should not prevent Orca from starting on + // Windows machines where icacls is unavailable or locked down differently. + } +} + +function getCurrentWindowsUserSid(): string | null { + if (cachedWindowsUserSid !== undefined) { + return cachedWindowsUserSid + } + try { + const output = execFileSync('whoami', ['/user', '/fo', 'csv', '/nh'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + timeout: 5000 + }).trim() + const columns = parseCsvLine(output) + cachedWindowsUserSid = columns[1] ?? null + } catch { + cachedWindowsUserSid = null + } + return cachedWindowsUserSid +} + +function parseCsvLine(line: string): string[] { + return line.split(/","/).map((part) => part.replace(/^"/, '').replace(/"$/, '')) +} diff --git a/src/shared/terminal-stream-protocol.test.ts b/src/shared/terminal-stream-protocol.test.ts index 5dec28c811e..eb82bbc3980 100644 --- a/src/shared/terminal-stream-protocol.test.ts +++ b/src/shared/terminal-stream-protocol.test.ts @@ -44,6 +44,61 @@ describe('terminal-stream-protocol', () => { }) }) + it('round-trips terminal input and resize frames', () => { + const input = decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 11, + seq: 1, + payload: encodeTerminalStreamText('a') + }) + ) + const resize = decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Resize, + streamId: 11, + seq: 2, + payload: encodeTerminalStreamJson({ cols: 120, rows: 40 }) + }) + ) + + expect(input?.opcode).toBe(TerminalStreamOpcode.Input) + expect(input ? decodeTerminalStreamText(input.payload) : '').toBe('a') + expect(resize?.opcode).toBe(TerminalStreamOpcode.Resize) + expect(resize && decodeTerminalStreamJson(resize.payload)).toEqual({ cols: 120, rows: 40 }) + }) + + it('round-trips multiplex subscribe and unsubscribe frames', () => { + const subscribe = decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 12, + terminal: 'terminal-1', + viewport: { cols: 120, rows: 40 } + }) + }) + ) + const unsubscribe = decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Unsubscribe, + streamId: 12, + seq: 2, + payload: new Uint8Array() + }) + ) + + expect(subscribe?.opcode).toBe(TerminalStreamOpcode.Subscribe) + expect(subscribe && decodeTerminalStreamJson(subscribe.payload)).toMatchObject({ + streamId: 12, + terminal: 'terminal-1' + }) + expect(unsubscribe?.opcode).toBe(TerminalStreamOpcode.Unsubscribe) + expect(unsubscribe?.streamId).toBe(12) + }) + it('rejects unknown frame versions and opcodes', () => { const encoded = encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Output, diff --git a/src/shared/terminal-stream-protocol.ts b/src/shared/terminal-stream-protocol.ts index daa709f415b..1dab528f97e 100644 --- a/src/shared/terminal-stream-protocol.ts +++ b/src/shared/terminal-stream-protocol.ts @@ -8,7 +8,11 @@ export enum TerminalStreamOpcode { SnapshotChunk = 3, SnapshotEnd = 4, Resized = 5, - Error = 6 + Error = 6, + Input = 7, + Resize = 8, + Subscribe = 9, + Unsubscribe = 10 } export type TerminalStreamFrame = { @@ -82,6 +86,10 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode { value === TerminalStreamOpcode.SnapshotChunk || value === TerminalStreamOpcode.SnapshotEnd || value === TerminalStreamOpcode.Resized || - value === TerminalStreamOpcode.Error + value === TerminalStreamOpcode.Error || + value === TerminalStreamOpcode.Input || + value === TerminalStreamOpcode.Resize || + value === TerminalStreamOpcode.Subscribe || + value === TerminalStreamOpcode.Unsubscribe ) } diff --git a/src/shared/types.ts b/src/shared/types.ts index 7a8b1470a9e..0f9624a10c4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -435,6 +435,7 @@ export type PersistedOpenFile = { worktreeId: string language: string isPreview?: boolean + runtimeEnvironmentId?: string } export type WorkspaceSessionState = { @@ -1382,6 +1383,9 @@ export type GlobalSettings = { * configuration surface and edge cases (conflicts with existing paths, * cleanup on worktree delete) are still being worked out. */ experimentalWorktreeSymlinks: boolean + /** Active non-local runtime environment for client-routed RPC. `null` + * preserves the current local desktop behavior. */ + activeRuntimeEnvironmentId?: string | null /** GitHub Project mode state — pinned/recent/active project, last selected * view per project. Optional because profiles created before this feature * landed won't have the key; `getDefaultSettings()` hydrates the empty diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 40bb2345322..d008f625330 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -125,7 +125,8 @@ const persistedOpenFileSchema = z.object({ relativePath: z.string(), worktreeId: z.string(), language: z.string(), - isPreview: z.boolean().optional() + isPreview: z.boolean().optional(), + runtimeEnvironmentId: z.string().optional() }) // ─── Browser ──────────────────────────────────────────────────────── From 029c860add20a116649815e73a18dbaf6415e8cf Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 15 May 2026 02:23:18 -0400 Subject: [PATCH 20/82] Add MCP config inspector (#1901) Co-authored-by: Orca --- .../components/settings/McpConfigSection.tsx | 375 ++++++++++++++++++ .../components/settings/RepositoryPane.tsx | 18 + src/shared/mcp-config.test.ts | 112 ++++++ src/shared/mcp-config.ts | 203 ++++++++++ 4 files changed, 708 insertions(+) create mode 100644 src/renderer/src/components/settings/McpConfigSection.tsx create mode 100644 src/shared/mcp-config.test.ts create mode 100644 src/shared/mcp-config.ts diff --git a/src/renderer/src/components/settings/McpConfigSection.tsx b/src/renderer/src/components/settings/McpConfigSection.tsx new file mode 100644 index 00000000000..7c662c30126 --- /dev/null +++ b/src/renderer/src/components/settings/McpConfigSection.tsx @@ -0,0 +1,375 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { AlertCircle, CheckCircle2, FileCode2, LoaderCircle, Plus, RefreshCw } from 'lucide-react' +import { toast } from 'sonner' +import type { Repo, Worktree } from '../../../../shared/types' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +import { + inspectMcpConfigContent, + MCP_CONFIG_CANDIDATES, + MCP_STARTER_CONFIG, + type McpConfigInspection +} from '../../../../shared/mcp-config' +import { useAppStore } from '../../store' +import { joinPath } from '../../lib/path' +import { extractIpcErrorMessage } from '../../lib/ipc-error' +import { Button } from '../ui/button' + +type LoadedInspection = McpConfigInspection & { + absolutePath: string + readError?: string +} + +type McpConfigSectionProps = { + repo: Repo +} + +const EMPTY_WORKTREES: Worktree[] = [] + +function isMissingFileError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return /ENOENT|no such file|not found/i.test(message) +} + +function isNoFilesystemProviderMessage(message: string | undefined): boolean { + return message ? /no filesystem provider/i.test(message) : false +} + +function countServers(configs: LoadedInspection[]): number { + return configs.reduce((sum, config) => sum + config.servers.length, 0) +} + +function statusLabel(config: LoadedInspection): string { + if (config.readError) { + return 'Unreadable' + } + if (config.status === 'missing') { + return 'Not found' + } + if (config.status === 'invalid') { + return 'Invalid JSON' + } + if (config.servers.length === 0) { + return 'No servers' + } + return `${config.servers.length} server${config.servers.length === 1 ? '' : 's'}` +} + +function statusClassName(config: LoadedInspection): string { + if (config.readError || config.status === 'invalid') { + return 'border-destructive/30 bg-destructive/10 text-destructive' + } + if (config.status === 'valid' && config.servers.length > 0) { + return 'border-border/60 bg-background text-foreground' + } + return 'border-border/60 bg-muted/60 text-muted-foreground' +} + +function serverDetailLabel(server: LoadedInspection['servers'][number]): string { + if (server.transport === 'http') { + return server.url ?? 'HTTP server' + } + if (server.transport === 'stdio') { + return server.command ?? 'stdio server' + } + return server.issue ?? 'Invalid server' +} + +export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Element { + const openFile = useAppStore((state) => state.openFile) + const setActiveView = useAppStore((state) => state.setActiveView) + const setActiveWorktree = useAppStore((state) => state.setActiveWorktree) + const ensureWorktreeRootGroup = useAppStore((state) => state.ensureWorktreeRootGroup) + const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) + const worktreesForRepo = useAppStore((state) => state.worktreesByRepo[repo.id] ?? EMPTY_WORKTREES) + const [configs, setConfigs] = useState([]) + const [loading, setLoading] = useState(true) + const [createConfirm, setCreateConfirm] = useState(false) + + const connectionId = repo.connectionId ?? undefined + const targetWorktree = useMemo(() => { + if (activeWorktreeId && getRepoIdFromWorktreeId(activeWorktreeId) === repo.id) { + return ( + worktreesForRepo.find((worktree) => worktree.id === activeWorktreeId) ?? { + id: activeWorktreeId, + path: repo.path + } + ) + } + return ( + worktreesForRepo.find((worktree) => worktree.isMainWorktree) ?? + worktreesForRepo.find((worktree) => worktree.path === repo.path) ?? + worktreesForRepo[0] ?? { id: `${repo.id}::${repo.path}`, path: repo.path } + ) + }, [activeWorktreeId, repo.id, repo.path, worktreesForRepo]) + const targetWorktreeId = targetWorktree.id + const targetRootPath = targetWorktree.path + const detectedCount = useMemo(() => configs.filter((config) => config.exists).length, [configs]) + const remoteFilesystemUnavailable = useMemo( + () => + Boolean(connectionId) && + configs.length > 0 && + configs.every((config) => isNoFilesystemProviderMessage(config.readError)), + [configs, connectionId] + ) + const visibleConfigs = useMemo( + () => + remoteFilesystemUnavailable + ? [] + : configs.filter( + (config) => config.exists || config.status === 'invalid' || config.readError + ), + [configs, remoteFilesystemUnavailable] + ) + const missingConfigs = useMemo( + () => + configs.filter( + (config) => !config.exists && config.status === 'missing' && !config.readError + ), + [configs] + ) + const serverCount = useMemo(() => countServers(configs), [configs]) + const canCreateStarter = detectedCount === 0 && !remoteFilesystemUnavailable + + const loadConfigs = useCallback(async (): Promise => { + setLoading(true) + const next = await Promise.all( + MCP_CONFIG_CANDIDATES.map(async (candidate): Promise => { + const absolutePath = joinPath(targetRootPath, candidate.relativePath) + try { + const result = await window.api.fs.readFile({ filePath: absolutePath, connectionId }) + const inspection = inspectMcpConfigContent( + candidate, + result.isBinary ? '' : result.content + ) + return { ...inspection, absolutePath } + } catch (error) { + if (isMissingFileError(error)) { + return { ...inspectMcpConfigContent(candidate, null), absolutePath } + } + return { + ...inspectMcpConfigContent(candidate, null), + exists: false, + status: 'invalid', + absolutePath, + readError: extractIpcErrorMessage(error, 'Unable to read config file.') + } + } + }) + ) + setConfigs(next) + setLoading(false) + }, [connectionId, targetRootPath]) + + useEffect(() => { + void loadConfigs() + }, [loadConfigs]) + + const handleOpen = (config: LoadedInspection): void => { + setActiveWorktree(targetWorktreeId) + const targetGroupId = ensureWorktreeRootGroup(targetWorktreeId) + openFile( + { + filePath: config.absolutePath, + relativePath: config.candidate.relativePath, + worktreeId: targetWorktreeId, + language: 'json', + mode: 'edit' + }, + { targetGroupId } + ) + setActiveView('terminal') + } + + const handleCreateStarter = async (): Promise => { + if (!createConfirm) { + setCreateConfirm(true) + window.setTimeout(() => setCreateConfirm(false), 3000) + return + } + + const target = joinPath(targetRootPath, '.mcp.json') + try { + // Why: v1 only creates the root workspace config so we do not need to + // guess per-agent directory layouts or mutate agent-specific files. + await window.api.fs.writeFile({ filePath: target, content: MCP_STARTER_CONFIG, connectionId }) + setCreateConfirm(false) + await loadConfigs() + setActiveWorktree(targetWorktreeId) + const targetGroupId = ensureWorktreeRootGroup(targetWorktreeId) + openFile( + { + filePath: target, + relativePath: '.mcp.json', + worktreeId: targetWorktreeId, + language: 'json', + mode: 'edit' + }, + { targetGroupId } + ) + setActiveView('terminal') + toast.success('MCP config created', { description: '.mcp.json' }) + } catch (error) { + toast.error(extractIpcErrorMessage(error, 'Failed to create MCP config.')) + } + } + + return ( +
+
+
+

MCP Configs

+

+ Inspect MCP server definitions that agents can use while working in this repo. +

+ {repo.connectionId ? ( +

+ SSH repos are read through the remote filesystem. Starter creation is limited to the + workspace root config. +

+ ) : null} +
+
+ + {canCreateStarter ? ( + + ) : null} +
+
+ +
+
+ + {detectedCount} detected · {serverCount} server{serverCount === 1 ? '' : 's'} + + {loading ? : null} +
+
+ {visibleConfigs.length === 0 ? ( +
+ {remoteFilesystemUnavailable ? ( + + ) : ( + + )} + {remoteFilesystemUnavailable ? ( + Connect this SSH repo to inspect or add MCP configs. + ) : ( + + No MCP config found. Add an empty workspace config when you want this repo to + define its own MCP servers. + + )} +
+ ) : ( +
+ {visibleConfigs.map((config) => ( +
+
+ {config.status === 'valid' && !config.readError ? ( + + ) : ( + + )} +
+
+

{config.candidate.label}

+

+ {config.candidate.relativePath} +

+
+
+ + {statusLabel(config)} + + {config.exists ? ( + + ) : null} +
+ + {config.error || config.readError ? ( +

+ {config.readError ?? config.error} +

+ ) : null} + + {config.servers.length > 0 ? ( +
+ {config.servers.map((server) => ( +
+
+
+ {server.name} + + {server.transport} + +
+

+ {serverDetailLabel(server)} +

+ {server.env && Object.keys(server.env).length > 0 ? ( +

+ env:{' '} + {Object.entries(server.env) + .map(([key, value]) => `${key}=${value}`) + .join(', ')} +

+ ) : null} +
+ + {server.status} + +
+ ))} +
+ ) : null} +
+ ))} +
+ )} + + {missingConfigs.length > 0 && !remoteFilesystemUnavailable ? ( +
+

Checked

+
+ {missingConfigs.map((config) => ( + + {config.candidate.relativePath} + + ))} +
+
+ ) : null} +
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index 8f335364bb0..297963ced99 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -10,6 +10,7 @@ import { Trash2 } from 'lucide-react' import { DEFAULT_REPO_HOOK_SETTINGS } from './SettingsConstants' import { BaseRefPicker } from './BaseRefPicker' import { RepositoryHooksSection } from './RepositoryHooksSection' +import { McpConfigSection } from './McpConfigSection' import { WorktreeSymlinksSection } from './WorktreeSymlinksSection' import { SparsePresetSettingsSection } from './SparsePresetSettingsSection' import { SearchableSetting } from './SearchableSetting' @@ -83,6 +84,19 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[ 'node_modules' ] }, + { + title: 'MCP Configs', + description: 'Inspect repo-level MCP server config files.', + keywords: [ + repo.displayName, + 'mcp', + 'model context protocol', + '.mcp.json', + '.cursor/mcp.json', + '.claude.json', + '.claude/mcp.json' + ] + }, { title: 'orca.yaml hooks', description: 'Shared setup and archive hook commands for this repository.', @@ -206,6 +220,7 @@ export function RepositoryPane({ 'Custom GitHub Issue Command' ].includes(entry.title) ) + const mcpEntries = allEntries.filter((entry) => entry.title === 'MCP Configs') const symlinkEntries = allEntries.filter((entry) => entry.title === 'Worktree Symlinks') const visibleSections = [ @@ -313,6 +328,9 @@ export function RepositoryPane({ !isFolder && matchesSettingsSearch(searchQuery, sparsePresetEntries) ? ( ) : null, + !isFolder && matchesSettingsSearch(searchQuery, mcpEntries) ? ( + + ) : null, !isFolder && matchesSettingsSearch(searchQuery, hooksEntries) ? ( { + const workspaceCandidate = MCP_CONFIG_CANDIDATES[0] + + it('reports missing configs', () => { + expect(inspectMcpConfigContent(workspaceCandidate, null)).toMatchObject({ + exists: false, + status: 'missing', + servers: [] + }) + }) + + it('reports invalid JSON without exposing file contents', () => { + const result = inspectMcpConfigContent(workspaceCandidate, '{') + expect(result.status).toBe('invalid') + expect(result.error).toContain('JSON') + expect(result.servers).toEqual([]) + }) + + it('summarizes stdio, http, disabled, and invalid servers', () => { + const result = inspectMcpConfigContent( + workspaceCandidate, + JSON.stringify({ + mcpServers: { + filesystem: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem'], + env: { NODE_ENV: 'production', API_TOKEN: 'secret-token' } + }, + docs: { type: 'http', url: 'https://example.com/mcp' }, + old: { command: 'node', enabled: false }, + broken: { args: ['missing-command'] } + } + }) + ) + + expect(result.status).toBe('valid') + expect(result.servers).toEqual([ + { + name: 'filesystem', + transport: 'stdio', + status: 'enabled', + command: 'npx', + env: { NODE_ENV: 'production', API_TOKEN: '••••••••' } + }, + { + name: 'docs', + transport: 'http', + status: 'enabled', + url: 'https://example.com/mcp' + }, + { + name: 'old', + transport: 'stdio', + status: 'disabled', + command: 'node' + }, + { + name: 'broken', + transport: 'unknown', + status: 'invalid', + issue: 'Missing command or URL.' + } + ]) + }) + + it('supports agent-specific command and URL shapes from common adapters', () => { + const result = inspectMcpConfigContent( + workspaceCandidate, + JSON.stringify({ + mcpServers: { + opencodeLocal: { type: 'local', command: ['uvx', 'server'] }, + geminiRemote: { httpUrl: 'https://example.com/sse' } + } + }) + ) + + expect(result.servers).toMatchObject([ + { name: 'opencodeLocal', transport: 'stdio', command: 'uvx' }, + { name: 'geminiRemote', transport: 'http', url: 'https://example.com/sse' } + ]) + }) + + it('masks env values that look sensitive by key or value', () => { + expect( + maskMcpEnv({ + NORMAL: 'visible', + PASSWORD: 'hunter2', + MAYBE: 'sk-abc123456789xyz' + }) + ).toEqual({ + NORMAL: 'visible', + PASSWORD: '••••••••', + MAYBE: '••••••••' + }) + }) + + it('keeps starter config valid and empty', () => { + expect(inspectMcpConfigContent(workspaceCandidate, MCP_STARTER_CONFIG)).toMatchObject({ + exists: true, + status: 'valid', + servers: [] + }) + }) +}) diff --git a/src/shared/mcp-config.ts b/src/shared/mcp-config.ts new file mode 100644 index 00000000000..5a81ac2b267 --- /dev/null +++ b/src/shared/mcp-config.ts @@ -0,0 +1,203 @@ +export type McpConfigFormat = 'workspace' | 'cursor' | 'claude' + +export type McpConfigCandidate = { + format: McpConfigFormat + label: string + relativePath: string + serversPath: string[] +} + +export type McpServerTransport = 'stdio' | 'http' | 'unknown' +export type McpServerStatus = 'enabled' | 'disabled' | 'invalid' + +export type McpServerSummary = { + name: string + transport: McpServerTransport + status: McpServerStatus + command?: string + url?: string + env?: Record + issue?: string +} + +export type McpConfigInspection = { + candidate: McpConfigCandidate + exists: boolean + status: 'missing' | 'valid' | 'invalid' + servers: McpServerSummary[] + error?: string +} + +export const MCP_CONFIG_CANDIDATES: McpConfigCandidate[] = [ + { + format: 'workspace', + label: 'Workspace', + relativePath: '.mcp.json', + serversPath: ['mcpServers'] + }, + { + format: 'cursor', + label: 'Cursor', + relativePath: '.cursor/mcp.json', + serversPath: ['mcpServers'] + }, + { + format: 'claude', + label: 'Claude', + relativePath: '.claude.json', + serversPath: ['mcpServers'] + }, + { + format: 'claude', + label: 'Claude workspace', + relativePath: '.claude/mcp.json', + serversPath: ['mcpServers'] + } +] + +export const MCP_STARTER_CONFIG = `{ + "mcpServers": {} +} +` + +const SENSITIVE_ENV_KEY_PATTERN = + /(api[_-]?key|auth|bearer|cookie|credential|password|private[_-]?key|secret|session|token)/i +const SENSITIVE_ENV_VALUE_PATTERN = + /(sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|xox[baprs]-[A-Za-z0-9-]{12,})/ + +export function inspectMcpConfigContent( + candidate: McpConfigCandidate, + content: string | null +): McpConfigInspection { + if (content === null) { + return { candidate, exists: false, status: 'missing', servers: [] } + } + + let parsed: unknown + try { + parsed = JSON.parse(content) + } catch (error) { + return { + candidate, + exists: true, + status: 'invalid', + servers: [], + error: error instanceof Error ? error.message : 'Invalid JSON' + } + } + + const rawServers = extractObjectAtPath(parsed, candidate.serversPath) + if (!rawServers) { + return { candidate, exists: true, status: 'valid', servers: [] } + } + + return { + candidate, + exists: true, + status: 'valid', + servers: Object.entries(rawServers).map(([name, entry]) => summarizeMcpServer(name, entry)) + } +} + +export function maskMcpEnv(env: unknown): Record | undefined { + if (!env || typeof env !== 'object' || Array.isArray(env)) { + return undefined + } + + const masked: Record = {} + for (const [key, rawValue] of Object.entries(env)) { + const value = typeof rawValue === 'string' ? rawValue : String(rawValue) + masked[key] = + SENSITIVE_ENV_KEY_PATTERN.test(key) || SENSITIVE_ENV_VALUE_PATTERN.test(value) + ? '••••••••' + : value + } + return masked +} + +function extractObjectAtPath( + value: unknown, + pathSegments: string[] +): Record | null { + let current = value + for (const segment of pathSegments) { + if (!current || typeof current !== 'object' || Array.isArray(current)) { + return null + } + current = (current as Record)[segment] + } + return current && typeof current === 'object' && !Array.isArray(current) + ? (current as Record) + : null +} + +function summarizeMcpServer(name: string, entry: unknown): McpServerSummary { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return { + name, + transport: 'unknown', + status: 'invalid', + issue: 'Server entry must be an object.' + } + } + + const raw = entry as Record + const command = readCommand(raw) + const url = readUrl(raw) + const transport = resolveTransport(raw, command, url) + const enabled = raw.enabled !== false && raw.disabled !== true + const env = maskMcpEnv(raw.env) + + if (transport === 'unknown') { + return { + name, + transport, + status: 'invalid', + env, + issue: 'Missing command or URL.' + } + } + + return { + name, + transport, + status: enabled ? 'enabled' : 'disabled', + command, + url, + env + } +} + +function readCommand(raw: Record): string | undefined { + if (typeof raw.command === 'string') { + return raw.command + } + if (Array.isArray(raw.command) && typeof raw.command[0] === 'string') { + return raw.command[0] + } + return undefined +} + +function readUrl(raw: Record): string | undefined { + if (typeof raw.url === 'string') { + return raw.url + } + if (typeof raw.httpUrl === 'string') { + return raw.httpUrl + } + return undefined +} + +function resolveTransport( + raw: Record, + command: string | undefined, + url: string | undefined +): McpServerTransport { + if (raw.type === 'http' || raw.type === 'remote' || url) { + return 'http' + } + if (raw.type === 'local' || command) { + return 'stdio' + } + return 'unknown' +} From dc83da70360e85d3554271ba8721170345d986de Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 14 May 2026 23:26:12 -0700 Subject: [PATCH 21/82] Add workspace status board (#1902) * Add workspace status board * Position workspace board beside sidebar * Simplify workspace drag target highlighting * Tune workspace board animation and opacity * Add workspace status visual customization * Improve workspace board interactions * Polish compact workspace cards * Fix workspace board drawer focus * Limit workspace drop listeners * Toggle workspace board from header * Update worktree status merge test --- src/main/ipc/worktree-logic.test.ts | 9 +- src/main/ipc/worktree-logic.ts | 2 + src/main/persistence.ts | 39 +- src/renderer/src/assets/main.css | 35 ++ .../src/components/sidebar/SidebarHeader.tsx | 258 ++++++----- .../sidebar/WorkspaceKanbanCard.tsx | 145 ++++++ .../sidebar/WorkspaceKanbanDrawer.tsx | 430 ++++++++++++++++++ .../sidebar/WorkspaceKanbanSettingsMenu.tsx | 156 +++++++ .../WorkspaceStatusAppearancePopover.tsx | 106 +++++ .../src/components/sidebar/WorktreeCard.tsx | 164 +------ .../sidebar/WorktreeContextMenu.tsx | 56 +++ .../src/components/sidebar/WorktreeList.tsx | 219 ++++++++- .../sidebar/use-workspace-status-drop.ts | 81 ++++ .../sidebar/use-worktree-activity-status.ts | 93 ++++ .../components/sidebar/workspace-status.ts | 232 ++++++++++ .../sidebar/worktree-list-groups.test.ts | 76 +++- .../sidebar/worktree-list-groups.ts | 128 +++--- src/renderer/src/components/ui/sheet.tsx | 6 +- src/renderer/src/store/slices/ui.test.ts | 18 + src/renderer/src/store/slices/ui.ts | 37 ++ src/renderer/src/store/slices/worktrees.ts | 1 + src/shared/constants.ts | 4 + src/shared/types.ts | 15 + src/shared/workspace-statuses.ts | 201 ++++++++ 24 files changed, 2160 insertions(+), 351 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx create mode 100644 src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx create mode 100644 src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx create mode 100644 src/renderer/src/components/sidebar/WorkspaceStatusAppearancePopover.tsx create mode 100644 src/renderer/src/components/sidebar/use-workspace-status-drop.ts create mode 100644 src/renderer/src/components/sidebar/use-worktree-activity-status.ts create mode 100644 src/renderer/src/components/sidebar/workspace-status.ts create mode 100644 src/shared/workspace-statuses.ts diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index 36927838fca..77073c29544 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -221,7 +221,9 @@ describe('mergeWorktree', () => { isUnread: true, isPinned: true, sortOrder: 5, - lastActivityAt: 1000 + lastActivityAt: 1000, + workspaceStatus: 'in-review', + diffComments: [] } const result = mergeWorktree('repo1', baseGit, meta) expect(result).toEqual({ @@ -243,7 +245,9 @@ describe('mergeWorktree', () => { isUnread: true, isPinned: true, sortOrder: 5, - lastActivityAt: 1000 + lastActivityAt: 1000, + workspaceStatus: 'in-review', + diffComments: [] }) }) @@ -258,6 +262,7 @@ describe('mergeWorktree', () => { expect(result.isPinned).toBe(false) expect(result.sortOrder).toBe(0) expect(result.lastActivityAt).toBe(0) + expect(result.workspaceStatus).toBe('in-progress') }) it('strips refs/heads/ prefix from branch for display name', () => { diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index fe2c086fc61..266718fb96f 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -1,6 +1,7 @@ import { basename, join, resolve, relative, isAbsolute, posix, win32 } from 'path' import type { GitWorktreeInfo, Worktree, WorktreeMeta } from '../../shared/types' import { splitWorktreeId } from '../../shared/worktree-id' +import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { getWslHome, parseWslPath } from '../wsl' /** @@ -205,6 +206,7 @@ export function mergeWorktree( : {}), ...(meta?.baseRef !== undefined ? { baseRef: meta.baseRef } : {}), ...(meta?.pushTarget !== undefined ? { pushTarget: meta.pushTarget } : {}), + workspaceStatus: meta?.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, // Why: diff comments are persisted on WorktreeMeta (see `WorktreeMeta` in // shared/types) and forwarded verbatim so the renderer store mirrors // on-disk state. `undefined` here means the worktree has no comments yet. diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 2d09b3c9086..28e2804a31b 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -57,6 +57,12 @@ import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history' import { getRepoIdFromWorktreeId } from '../shared/worktree-id' import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands' +import { + DEFAULT_WORKSPACE_STATUS_ID, + clampWorkspaceBoardOpacity, + normalizeWorkspaceBoardCompact, + normalizeWorkspaceStatuses +} from '../shared/workspace-statuses' function encrypt(plaintext: string): string { if (!plaintext || !safeStorage.isEncryptionAvailable()) { @@ -132,6 +138,16 @@ function backupPath(dataFile: string, index: number): string { return `${dataFile}.bak.${index}` } +function normalizeGroupBy(groupBy: unknown): PersistedState['ui']['groupBy'] { + if (groupBy === 'none' || groupBy === 'repo' || groupBy === 'pr-status') { + return groupBy + } + if (groupBy === 'workspace-status') { + return 'none' + } + return getDefaultUIState().groupBy +} + function normalizeSortBy(sortBy: unknown): 'name' | 'smart' | 'recent' | 'repo' { if (sortBy === 'smart' || sortBy === 'recent' || sortBy === 'repo' || sortBy === 'name') { return sortBy @@ -1195,7 +1211,11 @@ export class Store { return { ...getDefaultUIState(), ...this.state.ui, - sortBy: normalizeSortBy(this.state.ui?.sortBy) + groupBy: normalizeGroupBy(this.state.ui?.groupBy), + sortBy: normalizeSortBy(this.state.ui?.sortBy), + workspaceStatuses: normalizeWorkspaceStatuses(this.state.ui?.workspaceStatuses), + workspaceBoardOpacity: clampWorkspaceBoardOpacity(this.state.ui?.workspaceBoardOpacity), + workspaceBoardCompact: normalizeWorkspaceBoardCompact(this.state.ui?.workspaceBoardCompact) } } @@ -1203,9 +1223,21 @@ export class Store { this.state.ui = { ...this.state.ui, ...updates, + groupBy: updates.groupBy + ? normalizeGroupBy(updates.groupBy) + : normalizeGroupBy(this.state.ui?.groupBy), sortBy: updates.sortBy ? normalizeSortBy(updates.sortBy) - : normalizeSortBy(this.state.ui?.sortBy) + : normalizeSortBy(this.state.ui?.sortBy), + workspaceStatuses: normalizeWorkspaceStatuses( + updates.workspaceStatuses ?? this.state.ui?.workspaceStatuses + ), + workspaceBoardOpacity: clampWorkspaceBoardOpacity( + updates.workspaceBoardOpacity ?? this.state.ui?.workspaceBoardOpacity + ), + workspaceBoardCompact: normalizeWorkspaceBoardCompact( + updates.workspaceBoardCompact ?? this.state.ui?.workspaceBoardCompact + ) } this.scheduleSave() } @@ -1706,6 +1738,7 @@ function getDefaultWorktreeMeta(): WorktreeMeta { isUnread: false, isPinned: false, sortOrder: Date.now(), - lastActivityAt: 0 + lastActivityAt: 0, + workspaceStatus: DEFAULT_WORKSPACE_STATUS_ID } } diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 8504cdd746e..1e206c0f92c 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -728,6 +728,41 @@ overflow: hidden; } +/* Why: the workspace board is offset to the sidebar edge. The generic left + sheet translates by its full width, which makes an offset board fly in from + far offscreen; this reveals it from the sidebar boundary instead. */ +.workspace-kanban-sheet-content[data-state='open'] { + animation-name: workspace-kanban-sheet-in; + animation-duration: 300ms; + animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); +} + +.workspace-kanban-sheet-content[data-state='closed'] { + animation-name: workspace-kanban-sheet-out; + animation-duration: 200ms; + animation-timing-function: cubic-bezier(0.7, 0, 0.84, 0); +} + +@keyframes workspace-kanban-sheet-in { + from { + clip-path: inset(0 100% 0 0); + } + + to { + clip-path: inset(0 0 0 0); + } +} + +@keyframes workspace-kanban-sheet-out { + from { + clip-path: inset(0 0 0 0); + } + + to { + clip-path: inset(0 100% 0 0); + } +} + .sidebar-header { padding: 12px 16px 8px; font-size: 11px; diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index e84bde04b85..8c1d4e7d320 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -1,5 +1,5 @@ -import React from 'react' -import { Plus, SlidersHorizontal } from 'lucide-react' +import React, { useState } from 'react' +import { Kanban, Plus, SlidersHorizontal } from 'lucide-react' import { useAppStore } from '@/store' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' @@ -17,10 +17,11 @@ import { } from '@/components/ui/dropdown-menu' import type { WorktreeCardProperty } from '../../../../shared/types' import SidebarFilter from './SidebarFilter' +import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer' const GROUP_BY_OPTIONS = [ - { id: 'none', label: 'All' }, - { id: 'pr-status', label: 'PR Status' }, + { id: 'none', label: 'Status' }, + { id: 'pr-status', label: 'PR' }, { id: 'repo', label: 'Repo' } ] as const @@ -52,6 +53,7 @@ const isMac = navigator.userAgent.includes('Mac') const newWorktreeShortcutLabel = isMac ? '⌘N' : 'Ctrl+N' const SidebarHeader = React.memo(function SidebarHeader() { + const [workspaceBoardOpen, setWorkspaceBoardOpen] = useState(false) const openModal = useAppStore((s) => s.openModal) const repos = useAppStore((s) => s.repos) const canCreateWorktree = repos.some((repo) => isGitRepoKind(repo)) @@ -63,129 +65,151 @@ const SidebarHeader = React.memo(function SidebarHeader() { const groupBy = useAppStore((s) => s.groupBy) const setGroupBy = useAppStore((s) => s.setGroupBy) return ( -
- - Workspaces - -
- - + <> +
+
+ + Workspaces + - - - + - View options + Workspace board - - Group by -
- { - if (v) { - setGroupBy(v as typeof groupBy) +
+
+ + + + + + + + + + View options + + + + Group by +
+ { + if (v) { + setGroupBy(v as typeof groupBy) + } + }} + variant="outline" + size="sm" + className="h-6 w-full justify-start" + > + {GROUP_BY_OPTIONS.map((opt) => ( + + {opt.label} + + ))} + +
+ + + Sort by + setSortBy(v as typeof sortBy)} + > + {SORT_OPTIONS.map((opt) => { + const radioItem = ( + e.preventDefault()} + > + {opt.label} + + ) + if (!opt.description) { + return radioItem } + return ( + + {radioItem} + + {opt.description} + + + ) + })} + + + + Show properties + {PROPERTY_OPTIONS.map((opt) => ( + toggleWorktreeCardProperty(opt.id)} + onSelect={(e) => e.preventDefault()} + > + {opt.label} + + ))} +
+
+ + + +
- - - Sort by - setSortBy(v as typeof sortBy)} - > - {SORT_OPTIONS.map((opt) => { - const radioItem = ( - e.preventDefault()} - > - {opt.label} - - ) - if (!opt.description) { - return radioItem - } - return ( - - {radioItem} - - {opt.description} - - - ) - })} - - - - Show properties - {PROPERTY_OPTIONS.map((opt) => ( - toggleWorktreeCardProperty(opt.id)} - onSelect={(e) => e.preventDefault()} - > - {opt.label} - - ))} -
- - - - - - - - {canCreateWorktree - ? `New workspace (${newWorktreeShortcutLabel})` - : 'Add a Git project to create worktrees'} - - + + + + + {canCreateWorktree + ? `New workspace (${newWorktreeShortcutLabel})` + : 'Add a Git project to create worktrees'} + + +
-
+ + ) }) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx new file mode 100644 index 00000000000..adde53c34c6 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx @@ -0,0 +1,145 @@ +import React, { useCallback } from 'react' +import { Pin } from 'lucide-react' +import { useAppStore } from '@/store' +import { Badge } from '@/components/ui/badge' +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { getWorktreeStatusLabel } from '@/lib/worktree-status' +import { cn } from '@/lib/utils' +import type { Repo, Worktree } from '../../../../shared/types' +import StatusIndicator from './StatusIndicator' +import WorktreeCard from './WorktreeCard' +import WorktreeContextMenu from './WorktreeContextMenu' +import { useWorktreeActivityStatus } from './use-worktree-activity-status' +import { writeWorkspaceDragData } from './workspace-status' + +type WorkspaceKanbanCardProps = { + worktree: Worktree + repo: Repo | undefined + isActive: boolean + compact: boolean + onActivate: () => void +} + +export default function WorkspaceKanbanCard({ + worktree, + repo, + isActive, + compact, + onActivate +}: WorkspaceKanbanCardProps): React.JSX.Element { + if (compact) { + return ( + + ) + } + + return ( +
+ {worktree.isPinned ? ( + + + Pinned + + ) : null} + +
+ ) +} + +function WorkspaceKanbanCompactCard({ + worktree, + repo, + isActive, + onActivate +}: Omit): React.JSX.Element { + const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id]) + const isDeleting = deleteState?.isDeleting ?? false + const status = useWorktreeActivityStatus(worktree.id) + + const handleActivate = useCallback(() => { + if (isDeleting) { + return + } + activateAndRevealWorktree(worktree.id) + onActivate() + }, [isDeleting, onActivate, worktree.id]) + + const handleDragStart = useCallback( + (event: React.DragEvent) => { + if (isDeleting) { + event.preventDefault() + return + } + writeWorkspaceDragData(event.dataTransfer, worktree.id) + }, + [isDeleting, worktree.id] + ) + + return ( + + + + + + + + + + + ) +} diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx new file mode 100644 index 00000000000..51bab89d488 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx @@ -0,0 +1,430 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { useAllWorktrees, useRepoMap } from '@/store/selectors' +import { cn } from '@/lib/utils' +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle +} from '@/components/ui/sheet' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { LayoutList, Pin, Rows3, X } from 'lucide-react' +import WorkspaceKanbanCard from './WorkspaceKanbanCard' +import WorkspaceKanbanSettingsMenu from './WorkspaceKanbanSettingsMenu' +import { + getWorkspaceStatus, + hasWorkspaceDragData, + readWorkspaceDragData, + getWorkspaceStatusVisualMeta +} from './workspace-status' +import { useWorkspaceStatusDocumentDrop } from './use-workspace-status-drop' +import type { WorkspaceStatus, Worktree } from '../../../../shared/types' +import { makeWorkspaceStatusId } from '../../../../shared/workspace-statuses' + +type WorkspaceKanbanDrawerProps = { + open: boolean + onOpenChange: (open: boolean) => void +} + +function sortBoardWorktrees(a: Worktree, b: Worktree): number { + return b.lastActivityAt - a.lastActivityAt || a.displayName.localeCompare(b.displayName) +} + +export default function WorkspaceKanbanDrawer({ + open, + onOpenChange +}: WorkspaceKanbanDrawerProps): React.JSX.Element { + const allWorktrees = useAllWorktrees() + const repoMap = useRepoMap() + const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) + const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) + const setWorkspaceStatuses = useAppStore((s) => s.setWorkspaceStatuses) + const workspaceBoardOpacity = useAppStore((s) => s.workspaceBoardOpacity) + const setWorkspaceBoardOpacity = useAppStore((s) => s.setWorkspaceBoardOpacity) + const workspaceBoardCompact = useAppStore((s) => s.workspaceBoardCompact) + const setWorkspaceBoardCompact = useAppStore((s) => s.setWorkspaceBoardCompact) + const sidebarOpen = useAppStore((s) => s.sidebarOpen) + const sidebarWidth = useAppStore((s) => s.sidebarWidth) + const boardRef = useRef(null) + const [dragOverStatus, setDragOverStatus] = useState(null) + const [pinDragOver, setPinDragOver] = useState(false) + + const worktreesByStatus = useMemo(() => { + const grouped = new Map( + workspaceStatuses.map((status) => [status.id, []]) + ) + for (const worktree of allWorktrees) { + if (worktree.isArchived) { + continue + } + grouped.get(getWorkspaceStatus(worktree, workspaceStatuses))!.push(worktree) + } + for (const items of grouped.values()) { + items.sort((a, b) => Number(b.isPinned) - Number(a.isPinned) || sortBoardWorktrees(a, b)) + } + return grouped + }, [allWorktrees, workspaceStatuses]) + + const moveWorktreeToStatus = useCallback( + (worktreeId: string, status: WorkspaceStatus) => { + const current = allWorktrees.find((worktree) => worktree.id === worktreeId) + if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) { + return + } + void updateWorktreeMeta(worktreeId, { workspaceStatus: status }) + }, + [allWorktrees, updateWorktreeMeta, workspaceStatuses] + ) + + const pinWorktree = useCallback( + (worktreeId: string) => { + const current = allWorktrees.find((worktree) => worktree.id === worktreeId) + if (!current || current.isPinned) { + return + } + void updateWorktreeMeta(worktreeId, { isPinned: true }) + }, + [allWorktrees, updateWorktreeMeta] + ) + + const handleDragOver = useCallback((event: React.DragEvent, status: WorkspaceStatus) => { + if (!hasWorkspaceDragData(event.dataTransfer)) { + return + } + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setDragOverStatus(status) + }, []) + + const handleDragLeave = useCallback((event: React.DragEvent) => { + const relatedTarget = event.relatedTarget + if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { + return + } + setDragOverStatus(null) + }, []) + + const handlePinDragOver = useCallback((event: React.DragEvent) => { + if (!hasWorkspaceDragData(event.dataTransfer)) { + return + } + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setPinDragOver(true) + }, []) + + const handlePinDragLeave = useCallback((event: React.DragEvent) => { + const relatedTarget = event.relatedTarget + if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { + return + } + setPinDragOver(false) + }, []) + + const handleDragFinish = useCallback(() => { + setDragOverStatus(null) + setPinDragOver(false) + }, []) + + const handleDrop = useCallback( + (event: React.DragEvent, status: WorkspaceStatus) => { + const worktreeId = readWorkspaceDragData(event.dataTransfer) + if (!worktreeId) { + return + } + event.preventDefault() + setDragOverStatus(null) + moveWorktreeToStatus(worktreeId, status) + }, + [moveWorktreeToStatus] + ) + + const handleWorktreeActivate = useCallback(() => { + onOpenChange(false) + }, [onOpenChange]) + + const handleOpacityChange = useCallback( + (event: React.ChangeEvent) => { + setWorkspaceBoardOpacity(Number(event.target.value) / 100) + }, + [setWorkspaceBoardOpacity] + ) + + const handleRenameStatus = useCallback( + (statusId: string, label: string) => { + const trimmed = label.trim() + if (!trimmed) { + return + } + setWorkspaceStatuses( + workspaceStatuses.map((status) => + status.id === statusId ? { ...status, label: trimmed } : status + ) + ) + }, + [setWorkspaceStatuses, workspaceStatuses] + ) + + const handleChangeStatusColor = useCallback( + (statusId: string, color: string) => { + setWorkspaceStatuses( + workspaceStatuses.map((status) => (status.id === statusId ? { ...status, color } : status)) + ) + }, + [setWorkspaceStatuses, workspaceStatuses] + ) + + const handleChangeStatusIcon = useCallback( + (statusId: string, icon: string) => { + setWorkspaceStatuses( + workspaceStatuses.map((status) => (status.id === statusId ? { ...status, icon } : status)) + ) + }, + [setWorkspaceStatuses, workspaceStatuses] + ) + + const handleMoveStatus = useCallback( + (statusId: string, direction: -1 | 1) => { + const index = workspaceStatuses.findIndex((status) => status.id === statusId) + const nextIndex = index + direction + if (index === -1 || nextIndex < 0 || nextIndex >= workspaceStatuses.length) { + return + } + const next = [...workspaceStatuses] + const [moved] = next.splice(index, 1) + next.splice(nextIndex, 0, moved) + setWorkspaceStatuses(next) + }, + [setWorkspaceStatuses, workspaceStatuses] + ) + + const handleAddStatus = useCallback(() => { + const label = `Status ${workspaceStatuses.length + 1}` + setWorkspaceStatuses([ + ...workspaceStatuses, + { id: makeWorkspaceStatusId(label, workspaceStatuses), label } + ]) + }, [setWorkspaceStatuses, workspaceStatuses]) + + const handleRemoveStatus = useCallback( + (statusId: string) => { + if (workspaceStatuses.length <= 1) { + return + } + const index = workspaceStatuses.findIndex((status) => status.id === statusId) + if (index === -1) { + return + } + const next = workspaceStatuses.filter((status) => status.id !== statusId) + const fallbackStatus = next[Math.min(index, next.length - 1)]?.id ?? next[0]!.id + setWorkspaceStatuses(next) + for (const worktree of allWorktrees) { + if (getWorkspaceStatus(worktree, workspaceStatuses) === statusId) { + void updateWorktreeMeta(worktree.id, { workspaceStatus: fallbackStatus }) + } + } + }, + [allWorktrees, setWorkspaceStatuses, updateWorktreeMeta, workspaceStatuses] + ) + + useWorkspaceStatusDocumentDrop( + boardRef, + moveWorktreeToStatus, + pinWorktree, + handleDragFinish, + open + ) + + useEffect(() => { + if (!open) { + return + } + + const handlePointerDown = (event: PointerEvent): void => { + const content = boardRef.current?.closest('[data-slot="sheet-content"]') + if (!content) { + return + } + const target = event.target + if (target instanceof Node && content.contains(target)) { + return + } + const rect = content.getBoundingClientRect() + if (event.clientX > rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom) { + onOpenChange(false) + } + } + + document.addEventListener('pointerdown', handlePointerDown, true) + return () => document.removeEventListener('pointerdown', handlePointerDown, true) + }, [onOpenChange, open]) + + const opacityPercent = Math.round(workspaceBoardOpacity * 100) + const drawerLeft = sidebarOpen ? sidebarWidth : 0 + const BoardModeIcon = workspaceBoardCompact ? Rows3 : LayoutList + + return ( + + { + // Why: Radix focuses the first toolbar button on open, which opens + // its tooltip without hover and makes the drawer feel noisy. + event.preventDefault() + }} + onInteractOutside={(event) => { + const originalEvent = event.detail.originalEvent + if (originalEvent instanceof PointerEvent && originalEvent.clientX < drawerLeft) { + // Why: users need to scroll, click, and drag from the workspace + // sidebar while the companion board stays open. + event.preventDefault() + } + }} + > + + Workspace board + + Organize workspaces by status and open workspace cards. + + + +
+ + + + + + {workspaceBoardCompact ? 'Show detailed cards' : 'Show compact cards'} + + + + + + +
+ +
+
+ + Pinned + Drop here to pin without changing status. +
+ +
+
+ {workspaceStatuses.map((status) => { + const meta = getWorkspaceStatusVisualMeta(status) + const items = worktreesByStatus.get(status.id) ?? [] + const isDragTarget = dragOverStatus === status.id + + return ( +
handleDragOver(event, status.id)} + onDragLeave={handleDragLeave} + onDrop={(event) => handleDrop(event, status.id)} + > +
+ +
+ {status.label} +
+
+ {items.length} +
+
+ +
+ {items.length > 0 ? ( +
+ {items.map((worktree) => ( + + ))} +
+ ) : ( +
+ Empty +
+ )} +
+
+ ) + })} +
+
+
+
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx new file mode 100644 index 00000000000..dfc85908acc --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx @@ -0,0 +1,156 @@ +import React from 'react' +import { ArrowDown, ArrowUp, Plus, Settings2, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' +import type { WorkspaceStatusDefinition } from '../../../../shared/types' +import { getWorkspaceStatusVisualMeta } from './workspace-status' +import WorkspaceStatusAppearancePopover from './WorkspaceStatusAppearancePopover' + +type WorkspaceKanbanSettingsMenuProps = { + opacityPercent: number + workspaceStatuses: readonly WorkspaceStatusDefinition[] + onOpacityChange: (event: React.ChangeEvent) => void + onRenameStatus: (statusId: string, label: string) => void + onChangeStatusColor: (statusId: string, color: string) => void + onChangeStatusIcon: (statusId: string, icon: string) => void + onMoveStatus: (statusId: string, direction: -1 | 1) => void + onRemoveStatus: (statusId: string) => void + onAddStatus: () => void +} + +export default function WorkspaceKanbanSettingsMenu({ + opacityPercent, + workspaceStatuses, + onOpacityChange, + onRenameStatus, + onChangeStatusColor, + onChangeStatusIcon, + onMoveStatus, + onRemoveStatus, + onAddStatus +}: WorkspaceKanbanSettingsMenuProps): React.JSX.Element { + return ( + + + + + { + const target = event.target + if ( + target instanceof Element && + target.closest('[data-workspace-status-appearance-popover]') + ) { + event.preventDefault() + } + }} + > + Board opacity +
+
+ Pane opacity + {opacityPercent}% +
+ +
+ + Statuses +
+ {workspaceStatuses.map((status, index) => { + const meta = getWorkspaceStatusVisualMeta(status) + return ( +
+
+ + onRenameStatus(status.id, event.target.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === 'Enter') { + event.currentTarget.blur() + } + }} + className="h-7 min-w-0 flex-1 rounded-md border border-input bg-background px-2 text-[12px] text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring" + aria-label={`Rename ${status.label}`} + /> + + + + +
+
+ ) + })} + +
+
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorkspaceStatusAppearancePopover.tsx b/src/renderer/src/components/sidebar/WorkspaceStatusAppearancePopover.tsx new file mode 100644 index 00000000000..cce501faf84 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkspaceStatusAppearancePopover.tsx @@ -0,0 +1,106 @@ +import React from 'react' +import { Button } from '@/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import type { WorkspaceStatusDefinition } from '../../../../shared/types' +import { + WORKSPACE_STATUS_COLOR_OPTIONS, + WORKSPACE_STATUS_ICON_OPTIONS, + getWorkspaceStatusVisualMeta +} from './workspace-status' + +type WorkspaceStatusAppearancePopoverProps = { + status: WorkspaceStatusDefinition + onChangeColor: (statusId: string, color: string) => void + onChangeIcon: (statusId: string, icon: string) => void +} + +export default function WorkspaceStatusAppearancePopover({ + status, + onChangeColor, + onChangeIcon +}: WorkspaceStatusAppearancePopoverProps): React.JSX.Element { + const meta = getWorkspaceStatusVisualMeta(status) + + return ( + + + + + + + + + Appearance + + + + event.preventDefault()} + > +
Color
+
+ {WORKSPACE_STATUS_COLOR_OPTIONS.map((color) => ( + + + + + + {color.label} + + + ))} +
+ +
Icon
+
+ {WORKSPACE_STATUS_ICON_OPTIONS.map((icon) => ( + + + + + + {icon.label} + + + ))} +
+
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 646506caf78..a444bf39e19 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -1,6 +1,5 @@ /* eslint-disable max-lines -- Why: the worktree card centralizes sidebar card state (selection, drag, agent status, git info, context menu) in one cohesive component so sidebar rendering doesn't fan out across files. */ -import React, { useEffect, useMemo, useCallback, useState } from 'react' -import { useShallow } from 'zustand/react/shallow' +import React, { useEffect, useCallback, useState } from 'react' import { useAppStore } from '@/store' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' import { Badge } from '@/components/ui/badge' @@ -22,13 +21,7 @@ import { SshDisconnectedDialog } from './SshDisconnectedDialog' import WorktreeCardAgents from './WorktreeCardAgents' import { cn } from '@/lib/utils' import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { - getWorktreeStatusLabel, - resolveWorktreeStatus, - type WorktreeStatus -} from '@/lib/worktree-status' -import { isExplicitAgentStatusFresh } from '@/lib/agent-status' -import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' +import { getWorktreeStatusLabel } from '@/lib/worktree-status' import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import type { Worktree, Repo, IssueInfo } from '../../../../shared/types' @@ -36,15 +29,11 @@ import { branchDisplayName, checksLabel, CONFLICT_OPERATION_LABELS, - EMPTY_TABS, - EMPTY_BROWSER_TABS, FilledBellIcon } from './WorktreeCardHelpers' import { IssueSection, ReviewSection, CommentSection } from './WorktreeCardMeta' -import { - selectLivePtyIdsForWorktree, - selectRuntimePaneTitlesForWorktree -} from './worktree-card-status-inputs' +import { writeWorkspaceDragData } from './workspace-status' +import { useWorktreeActivityStatus } from './use-worktree-activity-status' type WorktreeCardProps = { worktree: Worktree @@ -53,6 +42,7 @@ type WorktreeCardProps = { isMultiSelected?: boolean selectedWorktrees?: readonly Worktree[] hideRepoBadge?: boolean + onActivate?: () => void onSelectionGesture?: (event: React.MouseEvent, worktreeId: string) => boolean onContextMenuSelect?: (event: React.MouseEvent) => readonly Worktree[] } @@ -68,6 +58,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ isActive, isMultiSelected = false, selectedWorktrees, + onActivate, onSelectionGesture, onContextMenuSelect, hideRepoBadge @@ -152,29 +143,6 @@ const WorktreeCard = React.memo(function WorktreeCard({ repo?.connectionId ? (s.sshTargetLabels.get(repo.connectionId) ?? '') : '' ) - // ── GRANULAR selectors: only subscribe to THIS worktree's data ── - const tabs = useAppStore((s) => s.tabsByWorktree[worktree.id] ?? EMPTY_TABS) - const browserTabs = useAppStore((s) => s.browserTabsByWorktree[worktree.id] ?? EMPTY_BROWSER_TABS) - const hasNotesSurface = useAppStore((s) => - (s.unifiedTabsByWorktree[worktree.id] ?? []).some((tab) => tab.contentType === 'notes') - ) - // Why: keep these as separate shallow selectors. Combining them into one - // returned object nests freshly-created maps under fresh keys, so Zustand's - // shallow memoization sees every unrelated store write as a change and - // re-renders every mounted card. - // tab.ptyId is the wake-hint sessionId preserved across sleep, not a - // liveness signal; sleep-then-card-render would lie the dot green if we - // read tab.ptyId; ptyIdsByTabId is the source of truth. - // tab.title is the focused-pane title (onActivePaneChange overwrites it), - // so the per-pane title map is needed to keep the sidebar spinner - // reflecting *any* working pane, not just the focused one. - const runtimePaneTitlesForWorktree = useAppStore( - useShallow((s) => selectRuntimePaneTitlesForWorktree(s, worktree.id)) - ) - const ptyIdsForWorktree = useAppStore( - useShallow((s) => selectLivePtyIdsForWorktree(s, worktree.id)) - ) - const branch = branchDisplayName(worktree.branch) const isFolder = repo ? isFolderRepo(repo) : false const hostedReviewCacheKey = @@ -207,109 +175,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ : null) const isDeleting = deleteState?.isDeleting ?? false - // Why: the sidebar dot overlays the *stable* hook-reported states (blocked, - // waiting, done) onto the title-heuristic base. `working` remains on the - // heuristic because hook pings flip on/off mid-turn and users complained - // that the spinner flickered; the blocked/waiting/done states don't have - // that problem — they're terminal (done) or attention-needed (blocked/ - // waiting) and persist until the user acts. Retained "done" snapshots are - // consulted too so the done dot keeps glowing after the agent process exits, - // matching the dashboard's retention behavior. - // - // Priority (highest first): permission (blocked/waiting) > heuristic - // 'working' > done > other heuristic ('active'/'inactive'). - // permission wins over everything because a newer blocked agent in the same - // worktree means the user needs to act now, not admire a previous - // completion. - // heuristic 'working' wins over done because a spinner means the user has - // already re-prompted the agent after it reported done — the newer "work - // in progress" signal is more informative than a retained completion dot. - // Only the 'working' heuristic earns this precedence; 'active'/'inactive' - // mean "quiet terminal", which shouldn't drown out a recent done. - // Why: collapse live hook entries to booleans inside the selector so the - // snapshot is a stable scalar (useShallow compares element identity — an - // array of freshly-constructed {state,updatedAt} objects would never hit - // the cache and trip React's "getSnapshot should be cached" infinite-loop - // guard). Staleness is applied here too so the selector already reflects - // the 30-min TTL; agentStatusEpoch pulls in the tick that fires when a - // fresh entry crosses the stale boundary. The same useShallow wrapper - // covers hasPermission, hasLiveDone, *and* hasRetainedDone — merging the - // retained scan into the same selector avoids a second full-map iteration - // per card on every retention write (with N sidebar cards on screen a - // standalone retained selector scans Object.values(...) N times per tick). - const { hasPermission, hasLiveDone, hasRetainedDone } = useAppStore( - useShallow((s) => { - // Touch the epoch so this selector re-runs when the freshness scheduler - // ticks — otherwise a stale transition wouldn't flip the booleans until - // some unrelated store write happened to rerun us. - void s.agentStatusEpoch - const wtTabs = s.tabsByWorktree[worktree.id] ?? EMPTY_TABS - let perm = false - let live = false - if (wtTabs.length > 0) { - const tabIds = new Set(wtTabs.map((t) => t.id)) - const now = Date.now() - for (const [paneKey, entry] of Object.entries(s.agentStatusByPaneKey)) { - const sepIdx = paneKey.indexOf(':') - if (sepIdx <= 0) { - continue - } - const tabId = paneKey.slice(0, sepIdx) - if (!tabIds.has(tabId)) { - continue - } - if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { - continue - } - if (entry.state === 'blocked' || entry.state === 'waiting') { - perm = true - } else if (entry.state === 'done') { - live = true - } - } - } - // Retained scan — one pass in the same selector avoids a second - // Object.values(...) per store tick (one per sidebar card). - let retained = false - for (const ra of Object.values(s.retainedAgentsByPaneKey)) { - if (ra.worktreeId === worktree.id) { - retained = true - break - } - } - return { hasPermission: perm, hasLiveDone: live, hasRetainedDone: retained } - }) - ) - - // Why: resolveWorktreeStatus enforces the runtime-liveness precondition — - // when no tab in this worktree has a live PTY (and no browser tab exists) - // it short-circuits to 'inactive' before consulting hook-reported state or - // retained-done snapshots. That keeps the dot honest across sleep, crash, - // and slept-with-retained-done while preserving the row data used by the - // inline agents list (retained 'done' rows still render inside the card). - const status: WorktreeStatus = useMemo( - () => - resolveWorktreeStatus({ - tabs, - browserTabs, - ptyIdsByTabId: ptyIdsForWorktree, - runtimePaneTitlesByTabId: runtimePaneTitlesForWorktree, - hasNotesSurface, - hasPermission, - hasLiveDone, - hasRetainedDone - }), - [ - tabs, - browserTabs, - ptyIdsForWorktree, - runtimePaneTitlesForWorktree, - hasNotesSurface, - hasPermission, - hasLiveDone, - hasRetainedDone - ] - ) + const status = useWorktreeActivityStatus(worktree.id) const showPR = cardProps.includes('pr') const showCI = cardProps.includes('ci') @@ -399,8 +265,9 @@ const WorktreeCard = React.memo(function WorktreeCard({ if (isSshDisconnected) { setShowDisconnectedDialog(true) } + onActivate?.() }, - [worktree.id, isSshDisconnected, onSelectionGesture] + [worktree.id, isSshDisconnected, onActivate, onSelectionGesture] ) const handleDoubleClick = useCallback(() => { @@ -431,6 +298,17 @@ const WorktreeCard = React.memo(function WorktreeCard({ const unreadTooltip = worktree.isUnread ? 'Mark read' : 'Mark unread' + const handleDragStart = useCallback( + (event: React.DragEvent) => { + if (isDeleting) { + event.preventDefault() + return + } + writeWorkspaceDragData(event.dataTransfer, worktree.id) + }, + [isDeleting, worktree.id] + ) + // Why: the 'unread' card property is the user's opt-out. When off, we render // as if the workspace is read so bold emphasis never appears. The persisted // `worktree.isUnread` flag is unchanged; only the rendering changes. @@ -452,6 +330,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ )} onClick={handleClick} onDoubleClick={handleDoubleClick} + draggable={!isDeleting} + onDragStart={handleDragStart} aria-busy={isDeleting} > {isDeleting && ( diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index 31b9f77245b..dd522a58a08 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -3,7 +3,12 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -18,6 +23,7 @@ import { Pencil, Pin, PinOff, + Kanban, Trash2 } from 'lucide-react' import { useAppStore } from '@/store' @@ -28,6 +34,7 @@ import { isFolderRepo } from '../../../../shared/repo-kind' import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flow' import { runSleepWorktrees } from './sleep-worktree-flow' import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' +import { getWorkspaceStatus, getWorkspaceStatusVisualMeta } from './workspace-status' type Props = { worktree: Worktree @@ -48,6 +55,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ onContextMenuSelect }: Props) { const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) + const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) const openModal = useAppStore((s) => s.openModal) const repo = useRepoById(worktree.repoId) const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id]) @@ -77,6 +85,16 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ () => activeContextWorktrees.some((item) => deleteStateByWorktreeId[item.id]?.isDeleting), [activeContextWorktrees, deleteStateByWorktreeId] ) + const contextWorkspaceStatus = useMemo(() => { + const [first, ...rest] = activeContextWorktrees + if (!first) { + return '' + } + const status = getWorkspaceStatus(first, workspaceStatuses) + return rest.every((item) => getWorkspaceStatus(item, workspaceStatuses) === status) + ? status + : '' + }, [activeContextWorktrees, workspaceStatuses]) const batchDeleteWorktrees = useMemo( () => activeContextWorktrees.filter((item) => { @@ -124,6 +142,20 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ updateWorktreeMeta(worktree.id, { isPinned: !worktree.isPinned }) }, [worktree.id, worktree.isPinned, updateWorktreeMeta]) + const handleAssignWorkspaceStatus = useCallback( + (status: string) => { + setMenuOpen(false) + void Promise.all( + activeContextWorktrees.map((item) => + getWorkspaceStatus(item, workspaceStatuses) === status + ? Promise.resolve() + : updateWorktreeMeta(item.id, { workspaceStatus: status }) + ) + ) + }, + [activeContextWorktrees, updateWorktreeMeta, workspaceStatuses] + ) + const handleRename = useCallback(() => { openModal('edit-meta', { worktreeId: worktree.id, @@ -286,6 +318,30 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ )} + + + + {isMultiContext ? 'Move Selected to Status' : 'Move to Status'} + + + + {workspaceStatuses.map((status) => { + const meta = getWorkspaceStatusVisualMeta(status) + return ( + handleAssignWorkspaceStatus(status.id)} + > + + {status.label} + + ) + })} + + + + void collapsedGroups: Set handleCreateForRepo: (repoId: string) => void @@ -104,6 +116,9 @@ type VirtualizedWorktreeViewportProps = { allRepoIds: string[] reorderRepos: (orderedIds: string[]) => void prCache: Record | null + workspaceStatuses: readonly WorkspaceStatusDefinition[] + onMoveWorktreeToStatus: (worktreeId: string, status: WorkspaceStatus) => void + onPinWorktree: (worktreeId: string) => void // Why: the viewport remounts when the row structure changes (see // viewportResetKey) so the virtualizer's measurementsCache cannot hold // heights tied to shifted indices. A fresh virtualizer would otherwise @@ -133,9 +148,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp allRepoIds, reorderRepos, prCache, + workspaceStatuses, + onMoveWorktreeToStatus, + onPinWorktree, scrollOffsetRef }: VirtualizedWorktreeViewportProps) { const scrollRef = useRef(null) + const [dragOverStatus, setDragOverStatus] = useState(null) + const [pinDragOver, setPinDragOver] = useState(false) // Drag is only meaningful when the user is grouping by repo. When inert // (groupBy !== 'repo'), the controller is still constructed for hook order @@ -231,19 +251,17 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp if (collapsedGroups.has(PINNED_GROUP_KEY)) { toggleGroup(PINNED_GROUP_KEY) } - } else if (targetWorktree && groupBy !== 'none') { - const groupKey = getGroupKeyForWorktree(groupBy, targetWorktree, repoMap, prCache) + } else if (targetWorktree) { + const groupKey = getGroupKeyForWorktree( + groupBy, + targetWorktree, + repoMap, + prCache, + workspaceStatuses + ) if (groupKey && collapsedGroups.has(groupKey)) { toggleGroup(groupKey) } - } else if (targetWorktree && groupBy === 'none') { - // Why: when any worktree is pinned, buildRows emits a sibling "All" - // header for the unpinned block (see worktree-list-groups.ts). If that - // header is collapsed, revealing an unpinned target would otherwise - // leave it hidden — uncollapse it so the card is actually visible. - if (collapsedGroups.has(ALL_GROUP_KEY)) { - toggleGroup(ALL_GROUP_KEY) - } } } @@ -272,7 +290,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp virtualizer, clearPendingRevealWorktreeId, toggleGroup, - collapsedGroups + collapsedGroups, + workspaceStatuses ]) const prCacheLen = useAppStore((s) => Object.keys(s.prCache).length) @@ -301,7 +320,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp repoMap, prCache, new Set(), - repoOrder + repoOrder, + workspaceStatuses ).filter((r): r is Extract => r.type === 'item') if (worktreeRows.length === 0) { return @@ -334,7 +354,17 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp virtualizer.scrollToIndex(rowIndex, { align: 'auto' }) } }, - [rows, activeWorktreeId, virtualizer, groupBy, worktrees, repoMap, prCache, repoOrder] + [ + rows, + activeWorktreeId, + virtualizer, + groupBy, + worktrees, + repoMap, + prCache, + repoOrder, + workspaceStatuses + ] ) useEffect(() => { @@ -393,6 +423,76 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ? getWorktreeOptionId(activeWorktreeId) : undefined + const hasWorkspaceDropTargets = useMemo( + () => + groupBy === 'none' || + rows.some((row) => row.type === 'header' && row.key === PINNED_GROUP_KEY), + [groupBy, rows] + ) + + const handleWorkspaceStatusDragOver = useCallback( + (event: React.DragEvent, status: WorkspaceStatus) => { + if (!hasWorkspaceDragData(event.dataTransfer)) { + return + } + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setDragOverStatus(status) + }, + [] + ) + + const handleWorkspaceStatusDragLeave = useCallback((event: React.DragEvent) => { + const relatedTarget = event.relatedTarget + if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { + return + } + setDragOverStatus(null) + }, []) + + const handleWorkspacePinDragOver = useCallback((event: React.DragEvent) => { + if (!hasWorkspaceDragData(event.dataTransfer)) { + return + } + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setPinDragOver(true) + }, []) + + const handleWorkspacePinDragLeave = useCallback((event: React.DragEvent) => { + const relatedTarget = event.relatedTarget + if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { + return + } + setPinDragOver(false) + }, []) + + const handleWorkspaceStatusDragFinish = useCallback(() => { + setDragOverStatus(null) + setPinDragOver(false) + }, []) + + const handleWorkspaceStatusDrop = useCallback( + (event: React.DragEvent, status: WorkspaceStatus) => { + const worktreeId = readWorkspaceDragData(event.dataTransfer) + if (!worktreeId) { + return + } + event.preventDefault() + setDragOverStatus(null) + onMoveWorktreeToStatus(worktreeId, status) + }, + [onMoveWorktreeToStatus] + ) + + useWorkspaceStatusDocumentDrop( + scrollRef, + onMoveWorktreeToStatus, + onPinWorktree, + handleWorkspaceStatusDragFinish, + hasWorkspaceDropTargets + ) + return (
handleWorkspaceStatusDragOver(event, headerWorkspaceStatus) + : undefined + } + onDragLeave={ + isPinnedHeader + ? handleWorkspacePinDragLeave + : headerWorkspaceStatus + ? handleWorkspaceStatusDragLeave + : undefined + } + onDrop={ + headerWorkspaceStatus + ? (event) => handleWorkspaceStatusDrop(event, headerWorkspaceStatus) + : undefined + } onClick={() => toggleGroup(row.key)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -527,6 +658,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) } + const itemWorkspaceStatus = + groupBy === 'none' ? getWorkspaceStatus(row.worktree, workspaceStatuses) : null + return (
handleWorkspaceStatusDragOver(event, itemWorkspaceStatus) + : undefined + } + onDragLeave={itemWorkspaceStatus ? handleWorkspaceStatusDragLeave : undefined} + onDrop={ + itemWorkspaceStatus + ? (event) => handleWorkspaceStatusDrop(event, itemWorkspaceStatus) + : undefined + } > s.worktreesByRepo) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const groupBy = useAppStore((s) => s.groupBy) + const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) const sortBy = useAppStore((s) => s.sortBy) const showActiveOnly = useAppStore((s) => s.showActiveOnly) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const openModal = useAppStore((s) => s.openModal) + const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) const activeView = useAppStore((s) => s.activeView) const activeModal = useAppStore((s) => s.activeModal) const pendingRevealWorktreeId = useAppStore((s) => s.pendingRevealWorktreeId) @@ -881,8 +1030,17 @@ const WorktreeList = React.memo(function WorktreeList() { // Build flat row list for rendering const rows: Row[] = useMemo( - () => buildRows(groupBy, worktrees, repoMap, prCache, collapsedGroups, repoOrder), - [groupBy, worktrees, repoMap, prCache, collapsedGroups, repoOrder] + () => + buildRows( + groupBy, + worktrees, + repoMap, + prCache, + collapsedGroups, + repoOrder, + workspaceStatuses + ), + [groupBy, worktrees, repoMap, prCache, collapsedGroups, repoOrder, workspaceStatuses] ) // Why: rows.length alone can stay the same when items migrate between // groups (e.g., PR cache loads on restart and a collapsed group absorbs @@ -1011,6 +1169,28 @@ const WorktreeList = React.memo(function WorktreeList() { [openModal] ) + const moveWorktreeToStatus = useCallback( + (worktreeId: string, status: WorkspaceStatus) => { + const current = worktreeMap.get(worktreeId) + if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) { + return + } + void updateWorktreeMeta(worktreeId, { workspaceStatus: status }) + }, + [updateWorktreeMeta, worktreeMap, workspaceStatuses] + ) + + const pinWorktree = useCallback( + (worktreeId: string) => { + const current = worktreeMap.get(worktreeId) + if (!current || current.isPinned) { + return + } + void updateWorktreeMeta(worktreeId, { isPinned: true }) + }, + [updateWorktreeMeta, worktreeMap] + ) + // Why: hideDefaultBranchWorkspace is counted as a filter here so the // empty-sidebar escape hatch (Clear Filters button below) is reachable when // it's the only reason the list is empty — otherwise a user whose only @@ -1081,6 +1261,9 @@ const WorktreeList = React.memo(function WorktreeList() { void reorderReposAction(orderedIds) }} prCache={prCache} + workspaceStatuses={workspaceStatuses} + onMoveWorktreeToStatus={moveWorktreeToStatus} + onPinWorktree={pinWorktree} scrollOffsetRef={sidebarScrollOffsetRef} /> ) diff --git a/src/renderer/src/components/sidebar/use-workspace-status-drop.ts b/src/renderer/src/components/sidebar/use-workspace-status-drop.ts new file mode 100644 index 00000000000..2dcb4c706dd --- /dev/null +++ b/src/renderer/src/components/sidebar/use-workspace-status-drop.ts @@ -0,0 +1,81 @@ +import { useEffect } from 'react' +import type React from 'react' +import type { WorkspaceStatus } from '../../../../shared/types' +import { hasWorkspaceDragData, readWorkspaceDragData } from './workspace-status' + +const WORKSPACE_STATUS_DROP_TARGET = '[data-workspace-status-drop-target]' +const WORKSPACE_PIN_DROP_TARGET = '[data-workspace-pin-drop-target]' + +type MoveWorktreeToStatus = (worktreeId: string, status: WorkspaceStatus) => void +type PinWorktree = (worktreeId: string) => void + +export function useWorkspaceStatusDocumentDrop( + containerRef: React.RefObject, + onMoveWorktreeToStatus: MoveWorktreeToStatus, + onPinWorktree: PinWorktree, + onDragFinish: () => void, + enabled = true +): void { + useEffect(() => { + if (!enabled) { + return + } + + const handleDrop = (event: DragEvent): void => { + const dataTransfer = event.dataTransfer + if (!dataTransfer || !hasWorkspaceDragData(dataTransfer)) { + return + } + + onDragFinish() + + const container = containerRef.current + const target = event.target + if (!container || !(target instanceof Element) || !container.contains(target)) { + return + } + + const pinTarget = target.closest(WORKSPACE_PIN_DROP_TARGET) + const statusTarget = target.closest(WORKSPACE_STATUS_DROP_TARGET) + const dropTarget = + pinTarget && container.contains(pinTarget) + ? pinTarget + : statusTarget && container.contains(statusTarget) + ? statusTarget + : null + if (!dropTarget) { + return + } + + const worktreeId = readWorkspaceDragData(dataTransfer) + if (!worktreeId) { + return + } + + // Why: Electron's preload bridge stops native drops before React sees + // them, so board drops commit from this scoped capture listener. + event.preventDefault() + event.stopPropagation() + if (dropTarget === pinTarget) { + onPinWorktree(worktreeId) + return + } + + const status = dropTarget.dataset.workspaceStatus + if (status) { + onMoveWorktreeToStatus(worktreeId, status) + } + } + + const handleDragFinish = (): void => { + onDragFinish() + } + + document.addEventListener('drop', handleDrop, true) + document.addEventListener('dragend', handleDragFinish, true) + return () => { + document.removeEventListener('drop', handleDrop, true) + document.removeEventListener('dragend', handleDragFinish, true) + } + }, [containerRef, enabled, onDragFinish, onMoveWorktreeToStatus, onPinWorktree]) +} diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts new file mode 100644 index 00000000000..7dc2e2dacae --- /dev/null +++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts @@ -0,0 +1,93 @@ +import { useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '@/store' +import { isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { resolveWorktreeStatus, type WorktreeStatus } from '@/lib/worktree-status' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' +import { EMPTY_BROWSER_TABS, EMPTY_TABS } from './WorktreeCardHelpers' +import { + selectLivePtyIdsForWorktree, + selectRuntimePaneTitlesForWorktree +} from './worktree-card-status-inputs' + +export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { + const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId] ?? EMPTY_TABS) + const browserTabs = useAppStore((s) => s.browserTabsByWorktree[worktreeId] ?? EMPTY_BROWSER_TABS) + const hasNotesSurface = useAppStore((s) => + (s.unifiedTabsByWorktree[worktreeId] ?? []).some((tab) => tab.contentType === 'notes') + ) + const runtimePaneTitlesForWorktree = useAppStore( + useShallow((s) => selectRuntimePaneTitlesForWorktree(s, worktreeId)) + ) + const ptyIdsForWorktree = useAppStore( + useShallow((s) => selectLivePtyIdsForWorktree(s, worktreeId)) + ) + const { hasPermission, hasLiveDone, hasRetainedDone } = useAppStore( + useShallow((s) => { + // Touch the epoch so this selector re-runs when a fresh hook entry + // crosses the stale boundary. + void s.agentStatusEpoch + const wtTabs = s.tabsByWorktree[worktreeId] ?? EMPTY_TABS + let perm = false + let live = false + if (wtTabs.length > 0) { + const tabIds = new Set(wtTabs.map((tab) => tab.id)) + const now = Date.now() + for (const [paneKey, entry] of Object.entries(s.agentStatusByPaneKey)) { + const sepIdx = paneKey.indexOf(':') + if (sepIdx <= 0) { + continue + } + const tabId = paneKey.slice(0, sepIdx) + if (!tabIds.has(tabId)) { + continue + } + if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + continue + } + if (entry.state === 'blocked' || entry.state === 'waiting') { + perm = true + } else if (entry.state === 'done') { + live = true + } + } + } + + let retained = false + for (const agent of Object.values(s.retainedAgentsByPaneKey)) { + if (agent.worktreeId === worktreeId) { + retained = true + break + } + } + return { hasPermission: perm, hasLiveDone: live, hasRetainedDone: retained } + }) + ) + + // Why: compact and detailed cards need the same status-dot semantics: + // runtime liveness gates title-derived states, then explicit agent rows can + // promote permission/done so the dot matches visible agent state. + return useMemo( + () => + resolveWorktreeStatus({ + tabs, + browserTabs, + ptyIdsByTabId: ptyIdsForWorktree, + runtimePaneTitlesByTabId: runtimePaneTitlesForWorktree, + hasNotesSurface, + hasPermission, + hasLiveDone, + hasRetainedDone + }), + [ + tabs, + browserTabs, + ptyIdsForWorktree, + runtimePaneTitlesForWorktree, + hasNotesSurface, + hasPermission, + hasLiveDone, + hasRetainedDone + ] + ) +} diff --git a/src/renderer/src/components/sidebar/workspace-status.ts b/src/renderer/src/components/sidebar/workspace-status.ts new file mode 100644 index 00000000000..79f91afc69a --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-status.ts @@ -0,0 +1,232 @@ +import type React from 'react' +import { + Ban, + Circle, + CircleAlert, + CircleCheckBig, + CircleDashed, + CircleDot, + CircleEllipsis, + CirclePause, + CirclePlay, + Flag, + GitPullRequest, + Timer +} from 'lucide-react' +import type { WorkspaceStatus, WorkspaceStatusDefinition } from '../../../../shared/types' +import { + DEFAULT_WORKSPACE_STATUS_COLOR_ID, + DEFAULT_WORKSPACE_STATUS_ICON_ID, + DEFAULT_WORKSPACE_STATUS_ID, + DEFAULT_WORKSPACE_STATUSES, + WORKSPACE_STATUS_COLOR_IDS, + WORKSPACE_STATUS_ICON_IDS, + getWorkspaceStatus, + getWorkspaceStatusFromGroupKey, + getWorkspaceStatusGroupKey, + isWorkspaceStatusId +} from '../../../../shared/workspace-statuses' + +export { + DEFAULT_WORKSPACE_STATUS_COLOR_ID, + DEFAULT_WORKSPACE_STATUS_ICON_ID, + DEFAULT_WORKSPACE_STATUS_ID, + DEFAULT_WORKSPACE_STATUSES, + WORKSPACE_STATUS_COLOR_IDS, + WORKSPACE_STATUS_ICON_IDS, + getWorkspaceStatus, + getWorkspaceStatusFromGroupKey, + getWorkspaceStatusGroupKey, + isWorkspaceStatusId +} + +export const WORKSPACE_STATUS_DRAG_TYPE = 'application/x-orca-worktree-id' + +type WorkspaceStatusColorOption = { + id: string + label: string + tone: string + swatch: string + border: string + laneTint: string +} + +type WorkspaceStatusIconOption = { + id: string + label: string + icon: React.ComponentType<{ className?: string }> +} + +export const WORKSPACE_STATUS_COLOR_OPTIONS: WorkspaceStatusColorOption[] = [ + { + id: 'neutral', + label: 'Neutral', + tone: 'text-muted-foreground', + swatch: 'bg-muted-foreground', + border: 'border-t-muted-foreground/45', + laneTint: 'bg-background/55' + }, + { + id: 'blue', + label: 'Blue', + tone: 'text-blue-600 dark:text-blue-300', + swatch: 'bg-blue-500', + border: 'border-t-blue-500/70', + laneTint: 'bg-blue-500/[0.04]' + }, + { + id: 'sky', + label: 'Sky', + tone: 'text-sky-600 dark:text-sky-300', + swatch: 'bg-sky-500', + border: 'border-t-sky-500/70', + laneTint: 'bg-sky-500/[0.04]' + }, + { + id: 'violet', + label: 'Violet', + tone: 'text-violet-600 dark:text-violet-300', + swatch: 'bg-violet-500', + border: 'border-t-violet-500/70', + laneTint: 'bg-violet-500/[0.04]' + }, + { + id: 'amber', + label: 'Amber', + tone: 'text-amber-700 dark:text-amber-200', + swatch: 'bg-amber-500', + border: 'border-t-amber-500/70', + laneTint: 'bg-amber-500/[0.04]' + }, + { + id: 'emerald', + label: 'Emerald', + tone: 'text-emerald-700 dark:text-emerald-200', + swatch: 'bg-emerald-500', + border: 'border-t-emerald-500/70', + laneTint: 'bg-emerald-500/[0.04]' + }, + { + id: 'rose', + label: 'Rose', + tone: 'text-rose-600 dark:text-rose-300', + swatch: 'bg-rose-500', + border: 'border-t-rose-500/70', + laneTint: 'bg-rose-500/[0.04]' + }, + { + id: 'zinc', + label: 'Zinc', + tone: 'text-zinc-600 dark:text-zinc-300', + swatch: 'bg-zinc-500', + border: 'border-t-zinc-500/70', + laneTint: 'bg-zinc-500/[0.04]' + } +] + +export const WORKSPACE_STATUS_ICON_OPTIONS: WorkspaceStatusIconOption[] = [ + { id: 'circle', label: 'Circle', icon: Circle }, + { id: 'circle-dot', label: 'Dot', icon: CircleDot }, + { id: 'circle-dashed', label: 'Dashed', icon: CircleDashed }, + { id: 'circle-ellipsis', label: 'Waiting', icon: CircleEllipsis }, + { id: 'git-pull-request', label: 'Review', icon: GitPullRequest }, + { id: 'timer', label: 'Timer', icon: Timer }, + { id: 'flag', label: 'Flag', icon: Flag }, + { id: 'circle-alert', label: 'Alert', icon: CircleAlert }, + { id: 'circle-pause', label: 'Paused', icon: CirclePause }, + { id: 'circle-play', label: 'Play', icon: CirclePlay }, + { id: 'circle-check', label: 'Done', icon: CircleCheckBig }, + { id: 'ban', label: 'Blocked', icon: Ban } +] + +const FALLBACK_COLOR_OPTION: WorkspaceStatusColorOption = WORKSPACE_STATUS_COLOR_OPTIONS[0] ?? { + id: DEFAULT_WORKSPACE_STATUS_COLOR_ID, + label: 'Neutral', + tone: 'text-muted-foreground', + swatch: 'bg-muted-foreground', + border: 'border-t-muted-foreground/45', + laneTint: 'bg-background/55' +} + +const FALLBACK_ICON_OPTION: WorkspaceStatusIconOption = WORKSPACE_STATUS_ICON_OPTIONS[1] ?? { + id: DEFAULT_WORKSPACE_STATUS_ICON_ID, + label: 'Dot', + icon: CircleDot +} + +const DEFAULT_STATUS_VISUALS: Record< + string, + { + color: string + icon: string + } +> = { + todo: { + color: 'neutral', + icon: 'circle' + }, + 'in-progress': { + color: 'blue', + icon: 'circle-dot' + }, + 'in-review': { + color: 'violet', + icon: 'git-pull-request' + }, + completed: { + color: 'emerald', + icon: 'circle-check' + } +} + +export function getWorkspaceStatusVisualMeta(status: WorkspaceStatus | WorkspaceStatusDefinition): { + tone: string + swatch: string + border: string + laneTint: string + icon: React.ComponentType<{ className?: string }> +} { + const statusId = typeof status === 'string' ? status : status.id + const visual = typeof status === 'string' ? DEFAULT_STATUS_VISUALS[status] : status + const colorId = visual?.color ?? DEFAULT_STATUS_VISUALS[statusId]?.color + const iconId = visual?.icon ?? DEFAULT_STATUS_VISUALS[statusId]?.icon + const color = + WORKSPACE_STATUS_COLOR_OPTIONS.find((option) => option.id === colorId) ?? + WORKSPACE_STATUS_COLOR_OPTIONS.find( + (option) => option.id === DEFAULT_WORKSPACE_STATUS_COLOR_ID + ) ?? + FALLBACK_COLOR_OPTION + const icon = + WORKSPACE_STATUS_ICON_OPTIONS.find((option) => option.id === iconId) ?? + WORKSPACE_STATUS_ICON_OPTIONS.find( + (option) => option.id === DEFAULT_WORKSPACE_STATUS_ICON_ID + ) ?? + FALLBACK_ICON_OPTION + + return { + tone: color.tone, + swatch: color.swatch, + border: color.border, + laneTint: color.laneTint, + icon: icon.icon + } +} + +export function writeWorkspaceDragData(dataTransfer: DataTransfer, worktreeId: string): void { + dataTransfer.effectAllowed = 'move' + dataTransfer.setData(WORKSPACE_STATUS_DRAG_TYPE, worktreeId) + dataTransfer.setData('text/plain', worktreeId) +} + +export function readWorkspaceDragData(dataTransfer: DataTransfer): string | null { + const typed = dataTransfer.getData(WORKSPACE_STATUS_DRAG_TYPE) + if (typed) { + return typed + } + return dataTransfer.getData('text/plain') || null +} + +export function hasWorkspaceDragData(dataTransfer: DataTransfer): boolean { + const types = Array.from(dataTransfer.types) + return types.includes(WORKSPACE_STATUS_DRAG_TYPE) || types.includes('text/plain') +} diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index 39d8a3d5b84..2f2c576520e 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -57,9 +57,14 @@ describe('buildRows with pinned worktrees', () => { expect(rows[1]).toMatchObject({ type: 'item', worktree: { id: 'wt-pinned' } }) }) - it('emits an All header between pinned and unpinned in groupBy none', () => { + it('emits status headers for unpinned worktrees in groupBy none', () => { const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set()) - expect(rows[2]).toMatchObject({ type: 'header', key: 'all', label: 'All', count: 2 }) + expect(rows[2]).toMatchObject({ + type: 'header', + key: 'workspace-status:in-progress', + label: 'In progress', + count: 2 + }) expect(rows[3]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } }) expect(rows[4]).toMatchObject({ type: 'item', worktree: { id: 'wt-2' } }) }) @@ -76,22 +81,36 @@ describe('buildRows with pinned worktrees', () => { } }) - it('does not emit pinned section when no worktrees are pinned', () => { + it('keeps an empty pinned drop section above statuses in groupBy none', () => { const rows = buildRows('none', [unpinned1, unpinned2], repoMap, null, new Set()) - expect(rows.every((r) => r.type === 'item')).toBe(true) + expect(rows[0]).toMatchObject({ + type: 'header', + key: 'pinned', + label: 'Pinned', + count: 0 + }) + expect(rows[1]).toMatchObject({ + type: 'header', + key: 'workspace-status:in-progress', + label: 'In progress', + count: 2 + }) + expect(rows[2]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } }) + expect(rows[3]).toMatchObject({ type: 'item', worktree: { id: 'wt-2' } }) }) it('collapses pinned group when in collapsedGroups', () => { const rows = buildRows('none', [pinned, unpinned1], repoMap, null, new Set(['pinned'])) expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned' }) - expect(rows[1]).toMatchObject({ type: 'header', key: 'all' }) + expect(rows[1]).toMatchObject({ type: 'header', key: 'workspace-status:in-progress' }) expect(rows[2]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } }) }) - it('does not emit All header when all worktrees are pinned', () => { + it('does not emit empty status sections when all worktrees are pinned', () => { const allPinned = { ...unpinned1, isPinned: true } const rows = buildRows('none', [pinned, allPinned], repoMap, null, new Set()) - expect(rows.some((r) => r.type === 'header' && r.key === 'all')).toBe(false) + expect(rows.filter((r) => r.type === 'header')).toHaveLength(1) + expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned', count: 2 }) }) it('preserves repo display casing in group labels', () => { @@ -135,6 +154,49 @@ describe('buildRows with pinned worktrees', () => { }) expect(rows[1]).toMatchObject({ type: 'item', worktree: { id: folderWorktree.id } }) }) + + it('emits assigned workspace statuses as sections in groupBy none', () => { + const review = { ...worktree, id: 'wt-review', workspaceStatus: 'in-review' as const } + const rows = buildRows('none', [review], repoMap, null, new Set()) + + expect( + rows + .filter((r) => r.type === 'header') + .map((r) => ({ key: r.key, label: r.label, count: r.count })) + ).toEqual([ + { key: 'pinned', label: 'Pinned', count: 0 }, + { key: 'workspace-status:in-review', label: 'In review', count: 1 } + ]) + }) + + it('uses customized workspace status labels and order', () => { + const customStatuses = [ + { id: 'blocked', label: 'Blocked' }, + { id: 'todo', label: 'Ready' }, + { id: 'in-progress', label: 'Doing' } + ] + const blocked = { ...worktree, id: 'wt-blocked', workspaceStatus: 'blocked' } + const doing = { ...worktree, id: 'wt-doing', workspaceStatus: 'in-progress' } + const rows = buildRows( + 'none', + [doing, blocked], + repoMap, + null, + new Set(), + undefined, + customStatuses + ) + + expect( + rows + .filter((r) => r.type === 'header') + .map((r) => ({ key: r.key, label: r.label, count: r.count })) + ).toEqual([ + { key: 'pinned', label: 'Pinned', count: 0 }, + { key: 'workspace-status:blocked', label: 'Blocked', count: 1 }, + { key: 'workspace-status:in-progress', label: 'Doing', count: 1 } + ]) + }) }) describe('buildRows repo grouping order', () => { diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 088624082df..50e2d645719 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -1,18 +1,19 @@ -import { - CircleCheckBig, - CircleDot, - CircleX, - Folder, - GitPullRequest, - LayoutList, - Pin -} from 'lucide-react' +import { CircleCheckBig, CircleDot, CircleX, Folder, GitPullRequest, Pin } from 'lucide-react' import type React from 'react' -import type { Repo, Worktree } from '../../../../shared/types' +import type { Repo, Worktree, WorkspaceStatusDefinition } from '../../../../shared/types' import { branchName } from '@/lib/git-utils' +import { + getWorkspaceStatus, + getWorkspaceStatusFromGroupKey, + getWorkspaceStatusGroupKey, + getWorkspaceStatusVisualMeta +} from './workspace-status' +import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses' export { branchName } +export type WorktreeGroupBy = 'none' | 'repo' | 'pr-status' + export type GroupHeaderRow = { type: 'header' key: string @@ -73,14 +74,6 @@ export const PINNED_GROUP_META = { icon: Pin } as const -export const ALL_GROUP_KEY = 'all' - -export const ALL_GROUP_META = { - label: 'All', - tone: 'text-foreground', - icon: LayoutList -} as const - export function getPRGroupKey( worktree: Worktree, repoMap: Map, @@ -118,10 +111,11 @@ function emitPinnedGroup( worktrees: Worktree[], repoMap: Map, collapsedGroups: Set, - result: Row[] + result: Row[], + force = false ): Set { const pinned = worktrees.filter((w) => w.isPinned) - if (pinned.length === 0) { + if (pinned.length === 0 && !force) { return new Set() } @@ -146,41 +140,19 @@ function emitPinnedGroup( * Extracted here to keep WorktreeList.tsx under the line-count lint limit. */ export function buildRows( - groupBy: 'none' | 'repo' | 'pr-status', + groupBy: WorktreeGroupBy, worktrees: Worktree[], repoMap: Map, prCache: Record | null, collapsedGroups: Set, - repoOrder?: Map + repoOrder?: Map, + workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses() ): Row[] { const result: Row[] = [] - const pinnedIds = emitPinnedGroup(worktrees, repoMap, collapsedGroups, result) + const pinnedIds = emitPinnedGroup(worktrees, repoMap, collapsedGroups, result, groupBy === 'none') const unpinned = pinnedIds.size > 0 ? worktrees.filter((w) => !pinnedIds.has(w.id)) : worktrees - if (groupBy === 'none') { - // Without an "All" header, the unpinned block is visually indistinguishable - // from a continuation of the Pinned section — so when pinned items exist, - // mark the boundary with a sibling header that mirrors the Pinned one. - if (pinnedIds.size > 0 && unpinned.length > 0) { - result.push({ - type: 'header', - key: ALL_GROUP_KEY, - label: ALL_GROUP_META.label, - count: unpinned.length, - tone: ALL_GROUP_META.tone, - icon: ALL_GROUP_META.icon - }) - if (collapsedGroups.has(ALL_GROUP_KEY)) { - return result - } - } - for (const w of unpinned) { - result.push({ type: 'item', worktree: w, repo: repoMap.get(w.repoId) }) - } - return result - } - const grouped = new Map() for (const w of unpinned) { let key: string @@ -190,6 +162,11 @@ export function buildRows( repo = repoMap.get(w.repoId) key = `repo:${w.repoId}` label = repo?.displayName ?? 'Unknown' + } else if (groupBy === 'none') { + const workspaceStatus = getWorkspaceStatus(w, workspaceStatuses) + key = getWorkspaceStatusGroupKey(workspaceStatus) + label = + workspaceStatuses.find((status) => status.id === workspaceStatus)?.label ?? workspaceStatus } else { const prGroup = getPRGroupKey(w, repoMap, prCache) key = `pr:${prGroup}` @@ -210,6 +187,17 @@ export function buildRows( orderedGroups.push([key, group]) } } + } else if (groupBy === 'none') { + // Why: the old "All" grouping now organizes workspaces by user status. + // Keep the sidebar compact by rendering only sections that contain cards; + // the board drawer is the wider all-lanes drag target. + for (const status of workspaceStatuses) { + const key = getWorkspaceStatusGroupKey(status.id) + const group = grouped.get(key) + if (group) { + orderedGroups.push([key, group]) + } + } } else { // Why: header order must follow the canonical state.repos array order, not // first-encounter from the smart-sorted worktree stream — otherwise sorting @@ -249,18 +237,35 @@ export function buildRows( icon: REPO_GROUP_META.icon, repo } - : (() => { - const prGroup = key.replace(/^pr:/, '') as PRGroupKey - const meta = PR_GROUP_META[prGroup] - return { - type: 'header' as const, - key, - label: meta.label, - count: group.items.length, - tone: meta.tone, - icon: meta.icon - } - })() + : groupBy === 'none' + ? (() => { + const workspaceStatus = + getWorkspaceStatusFromGroupKey(key, workspaceStatuses) ?? + workspaceStatuses[0]?.id ?? + 'in-progress' + const definition = workspaceStatuses.find((status) => status.id === workspaceStatus) + const meta = getWorkspaceStatusVisualMeta(definition ?? workspaceStatus) + return { + type: 'header' as const, + key, + label: definition?.label ?? workspaceStatus, + count: group.items.length, + tone: meta.tone, + icon: meta.icon + } + })() + : (() => { + const prGroup = key.replace(/^pr:/, '') as PRGroupKey + const meta = PR_GROUP_META[prGroup] + return { + type: 'header' as const, + key, + label: meta.label, + count: group.items.length, + tone: meta.tone, + icon: meta.icon + } + })() result.push(header) if (!isCollapsed) { @@ -274,13 +279,14 @@ export function buildRows( } export function getGroupKeyForWorktree( - groupBy: 'none' | 'repo' | 'pr-status', + groupBy: WorktreeGroupBy, worktree: Worktree, repoMap: Map, - prCache: Record | null + prCache: Record | null, + workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses() ): string | null { if (groupBy === 'none') { - return null + return getWorkspaceStatusGroupKey(getWorkspaceStatus(worktree, workspaceStatuses)) } if (groupBy === 'repo') { return `repo:${worktree.repoId}` diff --git a/src/renderer/src/components/ui/sheet.tsx b/src/renderer/src/components/ui/sheet.tsx index 1d26d4ac3ce..3e734b9294c 100644 --- a/src/renderer/src/components/ui/sheet.tsx +++ b/src/renderer/src/components/ui/sheet.tsx @@ -68,15 +68,19 @@ function SheetContent({ children, side = 'right', showCloseButton = true, + overlayClassName, + overlayStyle, style, ...props }: React.ComponentProps & VariantProps & { showCloseButton?: boolean + overlayClassName?: string + overlayStyle?: React.CSSProperties }) { return ( - + { expect(store.getState().hideDefaultBranchWorkspace).toBe(true) }) + it('restores compact workspace board mode only from an explicit true', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI( + makePersistedUI({ + workspaceBoardCompact: true + }) + ) + expect(store.getState().workspaceBoardCompact).toBe(true) + + store.getState().hydratePersistedUI( + makePersistedUI({ + workspaceBoardCompact: 'yes' as unknown as boolean + }) + ) + expect(store.getState().workspaceBoardCompact).toBe(false) + }) + it('hydrates a valid Kagi session link', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index f9de8d4140b..e807ec3a67a 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -12,6 +12,7 @@ import type { TaskViewPresetId, TuiAgent, UpdateStatus, + WorkspaceStatusDefinition, WorktreeCardProperty } from '../../../../shared/types' import { PET_SIZE_DEFAULT, PET_SIZE_MAX, PET_SIZE_MIN } from '../../../../shared/types' @@ -20,6 +21,12 @@ import { DEFAULT_STATUS_BAR_ITEMS, DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../../../shared/constants' +import { + clampWorkspaceBoardOpacity, + cloneDefaultWorkspaceStatuses, + normalizeWorkspaceBoardCompact, + normalizeWorkspaceStatuses +} from '../../../../shared/workspace-statuses' import { normalizeKagiSessionLink } from '../../../../shared/browser-url' import type { OrcaHookScriptKind } from '../../lib/orca-hook-trust' import { DEFAULT_PET_ID, isBundledPetId } from '../../components/pet/pet-models' @@ -308,6 +315,12 @@ export type UISlice = { toggleCollapsedGroup: (key: string) => void worktreeCardProperties: WorktreeCardProperty[] toggleWorktreeCardProperty: (prop: WorktreeCardProperty) => void + workspaceStatuses: WorkspaceStatusDefinition[] + setWorkspaceStatuses: (statuses: WorkspaceStatusDefinition[]) => void + workspaceBoardOpacity: number + setWorkspaceBoardOpacity: (opacity: number) => void + workspaceBoardCompact: boolean + setWorkspaceBoardCompact: (compact: boolean) => void statusBarItems: StatusBarItem[] toggleStatusBarItem: (item: StatusBarItem) => void statusBarVisible: boolean @@ -676,6 +689,27 @@ export const createUISlice: StateCreator = (set, get) return { worktreeCardProperties: updated } }), + workspaceStatuses: cloneDefaultWorkspaceStatuses(), + setWorkspaceStatuses: (statuses) => { + const normalized = normalizeWorkspaceStatuses(statuses) + window.api.ui.set({ workspaceStatuses: normalized }).catch(console.error) + set({ workspaceStatuses: normalized }) + }, + + workspaceBoardOpacity: 1, + setWorkspaceBoardOpacity: (opacity) => { + const clamped = clampWorkspaceBoardOpacity(opacity) + window.api.ui.set({ workspaceBoardOpacity: clamped }).catch(console.error) + set({ workspaceBoardOpacity: clamped }) + }, + + workspaceBoardCompact: false, + setWorkspaceBoardCompact: (compact) => { + const normalized = normalizeWorkspaceBoardCompact(compact) + window.api.ui.set({ workspaceBoardCompact: normalized }).catch(console.error) + set({ workspaceBoardCompact: normalized }) + }, + statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS], toggleStatusBarItem: (item) => set((s) => { @@ -815,6 +849,9 @@ export const createUISlice: StateCreator = (set, get) uiZoomLevel: ui.uiZoomLevel ?? 0, editorFontZoomLevel: ui.editorFontZoomLevel ?? 0, worktreeCardProperties: ui.worktreeCardProperties ?? [...DEFAULT_WORKTREE_CARD_PROPERTIES], + workspaceStatuses: normalizeWorkspaceStatuses(ui.workspaceStatuses), + workspaceBoardOpacity: clampWorkspaceBoardOpacity(ui.workspaceBoardOpacity), + workspaceBoardCompact: normalizeWorkspaceBoardCompact(ui.workspaceBoardCompact), statusBarItems: migrateStatusBarItems(ui.statusBarItems), statusBarVisible: ui.statusBarVisible ?? true, // Why: absent → true so existing users see the pet the first time diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 9d738dea8c6..32b2606864f 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -48,6 +48,7 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b worktree.isPinned === candidate.isPinned && worktree.sortOrder === candidate.sortOrder && worktree.lastActivityAt === candidate.lastActivityAt && + worktree.workspaceStatus === candidate.workspaceStatus && worktree.createdWithAgent === candidate.createdWithAgent && worktree.baseRef === candidate.baseRef && worktree.pushTarget?.remoteName === candidate.pushTarget?.remoteName && diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 6f6f33fa764..1c2de304c7a 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -13,6 +13,7 @@ import type { import { DEFAULT_TERMINAL_FONT_WEIGHT } from './terminal-fonts' import { getDefaultTerminalQuickCommands } from './terminal-quick-commands' import type { VoiceSettings } from './speech-types' +import { cloneDefaultWorkspaceStatuses } from './workspace-statuses' export const SCHEMA_VERSION = 1 export const DEFAULT_APP_FONT_FAMILY = 'Geist' @@ -324,6 +325,9 @@ export function getDefaultUIState(): PersistedUIState { uiZoomLevel: 0, editorFontZoomLevel: 0, worktreeCardProperties: [...DEFAULT_WORKTREE_CARD_PROPERTIES], + workspaceStatuses: cloneDefaultWorkspaceStatuses(), + workspaceBoardOpacity: 1, + workspaceBoardCompact: false, statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS], statusBarVisible: true, dismissedUpdateVersion: null, diff --git a/src/shared/types.ts b/src/shared/types.ts index 0f9624a10c4..7430e34428e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -106,6 +106,15 @@ export type GitWorktreeInfo = { } // ─── Worktree (app-level, enriched) ────────────────────────────────── +export type WorkspaceStatus = string + +export type WorkspaceStatusDefinition = { + id: WorkspaceStatus + label: string + color?: string + icon?: string +} + export type Worktree = { id: string // `${repoId}::${path}` repoId: string @@ -147,6 +156,7 @@ export type Worktree = { baseRef?: string /** Remote/branch Orca should publish review commits to when it created this worktree. */ pushTarget?: GitPushTarget + workspaceStatus?: WorkspaceStatus diffComments?: DiffComment[] } & GitWorktreeInfo @@ -183,6 +193,8 @@ export type WorktreeMeta = { baseRef?: string /** See {@link Worktree.pushTarget}. Persisted so refreshed worktree lists keep the target. */ pushTarget?: GitPushTarget + /** User-assigned workspace board status for manual sidebar organization. */ + workspaceStatus?: WorkspaceStatus diffComments?: DiffComment[] } @@ -1571,6 +1583,9 @@ export type PersistedUIState = { uiZoomLevel: number editorFontZoomLevel: number worktreeCardProperties: WorktreeCardProperty[] + workspaceStatuses?: WorkspaceStatusDefinition[] + workspaceBoardOpacity?: number + workspaceBoardCompact?: boolean statusBarItems: StatusBarItem[] statusBarVisible: boolean dismissedUpdateVersion: string | null diff --git a/src/shared/workspace-statuses.ts b/src/shared/workspace-statuses.ts new file mode 100644 index 00000000000..7163d661a5c --- /dev/null +++ b/src/shared/workspace-statuses.ts @@ -0,0 +1,201 @@ +import type { Worktree, WorkspaceStatus, WorkspaceStatusDefinition } from './types' + +const WORKSPACE_STATUS_GROUP_PREFIX = 'workspace-status:' +const MAX_STATUS_LABEL_LENGTH = 32 +const MAX_WORKSPACE_STATUSES = 12 + +export const DEFAULT_WORKSPACE_STATUS_ID: WorkspaceStatus = 'in-progress' +export const DEFAULT_WORKSPACE_STATUS_COLOR_ID = 'neutral' +export const DEFAULT_WORKSPACE_STATUS_ICON_ID = 'circle-dot' + +export const WORKSPACE_STATUS_COLOR_IDS = [ + 'neutral', + 'blue', + 'sky', + 'violet', + 'amber', + 'emerald', + 'rose', + 'zinc' +] as const + +export const WORKSPACE_STATUS_ICON_IDS = [ + 'circle', + 'circle-dot', + 'circle-dashed', + 'circle-ellipsis', + 'git-pull-request', + 'timer', + 'flag', + 'circle-alert', + 'circle-pause', + 'circle-play', + 'circle-check', + 'ban' +] as const + +const DEFAULT_STATUS_VISUALS: Record = { + todo: { color: 'neutral', icon: 'circle' }, + 'in-progress': { color: 'blue', icon: 'circle-dot' }, + 'in-review': { color: 'violet', icon: 'git-pull-request' }, + completed: { color: 'emerald', icon: 'circle-check' } +} + +export const DEFAULT_WORKSPACE_STATUSES = [ + { id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }, + { id: 'in-progress', label: 'In progress', color: 'blue', icon: 'circle-dot' }, + { id: 'in-review', label: 'In review', color: 'violet', icon: 'git-pull-request' }, + { id: 'completed', label: 'Completed', color: 'emerald', icon: 'circle-check' } +] as const satisfies readonly WorkspaceStatusDefinition[] + +export function cloneDefaultWorkspaceStatuses(): WorkspaceStatusDefinition[] { + return DEFAULT_WORKSPACE_STATUSES.map((status) => ({ ...status })) +} + +function sanitizeWorkspaceStatusLabel(value: unknown, fallback: string): string { + if (typeof value !== 'string') { + return fallback + } + const trimmed = value.trim().replace(/\s+/g, ' ') + return trimmed ? trimmed.slice(0, MAX_STATUS_LABEL_LENGTH) : fallback +} + +function slugWorkspaceStatusLabel(label: string): string { + const slug = label + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + return slug || 'status' +} + +function sanitizeWorkspaceStatusId(value: unknown, fallbackLabel: string): WorkspaceStatus { + if (typeof value !== 'string') { + return slugWorkspaceStatusLabel(fallbackLabel) + } + const trimmed = value.trim().toLowerCase() + if (!trimmed) { + return slugWorkspaceStatusLabel(fallbackLabel) + } + return trimmed.replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'status' +} + +function sanitizeWorkspaceStatusColor(value: unknown, statusId: string, index: number): string { + if (typeof value === 'string' && WORKSPACE_STATUS_COLOR_IDS.some((id) => id === value)) { + return value + } + const defaultVisual = DEFAULT_STATUS_VISUALS[statusId] + if (defaultVisual) { + return defaultVisual.color + } + return WORKSPACE_STATUS_COLOR_IDS[index % WORKSPACE_STATUS_COLOR_IDS.length] +} + +function sanitizeWorkspaceStatusIcon(value: unknown, statusId: string): string { + if (typeof value === 'string' && WORKSPACE_STATUS_ICON_IDS.some((id) => id === value)) { + return value + } + return DEFAULT_STATUS_VISUALS[statusId]?.icon ?? DEFAULT_WORKSPACE_STATUS_ICON_ID +} + +export function makeWorkspaceStatusId( + label: string, + existingStatuses: readonly WorkspaceStatusDefinition[] +): WorkspaceStatus { + const base = slugWorkspaceStatusLabel(label) + const existingIds = new Set(existingStatuses.map((status) => status.id)) + if (!existingIds.has(base)) { + return base + } + for (let index = 2; index < 100; index += 1) { + const candidate = `${base}-${index}` + if (!existingIds.has(candidate)) { + return candidate + } + } + return `status-${Date.now().toString(36)}` +} + +export function normalizeWorkspaceStatuses(value: unknown): WorkspaceStatusDefinition[] { + if (!Array.isArray(value)) { + return cloneDefaultWorkspaceStatuses() + } + + const statuses: WorkspaceStatusDefinition[] = [] + const usedIds = new Set() + for (const rawStatus of value.slice(0, MAX_WORKSPACE_STATUSES)) { + if (!rawStatus || typeof rawStatus !== 'object' || Array.isArray(rawStatus)) { + continue + } + const raw = rawStatus as Record + const fallbackLabel = `Status ${statuses.length + 1}` + const label = sanitizeWorkspaceStatusLabel(raw.label, fallbackLabel) + let id = sanitizeWorkspaceStatusId(raw.id, label) + if (usedIds.has(id)) { + id = makeWorkspaceStatusId(label, statuses) + } + usedIds.add(id) + statuses.push({ + id, + label, + color: sanitizeWorkspaceStatusColor(raw.color, id, statuses.length), + icon: sanitizeWorkspaceStatusIcon(raw.icon, id) + }) + } + + return statuses.length > 0 ? statuses : cloneDefaultWorkspaceStatuses() +} + +export function clampWorkspaceBoardOpacity(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 1 + } + return Math.min(1, Math.max(0.2, Math.round(value * 100) / 100)) +} + +export function normalizeWorkspaceBoardCompact(value: unknown): boolean { + return value === true +} + +export function isWorkspaceStatusId( + value: string, + statuses: readonly WorkspaceStatusDefinition[] +): value is WorkspaceStatus { + return statuses.some((status) => status.id === value) +} + +export function getDefaultWorkspaceStatusId( + statuses: readonly WorkspaceStatusDefinition[] +): WorkspaceStatus { + return statuses.some((status) => status.id === DEFAULT_WORKSPACE_STATUS_ID) + ? DEFAULT_WORKSPACE_STATUS_ID + : (statuses[0]?.id ?? DEFAULT_WORKSPACE_STATUS_ID) +} + +export function getWorkspaceStatus( + worktree: Pick, + statuses: readonly WorkspaceStatusDefinition[] +): WorkspaceStatus { + return worktree.workspaceStatus && isWorkspaceStatusId(worktree.workspaceStatus, statuses) + ? worktree.workspaceStatus + : getDefaultWorkspaceStatusId(statuses) +} + +export function getWorkspaceStatusGroupKey(status: WorkspaceStatus): string { + return `${WORKSPACE_STATUS_GROUP_PREFIX}${encodeURIComponent(status)}` +} + +export function getWorkspaceStatusFromGroupKey( + groupKey: string, + statuses: readonly WorkspaceStatusDefinition[] +): WorkspaceStatus | null { + if (!groupKey.startsWith(WORKSPACE_STATUS_GROUP_PREFIX)) { + return null + } + try { + const status = decodeURIComponent(groupKey.slice(WORKSPACE_STATUS_GROUP_PREFIX.length)) + return isWorkspaceStatusId(status, statuses) ? status : null + } catch { + return null + } +} From 2d44ee83b4b933f99d758e77df260a43528ea009 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 14 May 2026 23:30:22 -0700 Subject: [PATCH 22/82] Add Space manager V2 safeguards --- src/main/ipc/workspace-space.test.ts | 125 +++- src/main/ipc/workspace-space.ts | 102 ++- src/main/ipc/worktrees.test.ts | 61 +- src/main/ipc/worktrees.ts | 51 +- .../providers/ssh-filesystem-provider.test.ts | 21 + src/main/providers/ssh-filesystem-provider.ts | 14 + src/main/providers/ssh-git-provider.test.ts | 9 +- src/main/providers/ssh-git-provider.ts | 15 +- src/main/providers/types.ts | 7 +- src/main/runtime/orca-runtime.ts | 9 +- src/main/ssh/ssh-channel-multiplexer.test.ts | 28 + src/main/ssh/ssh-channel-multiplexer.ts | 58 +- src/main/workspace-space-analysis.test.ts | 116 ++- src/main/workspace-space-analysis.ts | 427 ++++++++--- src/main/worktree-removal-safety.ts | 79 ++ src/preload/api-types.ts | 9 +- src/preload/index.ts | 17 +- src/relay/dispatcher.test.ts | 22 + src/relay/dispatcher.ts | 28 +- src/relay/fs-handler.test.ts | 22 +- src/relay/fs-handler.ts | 9 +- src/relay/workspace-space-scan.ts | 375 ++++++++++ .../sidebar/DeleteWorktreeDialog.tsx | 15 +- .../sidebar/delete-worktree-flow.test.ts | 40 +- .../sidebar/delete-worktree-flow.ts | 30 +- .../status-bar/WorkspaceSpaceCompactPanel.tsx | 47 +- .../status-bar/WorkspaceSpaceManagerPanel.tsx | 694 +++++++++++++----- .../status-bar/workspace-space-format.test.ts | 13 + .../status-bar/workspace-space-format.ts | 47 +- .../workspace-space/WorkspaceSpacePage.tsx | 36 +- src/renderer/src/hooks/useIpcEvents.ts | 10 + .../src/store/slices/workspace-space.ts | 70 +- src/renderer/src/store/slices/worktrees.ts | 5 +- src/shared/workspace-space-compaction.ts | 41 ++ src/shared/workspace-space-types.ts | 25 + 35 files changed, 2276 insertions(+), 401 deletions(-) create mode 100644 src/main/worktree-removal-safety.ts create mode 100644 src/relay/workspace-space-scan.ts create mode 100644 src/shared/workspace-space-compaction.ts diff --git a/src/main/ipc/workspace-space.test.ts b/src/main/ipc/workspace-space.test.ts index 24f590786d8..437dd1c0cb0 100644 --- a/src/main/ipc/workspace-space.test.ts +++ b/src/main/ipc/workspace-space.test.ts @@ -1,15 +1,29 @@ import { describe, expect, it, vi } from 'vitest' -import type { WorkspaceSpaceAnalysis } from '../../shared/workspace-space-types' +import type { + WorkspaceSpaceAnalysis, + WorkspaceSpaceAnalyzeResult, + WorkspaceSpaceScanProgress +} from '../../shared/workspace-space-types' import type { Store } from '../persistence' -const { handlers, analyzeWorkspaceSpaceMock, removeHandlerMock, handleMock } = vi.hoisted(() => ({ - handlers: new Map Promise>(), - analyzeWorkspaceSpaceMock: vi.fn(), - removeHandlerMock: vi.fn(), - handleMock: vi.fn((channel: string, handler: () => Promise) => { - handlers.set(channel, handler) - }) -})) +const { + handlers, + analyzeWorkspaceSpaceMock, + removeHandlerMock, + handleMock, + WorkspaceSpaceScanCancelledErrorMock +} = vi.hoisted(() => { + const handlers = new Map Promise>() + return { + handlers, + analyzeWorkspaceSpaceMock: vi.fn(), + removeHandlerMock: vi.fn(), + handleMock: vi.fn((channel: string, handler: (...args: unknown[]) => Promise) => { + handlers.set(channel, handler) + }), + WorkspaceSpaceScanCancelledErrorMock: class WorkspaceSpaceScanCancelledError extends Error {} + } +}) vi.mock('electron', () => ({ ipcMain: { @@ -19,6 +33,7 @@ vi.mock('electron', () => ({ })) vi.mock('../workspace-space-analysis', () => ({ + WorkspaceSpaceScanCancelledError: WorkspaceSpaceScanCancelledErrorMock, analyzeWorkspaceSpace: analyzeWorkspaceSpaceMock })) @@ -37,6 +52,19 @@ function createAnalysis(scannedAt: number): WorkspaceSpaceAnalysis { } } +function createAnalyzeResult(scannedAt: number): WorkspaceSpaceAnalyzeResult { + return { ok: true, analysis: createAnalysis(scannedAt) } +} + +function createEvent() { + return { + sender: { + isDestroyed: vi.fn(() => false), + send: vi.fn() + } + } +} + describe('registerWorkspaceSpaceHandlers', () => { it('shares an in-flight analysis request', async () => { const store = {} as Store @@ -53,17 +81,84 @@ describe('registerWorkspaceSpaceHandlers', () => { const handler = handlers.get('workspaceSpace:analyze') expect(handler).toBeDefined() - const first = handler!() - const duplicate = handler!() + const firstEvent = createEvent() + const secondEvent = createEvent() + const first = handler!(firstEvent) + const duplicate = handler!(secondEvent) expect(analyzeWorkspaceSpaceMock).toHaveBeenCalledTimes(1) - expect(analyzeWorkspaceSpaceMock).toHaveBeenCalledWith(store) + expect(analyzeWorkspaceSpaceMock).toHaveBeenCalledWith( + store, + expect.objectContaining({ + scanId: expect.any(String), + signal: expect.any(AbortSignal), + onProgress: expect.any(Function) + }) + ) const firstResult = createAnalysis(1) resolveFirstScan(firstResult) - await expect(first).resolves.toBe(firstResult) - await expect(duplicate).resolves.toBe(firstResult) + await expect(first).resolves.toEqual({ ok: true, analysis: firstResult }) + await expect(duplicate).resolves.toEqual({ ok: true, analysis: firstResult }) - await expect(handler!()).resolves.toEqual(createAnalysis(2)) + await expect(handler!(createEvent())).resolves.toEqual(createAnalyzeResult(2)) expect(analyzeWorkspaceSpaceMock).toHaveBeenCalledTimes(2) }) + + it('forwards scan progress to the requesting renderer', async () => { + const store = {} as Store + let onProgress: ((progress: WorkspaceSpaceScanProgress) => void) | undefined + analyzeWorkspaceSpaceMock.mockImplementationOnce((_store, options) => { + onProgress = options.onProgress + return Promise.resolve(createAnalysis(1)) + }) + + registerWorkspaceSpaceHandlers(store) + const event = createEvent() + const handler = handlers.get('workspaceSpace:analyze') + const promise = handler!(event) + const progress: WorkspaceSpaceScanProgress = { + scanId: 'scan-1', + state: 'running', + startedAt: 1, + updatedAt: 1, + totalRepoCount: 1, + scannedRepoCount: 0, + totalWorktreeCount: 2, + scannedWorktreeCount: 1, + currentRepoDisplayName: 'orca', + currentWorktreeDisplayName: 'feature' + } + onProgress?.(progress) + await promise + + expect(event.sender.send).toHaveBeenCalledWith('workspaceSpace:progress', progress) + }) + + it('cancels the in-flight scan', async () => { + const store = {} as Store + let signal: AbortSignal | undefined + analyzeWorkspaceSpaceMock.mockImplementationOnce((_store, options) => { + signal = options.signal + return new Promise(() => {}) + }) + + registerWorkspaceSpaceHandlers(store) + const analyzeHandler = handlers.get('workspaceSpace:analyze') + const cancelHandler = handlers.get('workspaceSpace:cancel') + void analyzeHandler!(createEvent()) + + await expect(cancelHandler!()).resolves.toBe(true) + expect(signal?.aborted).toBe(true) + await expect(cancelHandler!()).resolves.toBe(false) + }) + + it('returns a normal cancelled result instead of rejecting expected cancellation', async () => { + const store = {} as Store + analyzeWorkspaceSpaceMock.mockRejectedValueOnce(new WorkspaceSpaceScanCancelledErrorMock()) + + registerWorkspaceSpaceHandlers(store) + const analyzeHandler = handlers.get('workspaceSpace:analyze') + + await expect(analyzeHandler!(createEvent())).resolves.toEqual({ ok: false, cancelled: true }) + }) }) diff --git a/src/main/ipc/workspace-space.ts b/src/main/ipc/workspace-space.ts index 8302503bdf8..d4449f3db52 100644 --- a/src/main/ipc/workspace-space.ts +++ b/src/main/ipc/workspace-space.ts @@ -1,19 +1,107 @@ import { ipcMain } from 'electron' import type { Store } from '../persistence' -import type { WorkspaceSpaceAnalysis } from '../../shared/workspace-space-types' -import { analyzeWorkspaceSpace } from '../workspace-space-analysis' +import type { + WorkspaceSpaceAnalyzeResult, + WorkspaceSpaceScanProgress +} from '../../shared/workspace-space-types' +import { + analyzeWorkspaceSpace, + WorkspaceSpaceScanCancelledError +} from '../workspace-space-analysis' + +const PROGRESS_EMIT_INTERVAL_MS = 100 + +type InFlightWorkspaceSpaceScan = { + scanId: string + controller: AbortController + progress: WorkspaceSpaceScanProgress + promise: Promise +} export function registerWorkspaceSpaceHandlers(store: Store): void { - let inFlightScan: Promise | null = null + let inFlightScan: InFlightWorkspaceSpaceScan | null = null + ipcMain.removeHandler('workspaceSpace:cancel') ipcMain.removeHandler('workspaceSpace:analyze') - ipcMain.handle('workspaceSpace:analyze', async (): Promise => { + ipcMain.handle('workspaceSpace:analyze', async (event): Promise => { if (!inFlightScan) { + const controller = new AbortController() + const scanId = `${Date.now()}-${Math.random().toString(36).slice(2)}` + let latestProgress: WorkspaceSpaceScanProgress = { + scanId, + state: 'running', + startedAt: Date.now(), + updatedAt: Date.now(), + totalRepoCount: 0, + scannedRepoCount: 0, + totalWorktreeCount: 0, + scannedWorktreeCount: 0, + currentRepoDisplayName: null, + currentWorktreeDisplayName: null + } + let lastProgressSentAt = 0 + const sendProgress = (progress: WorkspaceSpaceScanProgress): void => { + // Why: large fleets can report one progress event per worktree; keep + // the UI responsive without repainting the full Space page for each row. + const now = Date.now() + const isFirstProgress = lastProgressSentAt === 0 + const isTerminalProgress = + progress.state !== 'running' || + (progress.totalWorktreeCount > 0 && + progress.scannedWorktreeCount >= progress.totalWorktreeCount) + if ( + !isFirstProgress && + !isTerminalProgress && + now - lastProgressSentAt < PROGRESS_EMIT_INTERVAL_MS + ) { + return + } + lastProgressSentAt = now + if (!event.sender.isDestroyed()) { + event.sender.send('workspaceSpace:progress', progress) + } + } // Why: large worktree fleets require real disk traversal; duplicate // requests should share that IO instead of starting competing scans. - inFlightScan = analyzeWorkspaceSpace(store).finally(() => { - inFlightScan = null + const scan: InFlightWorkspaceSpaceScan = { + scanId, + controller, + progress: latestProgress, + promise: Promise.resolve(null as never) + } + inFlightScan = scan + scan.promise = analyzeWorkspaceSpace(store, { + scanId, + signal: controller.signal, + onProgress: (progress) => { + latestProgress = progress + scan.progress = progress + sendProgress(progress) + } }) + .then((analysis): WorkspaceSpaceAnalyzeResult => ({ ok: true, analysis })) + .catch((error: unknown): WorkspaceSpaceAnalyzeResult => { + if (error instanceof WorkspaceSpaceScanCancelledError) { + return { ok: false, cancelled: true } + } + throw error + }) + .finally(() => { + inFlightScan = null + }) } - return inFlightScan + return inFlightScan.promise + }) + + ipcMain.handle('workspaceSpace:cancel', async (): Promise => { + if (!inFlightScan || inFlightScan.controller.signal.aborted) { + return false + } + inFlightScan.controller.abort() + inFlightScan.progress = { + ...inFlightScan.progress, + state: 'cancelling', + updatedAt: Date.now() + } + return true }) } diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 25ff76c4dba..4a0de9b6e97 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -308,6 +308,25 @@ describe('registerWorktreeHandlers', () => { registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) }) + function mockKnownFeatureWorktree(path = '/workspace/feature-wt'): void { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/repo', + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path, + head: 'feature', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]) + } + it('auto-suffixes the branch name when the first choice collides with a remote branch', async () => { // Why: new-workspace flow should silently try improve-dashboard-2, -3, ... // rather than failing and forcing the user back to the name picker. @@ -1218,6 +1237,7 @@ describe('registerWorktreeHandlers', () => { }) it('prunes git worktree tracking when removing an orphaned worktree', async () => { + mockKnownFeatureWorktree() const orphanError = Object.assign(new Error('git worktree remove failed'), { stderr: "fatal: '/workspace/feature-wt' is not a working tree" }) @@ -1241,7 +1261,7 @@ describe('registerWorktreeHandlers', () => { }) it('runs the archive hook on remove when skipArchive is not set', async () => { - listWorktreesMock.mockResolvedValue([]) + mockKnownFeatureWorktree() removeWorktreeMock.mockResolvedValue(undefined) getEffectiveHooksMock.mockReturnValue({ scripts: { @@ -1267,7 +1287,7 @@ describe('registerWorktreeHandlers', () => { }) it('skips the archive hook on remove when skipArchive is true', async () => { - listWorktreesMock.mockResolvedValue([]) + mockKnownFeatureWorktree() removeWorktreeMock.mockResolvedValue(undefined) getEffectiveHooksMock.mockReturnValue({ scripts: { @@ -1289,8 +1309,43 @@ describe('registerWorktreeHandlers', () => { ) }) + it('rejects unregistered delete paths before teardown, hooks, or git removal', async () => { + mockKnownFeatureWorktree('/workspace/real-feature') + getEffectiveHooksMock.mockReturnValue({ + scripts: { + archive: 'echo archived' + } + }) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/not-a-worktree' + }) + ).rejects.toThrow('Refusing to delete unregistered worktree path') + + expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + expect(runHookMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + }) + + it('rejects the main worktree before teardown, hooks, or git removal', async () => { + mockKnownFeatureWorktree() + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/repo' + }) + ).rejects.toThrow('Refusing to delete protected worktree path') + + expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + expect(runHookMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + }) + it('IPC-initiated delete kills PTYs BEFORE git-level removal (design §4.3)', async () => { - listWorktreesMock.mockResolvedValue([]) + mockKnownFeatureWorktree() getEffectiveHooksMock.mockReturnValue(null) const callOrder: string[] = [] killAllProcessesForWorktreeMock.mockImplementation(async () => { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index eef52f3d289..8bb2b01494a 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -13,7 +13,7 @@ import type { Repo, WorktreeMeta } from '../../shared/types' -import { removeWorktree } from '../git/worktree' +import { listWorktrees as listGitWorktrees, removeWorktree } from '../git/worktree' import { gitExecFileAsync } from '../git/runner' import { getDefaultRemote } from '../git/repo' import { getPullRequestPushTarget, getWorkItem } from '../github/client' @@ -51,6 +51,10 @@ import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import { workspaceSourceSchema, type WorkspaceSource } from '../../shared/telemetry-events' import { classifyWorkspaceCreateError } from './workspace-create-error-classifier' +import { + canSafelyRemoveOrphanedWorktreeDirectory, + getRegisteredDeletableWorktree +} from '../worktree-removal-safety' // Why: worktrees discovered on disk (not created via Orca's UI) have no // persisted WorktreeMeta, so mergeWorktree falls back to `lastActivityAt: 0`. @@ -517,6 +521,21 @@ export function registerWorktreeHandlers( throw new Error('Folder mode does not support deleting worktrees.') } + // Why: the renderer-supplied worktreeId contains a filesystem path. + // Re-derive the canonical path from git before any destructive action. + const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : null + if (repo.connectionId && !provider) { + throw new Error(`No git provider for connection "${repo.connectionId}"`) + } + const registeredWorktrees = repo.connectionId + ? await provider!.listWorktrees(repo.path) + : await listGitWorktrees(repo.path) + const canonicalWorktreePath = getRegisteredDeletableWorktree( + repo.path, + worktreePath, + registeredWorktrees + ).path + // Why: kill every PTY belonging to this worktree BEFORE git-level // removal. The renderer pre-kills via shutdownWorktreeTerminals, but // defensive teardown here protects against: (a) a future renderer bug, @@ -543,11 +562,7 @@ export function registerWorktreeHandlers( } if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - throw new Error(`No git provider for connection "${repo.connectionId}"`) - } - await provider.removeWorktree(worktreePath, args.force) + await provider!.removeWorktree(canonicalWorktreePath, args.force) runtime.clearOptimisticReconcileToken(args.worktreeId) await runtime.unlinkNotesWorktree(repoId, args.worktreeId) store.removeWorktreeMeta(args.worktreeId) @@ -559,9 +574,9 @@ export function registerWorktreeHandlers( // Run archive hook before removal const hooks = getEffectiveHooks(repo) if (hooks?.scripts.archive && !args.skipArchive) { - const result = await runHook('archive', worktreePath, repo) + const result = await runHook('archive', canonicalWorktreePath, repo) if (!result.success) { - console.error(`[hooks] archive hook failed for ${worktreePath}:`, result.output) + console.error(`[hooks] archive hook failed for ${canonicalWorktreePath}:`, result.output) } } @@ -571,16 +586,24 @@ export function registerWorktreeHandlers( // first so the normal delete path keeps working — otherwise every // deletion would require the Force Delete toast once the feature is on. if (repo.symlinkPaths && repo.symlinkPaths.length > 0) { - await removeWorktreeSymlinks(worktreePath, repo.symlinkPaths) + await removeWorktreeSymlinks(canonicalWorktreePath, repo.symlinkPaths) } try { - await removeWorktree(repo.path, worktreePath, args.force ?? false) + await removeWorktree(repo.path, canonicalWorktreePath, args.force ?? false) } catch (error) { // If git no longer tracks this worktree, clean up the directory and metadata if (isOrphanedWorktreeError(error)) { - console.warn(`[worktrees] Orphaned worktree detected at ${worktreePath}, cleaning up`) - await rm(worktreePath, { recursive: true, force: true }).catch(() => {}) + console.warn( + `[worktrees] Orphaned worktree detected at ${canonicalWorktreePath}, cleaning up` + ) + if (await canSafelyRemoveOrphanedWorktreeDirectory(canonicalWorktreePath, repo.path)) { + await rm(canonicalWorktreePath, { recursive: true, force: true }).catch(() => {}) + } else { + console.warn( + `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` + ) + } // Why: `git worktree remove` failed, so git's internal worktree tracking // (`.git/worktrees/`) is still intact. Without pruning, `git worktree // list` continues to show the stale entry and the branch it had checked out @@ -594,7 +617,9 @@ export function registerWorktreeHandlers( notifyWorktreesChanged(mainWindow, repoId) return } - throw new Error(formatWorktreeRemovalError(error, worktreePath, args.force ?? false)) + throw new Error( + formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) + ) } runtime.clearOptimisticReconcileToken(args.worktreeId) await runtime.unlinkNotesWorktree(repoId, args.worktreeId) diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index 8fbf9551c00..037c7047756 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -162,6 +162,27 @@ describe('SshFilesystemProvider', () => { }) }) + it('scanWorkspaceSpace sends an abortable bulk scan request', async () => { + const result = { + sizeBytes: 1024, + skippedEntryCount: 0, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0 + } + const controller = new AbortController() + mux.request.mockResolvedValue(result) + + await expect( + provider.scanWorkspaceSpace('/home/user/project', { signal: controller.signal }) + ).resolves.toBe(result) + expect(mux.request).toHaveBeenCalledWith( + 'fs.workspaceSpaceScan', + { rootPath: '/home/user/project' }, + { signal: controller.signal, timeoutMs: 130000 } + ) + }) + it('deletePath sends fs.deletePath request', async () => { await provider.deletePath('/home/user/file.txt') expect(mux.request).toHaveBeenCalledWith('fs.deletePath', { targetPath: '/home/user/file.txt' }) diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index b33c0dbe08b..cc334a3cf83 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -4,6 +4,7 @@ import { uploadBuffer } from '../ssh/sftp-upload' import type { IFilesystemProvider, FileStat, FileReadResult } from './types' import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' import type { SFTPWrapper } from 'ssh2' type SftpFactory = () => Promise @@ -12,6 +13,8 @@ type WatchRegistration = { setupPromise: Promise } +const WORKSPACE_SPACE_SCAN_TIMEOUT_MS = 130_000 + export class SshFilesystemProvider implements IFilesystemProvider { private connectionId: string private mux: SshChannelMultiplexer @@ -122,6 +125,17 @@ export class SshFilesystemProvider implements IFilesystemProvider { return (await this.mux.request('fs.stat', { filePath })) as FileStat } + async scanWorkspaceSpace( + rootPath: string, + options?: { signal?: AbortSignal } + ): Promise { + return (await this.mux.request( + 'fs.workspaceSpaceScan', + { rootPath }, + { signal: options?.signal, timeoutMs: WORKSPACE_SPACE_SCAN_TIMEOUT_MS } + )) as WorkspaceSpaceDirectoryScanResult + } + async deletePath(targetPath: string, recursive?: boolean): Promise { await this.mux.request('fs.deletePath', { targetPath, recursive }) } diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 68346c81e0f..5643c3c2d29 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -200,8 +200,13 @@ describe('SshGitProvider', () => { ] mux.request.mockResolvedValue(worktrees) - const result = await provider.listWorktrees('/home/user/repo') - expect(mux.request).toHaveBeenCalledWith('git.listWorktrees', { repoPath: '/home/user/repo' }) + const controller = new AbortController() + const result = await provider.listWorktrees('/home/user/repo', { signal: controller.signal }) + expect(mux.request).toHaveBeenCalledWith( + 'git.listWorktrees', + { repoPath: '/home/user/repo' }, + { signal: controller.signal } + ) expect(result).toEqual(worktrees) }) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index e93ad886adf..45dec93c79b 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -123,10 +123,17 @@ export class SshGitProvider implements IGitProvider { })) as GitDiffResult[] } - async listWorktrees(repoPath: string): Promise { - return (await this.mux.request('git.listWorktrees', { - repoPath - })) as GitWorktreeInfo[] + async listWorktrees( + repoPath: string, + options?: { signal?: AbortSignal } + ): Promise { + return (await this.mux.request( + 'git.listWorktrees', + { + repoPath + }, + { signal: options?.signal } + )) as GitWorktreeInfo[] } async addWorktree( diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 998c22b3f73..3e091ea1f76 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -11,6 +11,7 @@ import type { SearchOptions, SearchResult } from '../../shared/types' +import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' // ─── PTY Provider ─────────────────────────────────────────────────── @@ -130,6 +131,10 @@ export type IFilesystemProvider = { realpath(filePath: string): Promise search(opts: SearchOptions): Promise listFiles(rootPath: string, options?: { excludePaths?: string[] }): Promise + scanWorkspaceSpace?( + rootPath: string, + options?: { signal?: AbortSignal } + ): Promise watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> } @@ -161,7 +166,7 @@ export type IGitProvider = { baseRef: string, options?: { includePatch?: boolean; filePath?: string; oldPath?: string } ): Promise - listWorktrees(repoPath: string): Promise + listWorktrees(repoPath: string, options?: { signal?: AbortSignal }): Promise addWorktree( repoPath: string, branchName: string, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 38fa678ec92..3ee8b60e261 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -222,6 +222,7 @@ import { shouldSetDisplayName, areWorktreePathsEqual } from '../ipc/worktree-logic' +import { canSafelyRemoveOrphanedWorktreeDirectory } from '../worktree-removal-safety' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { HeadlessEmulator } from '../daemon/headless-emulator' import { killAllProcessesForWorktree } from './worktree-teardown' @@ -6004,7 +6005,13 @@ export class OrcaRuntimeService { await removeWorktree(repo.path, worktree.path, force) } catch (error) { if (isOrphanedWorktreeError(error)) { - await rm(worktree.path, { recursive: true, force: true }).catch(() => {}) + if (await canSafelyRemoveOrphanedWorktreeDirectory(worktree.path, repo.path)) { + await rm(worktree.path, { recursive: true, force: true }).catch(() => {}) + } else { + console.warn( + `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${worktree.path}` + ) + } // Why: `git worktree remove` failed, so git's internal worktree tracking // (`.git/worktrees/`) is still intact. Without pruning, `git worktree // list` continues to show the stale entry and the branch it had checked out diff --git a/src/main/ssh/ssh-channel-multiplexer.test.ts b/src/main/ssh/ssh-channel-multiplexer.test.ts index f8ba76209b2..7df48286090 100644 --- a/src/main/ssh/ssh-channel-multiplexer.test.ts +++ b/src/main/ssh/ssh-channel-multiplexer.test.ts @@ -123,6 +123,34 @@ describe('SshChannelMultiplexer', () => { vi.advanceTimersByTime(1_000) await expect(promise).rejects.toThrow('timed out') + const cancelPayload = JSON.parse( + transport.written + .at(-1)! + .subarray(HEADER_LENGTH, HEADER_LENGTH + transport.written.at(-1)!.readUInt32BE(9)) + .toString() + ) + expect(cancelPayload).toMatchObject({ + method: 'rpc.cancel', + params: { id: 1 } + }) + }) + + it('uses per-request timeout overrides', async () => { + const promise = mux.request('fs.workspaceSpaceScan', {}, { timeoutMs: 60_000 }) + + for (let i = 0; i < 6; i++) { + vi.advanceTimersByTime(5_000) + transport.dataCallbacks[0](encodeKeepAliveFrame(i + 1, 0)) + } + await Promise.resolve() + const requestWrites = transport.written.filter((frame) => frame[0] === MessageType.Regular) + expect(requestWrites).toHaveLength(1) + + for (let i = 6; i < 12; i++) { + vi.advanceTimersByTime(5_000) + transport.dataCallbacks[0](encodeKeepAliveFrame(i + 1, 0)) + } + await expect(promise).rejects.toThrow('timed out after 60000ms') }) it('assigns unique request IDs', async () => { diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index 11a18bebe82..9d95bbd05b8 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: the SSH relay protocol state machine keeps + request, notification, keepalive, and cancellation semantics paired. */ import { FrameDecoder, MessageType, @@ -24,6 +26,7 @@ type PendingRequest = { resolve: (result: unknown) => void reject: (error: Error) => void timer: ReturnType + cleanup: () => void } export type NotificationHandler = (method: string, params: Record) => void @@ -127,10 +130,19 @@ export class SshChannelMultiplexer { /** * Send a JSON-RPC request and wait for the response. */ - async request(method: string, params?: Record): Promise { + async request( + method: string, + params?: Record, + options?: { signal?: AbortSignal; timeoutMs?: number } + ): Promise { if (this.disposed) { throw new Error('Multiplexer disposed') } + if (options?.signal?.aborted) { + const error = new Error(`Request "${method}" was cancelled`) as Error & { name: string } + error.name = 'AbortError' + throw error + } const id = this.nextRequestId++ const msg: JsonRpcRequest = { @@ -139,14 +151,46 @@ export class SshChannelMultiplexer { method, ...(params !== undefined ? { params } : {}) } + const timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS return new Promise((resolve, reject) => { - const timer = setTimeout(() => { + let timer: ReturnType + const cleanup = (): void => { + clearTimeout(timer) + if (options?.signal) { + options.signal.removeEventListener('abort', onAbort) + } + } + const onAbort = (): void => { + const pending = this.pendingRequests.get(id) + if (!pending) { + return + } + pending.cleanup() this.pendingRequests.delete(id) - reject(new Error(`Request "${method}" timed out after ${REQUEST_TIMEOUT_MS}ms`)) - }, REQUEST_TIMEOUT_MS) + // Why: Space scans can run long on SSH hosts. Let the relay stop its + // local filesystem work instead of only dropping the client promise. + this.notify('rpc.cancel', { id }) + const error = new Error(`Request "${method}" was cancelled`) as Error & { name: string } + error.name = 'AbortError' + pending.reject(error) + } + timer = setTimeout(() => { + const pending = this.pendingRequests.get(id) + if (pending) { + pending.cleanup() + // Why: request timeouts should stop relay-side long-running work, + // not just detach the client from the eventual response. + this.notify('rpc.cancel', { id }) + } + this.pendingRequests.delete(id) + reject(new Error(`Request "${method}" timed out after ${timeoutMs}ms`)) + }, timeoutMs) - this.pendingRequests.set(id, { resolve, reject, timer }) + if (options?.signal) { + options.signal.addEventListener('abort', onAbort, { once: true }) + } + this.pendingRequests.set(id, { resolve, reject, timer, cleanup }) this.sendMessage(msg) }) } @@ -194,7 +238,7 @@ export class SshChannelMultiplexer { const errorCode = reason === 'connection_lost' ? 'CONNECTION_LOST' : 'DISPOSED' for (const [id, pending] of this.pendingRequests) { - clearTimeout(pending.timer) + pending.cleanup() const err = new Error(errorMessage) as Error & { code: string } err.code = errorCode pending.reject(err) @@ -295,7 +339,7 @@ export class SshChannelMultiplexer { return } - clearTimeout(pending.timer) + pending.cleanup() this.pendingRequests.delete(msg.id) if (msg.error) { diff --git a/src/main/workspace-space-analysis.test.ts b/src/main/workspace-space-analysis.test.ts index 041219501c8..f2ab549b915 100644 --- a/src/main/workspace-space-analysis.test.ts +++ b/src/main/workspace-space-analysis.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: scan, cancellation, SSH fallback, and compaction tests share temp-repo fixtures. */ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -32,7 +33,7 @@ vi.mock('./providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock })) -import { analyzeWorkspaceSpace } from './workspace-space-analysis' +import { analyzeWorkspaceSpace, WorkspaceSpaceScanCancelledError } from './workspace-space-analysis' function createStore(repos: Repo[]): Store { return { @@ -123,6 +124,70 @@ describe('analyzeWorkspaceSpace', () => { expect(result.reclaimableBytes).toBe(feature?.sizeBytes) }) + it('reports scan progress as repos and worktrees are scanned', async () => { + const root = tempDir! + const repoPath = join(root, 'repo') + await mkdir(repoPath, { recursive: true }) + await writeSizedFile(join(repoPath, 'file.txt'), 128) + const repo: Repo = { + id: 'repo-1', + path: repoPath, + displayName: 'orca', + badgeColor: '#000', + addedAt: 0 + } + listRepoWorktreesMock.mockResolvedValue([ + { + path: repoPath, + head: 'a', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + const progress: unknown[] = [] + + await analyzeWorkspaceSpace(createStore([repo]), { + scanId: 'scan-1', + onProgress: (event) => progress.push(event) + }) + + expect(progress[0]).toMatchObject({ + scanId: 'scan-1', + totalRepoCount: 1, + scannedRepoCount: 0, + totalWorktreeCount: 0, + scannedWorktreeCount: 0 + }) + expect(progress).toContainEqual( + expect.objectContaining({ + totalWorktreeCount: 1, + currentRepoDisplayName: 'orca' + }) + ) + expect(progress.at(-1)).toMatchObject({ + scannedRepoCount: 1, + scannedWorktreeCount: 1 + }) + }) + + it('rejects when a scan is cancelled before it starts', async () => { + const repo: Repo = { + id: 'repo-1', + path: tempDir!, + displayName: 'orca', + badgeColor: '#000', + addedAt: 0 + } + const controller = new AbortController() + controller.abort() + + await expect( + analyzeWorkspaceSpace(createStore([repo]), { signal: controller.signal }) + ).rejects.toBeInstanceOf(WorkspaceSpaceScanCancelledError) + expect(listRepoWorktreesMock).not.toHaveBeenCalled() + }) + it('isolates missing worktrees as row-level scan failures', async () => { const root = tempDir! const repoPath = join(root, 'repo') @@ -219,6 +284,55 @@ describe('analyzeWorkspaceSpace', () => { expect(statMock).not.toHaveBeenCalledWith('/remote/feature/linked-cache') }) + it('uses the SSH bulk Space scan provider when available', async () => { + const repo: Repo = { + id: 'repo-remote', + path: '/remote/repo', + displayName: 'remote', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + getSshGitProviderMock.mockReturnValue({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/feature', + head: 'c', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + }) + const scanWorkspaceSpace = vi.fn().mockResolvedValue({ + sizeBytes: 4096, + skippedEntryCount: 0, + topLevelItems: [ + { + name: 'node_modules', + path: '/remote/feature/node_modules', + kind: 'directory', + sizeBytes: 4096 + } + ], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0 + }) + const readDir = vi.fn() + const stat = vi.fn() + getSshFilesystemProviderMock.mockReturnValue({ scanWorkspaceSpace, readDir, stat }) + + const result = await analyzeWorkspaceSpace(createStore([repo])) + + expect(scanWorkspaceSpace).toHaveBeenCalledWith( + '/remote/feature', + expect.objectContaining({ signal: undefined }) + ) + expect(readDir).not.toHaveBeenCalled() + expect(stat).not.toHaveBeenCalled() + expect(result.worktrees[0]?.sizeBytes).toBe(4096) + }) + it('reports disconnected SSH repos without failing the whole analysis', async () => { const repo: Repo = { id: 'repo-remote', diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts index 950f05c78bb..fd23f12b65b 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -2,7 +2,7 @@ semantics paired so reclaimable-byte, symlink, and partial-failure behavior cannot drift. */ import { lstat, readdir } from 'node:fs/promises' import { execFile } from 'node:child_process' -import { basename, posix, win32 } from 'node:path' +import { posix, win32 } from 'node:path' import { platform } from 'node:process' import { promisify } from 'node:util' import type { Dirent } from 'node:fs' @@ -11,19 +11,21 @@ import { isFolderRepo } from '../shared/repo-kind' import type { GitWorktreeInfo, Repo, Worktree } from '../shared/types' import type { WorkspaceSpaceAnalysis, + WorkspaceSpaceDirectoryScanResult, WorkspaceSpaceItem, WorkspaceSpaceItemKind, WorkspaceSpaceRepoSummary, + WorkspaceSpaceScanProgress, WorkspaceSpaceScanStatus, WorkspaceSpaceWorktree } from '../shared/workspace-space-types' +import { compactWorkspaceSpaceItems } from '../shared/workspace-space-compaction' import type { IFilesystemProvider } from './providers/types' import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees' import { mergeWorktree } from './ipc/worktree-logic' -const MAX_TOP_LEVEL_ITEMS = 48 const WORKTREE_SCAN_CONCURRENCY = 3 const LOCAL_FS_CONCURRENCY = 48 const REMOTE_FS_CONCURRENCY = 10 @@ -51,16 +53,78 @@ type RepoScanResult = { worktrees: WorkspaceSpaceWorktree[] } -function createAsyncLimiter(maxConcurrent: number): AsyncLimiter { +type WorkspaceSpaceAnalyzeOptions = { + signal?: AbortSignal + scanId?: string + onProgress?: (progress: WorkspaceSpaceScanProgress) => void +} + +type WorkspaceSpaceProgressState = WorkspaceSpaceScanProgress + +export class WorkspaceSpaceScanCancelledError extends Error { + constructor() { + super('Workspace space scan cancelled') + this.name = 'WorkspaceSpaceScanCancelledError' + } +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new WorkspaceSpaceScanCancelledError() + } +} + +function isAbortError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + return (error as { name?: unknown }).name === 'AbortError' +} + +function isRelayMethodNotFoundError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + return (error as { code?: unknown }).code === -32601 +} + +function createAsyncLimiter(maxConcurrent: number, signal?: AbortSignal): AsyncLimiter { let active = 0 - const queue: (() => void)[] = [] + const queue: { resolve: () => void; reject: (error: Error) => void }[] = [] const acquire = async (): Promise => { + throwIfAborted(signal) if (active < maxConcurrent) { active += 1 return } - await new Promise((resolve) => queue.push(resolve)) + await new Promise((resolve, reject) => { + let onAbort: (() => void) | null = null + const waiter = { + resolve: () => { + if (onAbort) { + signal?.removeEventListener('abort', onAbort) + } + resolve() + }, + reject + } + onAbort = () => { + const index = queue.indexOf(waiter) + if (index !== -1) { + queue.splice(index, 1) + } + reject(new WorkspaceSpaceScanCancelledError()) + } + queue.push(waiter) + if (signal) { + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) { + onAbort() + } + } + }) + throwIfAborted(signal) active += 1 } @@ -71,9 +135,7 @@ function createAsyncLimiter(maxConcurrent: number): AsyncLimiter { } finally { active -= 1 const next = queue.shift() - if (next) { - next() - } + next?.resolve() } } } @@ -91,23 +153,32 @@ function looksLikeWindowsPath(pathValue: string): boolean { return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') } +function basenameFilesystemPath(pathValue: string): string { + return looksLikeWindowsPath(pathValue) ? win32.basename(pathValue) : posix.basename(pathValue) +} + function joinFilesystemPath(parent: string, child: string): string { return looksLikeWindowsPath(parent) ? win32.join(parent, child) : posix.join(parent, child) } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + function normalizeLocalDuPath(pathValue: string): string { - const trimmed = pathValue.replace(/[\\/]+$/, '') + const separator = platform === 'win32' ? '\\' : '/' + const trimmed = pathValue.replace(new RegExp(`${escapeRegExp(separator)}+$`), '') return trimmed.length > 0 ? trimmed : pathValue } function parseDuDepthOneOutput(stdout: string): Map { const sizes = new Map() for (const line of stdout.split('\n')) { - const trimmed = line.trim() - if (!trimmed) { + const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line + if (!normalizedLine) { continue } - const match = /^(\d+)\s+(.+)$/.exec(trimmed) + const match = /^(\d+)\s+(.+)$/.exec(normalizedLine) if (!match) { continue } @@ -116,10 +187,14 @@ function parseDuDepthOneOutput(stdout: string): Map { return sizes } -async function readLocalDuDepthOne(rootPath: string): Promise> { +async function readLocalDuDepthOne( + rootPath: string, + signal?: AbortSignal +): Promise> { const { stdout } = await execFileAsync('du', ['-k', '-d', '1', rootPath], { encoding: 'utf8', maxBuffer: DU_MAX_BUFFER_BYTES, + signal, timeout: DU_TIMEOUT_MS }) return parseDuDepthOneOutput(stdout) @@ -153,44 +228,6 @@ function toWorkspaceSpaceItem(stats: ScanStats): WorkspaceSpaceItem { } } -function compactTopLevelItems(items: WorkspaceSpaceItem[]): { - topLevelItems: WorkspaceSpaceItem[] - omittedTopLevelItemCount: number - omittedTopLevelSizeBytes: number -} { - const sorted = [...items].sort( - (a, b) => b.sizeBytes - a.sizeBytes || a.name.localeCompare(b.name) - ) - if (sorted.length <= MAX_TOP_LEVEL_ITEMS) { - return { - topLevelItems: sorted, - omittedTopLevelItemCount: 0, - omittedTopLevelSizeBytes: 0 - } - } - - const visible = sorted.slice(0, MAX_TOP_LEVEL_ITEMS - 1) - const omitted = sorted.slice(MAX_TOP_LEVEL_ITEMS - 1) - const other = omitted.reduce( - (acc, item) => ({ - ...acc, - sizeBytes: acc.sizeBytes + item.sizeBytes - }), - { - name: 'Other', - path: '', - kind: 'other', - sizeBytes: 0 - } - ) - - return { - topLevelItems: [...visible, other], - omittedTopLevelItemCount: omitted.length, - omittedTopLevelSizeBytes: other.sizeBytes - } -} - function createBaseWorktreeRow( repo: Repo, worktree: Worktree, @@ -244,12 +281,34 @@ function createUnavailableWorktreeRow( } } +function createScannedWorktreeRow( + repo: Repo, + worktree: Worktree, + scannedAt: number, + scan: WorkspaceSpaceDirectoryScanResult +): WorkspaceSpaceWorktree { + return { + ...createBaseWorktreeRow(repo, worktree, scannedAt), + status: 'ok', + error: null, + sizeBytes: scan.sizeBytes, + reclaimableBytes: worktree.isMainWorktree ? 0 : scan.sizeBytes, + skippedEntryCount: scan.skippedEntryCount, + topLevelItems: scan.topLevelItems, + omittedTopLevelItemCount: scan.omittedTopLevelItemCount, + omittedTopLevelSizeBytes: scan.omittedTopLevelSizeBytes + } +} + async function scanLocalEntry( entryPath: string, name: string, - limit: AsyncLimiter + limit: AsyncLimiter, + signal?: AbortSignal ): Promise { + throwIfAborted(signal) const stats = await limit(() => lstat(entryPath)) + throwIfAborted(signal) if (stats.isSymbolicLink()) { return { @@ -289,8 +348,16 @@ async function scanLocalEntry( const childStats = await Promise.all( entries.map(async (entry): Promise => { try { - return await scanLocalEntry(joinFilesystemPath(entryPath, entry.name), entry.name, limit) - } catch { + return await scanLocalEntry( + joinFilesystemPath(entryPath, entry.name), + entry.name, + limit, + signal + ) + } catch (error) { + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } return null } }) @@ -322,8 +389,10 @@ async function scanRemoteEntry( name: string, provider: IFilesystemProvider, limit: AsyncLimiter, + signal?: AbortSignal, knownSymlink = false ): Promise { + throwIfAborted(signal) if (knownSymlink) { return { name, @@ -335,6 +404,7 @@ async function scanRemoteEntry( } const stats = await limit(() => provider.stat(entryPath)) + throwIfAborted(signal) if (stats.type === 'symlink') { return { name, @@ -358,7 +428,11 @@ async function scanRemoteEntry( let entries try { entries = await limit(() => provider.readDir(entryPath)) - } catch { + throwIfAborted(signal) + } catch (error) { + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } return { name, path: entryPath, @@ -376,9 +450,13 @@ async function scanRemoteEntry( entry.name, provider, limit, + signal, entry.isSymlink ) - } catch { + } catch (error) { + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } return null } }) @@ -409,9 +487,12 @@ async function scanLocalTopLevelEntry( entryPath: string, name: string, duSizes: Map, - limit: AsyncLimiter + limit: AsyncLimiter, + signal?: AbortSignal ): Promise { + throwIfAborted(signal) const stats = await limit(() => lstat(entryPath)) + throwIfAborted(signal) if (stats.isSymbolicLink()) { return { @@ -445,13 +526,20 @@ async function scanLocalTopLevelEntry( async function scanLocalWorktreeWithDu( repo: Repo, worktree: Worktree, - scannedAt: number + scannedAt: number, + signal?: AbortSignal ): Promise { + throwIfAborted(signal) const rootStats = await lstat(worktree.path) if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) { - const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY) - const root = await scanLocalEntry(worktree.path, basename(worktree.path), limit) - const compact = compactTopLevelItems((root.children ?? []).map(toWorkspaceSpaceItem)) + const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY, signal) + const root = await scanLocalEntry( + worktree.path, + basenameFilesystemPath(worktree.path), + limit, + signal + ) + const compact = compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem)) return { ...createBaseWorktreeRow(repo, worktree, scannedAt), status: 'ok', @@ -465,9 +553,10 @@ async function scanLocalWorktreeWithDu( const [entries, duSizes] = await Promise.all([ readdir(worktree.path, { withFileTypes: true }), - readLocalDuDepthOne(worktree.path) + readLocalDuDepthOne(worktree.path, signal) ]) - const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY) + throwIfAborted(signal) + const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY, signal) const childStats = await Promise.all( entries.map(async (entry): Promise => { try { @@ -475,9 +564,13 @@ async function scanLocalWorktreeWithDu( joinFilesystemPath(worktree.path, entry.name), entry.name, duSizes, - limit + limit, + signal ) - } catch { + } catch (error) { + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } return null } }) @@ -487,7 +580,7 @@ async function scanLocalWorktreeWithDu( const rootSize = duSizes.get(normalizeLocalDuPath(worktree.path)) ?? rootStats.size + children.reduce((sum, child) => sum + child.sizeBytes, 0) - const compact = compactTopLevelItems(children.map(toWorkspaceSpaceItem)) + const compact = compactWorkspaceSpaceItems(children.map(toWorkspaceSpaceItem)) return { ...createBaseWorktreeRow(repo, worktree, scannedAt), @@ -503,12 +596,18 @@ async function scanLocalWorktreeWithDu( async function scanLocalWorktreeWithNode( repo: Repo, worktree: Worktree, - scannedAt: number + scannedAt: number, + signal?: AbortSignal ): Promise { try { - const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY) - const root = await scanLocalEntry(worktree.path, basename(worktree.path), limit) - const compact = compactTopLevelItems((root.children ?? []).map(toWorkspaceSpaceItem)) + const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY, signal) + const root = await scanLocalEntry( + worktree.path, + basenameFilesystemPath(worktree.path), + limit, + signal + ) + const compact = compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem)) return { ...createBaseWorktreeRow(repo, worktree, scannedAt), status: 'ok', @@ -519,6 +618,9 @@ async function scanLocalWorktreeWithNode( ...compact } } catch (error) { + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } const classified = classifyError(error) return createUnavailableWorktreeRow( repo, @@ -533,41 +635,70 @@ async function scanLocalWorktreeWithNode( async function scanLocalWorktree( repo: Repo, worktree: Worktree, - scannedAt: number + scannedAt: number, + signal?: AbortSignal ): Promise { + throwIfAborted(signal) if (platform !== 'win32') { try { // Why: JS per-file stats are too slow for large local workspace fleets; // POSIX du gives bounded top-level sizing without following symlinks. - return await scanLocalWorktreeWithDu(repo, worktree, scannedAt) - } catch { + return await scanLocalWorktreeWithDu(repo, worktree, scannedAt, signal) + } catch (error) { + throwIfAborted(signal) + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } // Fall through to the portable scanner so unsupported du variants or // permission edge cases still produce partial rows instead of failing. } } - return scanLocalWorktreeWithNode(repo, worktree, scannedAt) + return scanLocalWorktreeWithNode(repo, worktree, scannedAt, signal) } async function scanRemoteWorktree( repo: Repo, worktree: Worktree, scannedAt: number, - provider: IFilesystemProvider + provider: IFilesystemProvider, + signal?: AbortSignal ): Promise { try { - const limit = createAsyncLimiter(REMOTE_FS_CONCURRENCY) - const root = await scanRemoteEntry(worktree.path, basename(worktree.path), provider, limit) - const compact = compactTopLevelItems((root.children ?? []).map(toWorkspaceSpaceItem)) - return { - ...createBaseWorktreeRow(repo, worktree, scannedAt), - status: 'ok', - error: null, + if (provider.scanWorkspaceSpace) { + try { + const scan = await provider.scanWorkspaceSpace(worktree.path, { signal }) + return createScannedWorktreeRow(repo, worktree, scannedAt, scan) + } catch (error) { + if (isAbortError(error)) { + throw new WorkspaceSpaceScanCancelledError() + } + if (!isRelayMethodNotFoundError(error)) { + throw error + } + // Why: old SSH relays do not know the bulk Space scan method. Fall + // back to the request-by-request walker instead of marking SSH rows + // unavailable after an app upgrade. + } + } + + const limit = createAsyncLimiter(REMOTE_FS_CONCURRENCY, signal) + const root = await scanRemoteEntry( + worktree.path, + basenameFilesystemPath(worktree.path), + provider, + limit, + signal + ) + const compact = compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem)) + return createScannedWorktreeRow(repo, worktree, scannedAt, { sizeBytes: root.sizeBytes, - reclaimableBytes: worktree.isMainWorktree ? 0 : root.sizeBytes, skippedEntryCount: root.skippedEntryCount, ...compact - } + }) } catch (error) { + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } const classified = classifyError(error) return createUnavailableWorktreeRow( repo, @@ -579,8 +710,12 @@ async function scanRemoteWorktree( } } -async function listWorktreesForSpaceScan(repo: Repo): Promise { +async function listWorktreesForSpaceScan( + repo: Repo, + signal?: AbortSignal +): Promise { try { + throwIfAborted(signal) if (isFolderRepo(repo)) { return { ok: true, worktrees: [createFolderWorktree(repo)] } } @@ -593,10 +728,17 @@ async function listWorktreesForSpaceScan(repo: Repo): Promise { - const listed = await listWorktreesForSpaceScan(repo) +function reportProgress( + progress: WorkspaceSpaceProgressState, + updates: Partial, + onProgress: WorkspaceSpaceAnalyzeOptions['onProgress'] +): void { + Object.assign(progress, updates, { updatedAt: Date.now() }) + onProgress?.({ ...progress }) +} + +async function scanRepo( + repo: Repo, + scannedAt: number, + store: Store, + progress: WorkspaceSpaceProgressState, + options: WorkspaceSpaceAnalyzeOptions +): Promise { + throwIfAborted(options.signal) + reportProgress( + progress, + { + currentRepoDisplayName: repo.displayName, + currentWorktreeDisplayName: null + }, + options.onProgress + ) + const listed = await listWorktreesForSpaceScan(repo, options.signal) if (!listed.ok) { + reportProgress( + progress, + { scannedRepoCount: progress.scannedRepoCount + 1 }, + options.onProgress + ) return { worktrees: [], summary: { @@ -630,22 +801,49 @@ async function scanRepo(repo: Repo, scannedAt: number, store: Store): Promise mergeForSpaceScan(repo, gitWorktree, store) ) + reportProgress( + progress, + { totalWorktreeCount: progress.totalWorktreeCount + worktrees.length }, + options.onProgress + ) const remoteProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : undefined const rows = await mapLimit(worktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => { - if (repo.connectionId) { - if (!remoteProvider) { - return createUnavailableWorktreeRow( - repo, - worktree, - scannedAt, - 'unavailable', - `SSH filesystem for "${repo.connectionId}" is not connected.` - ) - } - return scanRemoteWorktree(repo, worktree, scannedAt, remoteProvider) - } - return scanLocalWorktree(repo, worktree, scannedAt) + throwIfAborted(options.signal) + reportProgress( + progress, + { + currentRepoDisplayName: repo.displayName, + currentWorktreeDisplayName: worktree.displayName + }, + options.onProgress + ) + const row: WorkspaceSpaceWorktree = repo.connectionId + ? remoteProvider + ? await scanRemoteWorktree(repo, worktree, scannedAt, remoteProvider, options.signal) + : createUnavailableWorktreeRow( + repo, + worktree, + scannedAt, + 'unavailable', + `SSH filesystem for "${repo.connectionId}" is not connected.` + ) + : await scanLocalWorktree(repo, worktree, scannedAt, options.signal) + reportProgress( + progress, + { scannedWorktreeCount: progress.scannedWorktreeCount + 1 }, + options.onProgress + ) + return row }) + reportProgress( + progress, + { + scannedRepoCount: progress.scannedRepoCount + 1, + currentRepoDisplayName: repo.displayName, + currentWorktreeDisplayName: null + }, + options.onProgress + ) return { worktrees: rows, @@ -664,11 +862,30 @@ async function scanRepo(repo: Repo, scannedAt: number, store: Store): Promise { +export async function analyzeWorkspaceSpace( + store: Store, + options: WorkspaceSpaceAnalyzeOptions = {} +): Promise { + throwIfAborted(options.signal) const scannedAt = Date.now() - const repoResults = await mapLimit(store.getRepos(), 2, (repo) => - scanRepo(repo, scannedAt, store) + const reposToScan = store.getRepos() + const progress: WorkspaceSpaceProgressState = { + scanId: options.scanId ?? String(scannedAt), + state: 'running', + startedAt: scannedAt, + updatedAt: scannedAt, + totalRepoCount: reposToScan.length, + scannedRepoCount: 0, + totalWorktreeCount: 0, + scannedWorktreeCount: 0, + currentRepoDisplayName: null, + currentWorktreeDisplayName: null + } + options.onProgress?.({ ...progress }) + const repoResults = await mapLimit(reposToScan, 2, (repo) => + scanRepo(repo, scannedAt, store, progress, options) ) + throwIfAborted(options.signal) const repos = repoResults.map((result) => result.summary) const worktrees = repoResults .flatMap((result) => result.worktrees) diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts new file mode 100644 index 00000000000..dcd465c5863 --- /dev/null +++ b/src/main/worktree-removal-safety.ts @@ -0,0 +1,79 @@ +import { lstat } from 'fs/promises' +import { homedir } from 'os' +import { posix, win32 } from 'path' +import type { GitWorktreeInfo } from '../shared/types' +import { areWorktreePathsEqual } from './ipc/worktree-logic' + +type PathOps = typeof posix + +function looksLikeWindowsPath(pathValue: string): boolean { + return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') +} + +function getPathOps(...paths: string[]): PathOps { + return paths.some(looksLikeWindowsPath) ? win32 : posix +} + +function containsPath(parentPath: string, childPath: string, pathOps: PathOps): boolean { + const relativePath = pathOps.relative(parentPath, childPath) + return ( + relativePath === '' || + (!!relativePath && !relativePath.startsWith('..') && !pathOps.isAbsolute(relativePath)) + ) +} + +export function isDangerousWorktreeRemovalPath(worktreePath: string, repoPath: string): boolean { + if (!worktreePath.trim()) { + return true + } + + if (areWorktreePathsEqual(worktreePath, repoPath)) { + return true + } + + const pathOps = getPathOps(worktreePath, repoPath) + const resolvedWorktreePath = pathOps.resolve(worktreePath) + const rootPath = pathOps.parse(resolvedWorktreePath).root + if (resolvedWorktreePath === rootPath) { + return true + } + + const resolvedRepoPath = pathOps.resolve(repoPath) + if (containsPath(resolvedWorktreePath, resolvedRepoPath, pathOps)) { + return true + } + + const homePath = homedir() + return !!homePath && containsPath(resolvedWorktreePath, pathOps.resolve(homePath), pathOps) +} + +export function getRegisteredDeletableWorktree( + repoPath: string, + requestedWorktreePath: string, + worktrees: readonly GitWorktreeInfo[] +): GitWorktreeInfo { + const worktree = worktrees.find((item) => areWorktreePathsEqual(item.path, requestedWorktreePath)) + if (!worktree) { + throw new Error(`Refusing to delete unregistered worktree path: ${requestedWorktreePath}`) + } + if (worktree.isMainWorktree || isDangerousWorktreeRemovalPath(worktree.path, repoPath)) { + throw new Error(`Refusing to delete protected worktree path: ${worktree.path}`) + } + return worktree +} + +export async function canSafelyRemoveOrphanedWorktreeDirectory( + worktreePath: string, + repoPath: string +): Promise { + if (isDangerousWorktreeRemovalPath(worktreePath, repoPath)) { + return false + } + + try { + const gitEntry = await lstat(getPathOps(worktreePath).join(worktreePath, '.git')) + return gitEntry.isFile() || gitEntry.isDirectory() || gitEntry.isSymbolicLink() + } catch { + return false + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 533879c1fc7..9140e0e3133 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -172,7 +172,10 @@ import type { } from '../shared/claude-usage-types' import type { RateLimitState } from '../shared/rate-limit-types' import type { SpeechModelManifest, SpeechModelState } from '../shared/speech-types' -import type { WorkspaceSpaceAnalysis } from '../shared/workspace-space-types' +import type { + WorkspaceSpaceAnalyzeResult, + WorkspaceSpaceScanProgress +} from '../shared/workspace-space-types' import type { GhAuthDiagnostic } from '../shared/github-auth-types' import type { SshConnectionState, @@ -535,7 +538,9 @@ export type PreloadApi = { ) => () => void } workspaceSpace: { - analyze: () => Promise + analyze: () => Promise + cancel: () => Promise + onProgress: (callback: (progress: WorkspaceSpaceScanProgress) => void) => () => void } pty: { spawn: (opts: { diff --git a/src/preload/index.ts b/src/preload/index.ts index b9b5d34d123..6a203c2f204 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -40,7 +40,10 @@ import type { RuntimeMobileMarkdownResponse } from '../shared/mobile-markdown-document' import type { RateLimitState } from '../shared/rate-limit-types' -import type { WorkspaceSpaceAnalysis } from '../shared/workspace-space-types' +import type { + WorkspaceSpaceAnalyzeResult, + WorkspaceSpaceScanProgress +} from '../shared/workspace-space-types' import type { GhAuthDiagnostic } from '../shared/github-auth-types' import type { AddIssueCommentBySlugArgs, @@ -495,7 +498,17 @@ const api = { }, workspaceSpace: { - analyze: (): Promise => ipcRenderer.invoke('workspaceSpace:analyze') + analyze: (): Promise => + ipcRenderer.invoke('workspaceSpace:analyze'), + cancel: (): Promise => ipcRenderer.invoke('workspaceSpace:cancel'), + onProgress: (callback: (progress: WorkspaceSpaceScanProgress) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + progress: WorkspaceSpaceScanProgress + ): void => callback(progress) + ipcRenderer.on('workspaceSpace:progress', listener) + return () => ipcRenderer.removeListener('workspaceSpace:progress', listener) + } }, pty: { diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts index e5bf73f6d48..53733dcabd2 100644 --- a/src/relay/dispatcher.test.ts +++ b/src/relay/dispatcher.test.ts @@ -247,4 +247,26 @@ describe('RelayDispatcher', () => { }) expect(responses).toHaveLength(0) }) + + it('aborts in-flight request contexts after client invalidation', async () => { + let observedSignal: AbortSignal | undefined + let resolveHandler!: () => void + dispatcher.onRequest( + 'slow.method', + (_params, context) => + new Promise((resolve) => { + observedSignal = context.signal + resolveHandler = () => resolve(null) + }) + ) + + const req: JsonRpcRequest = { jsonrpc: '2.0', id: 100, method: 'slow.method' } + dispatcher.feed(encodeJsonRpcFrame(req, 1, 0)) + await vi.advanceTimersByTimeAsync(0) + dispatcher.invalidateClient() + + expect(observedSignal?.aborted).toBe(true) + resolveHandler() + await vi.advanceTimersByTimeAsync(0) + }) }) diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index b827195ba4b..1cd2cce034b 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -13,6 +13,7 @@ import { export type RequestContext = { isStale: () => boolean + signal?: AbortSignal } export type MethodHandler = ( @@ -27,6 +28,7 @@ export class RelayDispatcher { private write: (data: Buffer) => void private requestHandlers = new Map() private notificationHandlers = new Map() + private requestAbortControllers = new Map() private nextOutgoingSeq = 1 private highestReceivedSeq = 0 private keepaliveTimer: ReturnType | null = null @@ -54,6 +56,7 @@ export class RelayDispatcher { // up — causing the client's unacked-timeout checker to accumulate stale // timestamps that could eventually fire a false connection-dead signal. setWrite(write: (data: Buffer) => void): void { + this.abortActiveRequests() this.write = write this.nextOutgoingSeq = 1 this.highestReceivedSeq = 0 @@ -65,6 +68,7 @@ export class RelayDispatcher { // disconnects even if no replacement has connected yet. Otherwise a late // pty.spawn/fs.watch completion can create remote state nobody can own. invalidateClient(): void { + this.abortActiveRequests() this.generation++ } @@ -76,6 +80,13 @@ export class RelayDispatcher { this.notificationHandlers.set(method, handler) } + private abortActiveRequests(): void { + for (const controller of this.requestAbortControllers.values()) { + controller.abort() + } + this.requestAbortControllers.clear() + } + feed(data: Buffer): void { if (this.disposed) { return @@ -157,26 +168,37 @@ export class RelayDispatcher { // space. Sending it would misroute — the new client may have issued // its own request with the same JSON-RPC id. const gen = this.generation + const abortController = new AbortController() + this.requestAbortControllers.set(req.id, abortController) const context: RequestContext = { - isStale: () => this.generation !== gen + isStale: () => this.generation !== gen || abortController.signal.aborted, + signal: abortController.signal } try { const result = await handler(req.params ?? {}, context) - if (this.generation !== gen) { + if (this.generation !== gen || abortController.signal.aborted) { return } this.sendResponse(req.id, result) } catch (err) { - if (this.generation !== gen) { + if (this.generation !== gen || abortController.signal.aborted) { return } const message = err instanceof Error ? err.message : String(err) const code = (err as { code?: number }).code ?? -32000 this.sendResponse(req.id, undefined, { code, message }) + } finally { + this.requestAbortControllers.delete(req.id) } } private handleNotification(notif: JsonRpcNotification): void { + if (notif.method === 'rpc.cancel') { + const id = Number((notif.params ?? {}).id) + const controller = this.requestAbortControllers.get(id) + controller?.abort() + return + } const handler = this.notificationHandlers.get(notif.method) if (handler) { handler(notif.params ?? {}) diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index 590e9ea4665..d17df8c44a6 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: this suite covers relay filesystem RPCs, - file watcher lifecycle edges, and cross-platform path behavior together. */ + Space scans, file watcher lifecycle edges, and cross-platform path behavior together. */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { FsHandler } from './fs-handler' import { RelayContext } from './context' @@ -101,6 +101,7 @@ describe('FsHandler', () => { expect(methods).toContain('fs.realpath') expect(methods).toContain('fs.search') expect(methods).toContain('fs.listFiles') + expect(methods).toContain('fs.workspaceSpaceScan') expect(methods).toContain('fs.watch') const notifMethods = Array.from(dispatcher._notificationHandlers.keys()) @@ -247,6 +248,25 @@ describe('FsHandler', () => { expect(result.type).toBe('directory') }) + it('workspaceSpaceScan returns bounded top-level size details', async () => { + mkdirSync(path.join(tmpDir, 'node_modules')) + writeFileSync(path.join(tmpDir, 'node_modules', 'pkg.js'), Buffer.alloc(512)) + writeFileSync(path.join(tmpDir, 'file.log'), Buffer.alloc(128)) + + const result = (await dispatcher.callRequest( + 'fs.workspaceSpaceScan', + { rootPath: tmpDir }, + { isStale: () => false } + )) as { + sizeBytes: number + topLevelItems: { name: string; sizeBytes: number }[] + } + + expect(result.sizeBytes).toBeGreaterThanOrEqual(640) + expect(result.topLevelItems.map((item) => item.name)).toContain('node_modules') + expect(result.topLevelItems.map((item) => item.name)).toContain('file.log') + }) + it('deletePath removes files', async () => { const filePath = path.join(tmpDir, 'to-delete.txt') writeFileSync(filePath, 'bye') diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 3bedb3c856b..a2d6f2aa2a3 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: relay filesystem request handling shares - path expansion, file IO, search, streaming reads, and watch lifecycle state. */ + path expansion, file IO, search, streaming reads, Space scans, and watch lifecycle state. */ import { readdir, writeFile, stat, lstat, mkdir, rename, cp, rm, realpath } from 'fs/promises' import { execFile } from 'child_process' import { join } from 'path' @@ -20,6 +20,7 @@ import { buildExcludePathPrefixes } from '../shared/quick-open-filter' import { buildInstallRgMessage } from './fs-handler-install-rg' import { readRelayFileContent, readRelayFileStreamMetadata } from './fs-handler-file-read' import { RelayStreamRegistry } from './fs-stream-registry' +import { scanWorkspaceSpaceDirectory } from './workspace-space-scan' type WatchState = { rootPath: string @@ -72,6 +73,7 @@ export class FsHandler { this.dispatcher.onRequest('fs.realpath', (p) => this.realpath(p)) this.dispatcher.onRequest('fs.search', (p) => this.search(p)) this.dispatcher.onRequest('fs.listFiles', (p) => this.listFiles(p)) + this.dispatcher.onRequest('fs.workspaceSpaceScan', (p, c) => this.workspaceSpaceScan(p, c)) this.dispatcher.onRequest('fs.watch', (p, context) => this.watch(p, context)) this.dispatcher.onNotification('fs.unwatch', (p) => this.unwatch(p)) this.dispatcher.onNotification('fs.cancelStream', (p) => this.cancelStream(p)) @@ -279,6 +281,11 @@ export class FsHandler { } } + private async workspaceSpaceScan(params: Record, context: RequestContext) { + const rootPath = expandTilde(params.rootPath as string) + return scanWorkspaceSpaceDirectory(rootPath, context) + } + private async watch(params: Record, context?: RequestContext) { const rootPath = expandTilde(params.rootPath as string) diff --git a/src/relay/workspace-space-scan.ts b/src/relay/workspace-space-scan.ts new file mode 100644 index 00000000000..cc823457142 --- /dev/null +++ b/src/relay/workspace-space-scan.ts @@ -0,0 +1,375 @@ +/* eslint-disable max-lines -- Why: local and relay Space scans share the same + cancellation, symlink, and top-level compaction semantics in one scanner. */ +import { execFile } from 'node:child_process' +import type { Dirent } from 'node:fs' +import { lstat, readdir } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { platform } from 'node:process' +import { promisify } from 'node:util' +import type { + WorkspaceSpaceDirectoryScanResult, + WorkspaceSpaceItem, + WorkspaceSpaceItemKind +} from '../shared/workspace-space-types' +import { compactWorkspaceSpaceItems } from '../shared/workspace-space-compaction' +import type { RequestContext } from './dispatcher' + +const RELAY_FS_CONCURRENCY = 48 +const DU_TIMEOUT_MS = 120_000 +const DU_MAX_BUFFER_BYTES = 16 * 1024 * 1024 +const execFileAsync = promisify(execFile) + +type AsyncLimiter = (task: () => Promise) => Promise + +type ScanStats = { + name: string + path: string + kind: WorkspaceSpaceItemKind + sizeBytes: number + skippedEntryCount: number +} + +class RelayWorkspaceSpaceScanCancelledError extends Error { + constructor() { + super('Workspace space scan cancelled') + this.name = 'RelayWorkspaceSpaceScanCancelledError' + } +} + +function throwIfCancelled(context: RequestContext): void { + if (context.isStale() || context.signal?.aborted) { + throw new RelayWorkspaceSpaceScanCancelledError() + } +} + +function createAsyncLimiter(maxConcurrent: number, context: RequestContext): AsyncLimiter { + let active = 0 + const queue: { resolve: () => void }[] = [] + + const acquire = async (): Promise => { + throwIfCancelled(context) + if (active < maxConcurrent) { + active += 1 + return + } + await new Promise((resolve, reject) => { + let onAbort: (() => void) | null = null + const waiter = { + resolve: () => { + if (onAbort) { + context.signal?.removeEventListener('abort', onAbort) + } + resolve() + } + } + onAbort = () => { + const index = queue.indexOf(waiter) + if (index !== -1) { + queue.splice(index, 1) + } + reject(new RelayWorkspaceSpaceScanCancelledError()) + } + queue.push(waiter) + if (context.signal) { + context.signal.addEventListener('abort', onAbort, { once: true }) + if (context.signal.aborted) { + onAbort() + } + } + }) + throwIfCancelled(context) + active += 1 + } + + return async (task: () => Promise): Promise => { + await acquire() + try { + return await task() + } finally { + active -= 1 + const next = queue.shift() + next?.resolve() + } + } +} + +function normalizeDuPath(pathValue: string): string { + const trimmed = pathValue.replace(/\/+$/, '') + return trimmed.length > 0 ? trimmed : pathValue +} + +function parseDuDepthOneOutput(stdout: string): Map { + const sizes = new Map() + for (const line of stdout.split('\n')) { + const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line + if (!normalizedLine) { + continue + } + const match = /^(\d+)\s+(.+)$/.exec(normalizedLine) + if (!match) { + continue + } + sizes.set(normalizeDuPath(match[2]), Number(match[1]) * 1024) + } + return sizes +} + +async function readDuDepthOne( + rootPath: string, + context: RequestContext +): Promise> { + throwIfCancelled(context) + const { stdout } = await execFileAsync('du', ['-k', '-d', '1', rootPath], { + encoding: 'utf8', + maxBuffer: DU_MAX_BUFFER_BYTES, + signal: context.signal, + timeout: DU_TIMEOUT_MS + }) + throwIfCancelled(context) + return parseDuDepthOneOutput(stdout) +} + +function toWorkspaceSpaceItem(stats: ScanStats): WorkspaceSpaceItem { + return { + name: stats.name, + path: stats.path, + kind: stats.kind, + sizeBytes: stats.sizeBytes + } +} + +async function scanTopLevelEntryWithDu( + entryPath: string, + name: string, + duSizes: Map, + limit: AsyncLimiter, + context: RequestContext +): Promise { + throwIfCancelled(context) + const stats = await limit(() => lstat(entryPath)) + throwIfCancelled(context) + + if (stats.isSymbolicLink()) { + return { + name, + path: entryPath, + kind: 'symlink', + sizeBytes: stats.size, + skippedEntryCount: 0 + } + } + + if (!stats.isDirectory()) { + return { + name, + path: entryPath, + kind: 'file', + sizeBytes: stats.size, + skippedEntryCount: 0 + } + } + + return { + name, + path: entryPath, + kind: 'directory', + sizeBytes: duSizes.get(normalizeDuPath(entryPath)) ?? stats.size, + skippedEntryCount: 0 + } +} + +async function scanEntryAggregate( + entryPath: string, + name: string, + limit: AsyncLimiter, + context: RequestContext +): Promise { + throwIfCancelled(context) + const stats = await limit(() => lstat(entryPath)) + throwIfCancelled(context) + + if (stats.isSymbolicLink()) { + return { + name, + path: entryPath, + kind: 'symlink', + sizeBytes: stats.size, + skippedEntryCount: 0 + } + } + + if (!stats.isDirectory()) { + return { + name, + path: entryPath, + kind: 'file', + sizeBytes: stats.size, + skippedEntryCount: 0 + } + } + + let entries: Dirent[] + try { + entries = await limit(() => readdir(entryPath, { withFileTypes: true })) + } catch { + return { + name, + path: entryPath, + kind: 'directory', + sizeBytes: stats.size, + skippedEntryCount: 1 + } + } + + const childStats = await Promise.all( + entries.map(async (entry): Promise => { + try { + return await scanEntryAggregate(join(entryPath, entry.name), entry.name, limit, context) + } catch (error) { + if (error instanceof RelayWorkspaceSpaceScanCancelledError) { + throw error + } + return null + } + }) + ) + + let sizeBytes = stats.size + let skippedEntryCount = 0 + for (const child of childStats) { + if (!child) { + skippedEntryCount += 1 + continue + } + sizeBytes += child.sizeBytes + skippedEntryCount += child.skippedEntryCount + } + + return { + name, + path: entryPath, + kind: 'directory', + sizeBytes, + skippedEntryCount + } +} + +async function scanDirectoryWithDu( + rootPath: string, + context: RequestContext +): Promise { + throwIfCancelled(context) + const rootStats = await lstat(rootPath) + throwIfCancelled(context) + if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) { + return scanDirectoryWithNode(rootPath, context) + } + + const [entries, duSizes] = await Promise.all([ + readdir(rootPath, { withFileTypes: true }), + readDuDepthOne(rootPath, context) + ]) + throwIfCancelled(context) + const limit = createAsyncLimiter(RELAY_FS_CONCURRENCY, context) + const childStats = await Promise.all( + entries.map(async (entry): Promise => { + try { + return await scanTopLevelEntryWithDu( + join(rootPath, entry.name), + entry.name, + duSizes, + limit, + context + ) + } catch (error) { + if (error instanceof RelayWorkspaceSpaceScanCancelledError) { + throw error + } + return null + } + }) + ) + const children = childStats.filter((child): child is ScanStats => child !== null) + const compact = compactWorkspaceSpaceItems(children.map(toWorkspaceSpaceItem)) + + return { + sizeBytes: + duSizes.get(normalizeDuPath(rootPath)) ?? + rootStats.size + children.reduce((sum, child) => sum + child.sizeBytes, 0), + skippedEntryCount: childStats.length - children.length, + ...compact + } +} + +async function scanDirectoryWithNode( + rootPath: string, + context: RequestContext +): Promise { + throwIfCancelled(context) + const limit = createAsyncLimiter(RELAY_FS_CONCURRENCY, context) + const rootStats = await lstat(rootPath) + throwIfCancelled(context) + if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) { + const root = await scanEntryAggregate(rootPath, basename(rootPath), limit, context) + return { + sizeBytes: root.sizeBytes, + skippedEntryCount: root.skippedEntryCount, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0 + } + } + + let entries: Dirent[] + try { + entries = await readdir(rootPath, { withFileTypes: true }) + } catch { + return { + sizeBytes: rootStats.size, + skippedEntryCount: 1, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0 + } + } + + const childStats = await Promise.all( + entries.map(async (entry): Promise => { + try { + return await scanEntryAggregate(join(rootPath, entry.name), entry.name, limit, context) + } catch (error) { + if (error instanceof RelayWorkspaceSpaceScanCancelledError) { + throw error + } + return null + } + }) + ) + const children = childStats.filter((child): child is ScanStats => child !== null) + const compact = compactWorkspaceSpaceItems(children.map(toWorkspaceSpaceItem)) + + return { + sizeBytes: rootStats.size + children.reduce((sum, child) => sum + child.sizeBytes, 0), + skippedEntryCount: + children.reduce((sum, child) => sum + child.skippedEntryCount, 0) + + childStats.length - + children.length, + ...compact + } +} + +export async function scanWorkspaceSpaceDirectory( + rootPath: string, + context: RequestContext +): Promise { + if (platform !== 'win32') { + try { + return await scanDirectoryWithDu(rootPath, context) + } catch (error) { + if (error instanceof RelayWorkspaceSpaceScanCancelledError) { + throw error + } + } + } + return scanDirectoryWithNode(rootPath, context) +} diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx index 335a7f5b2c2..2854673162a 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx @@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button' import { AlertTriangle, Check, LoaderCircle, Trash2 } from 'lucide-react' import { useAppStore } from '@/store' import { toast } from 'sonner' -import { runWorktreeDeleteWithToast } from './delete-worktree-flow' +import { runWorktreeDeletesSequentially } from './delete-worktree-flow' const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { const activeModal = useAppStore((s) => s.activeModal) @@ -58,6 +58,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { const canForceDelete = deleteState?.canForceDelete ?? false const confirmButtonRef = useRef(null) const isBatchDelete = worktreeIds.length > 1 + const allowSkipConfirm = !isBatchDelete && modalData.allowSkipConfirm !== false // Why: the main worktree is the repo's original clone directory — `git worktree remove` // always rejects it. We block the delete button upfront so the user doesn't have to // discover this limitation via a confusing force-delete dead-end. @@ -136,7 +137,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { // Saving "don't ask again" from that state would conflate the recovery // action with a broader preference. Only persist the preference on the // primary (non-force) confirmation so users intentionally opt in. - if (dontAskAgain && !force) { + if (dontAskAgain && allowSkipConfirm && !force) { persistDontAskAgainPreference() } if (force) { @@ -160,12 +161,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { }) }) } else { - void Promise.all( - worktrees.map((target) => runWorktreeDeleteWithToast(target.id, target.displayName)) - ).then((results) => { - const deletedIds = worktrees - .filter((_, index) => results[index]) - .map((target) => target.id) + void runWorktreeDeletesSequentially(worktrees).then((deletedIds) => { if (deletedIds.length > 0) { onDeleted?.(deletedIds) } @@ -176,6 +172,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { [ closeModal, dontAskAgain, + allowSkipConfirm, onDeleted, persistDontAskAgainPreference, removeWorktree, @@ -262,7 +259,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
)} - {!isMainWorktree && !canForceDelete && ( + {!isMainWorktree && allowSkipConfirm && !canForceDelete && ( // Why: only show "Don't ask again" for the primary confirmation. The // force-delete variant is a recovery path that shouldn't double as a // preference checkpoint; see handleDelete for the matching guard. diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts index c562a9008b1..2c1556833a5 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts @@ -73,7 +73,8 @@ describe('runWorktreeBatchDelete', () => { expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledWith('wt-2') expect(mocks.state.clearWorktreeDeleteState).not.toHaveBeenCalledWith('main') expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { - worktreeIds: ['wt-1', 'wt-2'] + worktreeIds: ['wt-1', 'wt-2'], + allowSkipConfirm: false }) }) @@ -86,7 +87,7 @@ describe('runWorktreeBatchDelete', () => { expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { worktreeId: 'wt-1' }) }) - it('runs every eligible delete immediately when confirmation is skipped', async () => { + it('keeps batch deletes behind confirmation when confirmation is skipped', () => { mocks.state.settings = { skipDeleteWorktreeConfirm: true } setWorktrees([ { id: 'wt-1', displayName: 'one' }, @@ -96,12 +97,43 @@ describe('runWorktreeBatchDelete', () => { const started = runWorktreeBatchDelete(['wt-1', 'wt-2'], { onDeleted }) + expect(started).toBe(true) + expect(mocks.state.removeWorktree).not.toHaveBeenCalled() + expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { + worktreeIds: ['wt-1', 'wt-2'], + allowSkipConfirm: false, + onDeleted + }) + }) + + it('runs a single eligible delete immediately when confirmation is skipped', async () => { + mocks.state.settings = { skipDeleteWorktreeConfirm: true } + setWorktrees([{ id: 'wt-1', displayName: 'one' }]) + const onDeleted = vi.fn() + + const started = runWorktreeBatchDelete(['wt-1'], { onDeleted }) + expect(started).toBe(true) expect(mocks.state.openModal).not.toHaveBeenCalled() expect(mocks.state.removeWorktree).toHaveBeenCalledWith('wt-1', false) - expect(mocks.state.removeWorktree).toHaveBeenCalledWith('wt-2', false) await vi.waitFor(() => { - expect(onDeleted).toHaveBeenCalledWith(['wt-1', 'wt-2']) + expect(onDeleted).toHaveBeenCalledWith(['wt-1']) + }) + }) + + it('can force confirmation for a single eligible delete', () => { + mocks.state.settings = { skipDeleteWorktreeConfirm: true } + setWorktrees([{ id: 'wt-1', displayName: 'one' }]) + const onDeleted = vi.fn() + + const started = runWorktreeBatchDelete(['wt-1'], { forceConfirm: true, onDeleted }) + + expect(started).toBe(true) + expect(mocks.state.removeWorktree).not.toHaveBeenCalled() + expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { + worktreeId: 'wt-1', + allowSkipConfirm: false, + onDeleted }) }) diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.ts index e09200650cc..f915b13f98a 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.ts @@ -6,6 +6,7 @@ import { getDeleteWorktreeToastCopy } from './delete-worktree-toast' import type { Worktree } from '../../../../shared/types' type WorktreeBatchDeleteOptions = { + forceConfirm?: boolean onDeleted?: (worktreeIds: string[]) => void } @@ -21,6 +22,21 @@ function viewWorktreeDiff(worktreeId: string): void { state.setRightSidebarOpen(true) } +export async function runWorktreeDeletesSequentially( + targets: readonly Pick[] +): Promise { + const deletedIds: string[] = [] + for (const target of targets) { + // Why: git worktree removals for one repo contend on git lock files. + // Running the user-selected batch sequentially avoids partial lock races. + const deleted = await runWorktreeDeleteWithToast(target.id, target.displayName) + if (deleted) { + deletedIds.push(target.id) + } + } + return deletedIds +} + /** * Shared delete-with-toast flow used by both DeleteWorktreeDialog (confirm * path) and WorktreeContextMenu (skip-confirm path). Centralizes the error @@ -158,12 +174,14 @@ export function runWorktreeBatchDelete( state.clearWorktreeDeleteState(target.id) } - const skipConfirm = state.settings?.skipDeleteWorktreeConfirm ?? false + // Why: bulk cleanup can destroy many directories at once, so batch deletes + // and Space-triggered deletes must keep an explicit confirmation step. + const skipConfirm = + !options.forceConfirm && + targets.length === 1 && + (state.settings?.skipDeleteWorktreeConfirm ?? false) if (skipConfirm) { - void Promise.all( - targets.map((target) => runWorktreeDeleteWithToast(target.id, target.displayName)) - ).then((results) => { - const deletedIds = targets.filter((_, index) => results[index]).map((target) => target.id) + void runWorktreeDeletesSequentially(targets).then((deletedIds) => { if (deletedIds.length > 0) { options.onDeleted?.(deletedIds) } @@ -174,6 +192,7 @@ export function runWorktreeBatchDelete( if (targets.length === 1) { state.openModal('delete-worktree', { worktreeId: targets[0].id, + ...(options.forceConfirm ? { allowSkipConfirm: false } : {}), ...(options.onDeleted ? { onDeleted: options.onDeleted } : {}) }) return true @@ -181,6 +200,7 @@ export function runWorktreeBatchDelete( state.openModal('delete-worktree', { worktreeIds: targets.map((target) => target.id), + allowSkipConfirm: false, ...(options.onDeleted ? { onDeleted: options.onDeleted } : {}) }) return true diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceCompactPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceCompactPanel.tsx index 217816d5491..a324d94fdb4 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceCompactPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceCompactPanel.tsx @@ -1,9 +1,13 @@ import { useCallback } from 'react' -import { AlertTriangle, HardDrive, Loader2, RefreshCw } from 'lucide-react' +import { AlertTriangle, HardDrive, Loader2, RefreshCw, X } from 'lucide-react' import { useAppStore } from '../../store' import { Badge } from '../ui/badge' import { Button } from '../ui/button' -import { formatBytes, getWorkspaceSpaceScanTimeLabel } from './workspace-space-format' +import { + formatBytes, + getWorkspaceSpaceProgressLabel, + getWorkspaceSpaceScanTimeLabel +} from './workspace-space-format' export function WorkspaceSpaceCompactPanel({ onOpenFullPage @@ -11,9 +15,12 @@ export function WorkspaceSpaceCompactPanel({ onOpenFullPage: () => void }): React.JSX.Element { const analysis = useAppStore((state) => state.workspaceSpaceAnalysis) + const progress = useAppStore((state) => state.workspaceSpaceScanProgress) const scanError = useAppStore((state) => state.workspaceSpaceScanError) const isScanning = useAppStore((state) => state.workspaceSpaceScanning) const refreshWorkspaceSpace = useAppStore((state) => state.refreshWorkspaceSpace) + const cancelWorkspaceSpaceScan = useAppStore((state) => state.cancelWorkspaceSpaceScan) + const progressLabel = getWorkspaceSpaceProgressLabel(progress) const scan = useCallback((): void => { void refreshWorkspaceSpace().catch(() => { @@ -21,6 +28,10 @@ export function WorkspaceSpaceCompactPanel({ }) }, [refreshWorkspaceSpace]) + const cancelScan = useCallback((): void => { + void cancelWorkspaceSpaceScan() + }, [cancelWorkspaceSpaceScan]) + return (
@@ -35,25 +46,45 @@ export function WorkspaceSpaceCompactPanel({
{analysis - ? `${formatBytes(analysis.reclaimableBytes)} reclaimable · ${analysis.scannedWorktreeCount} workspaces` + ? isScanning + ? `${progressLabel ?? 'Scanning workspace sizes'} · last result kept` + : analysis.unavailableWorktreeCount > 0 + ? `${formatBytes(analysis.reclaimableBytes)} reclaimable · ${analysis.unavailableWorktreeCount} unavailable` + : `${formatBytes(analysis.reclaimableBytes)} reclaimable · ${analysis.scannedWorktreeCount} workspaces` : isScanning - ? 'Scanning workspace sizes.' + ? (progressLabel ?? 'Scanning workspace sizes.') : 'Workspace disk usage is not scanned.'}
-
diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index ce8ab9885e1..f7679049360 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -10,10 +10,14 @@ import { GitBranch, HardDrive, Loader2, + Minus, RefreshCw, Search, Server, - Trash2 + Trash2, + ZoomIn, + ZoomOut, + X } from 'lucide-react' import type { WorkspaceSpaceItem, @@ -25,12 +29,20 @@ import { useAppStore } from '../../store' import { runWorktreeBatchDelete } from '../sidebar/delete-worktree-flow' import { Badge } from '../ui/badge' import { Button } from '../ui/button' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger +} from '../ui/context-menu' import { Input } from '../ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { formatBytes, formatCompactCount, getWorkspaceSpaceBranchLabel, + getWorkspaceSpaceProgressLabel, + getWorkspaceSpaceScanDateTimeLabel, getWorkspaceSpaceScanTimeLabel, getWorkspaceSpaceStatusLabel } from './workspace-space-format' @@ -58,28 +70,73 @@ function getTreemapFill(rect: TreemapRect, selected: boolean): string { return TREEMAP_FILLS[rect.index % TREEMAP_FILLS.length] } -function Metric({ label, value }: { label: string; value: string }): React.JSX.Element { +function Metric({ + label, + value, + title +}: { + label: string + value: string + title?: string +}): React.JSX.Element { return (
{label}
-
{value}
+
+ {value} +
) } +function UpdatedMetric({ + scannedAt, + isScanning +}: { + scannedAt: number | null + isScanning: boolean +}): React.JSX.Element { + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + if (scannedAt === null) { + return + } + setNow(Date.now()) + const timer = window.setInterval(() => setNow(Date.now()), 60_000) + return () => window.clearInterval(timer) + }, [scannedAt]) + + return ( + + ) +} + function CheckButton({ checked, disabled, label, onClick }: { - checked: boolean + checked: boolean | 'mixed' disabled?: boolean label: string onClick: () => void }): React.JSX.Element { + const isChecked = checked === true + const isMixed = checked === 'mixed' return ( ) } @@ -137,35 +203,66 @@ function WorkspaceTreemap({ rows, isScanning, selectedWorktreeId, - onSelect + zoomedWorktree, + onSelect, + onZoomChange }: { rows: WorkspaceSpaceWorktree[] isScanning: boolean selectedWorktreeId: string | null + zoomedWorktree: WorkspaceSpaceWorktree | null onSelect: (worktreeId: string) => void + onZoomChange: (worktreeId: string | null) => void }): React.JSX.Element { + const selectedWorktree = rows.find((row) => row.worktreeId === selectedWorktreeId) ?? null + const canZoomSelected = + !!selectedWorktree && + selectedWorktree.status === 'ok' && + selectedWorktree.topLevelItems.length > 0 + const isZoomed = !!zoomedWorktree const rects = useMemo( () => buildTreemapLayout( - rows - .filter((row) => row.status === 'ok' && row.sizeBytes > 0) - .map((row) => ({ - id: row.worktreeId, - label: row.displayName, - sizeBytes: row.sizeBytes - })) + zoomedWorktree + ? zoomedWorktree.topLevelItems + .filter((item) => item.sizeBytes > 0) + .map((item) => ({ + id: item.path, + label: item.name, + sizeBytes: item.sizeBytes + })) + : rows + .filter((row) => row.status === 'ok' && row.sizeBytes > 0) + .map((row) => ({ + id: row.worktreeId, + label: row.displayName, + sizeBytes: row.sizeBytes + })) ), - [rows] + [rows, zoomedWorktree] ) if (rects.length === 0) { return ( -
+
+ {zoomedWorktree ? ( + + ) : null} {isScanning ? : null} {isScanning ? 'Scanning workspace sizes. You can leave this page.' - : 'No scanned workspace sizes yet.'} + : isZoomed + ? 'No top-level items to show.' + : 'No scanned workspace sizes yet.'}
) @@ -173,9 +270,69 @@ function WorkspaceTreemap({ return (
+
+ {zoomedWorktree ? ( + <> +
+ {zoomedWorktree.displayName} +
+ + + ) : canZoomSelected ? ( + + ) : null} +
{rects.map((rect) => { const area = rect.width * rect.height - const selected = rect.id === selectedWorktreeId + const selected = !isZoomed && rect.id === selectedWorktreeId + const rectStyle = { + left: `${rect.x}%`, + top: `${rect.y}%`, + width: `${rect.width}%`, + height: `${rect.height}%`, + background: getTreemapFill(rect, selected) + } + const rectContent = + area >= 80 ? ( + + {rect.label} + {area >= 180 ? ( + + {formatBytes(rect.sizeBytes)} + + ) : null} + + ) : null + + if (isZoomed) { + return ( +
+ {rectContent} +
+ ) + } + return ( ) })} @@ -310,7 +452,8 @@ function WorkspaceRow({ selected, inspected, onToggleSelected, - onInspect + onInspect, + onDelete }: { worktree: WorkspaceSpaceWorktree maxSize: number @@ -318,8 +461,10 @@ function WorkspaceRow({ inspected: boolean onToggleSelected: () => void onInspect: () => void + onDelete: () => void }): React.JSX.Element { - return ( + const canDelete = worktree.canDelete && worktree.status === 'ok' + const row = (
@@ -379,13 +524,31 @@ function WorkspaceRow({
) + + if (!canDelete) { + return row + } + + return ( + + {row} + + + + Delete workspace + + + + ) } export function WorkspaceSpaceManagerPanel(): React.JSX.Element { const analysis = useAppStore((state) => state.workspaceSpaceAnalysis) + const progress = useAppStore((state) => state.workspaceSpaceScanProgress) const scanError = useAppStore((state) => state.workspaceSpaceScanError) const isScanning = useAppStore((state) => state.workspaceSpaceScanning) const refreshWorkspaceSpace = useAppStore((state) => state.refreshWorkspaceSpace) + const cancelWorkspaceSpaceScan = useAppStore((state) => state.cancelWorkspaceSpaceScan) const removeWorkspaceSpaceWorktrees = useAppStore((state) => state.removeWorkspaceSpaceWorktrees) const [query, setQuery] = useState('') const [onlyDeletable, setOnlyDeletable] = useState(false) @@ -393,6 +556,7 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { const [sortDirection, setSortDirection] = useState('desc') const [selectedIds, setSelectedIds] = useState>(() => new Set()) const [inspectedWorktreeId, setInspectedWorktreeId] = useState(null) + const [treemapZoomWorktreeId, setTreemapZoomWorktreeId] = useState(null) const refresh = useCallback((): void => { void refreshWorkspaceSpace().catch(() => { @@ -400,6 +564,10 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { }) }, [refreshWorkspaceSpace]) + const cancelScan = useCallback((): void => { + void cancelWorkspaceSpaceScan() + }, [cancelWorkspaceSpaceScan]) + const sourceRows = useMemo(() => analysis?.worktrees ?? [], [analysis?.worktrees]) const rows = useMemo( @@ -416,14 +584,37 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { rows.find((row) => row.worktreeId === inspectedWorktreeId) ?? rows.find((row) => row.status === 'ok') ?? null + const zoomedWorktree = + sourceRows.find((row) => row.worktreeId === treemapZoomWorktreeId && row.status === 'ok') ?? + null const maxSize = Math.max(...rows.map((row) => row.sizeBytes), 0) - const selectedDeletableIds = getSelectedDeletableWorkspaceIds(rows, selectedIds) - const visibleDeletableIds = rows - .filter((row) => row.canDelete && row.status === 'ok') - .map((row) => row.worktreeId) + const selectedDeletableIds = useMemo( + () => getSelectedDeletableWorkspaceIds(rows, selectedIds), + [rows, selectedIds] + ) + const selectedDeletableIdSet = useMemo( + () => new Set(selectedDeletableIds), + [selectedDeletableIds] + ) + const visibleDeletableIds = useMemo( + () => rows.filter((row) => row.canDelete && row.status === 'ok').map((row) => row.worktreeId), + [rows] + ) const allVisibleSelected = visibleDeletableIds.length > 0 && visibleDeletableIds.every((id) => selectedIds.has(id)) + const someVisibleSelected = visibleDeletableIds.some((id) => selectedIds.has(id)) + const visibleSelectionState = allVisibleSelected ? true : someVisibleSelected ? 'mixed' : false const isInitialScan = isScanning && !analysis + const hasRows = sourceRows.length > 0 + const progressLabel = getWorkspaceSpaceProgressLabel(progress) + const repoErrors = analysis?.repos.filter((repo) => repo.error !== null) ?? [] + const selectedReclaimableBytes = useMemo( + () => + rows + .filter((row) => selectedDeletableIdSet.has(row.worktreeId)) + .reduce((sum, row) => sum + row.reclaimableBytes, 0), + [rows, selectedDeletableIdSet] + ) useEffect(() => { if (!analysis) { @@ -445,6 +636,14 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { }) }, [sourceRows]) + useEffect(() => { + setTreemapZoomWorktreeId((current) => + current && sourceRows.some((row) => row.worktreeId === current && row.status === 'ok') + ? current + : null + ) + }, [sourceRows]) + const toggleSort = (key: WorkspaceSpaceSortKey): void => { if (sortKey === key) { setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc')) @@ -454,6 +653,11 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { setSortDirection(key === 'name' || key === 'repo' ? 'asc' : 'desc') } + const selectSortKey = (key: WorkspaceSpaceSortKey): void => { + setSortKey(key) + setSortDirection(key === 'name' || key === 'repo' ? 'asc' : 'desc') + } + const toggleSelection = (worktreeId: string): void => { setSelectedIds((current) => { const next = new Set(current) @@ -482,31 +686,45 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { }) } + const deleteWorktrees = useCallback( + (worktreeIds: readonly string[]): void => { + if (worktreeIds.length === 0) { + return + } + runWorktreeBatchDelete(worktreeIds, { + forceConfirm: true, + onDeleted: (deletedIds) => { + removeWorkspaceSpaceWorktrees(deletedIds) + setInspectedWorktreeId((current) => + current && deletedIds.includes(current) ? null : current + ) + setTreemapZoomWorktreeId((current) => + current && deletedIds.includes(current) ? null : current + ) + setSelectedIds((current) => { + if (deletedIds.length === 0) { + return current + } + const next = new Set(current) + for (const id of deletedIds) { + next.delete(id) + } + return next + }) + toast.success(deletedIds.length === 1 ? 'Workspace deleted' : 'Workspaces deleted', { + description: `${deletedIds.length} ${deletedIds.length === 1 ? 'workspace' : 'workspaces'} removed from Space.` + }) + } + }) + }, + [removeWorkspaceSpaceWorktrees] + ) + const deleteSelected = (): void => { if (selectedDeletableIds.length === 0) { return } - runWorktreeBatchDelete(selectedDeletableIds, { - onDeleted: (deletedIds) => { - removeWorkspaceSpaceWorktrees(deletedIds) - setInspectedWorktreeId((current) => - current && deletedIds.includes(current) ? null : current - ) - setSelectedIds((current) => { - if (deletedIds.length === 0) { - return current - } - const next = new Set(current) - for (const id of deletedIds) { - next.delete(id) - } - return next - }) - toast.success(deletedIds.length === 1 ? 'Workspace deleted' : 'Workspaces deleted', { - description: `${deletedIds.length} ${deletedIds.length === 1 ? 'workspace' : 'workspaces'} removed from Space.` - }) - } - }) + deleteWorktrees(selectedDeletableIds) } return ( @@ -517,176 +735,264 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { label="Reclaimable" value={analysis ? formatBytes(analysis.reclaimableBytes) : '—'} /> - 0 + ? `${analysis.scannedWorktreeCount}/${analysis.worktreeCount}` + : String(analysis.scannedWorktreeCount) + : '—' } /> +
- + {isScanning ? ( + + ) : ( + + )} {analysis - ? `${formatBytes(analysis.reclaimableBytes)} can be reclaimed from linked worktrees.` + ? isScanning + ? `${progressLabel ?? 'Scanning workspace sizes'}. You can leave this page; the last result stays visible.` + : `${formatBytes(analysis.reclaimableBytes)} can be reclaimed from linked worktrees.` : isScanning - ? 'Scanning workspace sizes in the background. You can leave this page.' + ? `${progressLabel ?? 'Scanning workspace sizes'}. You can leave this page.` : 'Run a scan to inspect workspace sizes.'}
{scanError ? (
- {scanError} + + {scanError} + {analysis ? ' Last successful results remain visible.' : ''} +
) : null} -
- - -
+ {repoErrors.length > 0 ? ( +
+ {repoErrors.map((repo) => ( +
+ + + {repo.displayName}: {repo.error} + +
+ ))} +
+ ) : null} -
-
- - setQuery(event.target.value)} - placeholder="Filter workspaces" - className="pl-9" + {hasRows || isInitialScan ? ( +
+ +
+ ) : null} - - - - - - - -
- -
-
-
- - - -
State
+ {hasRows ? ( +
+
+ + {selectedDeletableIds.length} selected + + · + {formatBytes(selectedReclaimableBytes)} reclaimable +
+
+ + +
+ ) : null} -
- {isInitialScan ? ( -
- - Scanning workspaces. You can leave this page. + {hasRows ? ( +
+
+ + setQuery(event.target.value)} + placeholder="Filter workspaces" + className="pl-9" + /> +
+ + + + + + +
+ ) : null} + + {hasRows || isInitialScan ? ( +
+
+
+
+ +
+ + + +
State
- ) : rows.length === 0 ? ( -
- No matching workspaces. + +
+ {isInitialScan ? ( +
+ + Scanning workspaces. You can leave this page. +
+ ) : rows.length === 0 ? ( +
+ No matching workspaces. +
+ ) : ( + rows.map((worktree) => ( + toggleSelection(worktree.worktreeId)} + onInspect={() => setInspectedWorktreeId(worktree.worktreeId)} + onDelete={() => deleteWorktrees([worktree.worktreeId])} + /> + )) + )}
- ) : ( - rows.map((worktree) => ( - toggleSelection(worktree.worktreeId)} - onInspect={() => setInspectedWorktreeId(worktree.worktreeId)} - /> - )) - )} +
-
+ ) : ( +
+ {scanError + ? 'Scan failed before any workspace sizes were collected.' + : analysis + ? 'No workspace rows were available from the scan.' + : 'Run a scan to inspect workspace sizes.'} +
+ )}
) } diff --git a/src/renderer/src/components/status-bar/workspace-space-format.test.ts b/src/renderer/src/components/status-bar/workspace-space-format.test.ts index 9f239b0e075..6c43b22136c 100644 --- a/src/renderer/src/components/status-bar/workspace-space-format.test.ts +++ b/src/renderer/src/components/status-bar/workspace-space-format.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { formatBytes, formatCompactCount, + getWorkspaceSpaceScanTimeLabel, getWorkspaceSpaceStatusLabel } from './workspace-space-format' @@ -18,4 +19,16 @@ describe('workspace space format helpers', () => { expect(formatCompactCount(25_000)).toBe('25k') expect(getWorkspaceSpaceStatusLabel('permission-denied')).toBe('No access') }) + + it('formats scan times as relative age labels', () => { + const now = new Date('2026-05-14T22:15:00Z').getTime() + const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + + expect(getWorkspaceSpaceScanTimeLabel(now - 2 * 60_000, now)).toBe( + formatter.format(-2, 'minute') + ) + expect(getWorkspaceSpaceScanTimeLabel(now - 3 * 60 * 60_000, now)).toBe( + formatter.format(-3, 'hour') + ) + }) }) diff --git a/src/renderer/src/components/status-bar/workspace-space-format.ts b/src/renderer/src/components/status-bar/workspace-space-format.ts index 9566e4e3911..ab056ea68e4 100644 --- a/src/renderer/src/components/status-bar/workspace-space-format.ts +++ b/src/renderer/src/components/status-bar/workspace-space-format.ts @@ -1,9 +1,15 @@ import type { + WorkspaceSpaceScanProgress, WorkspaceSpaceScanStatus, WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types' const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const +const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) +const fullDateTimeFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short' +}) export function formatBytes(bytes: number): string { if (!Number.isFinite(bytes) || bytes <= 0) { @@ -34,8 +40,45 @@ export function formatCompactCount(count: number): string { return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}m` } -export function getWorkspaceSpaceScanTimeLabel(scannedAt: number): string { - return new Date(scannedAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) +export function getWorkspaceSpaceScanTimeLabel(scannedAt: number, now = Date.now()): string { + const diffMs = scannedAt - now + const diffMinutes = Math.round(diffMs / 60_000) + if (Math.abs(diffMinutes) < 60) { + return relativeTimeFormatter.format(diffMinutes, 'minute') + } + + const diffHours = Math.round(diffMinutes / 60) + if (Math.abs(diffHours) < 24) { + return relativeTimeFormatter.format(diffHours, 'hour') + } + + const diffDays = Math.round(diffHours / 24) + return relativeTimeFormatter.format(diffDays, 'day') +} + +export function getWorkspaceSpaceScanDateTimeLabel(scannedAt: number): string { + return fullDateTimeFormatter.format(new Date(scannedAt)) +} + +export function getWorkspaceSpaceProgressLabel( + progress: WorkspaceSpaceScanProgress | null +): string | null { + if (!progress) { + return null + } + if (progress.state === 'cancelling') { + return 'Cancelling scan' + } + + const current = + progress.currentWorktreeDisplayName ?? progress.currentRepoDisplayName ?? 'workspaces' + if (progress.totalWorktreeCount > 0) { + return `Scanning ${progress.scannedWorktreeCount} of ${progress.totalWorktreeCount} · ${current}` + } + if (progress.totalRepoCount > 0) { + return `Scanning ${progress.scannedRepoCount} of ${progress.totalRepoCount} repos · ${current}` + } + return 'Scanning workspace sizes' } export function getWorkspaceSpaceStatusLabel(status: WorkspaceSpaceScanStatus): string { diff --git a/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx b/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx index 0fed37c3afa..2060ce32724 100644 --- a/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx +++ b/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx @@ -9,21 +9,47 @@ export default function WorkspaceSpacePage(): React.JSX.Element { const closeSpacePage = useAppStore((state) => state.closeSpacePage) useEffect(() => { + const hasVisibleOverlay = (): boolean => + Array.from( + document.querySelectorAll('[role="dialog"], [role="listbox"], [role="menu"]') + ).some((element) => { + if (!(element instanceof HTMLElement)) { + return false + } + if (element.closest('[aria-hidden="true"]')) { + return false + } + const style = window.getComputedStyle(element) + return ( + style.display !== 'none' && + style.visibility !== 'hidden' && + element.getClientRects().length > 0 + ) + }) + const handleKeyDown = (event: KeyboardEvent): void => { - if (event.key !== 'Escape' || event.defaultPrevented) { + if (event.key !== 'Escape') { return } // Why: confirmation dialogs own Escape first; page-level Escape should - // only leave the full Space surface when no modal is active. - if (document.querySelector('[role="dialog"]')) { + // only leave the full Space surface when no modal or popover is active. + if (hasVisibleOverlay()) { + return + } + const target = event.target as HTMLElement | null + if ( + target?.matches('input, textarea, select, [contenteditable="true"], [contenteditable=""]') + ) { return } event.preventDefault() closeSpacePage() } - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) + // Why: tooltips can consume Escape before bubble listeners see it. Capture + // keeps the first Escape reliable while still deferring to real overlays. + window.addEventListener('keydown', handleKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) }, [closeSpacePage]) return ( diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index ada47027c8e..238c2b8cb3a 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -92,6 +92,7 @@ export function useIpcEvents(): void { removed ) afterState.purgeWorktreeTerminalState(removed) + afterState.removeWorkspaceSpaceWorktrees(removed) } }) ) @@ -891,6 +892,15 @@ export function useIpcEvents(): void { }) ) + const unsubscribeWorkspaceSpaceProgress = window.api.workspaceSpace?.onProgress?.( + (progress) => { + useAppStore.getState().applyWorkspaceSpaceProgress(progress) + } + ) + if (unsubscribeWorkspaceSpaceProgress) { + unsubs.push(unsubscribeWorkspaceSpaceProgress) + } + // Track SSH connection state changes so the renderer can show // disconnected indicators on remote worktrees. // Why: hydrate initial state for all known targets so worktree cards diff --git a/src/renderer/src/store/slices/workspace-space.ts b/src/renderer/src/store/slices/workspace-space.ts index 2d1bc99c000..f115156ed77 100644 --- a/src/renderer/src/store/slices/workspace-space.ts +++ b/src/renderer/src/store/slices/workspace-space.ts @@ -1,13 +1,19 @@ import type { StateCreator } from 'zustand' -import type { WorkspaceSpaceAnalysis } from '../../../../shared/workspace-space-types' +import type { + WorkspaceSpaceAnalysis, + WorkspaceSpaceScanProgress +} from '../../../../shared/workspace-space-types' import type { AppState } from '../types' let inFlightScan: Promise | null = null export type WorkspaceSpaceSlice = { workspaceSpaceAnalysis: WorkspaceSpaceAnalysis | null + workspaceSpaceScanProgress: WorkspaceSpaceScanProgress | null workspaceSpaceScanError: string | null workspaceSpaceScanning: boolean + applyWorkspaceSpaceProgress: (progress: WorkspaceSpaceScanProgress) => void + cancelWorkspaceSpaceScan: () => Promise refreshWorkspaceSpace: () => Promise removeWorkspaceSpaceWorktrees: (worktreeIds: readonly string[]) => void } @@ -54,27 +60,81 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } +function isWorkspaceSpaceScanCancelled(error: unknown): boolean { + const message = errorMessage(error).toLowerCase() + return message.includes('workspace space scan cancelled') || message.includes('was cancelled') +} + export const createWorkspaceSpaceSlice: StateCreator = ( set ) => ({ workspaceSpaceAnalysis: null, + workspaceSpaceScanProgress: null, workspaceSpaceScanError: null, workspaceSpaceScanning: false, + applyWorkspaceSpaceProgress: (progress) => + set((state) => { + if ( + state.workspaceSpaceScanProgress?.scanId !== progress.scanId && + !state.workspaceSpaceScanning + ) { + return state + } + return { + workspaceSpaceScanProgress: progress, + workspaceSpaceScanning: true + } + }), + cancelWorkspaceSpaceScan: async () => { + const cancelled = await window.api.workspaceSpace.cancel() + if (cancelled) { + set((state) => + state.workspaceSpaceScanProgress + ? { + workspaceSpaceScanProgress: { + ...state.workspaceSpaceScanProgress, + state: 'cancelling', + updatedAt: Date.now() + } + } + : state + ) + } + return cancelled + }, refreshWorkspaceSpace: async () => { if (inFlightScan) { return inFlightScan } - set({ workspaceSpaceScanning: true, workspaceSpaceScanError: null }) + set({ + workspaceSpaceScanning: true, + workspaceSpaceScanProgress: null, + workspaceSpaceScanError: null + }) // Why: the compact Resource Manager card and the full Space page share // one manual scan result; duplicate button presses should join the same IO. inFlightScan = window.api.workspaceSpace .analyze() - .then((analysis) => { - set({ workspaceSpaceAnalysis: analysis, workspaceSpaceScanning: false }) + .then((result) => { + if (!result.ok) { + throw new Error('Workspace space scan cancelled') + } + const analysis = result.analysis + set({ + workspaceSpaceAnalysis: analysis, + workspaceSpaceScanning: false, + workspaceSpaceScanProgress: null + }) return analysis }) .catch((error: unknown) => { - set({ workspaceSpaceScanError: errorMessage(error), workspaceSpaceScanning: false }) + set({ + workspaceSpaceScanError: isWorkspaceSpaceScanCancelled(error) + ? null + : errorMessage(error), + workspaceSpaceScanning: false, + workspaceSpaceScanProgress: null + }) throw error }) .finally(() => { diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 32b2606864f..2493e78f45e 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -580,9 +580,12 @@ export const createWorktreeSlice: StateCreator sortEpoch: s.sortEpoch + 1 } }) + get().removeWorkspaceSpaceWorktrees?.([worktreeId]) return { ok: true as const } } catch (err) { - console.error('Failed to remove worktree:', err) + // Why: git refusing a non-force delete for dirty/untracked files is a + // handled user decision point surfaced by the delete toast, not an app error. + console.warn('Failed to remove worktree:', err) const error = err instanceof Error ? err.message : String(err) set((s) => ({ deleteStateByWorktreeId: { diff --git a/src/shared/workspace-space-compaction.ts b/src/shared/workspace-space-compaction.ts new file mode 100644 index 00000000000..9b3ab739e92 --- /dev/null +++ b/src/shared/workspace-space-compaction.ts @@ -0,0 +1,41 @@ +import type { WorkspaceSpaceItem } from './workspace-space-types' + +export const WORKSPACE_SPACE_MAX_TOP_LEVEL_ITEMS = 48 + +export function compactWorkspaceSpaceItems(items: WorkspaceSpaceItem[]): { + topLevelItems: WorkspaceSpaceItem[] + omittedTopLevelItemCount: number + omittedTopLevelSizeBytes: number +} { + const sorted = [...items].sort( + (a, b) => b.sizeBytes - a.sizeBytes || a.name.localeCompare(b.name) + ) + if (sorted.length <= WORKSPACE_SPACE_MAX_TOP_LEVEL_ITEMS) { + return { + topLevelItems: sorted, + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0 + } + } + + const visible = sorted.slice(0, WORKSPACE_SPACE_MAX_TOP_LEVEL_ITEMS - 1) + const omitted = sorted.slice(WORKSPACE_SPACE_MAX_TOP_LEVEL_ITEMS - 1) + const other = omitted.reduce( + (acc, item) => ({ + ...acc, + sizeBytes: acc.sizeBytes + item.sizeBytes + }), + { + name: 'Other', + path: '', + kind: 'other', + sizeBytes: 0 + } + ) + + return { + topLevelItems: [...visible, other], + omittedTopLevelItemCount: omitted.length, + omittedTopLevelSizeBytes: other.sizeBytes + } +} diff --git a/src/shared/workspace-space-types.ts b/src/shared/workspace-space-types.ts index 9913ec87200..bfd173db4eb 100644 --- a/src/shared/workspace-space-types.ts +++ b/src/shared/workspace-space-types.ts @@ -61,3 +61,28 @@ export type WorkspaceSpaceAnalysis = { repos: WorkspaceSpaceRepoSummary[] worktrees: WorkspaceSpaceWorktree[] } + +export type WorkspaceSpaceAnalyzeResult = + | { ok: true; analysis: WorkspaceSpaceAnalysis } + | { ok: false; cancelled: true } + +export type WorkspaceSpaceDirectoryScanResult = { + sizeBytes: number + skippedEntryCount: number + topLevelItems: WorkspaceSpaceItem[] + omittedTopLevelItemCount: number + omittedTopLevelSizeBytes: number +} + +export type WorkspaceSpaceScanProgress = { + scanId: string + state: 'running' | 'cancelling' + startedAt: number + updatedAt: number + totalRepoCount: number + scannedRepoCount: number + totalWorktreeCount: number + scannedWorktreeCount: number + currentRepoDisplayName: string | null + currentWorktreeDisplayName: string | null +} From f4fcec5f598e8eabb1dbc5c5875747ae5bfa1ca4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 06:30:58 +0000 Subject: [PATCH 23/82] release: v1.4.2-rc.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5da12543545..9582a2632c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.2-rc.4", + "version": "1.4.2-rc.5", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 7d901f7809039920e0830a69cd2cacfc1887dc8d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 06:32:56 +0000 Subject: [PATCH 24/82] release: v1.4.2-rc.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9582a2632c7..2a3b88d46c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.2-rc.5", + "version": "1.4.2-rc.6", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From f71397b1b75ea810f91ffe3db59b7d3fa0895661 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 15 May 2026 00:05:42 -0700 Subject: [PATCH 25/82] perf(renderer): avoid runtime graph work on title ticks (#1903) --- src/renderer/src/App.tsx | 11 +- .../src/components/tab-bar/group-tab-order.ts | 26 ++- .../sync-runtime-graph-key-reuse.test.ts | 135 ++++++++++++++ .../src/runtime/sync-runtime-graph.ts | 170 +++++++++++++----- 4 files changed, 282 insertions(+), 60 deletions(-) create mode 100644 src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 68642e47ec1..13a8edf6bc9 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -617,11 +617,10 @@ function App(): React.JSX.Element { useEffect(() => { let previousKey = getRuntimeMobileSessionSyncKey(useAppStore.getState()) return useAppStore.subscribe((state, previousState) => { - // Why: skip the key build entirely when no input field has changed by - // reference. Mirrors every field used by getRuntimeMobileSessionSyncKey - // so this gate covers every "could the key have changed?" case. - // — if any field's reference is unchanged, neither the projection - // serialized from it nor the reference-compared map can have changed. + // Why: skip the key build entirely when every input field is unchanged + // by reference. Mirrors every field used by + // getRuntimeMobileSessionSyncKey so this gate covers every "could the + // key have changed?" case. if ( state.tabsByWorktree === previousState.tabsByWorktree && state.groupsByWorktree === previousState.groupsByWorktree && @@ -638,7 +637,7 @@ function App(): React.JSX.Element { ) { return } - const nextKey = getRuntimeMobileSessionSyncKey(state) + const nextKey = getRuntimeMobileSessionSyncKey(state, previousState, previousKey) if (runtimeMobileSessionSyncKeysEqual(nextKey, previousKey)) { return } diff --git a/src/renderer/src/components/tab-bar/group-tab-order.ts b/src/renderer/src/components/tab-bar/group-tab-order.ts index 983e51d085c..b0d4fa2f1e6 100644 --- a/src/renderer/src/components/tab-bar/group-tab-order.ts +++ b/src/renderer/src/components/tab-bar/group-tab-order.ts @@ -8,6 +8,13 @@ export type VisibleTabRef = { tabId?: string } +export type ActiveTabNavOrderIds = { + terminalIds?: string[] + editorIds?: string[] + browserIds?: string[] + notesIds?: string[] +} + /** * Compute the visible tab-strip order for a single group. * @@ -116,14 +123,19 @@ export function getActiveTabNavOrder( | 'openFiles' | 'browserTabsByWorktree' >, - worktreeId: string + worktreeId: string, + ids: ActiveTabNavOrderIds = {} ): VisibleTabRef[] { - const terminalIds = (state.tabsByWorktree[worktreeId] ?? []).map((t) => t.id) - const editorIds = state.openFiles.filter((f) => f.worktreeId === worktreeId).map((f) => f.id) - const browserIds = (state.browserTabsByWorktree[worktreeId] ?? []).map((t) => t.id) - const notesIds = (state.unifiedTabsByWorktree[worktreeId] ?? []) - .filter((tab) => tab.contentType === 'notes') - .map((tab) => tab.id) + const terminalIds = ids.terminalIds ?? (state.tabsByWorktree[worktreeId] ?? []).map((t) => t.id) + const editorIds = + ids.editorIds ?? state.openFiles.filter((f) => f.worktreeId === worktreeId).map((f) => f.id) + const browserIds = + ids.browserIds ?? (state.browserTabsByWorktree?.[worktreeId] ?? []).map((t) => t.id) + const notesIds = + ids.notesIds ?? + (state.unifiedTabsByWorktree?.[worktreeId] ?? []) + .filter((tab) => tab.contentType === 'notes') + .map((tab) => tab.id) const activeGroupId = state.activeGroupIdByWorktree[worktreeId] const group = activeGroupId diff --git a/src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts b/src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts new file mode 100644 index 00000000000..4a5294521fd --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + buildMobileSessionTabSnapshots, + getRuntimeMobileSessionSyncKey, + runtimeMobileSessionSyncKeysEqual +} from './sync-runtime-graph' +import type { AppState } from '../store/types' + +function makeState(overrides: Partial = {}): AppState { + return { + tabsByWorktree: {}, + terminalLayoutsByTabId: {} as AppState['terminalLayoutsByTabId'], + runtimePaneTitlesByTabId: {} as AppState['runtimePaneTitlesByTabId'], + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + unifiedTabsByWorktree: {}, + tabBarOrderByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + openFiles: [], + editorDrafts: {}, + activeTabId: null, + ...overrides + } as AppState +} + +function makeOpenMarkdownFile(): AppState['openFiles'][number] { + return { + id: '/repo/README.md', + filePath: '/repo/README.md', + relativePath: 'README.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit', + isDirty: false + } +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('runtime mobile session sync key projection reuse', () => { + it('reuses serialized projections when only runtime pane titles change', () => { + const base = makeState({ + tabsByWorktree: { + 'wt-1': [{ id: 'term-1', title: 'Codex working', customTitle: null }] + } as unknown as AppState['tabsByWorktree'], + runtimePaneTitlesByTabId: { + 'term-1': { 1: 'Codex working' } + } as unknown as AppState['runtimePaneTitlesByTabId'], + openFiles: [makeOpenMarkdownFile()], + editorDrafts: { '/repo/README.md': '# draft' } + }) + const baseKey = getRuntimeMobileSessionSyncKey(base) + const titleTick = makeState({ + ...base, + runtimePaneTitlesByTabId: { + 'term-1': { 1: 'Codex spinner frame' } + } as unknown as AppState['runtimePaneTitlesByTabId'] + }) + + const stringifySpy = vi.spyOn(JSON, 'stringify') + stringifySpy.mockClear() + const titleTickKey = getRuntimeMobileSessionSyncKey(titleTick, base, baseKey) + + expect(stringifySpy).not.toHaveBeenCalled() + expect(titleTickKey.tabsProjection).toBe(baseKey.tabsProjection) + expect(titleTickKey.openFilesProjection).toBe(baseKey.openFilesProjection) + expect(titleTickKey.editorDraftsProjection).toBe(baseKey.editorDraftsProjection) + expect(runtimeMobileSessionSyncKeysEqual(baseKey, titleTickKey)).toBe(false) + }) + + it('only rebuilds the tab projection when a terminal tab title changes', () => { + const base = makeState({ + tabsByWorktree: { + 'wt-1': [{ id: 'term-1', title: 'Codex working', customTitle: null }] + } as unknown as AppState['tabsByWorktree'], + openFiles: [makeOpenMarkdownFile()], + editorDrafts: { '/repo/README.md': '# draft' } + }) + const baseKey = getRuntimeMobileSessionSyncKey(base) + const titleTick = makeState({ + ...base, + tabsByWorktree: { + 'wt-1': [{ id: 'term-1', title: 'Codex spinner frame', customTitle: null }] + } as unknown as AppState['tabsByWorktree'] + }) + + const stringifySpy = vi.spyOn(JSON, 'stringify') + stringifySpy.mockClear() + const titleTickKey = getRuntimeMobileSessionSyncKey(titleTick, base, baseKey) + + expect(stringifySpy).toHaveBeenCalledTimes(1) + expect(titleTickKey.tabsProjection).not.toBe(baseKey.tabsProjection) + expect(titleTickKey.openFilesProjection).toBe(baseKey.openFilesProjection) + expect(titleTickKey.editorDraftsProjection).toBe(baseKey.editorDraftsProjection) + expect(runtimeMobileSessionSyncKeysEqual(baseKey, titleTickKey)).toBe(false) + }) +}) + +describe('mobile session snapshot reuse', () => { + it('reuses draft document versions when only runtime pane titles change', () => { + const draft = { + length: 1, + charCodeAt: vi.fn(() => 120) + } as unknown as string + const draftSpy = (draft as unknown as { charCodeAt: ReturnType }).charCodeAt + const base = makeState({ + tabsByWorktree: { + 'wt-1': [{ id: 'term-1', title: 'Codex working', customTitle: null }] + } as unknown as AppState['tabsByWorktree'], + tabBarOrderByWorktree: { 'wt-1': ['term-1', '/repo/README.md'] }, + runtimePaneTitlesByTabId: { + 'term-1': { 1: 'Codex working' } + } as unknown as AppState['runtimePaneTitlesByTabId'], + openFiles: [makeOpenMarkdownFile()], + editorDrafts: { '/repo/README.md': draft }, + activeFileId: '/repo/README.md' + }) + + buildMobileSessionTabSnapshots(base) + expect(draftSpy).toHaveBeenCalled() + draftSpy.mockClear() + + buildMobileSessionTabSnapshots({ + ...base, + runtimePaneTitlesByTabId: { + 'term-1': { 1: 'Codex spinner frame' } + } as unknown as AppState['runtimePaneTitlesByTabId'] + }) + + expect(draftSpy).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index e6fc07c9162..7d8de532d0e 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -25,6 +25,10 @@ type RegisteredTerminalTab = { } type OpenFileByWorktreeAndId = Map> +type OpenFileIndexes = { + byWorktreeAndId: OpenFileByWorktreeAndId + idsByWorktree: Map +} const registeredTabs = new Map() // Why: track when each tab was registered so we can suppress the "no live @@ -38,6 +42,10 @@ let syncScheduled = false let syncEnabled = false let getStoreState: (() => AppState) | null = null let mobileSessionSnapshotVersion = 0 +let cachedOpenFileIndexesSource: AppState['openFiles'] | null = null +let cachedOpenFileIndexes: OpenFileIndexes | null = null +let cachedEditorDraftsSource: AppState['editorDrafts'] | null = null +let cachedEditorDraftVersionByFileId: Map | null = null const mobileSessionPublicationEpoch = typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() @@ -120,7 +128,13 @@ export type RuntimeMobileSessionSyncKey = { editorDraftsProjection: string } -export function getRuntimeMobileSessionSyncKey(state: AppState): RuntimeMobileSessionSyncKey { +export function getRuntimeMobileSessionSyncKey( + state: AppState, + previousState?: AppState, + previousKey?: RuntimeMobileSessionSyncKey +): RuntimeMobileSessionSyncKey { + const canReusePrevious = previousState !== undefined && previousKey !== undefined + return { terminalLayoutsByTabId: state.terminalLayoutsByTabId, runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId, @@ -130,43 +144,66 @@ export function getRuntimeMobileSessionSyncKey(state: AppState): RuntimeMobileSe tabBarOrderByWorktree: state.tabBarOrderByWorktree, activeFileId: state.activeFileId, activeFileIdByWorktree: state.activeFileIdByWorktree, - tabsProjection: JSON.stringify( - Object.fromEntries( - Object.entries(state.tabsByWorktree).map(([worktreeId, tabs]) => [ - worktreeId, - tabs.map((tab) => ({ - id: tab.id, - title: tab.title, - customTitle: tab.customTitle, - active: state.activeTabId === tab.id - })) - ]) - ) - ), - openFilesProjection: JSON.stringify( - state.openFiles.map((file) => ({ - id: file.id, - filePath: file.filePath, - relativePath: file.relativePath, - worktreeId: file.worktreeId, - language: file.language, - mode: file.mode, - isDirty: file.isDirty, - isUntitled: file.isUntitled, - markdownPreviewSourceFileId: file.markdownPreviewSourceFileId - })) - ), - editorDraftsProjection: JSON.stringify( - Object.fromEntries( - Object.entries(state.editorDrafts).map(([fileId, content]) => [ - fileId, - stableHashString(content) - ]) - ) - ) + // Why: background agent title ticks can change runtimePaneTitlesByTabId + // many times per second while the user types elsewhere. Reuse unchanged + // projections so those ticks do not rescan all tabs, files, and drafts. + tabsProjection: + canReusePrevious && + state.tabsByWorktree === previousState.tabsByWorktree && + state.activeTabId === previousState.activeTabId + ? previousKey.tabsProjection + : buildRuntimeMobileTabsProjection(state), + openFilesProjection: + canReusePrevious && state.openFiles === previousState.openFiles + ? previousKey.openFilesProjection + : buildRuntimeMobileOpenFilesProjection(state.openFiles), + editorDraftsProjection: + canReusePrevious && state.editorDrafts === previousState.editorDrafts + ? previousKey.editorDraftsProjection + : buildRuntimeMobileEditorDraftsProjection(state.editorDrafts) } } +function buildRuntimeMobileTabsProjection(state: AppState): string { + return JSON.stringify( + Object.fromEntries( + Object.entries(state.tabsByWorktree).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.map((tab) => ({ + id: tab.id, + title: tab.title, + customTitle: tab.customTitle, + active: state.activeTabId === tab.id + })) + ]) + ) + ) +} + +function buildRuntimeMobileOpenFilesProjection(openFiles: AppState['openFiles']): string { + return JSON.stringify( + openFiles.map((file) => ({ + id: file.id, + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, + language: file.language, + mode: file.mode, + isDirty: file.isDirty, + isUntitled: file.isUntitled, + markdownPreviewSourceFileId: file.markdownPreviewSourceFileId + })) + ) +} + +function buildRuntimeMobileEditorDraftsProjection(editorDrafts: AppState['editorDrafts']): string { + return JSON.stringify( + Object.fromEntries( + Object.entries(editorDrafts).map(([fileId, content]) => [fileId, stableHashString(content)]) + ) + ) +} + export function runtimeMobileSessionSyncKeysEqual( a: RuntimeMobileSessionSyncKey, b: RuntimeMobileSessionSyncKey @@ -267,10 +304,11 @@ async function syncRuntimeGraph(): Promise { export function buildMobileSessionTabSnapshots( state: AppState ): RuntimeMobileSessionTabsSnapshot[] { - // Why: mobile publication walks the tab order for every worktree. A single - // worktree-scoped file map keeps large editor sessions linear without - // collapsing SSH worktrees that expose the same absolute remote path. - const openFileByWorktreeAndId = indexOpenFilesByWorktreeAndId(state.openFiles) + // Why: mobile publication can run on high-frequency background agent title + // ticks. Cache open-file indexes and draft hashes by immutable store-slice + // reference so title-only syncs do not rescan or rehash editor state. + const openFileIndexes = getOpenFileIndexes(state.openFiles) + const editorDraftVersionByFileId = getEditorDraftVersionByFileId(state.editorDrafts) const worktreeIds = new Set([ ...Object.keys(state.tabsByWorktree), ...Object.keys(state.groupsByWorktree), @@ -281,7 +319,9 @@ export function buildMobileSessionTabSnapshots( const snapshots: RuntimeMobileSessionTabsSnapshot[] = [] for (const worktreeId of worktreeIds) { const activeGroupId = state.activeGroupIdByWorktree[worktreeId] ?? null - const order = getActiveTabNavOrder(state, worktreeId) + const order = getActiveTabNavOrder(state, worktreeId, { + editorIds: openFileIndexes.idsByWorktree.get(worktreeId) ?? [] + }) const terminalTabByIdForWorktree = new Map( (state.tabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab]) ) @@ -295,11 +335,17 @@ export function buildMobileSessionTabSnapshots( } tabs.push(...buildMobileTerminalSurfaceTabs(state, terminal, worktreeId, item.tabId)) } else if (item.type === 'editor') { - const file = openFileByWorktreeAndId.get(worktreeId)?.get(item.id) + const file = openFileIndexes.byWorktreeAndId.get(worktreeId)?.get(item.id) if (!file) { continue } - const markdown = buildMobileMarkdownTab(state, openFileByWorktreeAndId, file, item.tabId) + const markdown = buildMobileMarkdownTab( + state, + openFileIndexes.byWorktreeAndId, + editorDraftVersionByFileId, + file, + item.tabId + ) if (markdown) { tabs.push(markdown) } else { @@ -323,19 +369,49 @@ export function buildMobileSessionTabSnapshots( return snapshots } -function indexOpenFilesByWorktreeAndId(openFiles: AppState['openFiles']): OpenFileByWorktreeAndId { +function getOpenFileIndexes(openFiles: AppState['openFiles']): OpenFileIndexes { + if (cachedOpenFileIndexesSource === openFiles && cachedOpenFileIndexes) { + return cachedOpenFileIndexes + } + const byWorktreeAndId: OpenFileByWorktreeAndId = new Map() + const idsByWorktree = new Map() for (const file of openFiles) { let filesById = byWorktreeAndId.get(file.worktreeId) if (!filesById) { filesById = new Map() byWorktreeAndId.set(file.worktreeId, filesById) } + let ids = idsByWorktree.get(file.worktreeId) + if (!ids) { + ids = [] + idsByWorktree.set(file.worktreeId, ids) + } if (!filesById.has(file.id)) { filesById.set(file.id, file) + ids.push(file.id) } } - return byWorktreeAndId + + cachedOpenFileIndexesSource = openFiles + cachedOpenFileIndexes = { byWorktreeAndId, idsByWorktree } + return cachedOpenFileIndexes +} + +function getEditorDraftVersionByFileId( + editorDrafts: AppState['editorDrafts'] +): Map { + if (cachedEditorDraftsSource === editorDrafts && cachedEditorDraftVersionByFileId) { + return cachedEditorDraftVersionByFileId + } + + const versions = new Map() + for (const [fileId, content] of Object.entries(editorDrafts)) { + versions.set(fileId, stableHashString(content)) + } + cachedEditorDraftsSource = editorDrafts + cachedEditorDraftVersionByFileId = versions + return versions } function mobileTerminalSurfaceId(parentTabId: string, leafId: string): string { @@ -399,6 +475,7 @@ function buildMobileTerminalSurfaceTabs( function buildMobileMarkdownTab( state: AppState, openFileByWorktreeAndId: OpenFileByWorktreeAndId, + editorDraftVersionByFileId: ReadonlyMap, file: AppState['openFiles'][number], unifiedTabId?: string ): RuntimeMobileSessionMarkdownTab | null { @@ -414,7 +491,7 @@ function buildMobileMarkdownTab( ? (openFileByWorktreeAndId.get(file.worktreeId)?.get(file.markdownPreviewSourceFileId) ?? file) : file - const draftContent = state.editorDrafts[sourceFile.id] + const draftVersion = editorDraftVersionByFileId.get(sourceFile.id) const title = file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'Markdown' return { @@ -434,8 +511,7 @@ function buildMobileMarkdownTab( sourceFileId: sourceFile.id, sourceFilePath: sourceFile.filePath, sourceRelativePath: sourceFile.relativePath, - documentVersion: - draftContent !== undefined ? stableHashString(draftContent) : `file:${sourceFile.id}` + documentVersion: draftVersion ?? `file:${sourceFile.id}` } } From 39b8c5026af3b1bb9163abe14f83ec7c73c98943 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 00:20:13 -0700 Subject: [PATCH 26/82] docs: add Korean README (#1905) --- README.md | 2 +- docs/README.es.md | 2 +- docs/README.ja.md | 2 +- docs/README.ko.md | 146 +++++++++++++++++++++++++++++++++++++++++++ docs/README.zh-CN.md | 2 +- 5 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 docs/README.ko.md diff --git a/README.md b/README.md index 18883b5f506..5dfd755e0c2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- English · 中文 · 日本語 · Español + English · 中文 · 日本語 · 한국어 · Español

diff --git a/docs/README.es.md b/docs/README.es.md index 4c202863d9c..1147e3b7bc8 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -9,7 +9,7 @@

- English · 中文 · 日本語 · Español + English · 中文 · 日本語 · 한국어 · Español

diff --git a/docs/README.ja.md b/docs/README.ja.md index 22fa693960b..35f688e949b 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -9,7 +9,7 @@

- English · 中文 · 日本語 · Español + English · 中文 · 日本語 · 한국어 · Español

diff --git a/docs/README.ko.md b/docs/README.ko.md new file mode 100644 index 00000000000..42fabf357ed --- /dev/null +++ b/docs/README.ko.md @@ -0,0 +1,146 @@ +

+ Orca Orca +

+ +

+ 지원 플랫폼 + Discord + X에서 팔로우 +

+ +

+ English · 中文 · 日本語 · 한국어 · Español +

+ +

+ 100x 빌더를 위한 AI 오케스트레이터.
+ Claude Code, Codex, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
+ macOS, Windows, Linux에서 사용할 수 있습니다. +

+ +

+ 다운로드 🐋 +

+ +

+ Orca 스크린샷 +

+ +## 지원 에이전트 + +Orca는 모든 CLI 에이전트를 지원합니다(*아래 목록에만 한정되지 않습니다*). + +

+ Claude Code   + Codex   + Gemini   + Pi   + Hermes Agent   + OpenCode   + Goose   + Amp   + Auggie   + Autohand Code   + Charm   + Cline   + Codebuff   + Continue   + Cursor   + Droid   + GitHub Copilot   + Kilocode   + Kimi   + Kiro   + Mistral Vibe   + Qwen Code   + Rovo Dev +

+ +--- + +## 기능 + +- **로그인 불필요** — 보유한 Claude Code 또는 Codex 구독을 그대로 사용하세요. +- **Worktree 네이티브** — 모든 기능은 자체 worktree를 가집니다. stash나 브랜치 전환에 얽매이지 않고 즉시 만들고 전환할 수 있습니다. +- **멀티 에이전트 터미널** — 여러 AI 에이전트를 탭과 패널에서 나란히 실행하세요. 어떤 에이전트가 활성 상태인지 한눈에 볼 수 있습니다. +- **내장 소스 관리** — AI가 생성한 diff를 검토하고, 빠르게 수정하고, Orca를 떠나지 않고 커밋할 수 있습니다. +- **GitHub 통합** — PR, issue, Actions 체크가 각 worktree에 자동으로 연결됩니다. +- **SSH 지원** — 원격 머신에 연결하고 Orca에서 직접 에이전트를 실행할 수 있습니다. +- **알림** — 에이전트가 완료되거나 주의가 필요할 때 알려줍니다. 스레드를 읽지 않음으로 표시해 나중에 다시 볼 수 있습니다. + +--- + +## 설치 + +### Mac, Linux, Windows + +- **[onOrca.dev에서 다운로드](https://onOrca.dev)** +- 또는 **[GitHub Releases 페이지](https://github.com/stablyai/orca/releases/latest)** 에서 받기 + +*패키지 매니저로도 설치할 수 있습니다:* + +### macOS (Homebrew) + +```bash +brew install --cask stablyai/orca/orca +``` + +### Arch Linux (AUR) + +```bash +# 사전 컴파일된 바이너리 +yay -S stably-orca-bin + +# GitHub 소스에서 빌드 +yay -S stably-orca-git +``` + +--- + +## 모바일 Companion 앱 + +휴대폰에서 에이전트를 제어하세요. + +

+ Orca 데스크톱과 모바일 companion 앱 +

+ +- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) +- **Android:** [GH release에서 다운로드(최신 mobile 릴리스를 찾으세요)](https://github.com/stablyai/orca/releases) + +--- + +## 기능 쇼케이스 + +타일을 클릭해 각 워크플로를 살펴보세요. + +

+ 병렬 Worktree

병렬 worktree 오케스트레이션
   + 터미널 분할

Ghostty급 터미널 분할


+ 디자인 모드

내장 브라우저와 디자인 모드
   + GitHub 및 Linear 네이티브

Orca의 GitHub 및 Linear 워크플로


+ 모든 CLI 에이전트

모든 CLI 에이전트 지원
   + SSH Worktree

SSH를 통한 원격 worktree


+ 에이전트로 파일 드래그

파일과 이미지를 에이전트 프롬프트로 드래그
   + AI Diff 주석

AI가 생성한 diff에 주석 달기


+ Orca CLI

CLI에서 Orca 스크립팅
   + 네이티브 검색

Orca 워크플로 전반의 네이티브 검색


+ 계정 전환 및 사용량 추적

계정 전환 및 사용량 추적
   + 풍부한 리포지토리 미리보기

Markdown, 이미지, PDF, 리포지토리 문서 미리보기


+ 무엇이든 분할

에이전트, 터미널, 브라우저, 파일을 위한 분할 패널
+

+ +--- + +## 커뮤니티와 지원 + +- **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** 커뮤니티에 참여하세요. +- **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요. +- **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요. +- **응원하기:** 이 리포지토리에 star를 눌러 일일 릴리스를 따라와 주세요. + +--- + +## 개발 + +기여하거나 로컬에서 실행하고 싶으신가요? [CONTRIBUTING.md](../.github/CONTRIBUTING.md) 가이드를 확인하세요. diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 83431be966a..855471739c8 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -9,7 +9,7 @@

- English · 中文 · 日本語 · Español + English · 中文 · 日本語 · 한국어 · Español

From 4415bde1e5f54f610109934010d8644e90b88b82 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 15 May 2026 00:25:13 -0700 Subject: [PATCH 27/82] Open workspace board on header hover (#1904) --- .../src/components/sidebar/SidebarHeader.tsx | 132 +++++++++++++++--- .../sidebar/WorkspaceKanbanDrawer.tsx | 5 +- 2 files changed, 115 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index 8c1d4e7d320..57ff07b7934 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react' -import { Kanban, Plus, SlidersHorizontal } from 'lucide-react' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { Plus, SlidersHorizontal } from 'lucide-react' import { useAppStore } from '@/store' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' @@ -51,9 +51,18 @@ const SORT_OPTIONS = [ const isMac = navigator.userAgent.includes('Mac') const newWorktreeShortcutLabel = isMac ? '⌘N' : 'Ctrl+N' +const WORKSPACE_BOARD_HOVER_OPEN_DELAY_MS = 50 +// Why: gives the pointer room to travel from the header into the board before +// the temporary hover preview collapses. +const WORKSPACE_BOARD_HOVER_CLOSE_DELAY_MS = 220 const SidebarHeader = React.memo(function SidebarHeader() { const [workspaceBoardOpen, setWorkspaceBoardOpen] = useState(false) + // Why: entering the board turns the hover preview into a persistent drawer + // until the user explicitly closes it or clicks outside. + const workspaceBoardPinnedOpenRef = useRef(false) + const workspaceBoardHoverOpenTimerRef = useRef(null) + const workspaceBoardHoverCloseTimerRef = useRef(null) const openModal = useAppStore((s) => s.openModal) const repos = useAppStore((s) => s.repos) const canCreateWorktree = repos.some((repo) => isGitRepoKind(repo)) @@ -64,30 +73,107 @@ const SidebarHeader = React.memo(function SidebarHeader() { const setSortBy = useAppStore((s) => s.setSortBy) const groupBy = useAppStore((s) => s.groupBy) const setGroupBy = useAppStore((s) => s.setGroupBy) + + const clearWorkspaceBoardHoverClose = useCallback(() => { + if (workspaceBoardHoverCloseTimerRef.current === null) { + return + } + window.clearTimeout(workspaceBoardHoverCloseTimerRef.current) + workspaceBoardHoverCloseTimerRef.current = null + }, []) + + const clearWorkspaceBoardHoverOpen = useCallback(() => { + if (workspaceBoardHoverOpenTimerRef.current === null) { + return + } + window.clearTimeout(workspaceBoardHoverOpenTimerRef.current) + workspaceBoardHoverOpenTimerRef.current = null + }, []) + + useEffect( + () => () => { + clearWorkspaceBoardHoverOpen() + clearWorkspaceBoardHoverClose() + }, + [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen] + ) + + const setWorkspaceBoardPinned = useCallback((pinned: boolean) => { + workspaceBoardPinnedOpenRef.current = pinned + }, []) + + const handleWorkspaceBoardOpenChange = useCallback( + (open: boolean) => { + clearWorkspaceBoardHoverOpen() + clearWorkspaceBoardHoverClose() + setWorkspaceBoardOpen(open) + if (!open) { + setWorkspaceBoardPinned(false) + } + }, + [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen, setWorkspaceBoardPinned] + ) + + const handleWorkspaceHeaderPointerEnter = useCallback( + (event: React.PointerEvent) => { + if (event.pointerType !== 'mouse') { + return + } + clearWorkspaceBoardHoverClose() + if (workspaceBoardOpen) { + return + } + clearWorkspaceBoardHoverOpen() + workspaceBoardHoverOpenTimerRef.current = window.setTimeout(() => { + workspaceBoardHoverOpenTimerRef.current = null + setWorkspaceBoardOpen(true) + }, WORKSPACE_BOARD_HOVER_OPEN_DELAY_MS) + }, + [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen, workspaceBoardOpen] + ) + + const handleWorkspaceHeaderPointerLeave = useCallback( + (event: React.PointerEvent) => { + clearWorkspaceBoardHoverOpen() + if (event.pointerType !== 'mouse' || workspaceBoardPinnedOpenRef.current) { + return + } + clearWorkspaceBoardHoverClose() + workspaceBoardHoverCloseTimerRef.current = window.setTimeout(() => { + workspaceBoardHoverCloseTimerRef.current = null + if (workspaceBoardPinnedOpenRef.current) { + return + } + setWorkspaceBoardOpen(false) + }, WORKSPACE_BOARD_HOVER_CLOSE_DELAY_MS) + }, + [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen] + ) + + const handleWorkspaceBoardPointerEnter = useCallback( + (event: React.PointerEvent) => { + if (event.pointerType !== 'mouse') { + return + } + clearWorkspaceBoardHoverOpen() + clearWorkspaceBoardHoverClose() + setWorkspaceBoardPinned(true) + setWorkspaceBoardOpen(true) + }, + [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen, setWorkspaceBoardPinned] + ) + return ( <> -

+
Workspaces - - - - - - Workspace board - -
@@ -208,7 +294,11 @@ const SidebarHeader = React.memo(function SidebarHeader() {
- + ) }) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx index 51bab89d488..9fb88777aa6 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx @@ -28,6 +28,7 @@ import { makeWorkspaceStatusId } from '../../../../shared/workspace-statuses' type WorkspaceKanbanDrawerProps = { open: boolean onOpenChange: (open: boolean) => void + onPointerEnter?: React.PointerEventHandler } function sortBoardWorktrees(a: Worktree, b: Worktree): number { @@ -36,7 +37,8 @@ function sortBoardWorktrees(a: Worktree, b: Worktree): number { export default function WorkspaceKanbanDrawer({ open, - onOpenChange + onOpenChange, + onPointerEnter }: WorkspaceKanbanDrawerProps): React.JSX.Element { const allWorktrees = useAllWorktrees() const repoMap = useRepoMap() @@ -287,6 +289,7 @@ export default function WorkspaceKanbanDrawer({ } as React.CSSProperties } data-workspace-board-compact={workspaceBoardCompact ? 'true' : 'false'} + onPointerEnter={onPointerEnter} onOpenAutoFocus={(event) => { // Why: Radix focuses the first toolbar button on open, which opens // its tooltip without hover and makes the drawer feel noisy. From 5451f35af324d761e8cb2bfab709728012787691 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 07:31:19 +0000 Subject: [PATCH 28/82] release: v1.4.2-rc.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2a3b88d46c1..2c9c6ea5fae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.2-rc.6", + "version": "1.4.2-rc.7", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From bce9f50205a216a8cd26165972ab2be6367ab219 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 00:46:46 -0700 Subject: [PATCH 29/82] Implemented the skip-flow fix. (#1906) Changed: - src/renderer/src/components/sidebar/AddRepoDialog.tsx: Skip now finalizes the imported repo/worktree list without opening an existing worktree or launching create-worktree. - src/renderer/src/components/sidebar/add-repo-skip-finalization.ts: added focused logic to make the imported worktrees visible by clearing sidebar filters that would hide them. - src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts: regression coverage for skipped imports. Co-authored-by: Orca --- .../src/components/sidebar/AddRepoDialog.tsx | 21 +++- .../add-repo-skip-finalization.test.ts | 102 ++++++++++++++++++ .../sidebar/add-repo-skip-finalization.ts | 42 ++++++++ 3 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts create mode 100644 src/renderer/src/components/sidebar/add-repo-skip-finalization.ts diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index 1a83b4b45de..600c1f7225e 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -21,6 +21,7 @@ import { getDefaultCloneParent } from './clone-defaults' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { isGitRepoKind } from '../../../../shared/repo-kind' import type { Repo, Worktree } from '../../../../shared/types' +import { finalizeImportedRepoAfterSkip } from './add-repo-skip-finalization' const AddRepoDialog = React.memo(function AddRepoDialog() { const activeModal = useAppStore((s) => s.activeModal) @@ -299,14 +300,26 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { openSettingsPage() }, [closeModal, openSettingsTarget, openSettingsPage, repoId]) + const finishImportedRepoWithoutOpening = useCallback(async () => { + const importedRepoId = repoId + closeModal() + resetState() + if (!importedRepoId) { + return + } + + await fetchWorktrees(importedRepoId) + const state = useAppStore.getState() + finalizeImportedRepoAfterSkip(state, importedRepoId) + }, [closeModal, fetchWorktrees, repoId, resetState]) + // Why: handleBack reuses resetState which already aborts clones and resets all fields. const handleBack = resetState const handleSkip = useCallback(() => { track('add_repo_setup_step_action', { action: 'skip' }) - closeModal() - resetState() - }, [closeModal, resetState]) + void finishImportedRepoWithoutOpening() + }, [finishImportedRepoWithoutOpening]) // Why: only the Setup step's "Add another project" back arrow counts as a // funnel event — the in-flight Back arrows on clone/remote/create are not @@ -328,6 +341,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { // on the Setup step are funnel-equivalent to Skip. if (step === 'setup') { track('add_repo_setup_step_action', { action: 'skip' }) + void finishImportedRepoWithoutOpening() + return } closeModal() resetState() diff --git a/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts b/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts new file mode 100644 index 00000000000..5654f6417ca --- /dev/null +++ b/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Worktree } from '../../../../shared/types' +import { + finalizeImportedRepoAfterSkip, + type AddRepoSkipFinalizationState +} from './add-repo-skip-finalization' + +function makeWorktree(overrides: Partial & { id: string; repoId: string }): Worktree { + return { + path: `/tmp/${overrides.id}`, + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false, + displayName: overrides.id, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +function makeState(overrides: Partial): AddRepoSkipFinalizationState { + return { + activeRepoId: null, + filterRepoIds: [], + showActiveOnly: false, + hideDefaultBranchWorkspace: false, + worktreesByRepo: {}, + setActiveRepo: vi.fn(), + setFilterRepoIds: vi.fn(), + setShowActiveOnly: vi.fn(), + setHideDefaultBranchWorkspace: vi.fn(), + ...overrides + } +} + +describe('finalizeImportedRepoAfterSkip', () => { + it('keeps skipped imported worktrees visible without activating a worktree', () => { + const state = makeState({ + activeRepoId: 'repo-old', + filterRepoIds: ['repo-old'], + showActiveOnly: true, + hideDefaultBranchWorkspace: false, + worktreesByRepo: { + 'repo-new': [makeWorktree({ id: 'repo-new::/repo/feature', repoId: 'repo-new' })] + } + }) + + finalizeImportedRepoAfterSkip(state, 'repo-new') + + expect(state.setActiveRepo).toHaveBeenCalledWith('repo-new') + expect(state.setFilterRepoIds).toHaveBeenCalledWith([]) + expect(state.setShowActiveOnly).toHaveBeenCalledWith(false) + expect(state.setHideDefaultBranchWorkspace).not.toHaveBeenCalled() + }) + + it('clears default-branch hiding when it would hide every imported worktree', () => { + const state = makeState({ + hideDefaultBranchWorkspace: true, + worktreesByRepo: { + 'repo-new': [ + makeWorktree({ + id: 'repo-new::/repo/main', + repoId: 'repo-new', + isMainWorktree: true, + branch: 'refs/heads/main' + }) + ] + } + }) + + finalizeImportedRepoAfterSkip(state, 'repo-new') + + expect(state.setHideDefaultBranchWorkspace).toHaveBeenCalledWith(false) + }) + + it('does nothing when the imported repo has no discovered worktrees yet', () => { + const state = makeState({ + activeRepoId: 'repo-old', + filterRepoIds: ['repo-old'], + showActiveOnly: true, + hideDefaultBranchWorkspace: true, + worktreesByRepo: { 'repo-new': [] } + }) + + finalizeImportedRepoAfterSkip(state, 'repo-new') + + expect(state.setActiveRepo).not.toHaveBeenCalled() + expect(state.setFilterRepoIds).not.toHaveBeenCalled() + expect(state.setShowActiveOnly).not.toHaveBeenCalled() + expect(state.setHideDefaultBranchWorkspace).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts b/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts new file mode 100644 index 00000000000..4185c3e6fbe --- /dev/null +++ b/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts @@ -0,0 +1,42 @@ +import type { Worktree } from '../../../../shared/types' +import { isDefaultBranchWorkspace } from './visible-worktrees' + +export type AddRepoSkipFinalizationState = { + activeRepoId: string | null + filterRepoIds: string[] + showActiveOnly: boolean + hideDefaultBranchWorkspace: boolean + worktreesByRepo: Record + setActiveRepo: (repoId: string | null) => void + setFilterRepoIds: (repoIds: string[]) => void + setShowActiveOnly: (value: boolean) => void + setHideDefaultBranchWorkspace: (value: boolean) => void +} + +export function finalizeImportedRepoAfterSkip( + state: AddRepoSkipFinalizationState, + importedRepoId: string +): void { + const importedWorktrees = state.worktreesByRepo[importedRepoId] ?? [] + if (importedWorktrees.length === 0) { + return + } + + // Why: Skip means "do not open or create a worktree", not "hide the + // imported project behind sidebar filters so it looks like nothing landed." + if (state.activeRepoId !== importedRepoId) { + state.setActiveRepo(importedRepoId) + } + if (state.filterRepoIds.length > 0 && !state.filterRepoIds.includes(importedRepoId)) { + state.setFilterRepoIds([]) + } + if (state.showActiveOnly) { + state.setShowActiveOnly(false) + } + if ( + state.hideDefaultBranchWorkspace && + importedWorktrees.every((worktree) => isDefaultBranchWorkspace(worktree)) + ) { + state.setHideDefaultBranchWorkspace(false) + } +} From f81b3f219f15422f88dd5e9b37f2251ad15fb553 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 15 May 2026 01:56:05 -0700 Subject: [PATCH 30/82] Add Gitea hosted review support --- src/main/gitea/client.test.ts | 159 +++++++++++ src/main/gitea/client.ts | 253 ++++++++++++++++++ src/main/gitea/pull-request-mappers.test.ts | 76 ++++++ src/main/gitea/pull-request-mappers.ts | 129 +++++++++ src/main/gitea/repository-ref.test.ts | 86 ++++++ src/main/gitea/repository-ref.ts | 131 +++++++++ src/main/ipc/hosted-review.ts | 3 +- src/main/ipc/preflight.test.ts | 40 ++- src/main/ipc/preflight.ts | 16 +- src/main/runtime/orca-runtime.ts | 4 +- .../runtime/rpc/methods/hosted-review.test.ts | 3 +- src/main/runtime/rpc/methods/hosted-review.ts | 6 +- .../hosted-review-gitea.integration.test.ts | 113 ++++++++ src/main/source-control/hosted-review.test.ts | 56 +++- src/main/source-control/hosted-review.ts | 40 ++- src/preload/api-types.ts | 7 + src/preload/index.ts | 7 + .../components/settings/IntegrationsPane.tsx | 129 ++++++++- .../components/sidebar/WorktreeCardMeta.tsx | 3 + .../src/store/slices/hosted-review.test.ts | 6 +- .../src/store/slices/hosted-review.ts | 7 +- src/shared/hosted-review.ts | 3 +- 22 files changed, 1247 insertions(+), 30 deletions(-) create mode 100644 src/main/gitea/client.test.ts create mode 100644 src/main/gitea/client.ts create mode 100644 src/main/gitea/pull-request-mappers.test.ts create mode 100644 src/main/gitea/pull-request-mappers.ts create mode 100644 src/main/gitea/repository-ref.test.ts create mode 100644 src/main/gitea/repository-ref.ts create mode 100644 src/main/source-control/hosted-review-gitea.integration.test.ts diff --git a/src/main/gitea/client.test.ts b/src/main/gitea/client.test.ts new file mode 100644 index 00000000000..620ab33225f --- /dev/null +++ b/src/main/gitea/client.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock +})) + +import { + getGiteaAuthStatus, + getGiteaPullRequestForBranch, + normalizeGiteaApiBaseUrl +} from './client' +import { _resetGiteaRepoRefCache } from './repository-ref' + +const OLD_ENV = process.env + +function giteaPr(index = 7, branch = 'feature/gitea') { + return { + number: index, + title: 'Add Gitea', + state: 'open', + html_url: `https://git.example.com/team/repo/pulls/${index}`, + updated_at: '2026-05-15T00:00:00Z', + mergeable: true, + head: { + ref: branch, + label: `team:${branch}`, + sha: 'abc123' + } + } +} + +describe('Gitea client', () => { + beforeEach(() => { + process.env = { ...OLD_ENV } + process.env.ORCA_GITEA_TOKEN = 'gitea-token' + delete process.env.ORCA_GITEA_API_BASE_URL + gitExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'https://git.example.com/team/repo.git\n', + stderr: '' + }) + _resetGiteaRepoRefCache() + vi.unstubAllGlobals() + }) + + it('normalizes Gitea API base URLs', () => { + expect(normalizeGiteaApiBaseUrl('https://git.example.com')).toBe( + 'https://git.example.com/api/v1' + ) + expect(normalizeGiteaApiBaseUrl('https://git.example.com/api/v1/')).toBe( + 'https://git.example.com/api/v1' + ) + }) + + it('fetches a branch pull request and commit status', async () => { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + const parsed = new URL(url) + if (!init) { + throw new Error('expected request init') + } + expect((init.headers as Record).Authorization).toBe('token gitea-token') + if (parsed.pathname.endsWith('/commits/abc123/status')) { + return Response.json({ state: 'success' }) + } + return Response.json([giteaPr()]) + }) + vi.stubGlobal('fetch', fetchMock) + + await expect( + getGiteaPullRequestForBranch('/repo', 'refs/heads/feature/gitea') + ).resolves.toEqual({ + number: 7, + title: 'Add Gitea', + state: 'open', + url: 'https://git.example.com/team/repo/pulls/7', + status: 'success', + updatedAt: '2026-05-15T00:00:00Z', + mergeable: 'MERGEABLE', + headSha: 'abc123' + }) + + const listUrl = new URL(String(fetchMock.mock.calls[0]?.[0])) + expect(listUrl.origin).toBe('https://git.example.com') + expect(listUrl.pathname).toBe('/api/v1/repos/team/repo/pulls') + expect(listUrl.searchParams.get('state')).toBe('all') + expect(listUrl.searchParams.get('sort')).toBe('recentupdate') + expect(listUrl.searchParams.get('page')).toBe('1') + expect(listUrl.searchParams.get('limit')).toBe('50') + }) + + it('uses an API base URL override for subpath or non-standard deployments', async () => { + process.env.ORCA_GITEA_API_BASE_URL = 'https://git.example.com/code' + const fetchMock = vi.fn(async (url: string | URL) => { + if (String(url).includes('/commits/abc123/status')) { + return Response.json({ state: 'pending' }) + } + return Response.json([giteaPr()]) + }) + vi.stubGlobal('fetch', fetchMock) + + await expect(getGiteaPullRequestForBranch('/repo', 'feature/gitea')).resolves.toMatchObject({ + number: 7, + status: 'pending' + }) + expect(String(fetchMock.mock.calls[0]?.[0])).toContain( + 'https://git.example.com/code/api/v1/repos/team/repo/pulls' + ) + }) + + it('falls back to a linked PR number when branch lookup misses', async () => { + const fetchMock = vi.fn(async (url: string | URL) => { + const requestUrl = String(url) + if (requestUrl.includes('/commits/abc123/status')) { + return Response.json({ state: 'success' }) + } + if (requestUrl.endsWith('/pulls/42')) { + return Response.json(giteaPr(42, 'renamed-local-branch')) + } + return Response.json([]) + }) + vi.stubGlobal('fetch', fetchMock) + + await expect(getGiteaPullRequestForBranch('/repo', 'local-name', 42)).resolves.toMatchObject({ + number: 42, + status: 'success' + }) + }) + + it('reports configured token auth without a global API base URL', async () => { + await expect(getGiteaAuthStatus()).resolves.toEqual({ + configured: true, + authenticated: true, + account: null, + baseUrl: null, + tokenConfigured: true + }) + }) + + it('verifies token auth when a global API base URL is configured', async () => { + process.env.ORCA_GITEA_API_BASE_URL = 'https://git.example.com' + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => + Response.json({ login: 'gitea-user' }) + ) + vi.stubGlobal('fetch', fetchMock) + + await expect(getGiteaAuthStatus()).resolves.toEqual({ + configured: true, + authenticated: true, + account: 'gitea-user', + baseUrl: 'https://git.example.com/api/v1', + tokenConfigured: true + }) + expect(String(fetchMock.mock.calls[0]?.[0])).toBe('https://git.example.com/api/v1/user') + }) +}) diff --git a/src/main/gitea/client.ts b/src/main/gitea/client.ts new file mode 100644 index 00000000000..c86dd89a9a4 --- /dev/null +++ b/src/main/gitea/client.ts @@ -0,0 +1,253 @@ +import { + deriveGiteaCommitStatus, + mapGiteaPullRequest, + type GiteaPullRequestInfo, + type RawGiteaCombinedStatus, + type RawGiteaPullRequest +} from './pull-request-mappers' +import { getGiteaRepoRef, type GiteaRepoRef } from './repository-ref' + +const REQUEST_TIMEOUT_MS = 5000 +const PULL_REQUEST_PAGE_LIMIT = 50 +const MAX_PULL_REQUEST_PAGES = 5 + +type GiteaAuthConfig = { + apiBaseUrl: string | null + token: string | null +} + +export type GiteaAuthStatus = { + configured: boolean + authenticated: boolean + account: string | null + baseUrl: string | null + tokenConfigured: boolean +} + +type RequestOptions = { + searchParams?: Record + timeoutMs?: number +} + +function envValue(name: string): string | null { + const value = process.env[name]?.trim() ?? '' + return value.length > 0 ? value : null +} + +export function normalizeGiteaApiBaseUrl(value: string): string { + const trimmed = value.trim().replace(/\/+$/, '') + return /\/api\/v1$/i.test(trimmed) ? trimmed : `${trimmed}/api/v1` +} + +function getAuthConfig(): GiteaAuthConfig { + const apiBaseUrl = envValue('ORCA_GITEA_API_BASE_URL') + return { + apiBaseUrl: apiBaseUrl ? normalizeGiteaApiBaseUrl(apiBaseUrl) : null, + token: envValue('ORCA_GITEA_TOKEN') + } +} + +function authHeaders(config: Pick): Record { + return config.token ? { Authorization: `token ${config.token}` } : {} +} + +function configuredApiBaseUrl(repo: GiteaRepoRef): string { + return getAuthConfig().apiBaseUrl ?? repo.apiBaseUrl +} + +function apiUrl(baseUrl: string, path: string, searchParams?: RequestOptions['searchParams']): URL { + const url = new URL(`${baseUrl.replace(/\/+$/, '')}${path}`) + if (searchParams) { + for (const [key, value] of Object.entries(searchParams)) { + url.searchParams.set(key, String(value)) + } + } + return url +} + +async function requestJsonAtBase( + baseUrl: string, + path: string, + options: RequestOptions = {} +): Promise { + const config = getAuthConfig() + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? REQUEST_TIMEOUT_MS) + try { + const response = await fetch(apiUrl(baseUrl, path, options.searchParams), { + headers: { + Accept: 'application/json', + ...authHeaders(config) + }, + signal: controller.signal + }) + if (!response.ok) { + return null + } + return (await response.json()) as T + } catch { + return null + } finally { + clearTimeout(timeout) + } +} + +function requestJson( + repo: GiteaRepoRef, + path: string, + options: RequestOptions = {} +): Promise { + return requestJsonAtBase(configuredApiBaseUrl(repo), path, options) +} + +function encodedRepoPath(repo: GiteaRepoRef): string { + return `${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}` +} + +async function getCommitStatus( + repo: GiteaRepoRef, + headSha: string | undefined +): Promise> { + if (!headSha) { + return 'neutral' + } + const data = await requestJson( + repo, + `/repos/${encodedRepoPath(repo)}/commits/${encodeURIComponent(headSha)}/status` + ) + return deriveGiteaCommitStatus(data) +} + +async function normalizePullRequest( + repo: GiteaRepoRef, + raw: RawGiteaPullRequest +): Promise { + const status = await getCommitStatus(repo, raw.head?.sha?.trim()) + return mapGiteaPullRequest(raw, status) +} + +function matchesBranch(raw: RawGiteaPullRequest, branchName: string): boolean { + const ref = raw.head?.ref?.trim() + if (ref === branchName) { + return true + } + const label = raw.head?.label?.trim() + return label === branchName || label?.endsWith(`:${branchName}`) === true +} + +export async function getGiteaAuthStatus(): Promise { + const config = getAuthConfig() + const tokenConfigured = config.token !== null + if (!config.apiBaseUrl && !tokenConfigured) { + return { + configured: false, + authenticated: false, + account: null, + baseUrl: null, + tokenConfigured: false + } + } + if (!config.apiBaseUrl) { + return { + configured: true, + authenticated: true, + account: null, + baseUrl: null, + tokenConfigured + } + } + + if (!tokenConfigured) { + const version = await requestJsonAtBase<{ version?: string }>(config.apiBaseUrl, '/version', { + timeoutMs: 4000 + }) + return { + configured: version !== null, + authenticated: false, + account: null, + baseUrl: config.apiBaseUrl, + tokenConfigured + } + } + + const user = await requestJsonAtBase<{ + login?: string | null + username?: string | null + full_name?: string | null + }>(config.apiBaseUrl, '/user', { timeoutMs: 4000 }) + return { + configured: true, + authenticated: user !== null, + account: user?.login ?? user?.username ?? user?.full_name ?? null, + baseUrl: config.apiBaseUrl, + tokenConfigured + } +} + +export async function getGiteaPullRequest( + repoPath: string, + prNumber: number +): Promise { + const repo = await getGiteaRepoRef(repoPath) + if (!repo) { + return null + } + const raw = await requestJson( + repo, + `/repos/${encodedRepoPath(repo)}/pulls/${encodeURIComponent(String(prNumber))}` + ) + return raw ? normalizePullRequest(repo, raw) : null +} + +export async function getGiteaPullRequestForBranch( + repoPath: string, + branch: string, + linkedPRNumber?: number | null +): Promise { + const branchName = branch.replace(/^refs\/heads\//, '') + if (!branchName && linkedPRNumber == null) { + return null + } + + const repo = await getGiteaRepoRef(repoPath) + if (!repo) { + return null + } + + if (branchName) { + for (let page = 1; page <= MAX_PULL_REQUEST_PAGES; page++) { + const list = await requestJson( + repo, + `/repos/${encodedRepoPath(repo)}/pulls`, + { + searchParams: { + state: 'all', + sort: 'recentupdate', + page, + limit: PULL_REQUEST_PAGE_LIMIT + } + } + ) + const raw = list?.find((item) => matchesBranch(item, branchName)) + if (raw) { + return normalizePullRequest(repo, raw) + } + if (!list || list.length < PULL_REQUEST_PAGE_LIMIT) { + break + } + } + } + + if (typeof linkedPRNumber !== 'number') { + return null + } + const raw = await requestJson( + repo, + `/repos/${encodedRepoPath(repo)}/pulls/${encodeURIComponent(String(linkedPRNumber))}` + ) + return raw ? normalizePullRequest(repo, raw) : null +} + +export async function getGiteaRepoSlug(repoPath: string): Promise { + return getGiteaRepoRef(repoPath) +} diff --git a/src/main/gitea/pull-request-mappers.test.ts b/src/main/gitea/pull-request-mappers.test.ts new file mode 100644 index 00000000000..3b71a93ffdd --- /dev/null +++ b/src/main/gitea/pull-request-mappers.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { + deriveGiteaCommitStatus, + mapGiteaMergeable, + mapGiteaPullRequest, + mapGiteaPullRequestState +} from './pull-request-mappers' + +describe('Gitea pull request mappers', () => { + it('maps pull request states', () => { + expect(mapGiteaPullRequestState({ state: 'open' })).toBe('open') + expect(mapGiteaPullRequestState({ state: 'closed' })).toBe('closed') + expect(mapGiteaPullRequestState({ state: 'open', draft: true })).toBe('draft') + expect(mapGiteaPullRequestState({ state: 'closed', merged: true })).toBe('merged') + }) + + it('maps mergeability', () => { + expect(mapGiteaMergeable(true)).toBe('MERGEABLE') + expect(mapGiteaMergeable(false)).toBe('CONFLICTING') + expect(mapGiteaMergeable(undefined)).toBe('UNKNOWN') + }) + + it('derives commit status from the combined state', () => { + expect(deriveGiteaCommitStatus({ state: 'success' })).toBe('success') + expect(deriveGiteaCommitStatus({ state: 'failure' })).toBe('failure') + expect(deriveGiteaCommitStatus({ state: 'error' })).toBe('failure') + expect(deriveGiteaCommitStatus({ state: 'pending' })).toBe('pending') + expect(deriveGiteaCommitStatus({ state: 'skipped' })).toBe('neutral') + }) + + it('rolls up individual statuses when the combined state is neutral', () => { + expect( + deriveGiteaCommitStatus({ + state: '', + statuses: [{ status: 'success' }, { status: 'failure' }] + }) + ).toBe('failure') + expect( + deriveGiteaCommitStatus({ + statuses: [{ status: 'success' }, { status: 'pending' }] + }) + ).toBe('pending') + expect(deriveGiteaCommitStatus({ statuses: [{ status: 'success' }] })).toBe('success') + expect(deriveGiteaCommitStatus({ statuses: [] })).toBe('neutral') + }) + + it('maps a raw pull request to the hosted review shape', () => { + expect( + mapGiteaPullRequest( + { + number: 12, + title: 'Gitea branch', + state: 'open', + html_url: 'https://git.example.com/team/project/pulls/12', + updated_at: '2026-05-15T00:00:00Z', + mergeable: true, + head: { sha: 'abc123' } + }, + 'success' + ) + ).toEqual({ + number: 12, + title: 'Gitea branch', + state: 'open', + url: 'https://git.example.com/team/project/pulls/12', + status: 'success', + updatedAt: '2026-05-15T00:00:00Z', + mergeable: 'MERGEABLE', + headSha: 'abc123' + }) + }) + + it('rejects incomplete pull request payloads', () => { + expect(mapGiteaPullRequest({ number: 1, title: 'missing url' }, 'neutral')).toBeNull() + }) +}) diff --git a/src/main/gitea/pull-request-mappers.ts b/src/main/gitea/pull-request-mappers.ts new file mode 100644 index 00000000000..090e38bd39d --- /dev/null +++ b/src/main/gitea/pull-request-mappers.ts @@ -0,0 +1,129 @@ +import type { CheckStatus, PRMergeableState } from '../../shared/types' + +export type RawGiteaPullRequest = { + number?: number + title?: string + state?: string | null + html_url?: string | null + updated_at?: string | null + merged?: boolean | null + draft?: boolean | null + mergeable?: boolean | null + head?: { + ref?: string | null + label?: string | null + sha?: string | null + } | null +} + +export type GiteaPullRequestInfo = { + number: number + title: string + state: 'open' | 'closed' | 'merged' | 'draft' + url: string + status: CheckStatus + updatedAt: string + mergeable: PRMergeableState + headSha?: string +} + +export type RawGiteaCombinedStatus = { + state?: string | null + statuses?: RawGiteaCommitStatus[] | null +} + +export type RawGiteaCommitStatus = { + status?: string | null + state?: string | null +} + +function classifyGiteaStatus(status: string | null | undefined): CheckStatus { + switch (status?.trim().toLowerCase()) { + case 'success': + return 'success' + case 'failure': + case 'error': + case 'warning': + return 'failure' + case 'pending': + return 'pending' + case 'skipped': + default: + return 'neutral' + } +} + +export function deriveGiteaCommitStatus(rollup: RawGiteaCombinedStatus | null): CheckStatus { + if (!rollup) { + return 'neutral' + } + const combined = classifyGiteaStatus(rollup.state) + if (combined !== 'neutral') { + return combined + } + const statuses = rollup.statuses ?? [] + if (statuses.length === 0) { + return 'neutral' + } + + let hasPending = false + for (const status of statuses) { + const classified = classifyGiteaStatus(status.status ?? status.state) + if (classified === 'failure') { + return 'failure' + } + if (classified === 'pending') { + hasPending = true + } + } + if (hasPending) { + return 'pending' + } + return statuses.every( + (status) => classifyGiteaStatus(status.status ?? status.state) === 'success' + ) + ? 'success' + : 'neutral' +} + +export function mapGiteaPullRequestState( + raw: Pick +): GiteaPullRequestInfo['state'] { + if (raw.merged) { + return 'merged' + } + if (raw.draft) { + return 'draft' + } + return raw.state?.trim().toLowerCase() === 'closed' ? 'closed' : 'open' +} + +export function mapGiteaMergeable(value: boolean | null | undefined): PRMergeableState { + if (value === true) { + return 'MERGEABLE' + } + if (value === false) { + return 'CONFLICTING' + } + return 'UNKNOWN' +} + +export function mapGiteaPullRequest( + raw: RawGiteaPullRequest, + status: CheckStatus +): GiteaPullRequestInfo | null { + if (typeof raw.number !== 'number' || !raw.title || !raw.html_url) { + return null + } + const headSha = raw.head?.sha?.trim() + return { + number: raw.number, + title: raw.title, + state: mapGiteaPullRequestState(raw), + url: raw.html_url, + status, + updatedAt: raw.updated_at ?? '', + mergeable: mapGiteaMergeable(raw.mergeable), + ...(headSha ? { headSha } : {}) + } +} diff --git a/src/main/gitea/repository-ref.test.ts b/src/main/gitea/repository-ref.test.ts new file mode 100644 index 00000000000..4ff86d8b7ae --- /dev/null +++ b/src/main/gitea/repository-ref.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock +})) + +import { _resetGiteaRepoRefCache, getGiteaRepoRef, parseGiteaRepoRef } from './repository-ref' + +describe('Gitea repository ref parsing', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + _resetGiteaRepoRefCache() + }) + + it('parses HTTPS remotes and derives the API base URL', () => { + expect(parseGiteaRepoRef('https://git.example.com/team/project.git')).toEqual({ + host: 'git.example.com', + owner: 'team', + repo: 'project', + apiBaseUrl: 'https://git.example.com/api/v1', + webBaseUrl: 'https://git.example.com' + }) + }) + + it('preserves an HTTP subpath when deriving the API base URL', () => { + expect(parseGiteaRepoRef('https://git.example.com/code/team/project.git')).toEqual({ + host: 'git.example.com', + owner: 'team', + repo: 'project', + apiBaseUrl: 'https://git.example.com/code/api/v1', + webBaseUrl: 'https://git.example.com/code' + }) + }) + + it('parses scp-like SSH remotes with an HTTPS web/API base', () => { + expect(parseGiteaRepoRef('git@gitea.example.test:team/project.git')).toEqual({ + host: 'gitea.example.test', + owner: 'team', + repo: 'project', + apiBaseUrl: 'https://gitea.example.test/api/v1', + webBaseUrl: 'https://gitea.example.test' + }) + }) + + it('parses ssh:// remotes without carrying the SSH port into web/API URLs', () => { + expect(parseGiteaRepoRef('ssh://git@gitea.example.test:2222/team/project.git')).toEqual({ + host: 'gitea.example.test', + owner: 'team', + repo: 'project', + apiBaseUrl: 'https://gitea.example.test/api/v1', + webBaseUrl: 'https://gitea.example.test' + }) + }) + + it('does not claim public hosts handled by more specific providers', () => { + expect(parseGiteaRepoRef('git@github.com:team/project.git')).toBeNull() + expect(parseGiteaRepoRef('https://gitlab.com/team/project.git')).toBeNull() + expect(parseGiteaRepoRef('https://bitbucket.org/team/project.git')).toBeNull() + }) + + it('reads and caches the origin remote', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'https://git.example.com/team/project.git\n', + stderr: '' + }) + + await expect(getGiteaRepoRef('/repo')).resolves.toMatchObject({ + host: 'git.example.com', + owner: 'team', + repo: 'project' + }) + await expect(getGiteaRepoRef('/repo')).resolves.toMatchObject({ + host: 'git.example.com', + owner: 'team', + repo: 'project' + }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { + cwd: '/repo' + }) + }) +}) diff --git a/src/main/gitea/repository-ref.ts b/src/main/gitea/repository-ref.ts new file mode 100644 index 00000000000..d6bc6b2cce4 --- /dev/null +++ b/src/main/gitea/repository-ref.ts @@ -0,0 +1,131 @@ +import { gitExecFileAsync } from '../git/runner' + +export type GiteaRepoRef = { + host: string + owner: string + repo: string + apiBaseUrl: string + webBaseUrl: string +} + +const KNOWN_NON_GITEA_HOSTS = new Set(['github.com', 'gitlab.com', 'bitbucket.org']) +const repoRefCache = new Map() + +/** @internal - exposed for tests only */ +export function _resetGiteaRepoRefCache(): void { + repoRefCache.clear() +} + +function decodeSegment(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +function parsePath(pathname: string): { owner: string; repo: string; basePath: string } | null { + const withoutSuffix = pathname.replace(/\.git$/i, '') + const parts = withoutSuffix + .split('/') + .map((part) => part.trim()) + .filter(Boolean) + if (parts.length < 2) { + return null + } + + const owner = decodeSegment(parts.at(-2) ?? '') + const repo = decodeSegment(parts.at(-1) ?? '') + if (!owner || !repo) { + return null + } + + return { + owner, + repo, + basePath: parts.slice(0, -2).join('/') + } +} + +function apiBaseUrlFromWebBase(webBaseUrl: string): string { + return `${webBaseUrl.replace(/\/+$/, '')}/api/v1` +} + +function makeRepoRef(host: string, path: string, webBaseUrl: string): GiteaRepoRef | null { + const normalizedHost = host.toLowerCase() + if (!normalizedHost || KNOWN_NON_GITEA_HOSTS.has(normalizedHost)) { + return null + } + + const parsed = parsePath(path) + if (!parsed) { + return null + } + + return { + host: normalizedHost, + owner: parsed.owner, + repo: parsed.repo, + apiBaseUrl: apiBaseUrlFromWebBase(webBaseUrl), + webBaseUrl + } +} + +export function parseGiteaRepoRef(remoteUrl: string): GiteaRepoRef | null { + const trimmed = remoteUrl.trim() + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { + const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/) + if (scpLike) { + const host = scpLike[1] + const path = scpLike[2] + return makeRepoRef(host, path, `https://${host.toLowerCase()}`) + } + } + + try { + const url = new URL(trimmed) + const protocol = url.protocol.toLowerCase() + if (!['http:', 'https:', 'ssh:', 'git+ssh:'].includes(protocol)) { + return null + } + + const parsed = parsePath(url.pathname) + if (!parsed) { + return null + } + + const webOrigin = + protocol === 'http:' || protocol === 'https:' + ? `${protocol}//${url.host}` + : `https://${url.hostname.toLowerCase()}` + const webBaseUrl = parsed.basePath ? `${webOrigin}/${parsed.basePath}` : webOrigin + return makeRepoRef(url.hostname, url.pathname, webBaseUrl) + } catch { + return null + } +} + +export async function getGiteaRepoRefForRemote( + repoPath: string, + remoteName: string +): Promise { + const cacheKey = `${repoPath}\0${remoteName}` + if (repoRefCache.has(cacheKey)) { + return repoRefCache.get(cacheKey)! + } + try { + const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], { + cwd: repoPath + }) + const result = parseGiteaRepoRef(stdout) + repoRefCache.set(cacheKey, result) + return result + } catch { + repoRefCache.set(cacheKey, null) + return null + } +} + +export async function getGiteaRepoRef(repoPath: string): Promise { + return getGiteaRepoRefForRemote(repoPath, 'origin') +} diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index edd2fa2a5c8..acd89711356 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -23,7 +23,8 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector branch: args.branch, linkedGitHubPR: args.linkedGitHubPR ?? null, linkedGitLabMR: args.linkedGitLabMR ?? null, - linkedBitbucketPR: args.linkedBitbucketPR ?? null + linkedBitbucketPR: args.linkedBitbucketPR ?? null, + linkedGiteaPR: args.linkedGiteaPR ?? null }) if (review?.provider === 'github' && !stats.hasCountedPR(review.url)) { stats.record({ diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index d2d3327322f..fefc0d0ad3d 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: preflight tests share expensive process/preload mocks across + install, auth, agent detection, and refresh branches. */ import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -6,14 +8,16 @@ const { execFileAsyncMock, hydrateShellPathMock, mergePathSegmentsMock, - getBitbucketAuthStatusMock + getBitbucketAuthStatusMock, + getGiteaAuthStatusMock } = vi.hoisted(() => ({ handleMock: vi.fn(), execFileMock: vi.fn(), execFileAsyncMock: vi.fn(), hydrateShellPathMock: vi.fn(), mergePathSegmentsMock: vi.fn(), - getBitbucketAuthStatusMock: vi.fn() + getBitbucketAuthStatusMock: vi.fn(), + getGiteaAuthStatusMock: vi.fn() })) vi.mock('electron', () => ({ @@ -41,6 +45,10 @@ vi.mock('../bitbucket/client', () => ({ getBitbucketAuthStatus: getBitbucketAuthStatusMock })) +vi.mock('../gitea/client', () => ({ + getGiteaAuthStatus: getGiteaAuthStatusMock +})) + import { _resetPreflightCache, detectInstalledAgents, @@ -52,6 +60,14 @@ type HandlerMap = Record { const handlers: HandlerMap = {} + const defaultBitbucketStatus = { configured: false, authenticated: false, account: null } + const defaultGiteaStatus = { + configured: false, + authenticated: false, + account: null, + baseUrl: null, + tokenConfigured: false + } beforeEach(() => { handleMock.mockReset() @@ -59,11 +75,9 @@ describe('preflight', () => { hydrateShellPathMock.mockReset() mergePathSegmentsMock.mockReset() getBitbucketAuthStatusMock.mockReset() - getBitbucketAuthStatusMock.mockResolvedValue({ - configured: false, - authenticated: false, - account: null - }) + getGiteaAuthStatusMock.mockReset() + getBitbucketAuthStatusMock.mockResolvedValue(defaultBitbucketStatus) + getGiteaAuthStatusMock.mockResolvedValue(defaultGiteaStatus) _resetPreflightCache() for (const key of Object.keys(handlers)) { @@ -92,7 +106,8 @@ describe('preflight', () => { git: { installed: true }, gh: { installed: true, authenticated: true }, glab: { installed: true, authenticated: true }, - bitbucket: { configured: false, authenticated: false, account: null } + bitbucket: defaultBitbucketStatus, + gitea: defaultGiteaStatus }) expect(execFileAsyncMock).toHaveBeenNthCalledWith(4, 'gh', ['auth', 'status'], { encoding: 'utf-8' @@ -193,7 +208,8 @@ describe('preflight', () => { git: { installed: true }, gh: { installed: true, authenticated: true }, glab: { installed: true, authenticated: true }, - bitbucket: { configured: false, authenticated: false, account: null } + bitbucket: defaultBitbucketStatus, + gitea: defaultGiteaStatus }) }) @@ -219,13 +235,15 @@ describe('preflight', () => { git: { installed: true }, gh: { installed: true, authenticated: false }, glab: { installed: true, authenticated: true }, - bitbucket: { configured: false, authenticated: false, account: null } + bitbucket: defaultBitbucketStatus, + gitea: defaultGiteaStatus }) expect(refreshedStatus).toEqual({ git: { installed: true }, gh: { installed: true, authenticated: true }, glab: { installed: true, authenticated: true }, - bitbucket: { configured: false, authenticated: false, account: null } + bitbucket: defaultBitbucketStatus, + gitea: defaultGiteaStatus }) }) diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 31022751e41..865c6d33414 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -6,6 +6,7 @@ import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import type { PathSource, ShellHydrationFailureReason } from '../../shared/types' import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' import { getBitbucketAuthStatus } from '../bitbucket/client' +import { getGiteaAuthStatus } from '../gitea/client' import { getActiveMultiplexer } from './ssh' const execFileAsync = promisify(execFile) @@ -19,6 +20,13 @@ export type PreflightStatus = { // gate on `glab?.authenticated`. glab?: { installed: boolean; authenticated: boolean } bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } + gitea?: { + configured: boolean + authenticated: boolean + account: string | null + baseUrl: string | null + tokenConfigured: boolean + } } // Why: cache the result so repeated Landing mounts don't re-spawn processes. @@ -150,17 +158,19 @@ export async function runPreflightCheck(force = false): Promise isCommandAvailable('glab') ]) - const [ghAuthenticated, glabAuthenticated, bitbucket] = await Promise.all([ + const [ghAuthenticated, glabAuthenticated, bitbucket, gitea] = await Promise.all([ ghInstalled ? isGhAuthenticated() : Promise.resolve(false), glabInstalled ? isGlabAuthenticated() : Promise.resolve(false), - getBitbucketAuthStatus() + getBitbucketAuthStatus(), + getGiteaAuthStatus() ]) cached = { git: { installed: gitInstalled }, gh: { installed: ghInstalled, authenticated: ghAuthenticated }, glab: { installed: glabInstalled, authenticated: glabAuthenticated }, - bitbucket + bitbucket, + gitea } return cached diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3ee8b60e261..00767acf3c8 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -4453,6 +4453,7 @@ export class OrcaRuntimeService { linkedGitHubPR?: number | null linkedGitLabMR?: number | null linkedBitbucketPR?: number | null + linkedGiteaPR?: number | null }): Promise { const repo = await this.resolveRepoSelector(args.repoSelector) this.assertHostIntegrationRepoIsLocal(repo, 'hosted_review') @@ -4461,7 +4462,8 @@ export class OrcaRuntimeService { branch: args.branch, linkedGitHubPR: args.linkedGitHubPR ?? null, linkedGitLabMR: args.linkedGitLabMR ?? null, - linkedBitbucketPR: args.linkedBitbucketPR ?? null + linkedBitbucketPR: args.linkedBitbucketPR ?? null, + linkedGiteaPR: args.linkedGiteaPR ?? null }) if (review?.provider === 'github' && this.stats && !this.stats.hasCountedPR(review.url)) { this.stats.record({ diff --git a/src/main/runtime/rpc/methods/hosted-review.test.ts b/src/main/runtime/rpc/methods/hosted-review.test.ts index df3ecc0f544..e7eaab3009f 100644 --- a/src/main/runtime/rpc/methods/hosted-review.test.ts +++ b/src/main/runtime/rpc/methods/hosted-review.test.ts @@ -38,7 +38,8 @@ describe('hosted review RPC methods', () => { branch: 'feature/windows', linkedGitHubPR: 12, linkedGitLabMR: null, - linkedBitbucketPR: null + linkedBitbucketPR: null, + linkedGiteaPR: null }) expect(response).toMatchObject({ ok: true, diff --git a/src/main/runtime/rpc/methods/hosted-review.ts b/src/main/runtime/rpc/methods/hosted-review.ts index 952f00f6be4..3d38f36604a 100644 --- a/src/main/runtime/rpc/methods/hosted-review.ts +++ b/src/main/runtime/rpc/methods/hosted-review.ts @@ -7,7 +7,8 @@ const HostedReviewForBranch = z.object({ branch: requiredString('Missing branch'), linkedGitHubPR: z.number().int().positive().nullable().optional(), linkedGitLabMR: z.number().int().positive().nullable().optional(), - linkedBitbucketPR: z.number().int().positive().nullable().optional() + linkedBitbucketPR: z.number().int().positive().nullable().optional(), + linkedGiteaPR: z.number().int().positive().nullable().optional() }) export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ @@ -20,7 +21,8 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ branch: params.branch, linkedGitHubPR: params.linkedGitHubPR ?? null, linkedGitLabMR: params.linkedGitLabMR ?? null, - linkedBitbucketPR: params.linkedBitbucketPR ?? null + linkedBitbucketPR: params.linkedBitbucketPR ?? null, + linkedGiteaPR: params.linkedGiteaPR ?? null }) }) ] diff --git a/src/main/source-control/hosted-review-gitea.integration.test.ts b/src/main/source-control/hosted-review-gitea.integration.test.ts new file mode 100644 index 00000000000..a8bf4c40454 --- /dev/null +++ b/src/main/source-control/hosted-review-gitea.integration.test.ts @@ -0,0 +1,113 @@ +import { execFile } from 'child_process' +import { mkdtemp, rm } from 'fs/promises' +import { createServer, type IncomingMessage, type ServerResponse } from 'http' +import { tmpdir } from 'os' +import { join } from 'path' +import { promisify } from 'util' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { _resetGiteaRepoRefCache } from '../gitea/repository-ref' +import { getHostedReviewForBranch } from './hosted-review' + +const execFileAsync = promisify(execFile) +const OLD_ENV = process.env + +type SeenRequest = { + pathname: string + search: string + authorization: string | undefined +} + +function sendJson(res: ServerResponse, body: unknown): void { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(body)) +} + +describe('Gitea hosted review integration', () => { + beforeEach(() => { + process.env = { ...OLD_ENV, ORCA_GITEA_TOKEN: 'local-token' } + delete process.env.ORCA_GITEA_API_BASE_URL + _resetGiteaRepoRefCache() + }) + + afterEach(() => { + process.env = OLD_ENV + _resetGiteaRepoRefCache() + }) + + it('resolves a Gitea PR through real git remote parsing and HTTP API calls', async () => { + const seen: SeenRequest[] = [] + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`) + seen.push({ + pathname: url.pathname, + search: url.search, + authorization: req.headers.authorization + }) + + if (url.pathname === '/api/v1/repos/team/repo/pulls') { + sendJson(res, [ + { + number: 9, + title: 'Local Gitea branch', + state: 'open', + html_url: 'http://127.0.0.1/team/repo/pulls/9', + updated_at: '2026-05-15T00:00:00Z', + mergeable: true, + head: { ref: 'feature/gitea', label: 'team:feature/gitea', sha: 'abc123' } + } + ]) + return + } + + if (url.pathname === '/api/v1/repos/team/repo/commits/abc123/status') { + sendJson(res, { state: 'success' }) + return + } + + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ message: 'not found' })) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + + const repoPath = await mkdtemp(join(tmpdir(), 'orca-gitea-review-')) + try { + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('expected TCP server address') + } + + await execFileAsync('git', ['init'], { cwd: repoPath }) + await execFileAsync( + 'git', + ['remote', 'add', 'origin', `http://127.0.0.1:${address.port}/team/repo.git`], + { cwd: repoPath } + ) + + await expect( + getHostedReviewForBranch({ repoPath, branch: 'refs/heads/feature/gitea' }) + ).resolves.toEqual({ + provider: 'gitea', + number: 9, + title: 'Local Gitea branch', + state: 'open', + url: 'http://127.0.0.1/team/repo/pulls/9', + status: 'success', + updatedAt: '2026-05-15T00:00:00Z', + mergeable: 'MERGEABLE', + headSha: 'abc123' + }) + + expect(seen.map((request) => request.pathname)).toEqual([ + '/api/v1/repos/team/repo/pulls', + '/api/v1/repos/team/repo/commits/abc123/status' + ]) + expect(seen.every((request) => request.authorization === 'token local-token')).toBe(true) + expect(new URLSearchParams(seen[0].search).get('state')).toBe('all') + } finally { + await rm(repoPath, { recursive: true, force: true }) + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + } + }) +}) diff --git a/src/main/source-control/hosted-review.test.ts b/src/main/source-control/hosted-review.test.ts index ffd29cd1d9b..c45138b2663 100644 --- a/src/main/source-control/hosted-review.test.ts +++ b/src/main/source-control/hosted-review.test.ts @@ -6,14 +6,18 @@ const { getRepoSlugMock, getPRForBranchMock, getBitbucketRepoSlugMock, - getBitbucketPullRequestForBranchMock + getBitbucketPullRequestForBranchMock, + getGiteaRepoSlugMock, + getGiteaPullRequestForBranchMock } = vi.hoisted(() => ({ getProjectSlugMock: vi.fn(), getMergeRequestForBranchMock: vi.fn(), getRepoSlugMock: vi.fn(), getPRForBranchMock: vi.fn(), getBitbucketRepoSlugMock: vi.fn(), - getBitbucketPullRequestForBranchMock: vi.fn() + getBitbucketPullRequestForBranchMock: vi.fn(), + getGiteaRepoSlugMock: vi.fn(), + getGiteaPullRequestForBranchMock: vi.fn() })) vi.mock('../gitlab/client', () => ({ @@ -33,6 +37,12 @@ vi.mock('../bitbucket/client', () => ({ getBitbucketPullRequest: vi.fn() })) +vi.mock('../gitea/client', () => ({ + getGiteaRepoSlug: getGiteaRepoSlugMock, + getGiteaPullRequestForBranch: getGiteaPullRequestForBranchMock, + getGiteaPullRequest: vi.fn() +})) + import { getHostedReviewForBranch } from './hosted-review' describe('getHostedReviewForBranch', () => { @@ -43,6 +53,8 @@ describe('getHostedReviewForBranch', () => { getPRForBranchMock.mockReset() getBitbucketRepoSlugMock.mockReset() getBitbucketPullRequestForBranchMock.mockReset() + getGiteaRepoSlugMock.mockReset() + getGiteaPullRequestForBranchMock.mockReset() }) it('maps GitLab merge requests into the hosted review surface', async () => { @@ -137,4 +149,44 @@ describe('getHostedReviewForBranch', () => { 11 ) }) + + it('falls through to Gitea when origin is not another hosted provider', async () => { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue({ + host: 'git.example.com', + owner: 'team', + repo: 'orca' + }) + getGiteaPullRequestForBranchMock.mockResolvedValue({ + number: 14, + title: 'Gitea branch', + state: 'open', + url: 'https://git.example.com/team/orca/pulls/14', + status: 'pending', + updatedAt: '2026-05-15T00:00:00.000Z', + mergeable: 'MERGEABLE', + headSha: 'def456' + }) + + await expect( + getHostedReviewForBranch({ + repoPath: '/repo', + branch: 'feature/gitea', + linkedGiteaPR: 14 + }) + ).resolves.toEqual({ + provider: 'gitea', + number: 14, + title: 'Gitea branch', + state: 'open', + url: 'https://git.example.com/team/orca/pulls/14', + status: 'pending', + updatedAt: '2026-05-15T00:00:00.000Z', + mergeable: 'MERGEABLE', + headSha: 'def456' + }) + expect(getGiteaPullRequestForBranchMock).toHaveBeenCalledWith('/repo', 'feature/gitea', 14) + }) }) diff --git a/src/main/source-control/hosted-review.ts b/src/main/source-control/hosted-review.ts index a8f848d211e..1e3ce4e4b40 100644 --- a/src/main/source-control/hosted-review.ts +++ b/src/main/source-control/hosted-review.ts @@ -6,6 +6,12 @@ import { getBitbucketRepoSlug } from '../bitbucket/client' import type { BitbucketPullRequestInfo } from '../bitbucket/pull-request-mappers' +import { + getGiteaPullRequest, + getGiteaPullRequestForBranch, + getGiteaRepoSlug +} from '../gitea/client' +import type { GiteaPullRequestInfo } from '../gitea/pull-request-mappers' import { getPRForBranch, getRepoSlug } from '../github/client' import { getMergeRequest, getMergeRequestForBranch, getProjectSlug } from '../gitlab/client' @@ -60,19 +66,35 @@ function mapBitbucketReview(pr: BitbucketPullRequestInfo): HostedReviewInfo { } } +function mapGiteaReview(pr: GiteaPullRequestInfo): HostedReviewInfo { + return { + provider: 'gitea', + number: pr.number, + title: pr.title, + state: pr.state, + url: pr.url, + status: pr.status, + updatedAt: pr.updatedAt, + mergeable: pr.mergeable, + ...(pr.headSha ? { headSha: pr.headSha } : {}) + } +} + export async function getHostedReviewForBranch(input: { repoPath: string branch: string linkedGitHubPR?: number | null linkedGitLabMR?: number | null linkedBitbucketPR?: number | null + linkedGiteaPR?: number | null }): Promise { const branchName = input.branch.replace(/^refs\/heads\//, '') if ( !branchName && input.linkedGitHubPR == null && input.linkedGitLabMR == null && - input.linkedBitbucketPR == null + input.linkedBitbucketPR == null && + input.linkedGiteaPR == null ) { return null } @@ -104,12 +126,22 @@ export async function getHostedReviewForBranch(input: { return pr ? mapBitbucketReview(pr) : null } + const giteaRepo = await getGiteaRepoSlug(input.repoPath) + if (giteaRepo) { + const pr = await getGiteaPullRequestForBranch( + input.repoPath, + branchName, + input.linkedGiteaPR ?? null + ) + return pr ? mapGiteaReview(pr) : null + } + return null } export async function getHostedReviewByNumber(input: { repoPath: string - provider: 'github' | 'gitlab' | 'bitbucket' + provider: 'github' | 'gitlab' | 'bitbucket' | 'gitea' number: number }): Promise { if (input.provider === 'gitlab') { @@ -120,6 +152,10 @@ export async function getHostedReviewByNumber(input: { const pr = await getBitbucketPullRequest(input.repoPath, input.number) return pr ? mapBitbucketReview(pr) : null } + if (input.provider === 'gitea') { + const pr = await getGiteaPullRequest(input.repoPath, input.number) + return pr ? mapGiteaReview(pr) : null + } const pr = await getPRForBranch(input.repoPath, '', input.number) return pr ? mapGitHubReview(pr) : null } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 9140e0e3133..73322f995ab 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -312,6 +312,13 @@ export type PreflightStatus = { * include it. Consumers gate on `glab?.installed` / `authenticated`. */ glab?: { installed: boolean; authenticated: boolean } bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } + gitea?: { + configured: boolean + authenticated: boolean + account: string | null + baseUrl: string | null + tokenConfigured: boolean + } } export type RefreshAgentsResult = { diff --git a/src/preload/index.ts b/src/preload/index.ts index 6a203c2f204..5e987664eab 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1068,6 +1068,13 @@ const api = { gh: { installed: boolean; authenticated: boolean } glab?: { installed: boolean; authenticated: boolean } bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } + gitea?: { + configured: boolean + authenticated: boolean + account: string | null + baseUrl: string | null + tokenConfigured: boolean + } linear: { connected: boolean } }> => ipcRenderer.invoke('preflight:check', args), detectAgents: (): Promise => ipcRenderer.invoke('preflight:detectAgents'), diff --git a/src/renderer/src/components/settings/IntegrationsPane.tsx b/src/renderer/src/components/settings/IntegrationsPane.tsx index a238bc78618..aa69b840b7b 100644 --- a/src/renderer/src/components/settings/IntegrationsPane.tsx +++ b/src/renderer/src/components/settings/IntegrationsPane.tsx @@ -1,5 +1,5 @@ -/* eslint-disable max-lines -- Why: this pane co-locates GitHub, GitLab, - and Linear integration cards so the preflight-check + status-badge + +/* eslint-disable max-lines -- Why: this pane co-locates source-host and + Linear integration cards so the preflight-check + status-badge + install/auth-prompt scaffolding lives in one place rather than fanning out across per-integration files that would each repeat the same pattern. Splitting buys nothing while the surface stays this narrow. */ @@ -53,6 +53,11 @@ export const INTEGRATIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ description: 'Bitbucket Cloud authentication via API token environment variables.', keywords: ['bitbucket', 'integration', 'pull request', 'api token'] }, + { + title: 'Gitea Integration', + description: 'Gitea authentication via API token environment variables.', + keywords: ['gitea', 'self-hosted', 'integration', 'pull request', 'api token'] + }, { title: 'Linear Integration', description: 'Connect Linear to browse and link issues.', @@ -65,6 +70,25 @@ type GhStatus = 'checking' | 'connected' | 'not-installed' | 'not-authenticated' // modes (probe in-flight / installed-but-unauth / missing entirely). type GlabStatus = GhStatus type BitbucketStatus = 'checking' | 'connected' | 'not-configured' | 'not-authenticated' +type GiteaStatus = 'checking' | 'configured' | 'not-configured' | 'not-authenticated' + +type GiteaPreflightStatus = { + configured: boolean + authenticated: boolean + account: string | null + baseUrl: string | null + tokenConfigured: boolean +} + +function giteaStatusFromPreflight(status: GiteaPreflightStatus | undefined): GiteaStatus { + if (!status?.configured) { + return 'not-configured' + } + if (status.tokenConfigured && !status.authenticated) { + return 'not-authenticated' + } + return 'configured' +} export function IntegrationsPane(): React.JSX.Element { const linearStatus = useAppStore((s) => s.linearStatus) @@ -77,6 +101,9 @@ export function IntegrationsPane(): React.JSX.Element { const [glabStatus, setGlabStatus] = useState('checking') const [bitbucketStatus, setBitbucketStatus] = useState('checking') const [bitbucketAccount, setBitbucketAccount] = useState(null) + const [giteaStatus, setGiteaStatus] = useState('checking') + const [giteaAccount, setGiteaAccount] = useState(null) + const [giteaBaseUrl, setGiteaBaseUrl] = useState(null) const [linearDialogOpen, setLinearDialogOpen] = useState(false) const [linearApiKeyDraft, setLinearApiKeyDraft] = useState('') const [linearConnectState, setLinearConnectState] = useState<'idle' | 'connecting' | 'error'>( @@ -118,6 +145,10 @@ export function IntegrationsPane(): React.JSX.Element { } else { setBitbucketStatus('connected') } + const gitea = status.gitea + setGiteaAccount(gitea?.account ?? null) + setGiteaBaseUrl(gitea?.baseUrl ?? null) + setGiteaStatus(giteaStatusFromPreflight(gitea)) }) // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount check }, []) @@ -210,6 +241,16 @@ export function IntegrationsPane(): React.JSX.Element { }) } + const handleRefreshGitea = (): void => { + setGiteaStatus('checking') + void window.api.preflight.check({ force: true }).then((status) => { + const gitea = status.gitea + setGiteaAccount(gitea?.account ?? null) + setGiteaBaseUrl(gitea?.baseUrl ?? null) + setGiteaStatus(giteaStatusFromPreflight(gitea)) + }) + } + return (
{/* GitHub */} @@ -449,6 +490,90 @@ export function IntegrationsPane(): React.JSX.Element { )}
+ {/* Gitea */} +
+
+ +
+

Gitea

+

+ {giteaStatus === 'configured' + ? giteaAccount + ? `${giteaAccount} · Pull requests and commit statuses` + : giteaBaseUrl + ? `${giteaBaseUrl} · Pull requests and commit statuses` + : 'Pull requests and commit statuses for detected repositories' + : 'Pull requests and commit statuses via the Gitea REST API.'} +

+
+ {giteaStatus === 'checking' ? ( + + ) : giteaStatus === 'configured' ? ( + + {giteaAccount ? 'Connected' : 'Configured'} + + ) : ( + + {giteaStatus === 'not-configured' ? 'Optional setup' : 'Auth failed'} + + )} +
+ + {giteaStatus !== 'checking' && giteaStatus !== 'configured' && ( +
+ {giteaStatus === 'not-configured' ? ( + <> +

+ Public repositories are detected from their git remote. Set{' '} + ORCA_GITEA_TOKEN for private + repositories, and set{' '} + ORCA_GITEA_API_BASE_URL only when + Orca cannot derive the API URL from the remote. +

+
+ + +
+ + ) : ( + <> +

+ Gitea credentials are configured but could not authenticate. Check the token, API + base URL, and repository permissions, then restart Orca if environment variables + changed. +

+
+ + +
+ + )} +
+ )} +
+ {/* Linear */}
diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx index 59ce508b06d..dc9ef9d5ae4 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx @@ -109,6 +109,9 @@ function getProviderName(review: HostedReviewInfo): string { if (review.provider === 'bitbucket') { return 'Bitbucket' } + if (review.provider === 'gitea') { + return 'Gitea' + } return 'GitHub' } diff --git a/src/renderer/src/store/slices/hosted-review.test.ts b/src/renderer/src/store/slices/hosted-review.test.ts index 3f90fb4bfbb..13466b0a19c 100644 --- a/src/renderer/src/store/slices/hosted-review.test.ts +++ b/src/renderer/src/store/slices/hosted-review.test.ts @@ -71,7 +71,8 @@ describe('hosted review slice', () => { branch: 'feature/gitlab', linkedGitHubPR: null, linkedGitLabMR: 5, - linkedBitbucketPR: null + linkedBitbucketPR: null, + linkedGiteaPR: null }) }) @@ -96,7 +97,8 @@ describe('hosted review slice', () => { branch: 'feature/windows', linkedGitHubPR: 12, linkedGitLabMR: null, - linkedBitbucketPR: null + linkedBitbucketPR: null, + linkedGiteaPR: null }, { timeoutMs: 30_000 } ) diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index 39b545f62e2..1c86e9d1221 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -38,6 +38,7 @@ export type HostedReviewSlice = { linkedGitHubPR?: number | null linkedGitLabMR?: number | null linkedBitbucketPR?: number | null + linkedGiteaPR?: number | null } ) => Promise } @@ -61,7 +62,8 @@ export const createHostedReviewSlice: StateCreator Date: Fri, 15 May 2026 02:01:19 -0700 Subject: [PATCH 31/82] Fix Windows CJK terminal WebGL rendering --- .../lib/pane-manager/terminal-complex-script.test.ts | 9 +++++++++ .../src/lib/pane-manager/terminal-complex-script.ts | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts b/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts index 20d131fbe7e..5c66dc0646e 100644 --- a/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts @@ -10,6 +10,15 @@ describe('terminalOutputPrefersDomRenderer', () => { expect(terminalOutputPrefersDomRenderer('Hebrew: שלום')).toBe(true) }) + it('detects East Asian wide and fullwidth terminal output', () => { + expect( + terminalOutputPrefersDomRenderer('直接接请求本地 /api/mcp,带同一个 Bearer token,成功') + ).toBe(true) + expect(terminalOutputPrefersDomRenderer('Japanese: ターミナル')).toBe(true) + expect(terminalOutputPrefersDomRenderer('Korean: 터미널')).toBe(true) + expect(terminalOutputPrefersDomRenderer('Fullwidth: ABC123')).toBe(true) + }) + it('detects glyph classes common in agent terminal UIs', () => { expect(terminalOutputPrefersDomRenderer('⠋ Working')).toBe(true) expect(terminalOutputPrefersDomRenderer('├─ file.ts')).toBe(true) diff --git a/src/renderer/src/lib/pane-manager/terminal-complex-script.ts b/src/renderer/src/lib/pane-manager/terminal-complex-script.ts index 23f226319f5..beecca3cfc9 100644 --- a/src/renderer/src/lib/pane-manager/terminal-complex-script.ts +++ b/src/renderer/src/lib/pane-manager/terminal-complex-script.ts @@ -10,17 +10,29 @@ function isRendererRiskCodePoint(value: number): boolean { return ( isInRange(value, 0x0590, 0x08ff) || value === 0x200d || + isInRange(value, 0x1100, 0x11ff) || isInRange(value, 0x2500, 0x259f) || isInRange(value, 0x25a0, 0x25ff) || isInRange(value, 0x2800, 0x28ff) || + // Why: xterm WebGL can leave stale atlas cells for East Asian wide glyphs + // on Windows; force browser text rendering before long CJK output paints. + isInRange(value, 0x2e80, 0x9fff) || + isInRange(value, 0xa960, 0xa97f) || + isInRange(value, 0xac00, 0xd7ff) || isInRange(value, 0xd800, 0xdfff) || isInRange(value, 0xe000, 0xf8ff) || + isInRange(value, 0xf900, 0xfaff) || + isInRange(value, 0xfe10, 0xfe1f) || + isInRange(value, 0xfe30, 0xfe4f) || isInRange(value, 0xfb1d, 0xfdff) || isInRange(value, 0xfe00, 0xfe0f) || isInRange(value, 0xfe70, 0xfeff) || + isInRange(value, 0xff00, 0xffef) || value === 0xfffd || isInRange(value, 0x10ec0, 0x10eff) || isInRange(value, 0x1e900, 0x1e95f) || + isInRange(value, 0x20000, 0x2fa1f) || + isInRange(value, 0x30000, 0x3134f) || isInRange(value, 0xe0100, 0xe01ef) ) } From b030257c3b6fe98fc5d8b28daeaf5954e9d1ca69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leynier=20Guti=C3=A9rrez=20Gonz=C3=A1lez?= Date: Fri, 15 May 2026 03:18:46 -0600 Subject: [PATCH 32/82] feat(source-control): AI commit message generation (#1487) Co-authored-by: Orca Co-authored-by: brennanb2025 --- src/main/attribution/terminal-attribution.ts | 2 +- src/main/codex-cli/command.ts | 9 +- src/main/git/status.test.ts | 34 ++ src/main/git/status.ts | 37 ++ src/main/index.ts | 14 +- src/main/ipc/filesystem.test.ts | 307 +++++++++++ src/main/ipc/filesystem.ts | 151 +++++- src/main/ipc/register-core-handlers.ts | 14 +- src/main/providers/ssh-git-dispatch.ts | 8 +- src/main/providers/ssh-git-provider.test.ts | 76 +++ src/main/providers/ssh-git-provider.ts | 55 ++ src/main/providers/types.ts | 2 + .../commit-message-text-generation.test.ts | 473 +++++++++++++++++ .../commit-message-text-generation.ts | 450 ++++++++++++++++ src/main/win32-utils.test.ts | 14 +- src/main/win32-utils.ts | 31 +- src/preload/api-types.ts | 11 + src/preload/index.ts | 8 + src/relay/agent-exec-handler.test.ts | 259 ++++++++++ src/relay/agent-exec-handler.ts | 254 +++++++++ src/relay/git-exec-validator.test.ts | 17 + src/relay/git-exec-validator.ts | 22 + src/relay/relay.ts | 8 + .../CommitArea.chevron-spinner.test.tsx | 8 + .../CommitArea.generate.test.tsx | 213 ++++++++ .../CommitArea.primary-icons.test.tsx | 8 + .../right-sidebar/CommitArea.test.tsx | 11 + .../right-sidebar/SourceControl.tsx | 236 ++++++++- .../settings/CommitMessageAiPane.test.tsx | 97 ++++ .../settings/CommitMessageAiPane.tsx | 480 ++++++++++++++++++ .../src/components/settings/Settings.tsx | 17 +- .../settings/commit-message-ai-search.ts | 45 ++ src/shared/commit-message-agent-spec.test.ts | 165 ++++++ src/shared/commit-message-agent-spec.ts | 266 ++++++++++ src/shared/commit-message-generation.test.ts | 50 ++ src/shared/commit-message-generation.ts | 84 +++ src/shared/commit-message-plan.test.ts | 101 ++++ src/shared/commit-message-plan.ts | 127 +++++ src/shared/commit-message-prompt.test.ts | 203 ++++++++ src/shared/commit-message-prompt.ts | 231 +++++++++ src/shared/constants.ts | 12 + src/shared/orca-attribution.ts | 6 + src/shared/types.ts | 20 + .../source-control-commit-message-ai.spec.ts | 132 +++++ 44 files changed, 4731 insertions(+), 37 deletions(-) create mode 100644 src/main/text-generation/commit-message-text-generation.test.ts create mode 100644 src/main/text-generation/commit-message-text-generation.ts create mode 100644 src/relay/agent-exec-handler.test.ts create mode 100644 src/relay/agent-exec-handler.ts create mode 100644 src/renderer/src/components/right-sidebar/CommitArea.generate.test.tsx create mode 100644 src/renderer/src/components/settings/CommitMessageAiPane.test.tsx create mode 100644 src/renderer/src/components/settings/CommitMessageAiPane.tsx create mode 100644 src/renderer/src/components/settings/commit-message-ai-search.ts create mode 100644 src/shared/commit-message-agent-spec.test.ts create mode 100644 src/shared/commit-message-agent-spec.ts create mode 100644 src/shared/commit-message-generation.test.ts create mode 100644 src/shared/commit-message-generation.ts create mode 100644 src/shared/commit-message-plan.test.ts create mode 100644 src/shared/commit-message-plan.ts create mode 100644 src/shared/commit-message-prompt.test.ts create mode 100644 src/shared/commit-message-prompt.ts create mode 100644 src/shared/orca-attribution.ts create mode 100644 tests/e2e/source-control-commit-message-ai.spec.ts diff --git a/src/main/attribution/terminal-attribution.ts b/src/main/attribution/terminal-attribution.ts index 22c7af94bd8..4162e15e33c 100644 --- a/src/main/attribution/terminal-attribution.ts +++ b/src/main/attribution/terminal-attribution.ts @@ -4,11 +4,11 @@ to the env injection code makes the attribution behavior auditable as one unit instead of scattering generated shell fragments across files. */ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { join } from 'path' +import { ORCA_GIT_COMMIT_TRAILER } from '../../shared/orca-attribution' const ATTRIBUTION_ROOT_DIR = 'orca-terminal-attribution' const ATTRIBUTION_SHIM_VERSION = '6' const ORCA_PRODUCT_URL = 'https://github.com/stablyai/orca' -const ORCA_GIT_COMMIT_TRAILER = 'Co-authored-by: Orca ' const ORCA_GH_FOOTER = `Made with [Orca](${ORCA_PRODUCT_URL}) 🐋` const SHELL_DOLLAR = '$' const POWERSHELL_TICK = '`' diff --git a/src/main/codex-cli/command.ts b/src/main/codex-cli/command.ts index ccdb7981ddd..2215583ac54 100644 --- a/src/main/codex-cli/command.ts +++ b/src/main/codex-cli/command.ts @@ -121,7 +121,10 @@ function getVersionManagerDirectories( return directories } -function resolveCommand(commandName: string, options: ResolveCommandOptions = {}): string { +export function resolveCliCommand( + commandName: string, + options: ResolveCommandOptions = {} +): string { const platform = options.platform ?? process.platform const executableNames = getExecutableNames(platform, commandName) const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null @@ -139,11 +142,11 @@ function resolveCommand(commandName: string, options: ResolveCommandOptions = {} } export function resolveCodexCommand(options: ResolveCommandOptions = {}): string { - return resolveCommand('codex', options) + return resolveCliCommand('codex', options) } export function resolveClaudeCommand(options: ResolveCommandOptions = {}): string { - return resolveCommand('claude', options) + return resolveCliCommand('claude', options) } // Why: GUI-launched Electron apps inherit a minimal PATH that excludes Node diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 3a815558c18..24f2648d77d 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -33,6 +33,7 @@ import { discardChanges, getBranchCompare, getDiff, + getStagedCommitContext, getStatus, isWithinWorktree } from './status' @@ -406,6 +407,39 @@ describe('getStatus', () => { }) }) +describe('getStagedCommitContext', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('uses explicit large buffers before prompt truncation', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'feature/ai\n' }) + .mockResolvedValueOnce({ stdout: 'M\tREADME.md\n' }) + .mockResolvedValueOnce({ stdout: 'diff --git a/README.md b/README.md\n+hello\n' }) + + const result = await getStagedCommitContext('/repo') + + expect(result).toEqual({ + branch: 'feature/ai', + stagedSummary: 'M\tREADME.md', + stagedPatch: 'diff --git a/README.md b/README.md\n+hello\n' + }) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['diff', '--cached', '--name-status'], { + cwd: '/repo', + maxBuffer: 10 * 1024 * 1024 + }) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( + 3, + ['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff'], + { + cwd: '/repo', + maxBuffer: 10 * 1024 * 1024 + } + ) + }) +}) + describe('detectConflictOperation', () => { beforeEach(() => { readFileMock.mockReset() diff --git a/src/main/git/status.ts b/src/main/git/status.ts index b24535d4454..1b74904fde7 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -14,9 +14,11 @@ import type { GitStatusEntry, GitStatusResult } from '../../shared/types' +import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import { gitExecFileAsync, gitExecFileAsyncBuffer } from './runner' const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 +const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES const BULK_CHUNK_SIZE = 100 /** @@ -728,6 +730,41 @@ export async function unstageFile(worktreePath: string, filePath: string): Promi await gitExecFileAsync(['restore', '--staged', '--', filePath], { cwd: worktreePath }) } +export async function getStagedCommitContext( + worktreePath: string +): Promise { + const branchPromise = gitExecFileAsync(['branch', '--show-current'], { + cwd: worktreePath + }).catch(() => ({ stdout: '' })) + const summaryPromise = gitExecFileAsync(['diff', '--cached', '--name-status'], { + cwd: worktreePath, + maxBuffer: MAX_STAGED_COMMIT_CONTEXT_BYTES + }) + + const [branchResult, summaryResult] = await Promise.all([branchPromise, summaryPromise]) + const stagedSummary = summaryResult.stdout.trim() + if (!stagedSummary) { + return null + } + + const { stdout: stagedPatch } = await gitExecFileAsync( + ['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff'], + { + cwd: worktreePath, + // Why: the prompt builder truncates large staged patches later. Give git + // enough buffer room to reach that truncation step instead of failing at + // Node's default execFile limit first. + maxBuffer: MAX_STAGED_COMMIT_CONTEXT_BYTES + } + ) + + return { + branch: branchResult.stdout.trim() || null, + stagedSummary, + stagedPatch + } +} + export async function commitChanges( worktreePath: string, message: string diff --git a/src/main/index.ts b/src/main/index.ts index 19c20b07e10..d16fdb2f141 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -269,7 +269,14 @@ function openMainWindow(): BrowserWindow { claudeAccounts, rateLimits, window.webContents.id, - automations + automations, + { + prepareForCodexLaunch: () => + store!.getSettings().activeCodexManagedAccountId + ? codexRuntimeHome!.prepareForCodexLaunch() + : null, + prepareForClaudeLaunch: () => claudeRuntimeAuth!.prepareForClaudeLaunch() + } ) automations.setWebContents(window.webContents) automations.start() @@ -277,7 +284,10 @@ function openMainWindow(): BrowserWindow { window, store, runtime, - () => codexRuntimeHome!.prepareForCodexLaunch(), + () => + store!.getSettings().activeCodexManagedAccountId + ? codexRuntimeHome!.prepareForCodexLaunch() + : null, () => claudeRuntimeAuth!.prepareForClaudeLaunch() ) rateLimits.attach(window) diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index b0c267f624a..5196bdb475b 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -18,6 +18,7 @@ const { getDiffMock, getBranchCompareMock, getBranchDiffMock, + getStagedCommitContextMock, stageFileMock, bulkStageFilesMock, unstageFileMock, @@ -25,6 +26,9 @@ const { bulkDiscardChangesMock, discardChangesMock, listWorktreesMock, + resolveCommitMessageSettingsMock, + generateCommitMessageFromContextMock, + cancelGenerateCommitMessageLocalMock, getSshFilesystemProviderMock, getSshGitProviderMock } = vi.hoisted(() => ({ @@ -42,6 +46,7 @@ const { getDiffMock: vi.fn(), getBranchCompareMock: vi.fn(), getBranchDiffMock: vi.fn(), + getStagedCommitContextMock: vi.fn(), stageFileMock: vi.fn(), bulkStageFilesMock: vi.fn(), unstageFileMock: vi.fn(), @@ -49,6 +54,9 @@ const { bulkDiscardChangesMock: vi.fn(), discardChangesMock: vi.fn(), listWorktreesMock: vi.fn(), + resolveCommitMessageSettingsMock: vi.fn(), + generateCommitMessageFromContextMock: vi.fn(), + cancelGenerateCommitMessageLocalMock: vi.fn(), getSshFilesystemProviderMock: vi.fn(), getSshGitProviderMock: vi.fn() })) @@ -78,6 +86,7 @@ vi.mock('../git/status', () => ({ getDiff: getDiffMock, getBranchCompare: getBranchCompareMock, getBranchDiff: getBranchDiffMock, + getStagedCommitContext: getStagedCommitContextMock, stageFile: stageFileMock, bulkStageFiles: bulkStageFilesMock, unstageFile: unstageFileMock, @@ -98,6 +107,12 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock })) +vi.mock('../text-generation/commit-message-text-generation', () => ({ + resolveCommitMessageSettings: resolveCommitMessageSettingsMock, + generateCommitMessageFromContext: generateCommitMessageFromContextMock, + cancelGenerateCommitMessageLocal: cancelGenerateCommitMessageLocalMock +})) + import { registerFilesystemHandlers } from './filesystem' import { invalidateAuthorizedRootsCache, registerWorktreeRootsForRepo } from './filesystem-auth' @@ -161,6 +176,7 @@ describe('registerFilesystemHandlers', () => { getDiffMock, getBranchCompareMock, getBranchDiffMock, + getStagedCommitContextMock, stageFileMock, bulkStageFilesMock, unstageFileMock, @@ -168,6 +184,9 @@ describe('registerFilesystemHandlers', () => { bulkDiscardChangesMock, discardChangesMock, listWorktreesMock, + resolveCommitMessageSettingsMock, + generateCommitMessageFromContextMock, + cancelGenerateCommitMessageLocalMock, getSshFilesystemProviderMock, getSshGitProviderMock ]) { @@ -720,6 +739,294 @@ describe('registerFilesystemHandlers', () => { ).resolves.toEqual({ success: false, error: 'hook failed' }) }) + it('generates a local commit message from main-process staged context', async () => { + const context = { + branch: 'feature/ai', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + } + const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' } + resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) + getStagedCommitContextMock.mockResolvedValue(context) + generateCommitMessageFromContextMock.mockResolvedValue({ + success: true, + message: 'Update README' + }) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:generateCommitMessage')!(null, { + worktreePath: WORKTREE_FEATURE_PATH + }) + ).resolves.toEqual({ success: true, message: 'Update README' }) + + expect(getStagedCommitContextMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH) + expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(context, params, { + kind: 'local', + cwd: WORKTREE_FEATURE_PATH + }) + }) + + it('prepares the selected Codex account home before local generation', async () => { + const context = { + branch: 'feature/ai', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + } + const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' } + resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) + getStagedCommitContextMock.mockResolvedValue(context) + generateCommitMessageFromContextMock.mockResolvedValue({ + success: true, + message: 'Update README' + }) + + registerFilesystemHandlers(store as never, { + prepareForCodexLaunch: () => '/managed/codex-home' + }) + + await handlers.get('git:generateCommitMessage')!(null, { + worktreePath: WORKTREE_FEATURE_PATH + }) + + expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith( + context, + params, + expect.objectContaining({ + kind: 'local', + cwd: WORKTREE_FEATURE_PATH, + env: expect.objectContaining({ CODEX_HOME: '/managed/codex-home' }) + }) + ) + }) + + it('preserves the inherited Codex environment when no managed account is selected', async () => { + const previousCodexHome = process.env.CODEX_HOME + process.env.CODEX_HOME = '/system/codex-home' + const context = { + branch: 'feature/ai', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + } + const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' } + resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) + getStagedCommitContextMock.mockResolvedValue(context) + generateCommitMessageFromContextMock.mockResolvedValue({ + success: true, + message: 'Update README' + }) + + try { + registerFilesystemHandlers(store as never, { + prepareForCodexLaunch: () => null + }) + + await handlers.get('git:generateCommitMessage')!(null, { + worktreePath: WORKTREE_FEATURE_PATH + }) + + expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(context, params, { + kind: 'local', + cwd: WORKTREE_FEATURE_PATH + }) + } finally { + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = previousCodexHome + } + } + }) + + it('returns a sanitized error when local agent account preparation fails', async () => { + const context = { + branch: 'feature/ai', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + } + const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' } + resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) + getStagedCommitContextMock.mockResolvedValue(context) + + registerFilesystemHandlers(store as never, { + prepareForCodexLaunch: () => { + throw new Error('failed to read /Users/alice/.codex/auth.json') + } + }) + + await expect( + handlers.get('git:generateCommitMessage')!(null, { + worktreePath: WORKTREE_FEATURE_PATH + }) + ).resolves.toEqual({ + success: false, + error: 'Failed to prepare the selected agent account for commit message generation.' + }) + expect(generateCommitMessageFromContextMock).not.toHaveBeenCalled() + }) + + it('prepares the selected Claude auth environment before local generation', async () => { + const previousAnthropicApiKey = process.env.ANTHROPIC_API_KEY + process.env.ANTHROPIC_API_KEY = 'do-not-leak-managed-auth-conflict' + const context = { + branch: 'feature/ai', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + } + const params = { agentId: 'claude', model: 'claude-haiku-4-5' } + resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) + getStagedCommitContextMock.mockResolvedValue(context) + generateCommitMessageFromContextMock.mockResolvedValue({ + success: true, + message: 'Update README' + }) + + try { + registerFilesystemHandlers(store as never, { + prepareForClaudeLaunch: async () => ({ + configDir: '/managed/claude', + envPatch: { CLAUDE_CONFIG_DIR: '/managed/claude' }, + stripAuthEnv: true, + provenance: 'managed:account-1' + }) + }) + + await handlers.get('git:generateCommitMessage')!(null, { + worktreePath: WORKTREE_FEATURE_PATH + }) + + const target = generateCommitMessageFromContextMock.mock.calls[0]?.[2] as + | { env?: NodeJS.ProcessEnv } + | undefined + expect(target?.env).toEqual( + expect.objectContaining({ + CLAUDE_CONFIG_DIR: '/managed/claude' + }) + ) + expect(target?.env?.ANTHROPIC_API_KEY).toBeUndefined() + } finally { + if (previousAnthropicApiKey === undefined) { + delete process.env.ANTHROPIC_API_KEY + } else { + process.env.ANTHROPIC_API_KEY = previousAnthropicApiKey + } + } + }) + + it('generates an SSH commit message using remote staged context and relay execution', async () => { + const context = { + branch: 'main', + stagedSummary: 'A\tremote.txt', + stagedPatch: '+remote' + } + const params = { agentId: 'custom', model: '', customAgentCommand: 'agent' } + const executeCommitMessagePlan = vi.fn() + const prepareForCodexLaunch = vi.fn(() => '/managed/codex-home') + const prepareForClaudeLaunch = vi.fn() + resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) + getSshGitProviderMock.mockReturnValue({ + getStagedCommitContext: vi.fn().mockResolvedValue(context), + executeCommitMessagePlan + }) + generateCommitMessageFromContextMock.mockResolvedValue({ + success: true, + message: 'Add remote file' + }) + + registerFilesystemHandlers(store as never, { + prepareForCodexLaunch, + prepareForClaudeLaunch + }) + + await expect( + handlers.get('git:generateCommitMessage')!(null, { + worktreePath: '/remote/repo', + connectionId: 'conn-1' + }) + ).resolves.toEqual({ success: true, message: 'Add remote file' }) + + expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith( + context, + params, + expect.objectContaining({ + kind: 'remote', + cwd: '/remote/repo', + missingBinaryLocation: 'remote PATH' + }) + ) + const target = generateCommitMessageFromContextMock.mock.calls[0]?.[2] + await target.execute( + { binary: 'agent', args: [], stdinPayload: null, label: 'agent' }, + '/cwd', + 1 + ) + expect(executeCommitMessagePlan).toHaveBeenCalledWith( + { binary: 'agent', args: [], stdinPayload: null, label: 'agent' }, + '/cwd', + 1 + ) + expect(prepareForCodexLaunch).not.toHaveBeenCalled() + expect(prepareForClaudeLaunch).not.toHaveBeenCalled() + }) + + it('does not call the generator when no staged changes exist', async () => { + resolveCommitMessageSettingsMock.mockReturnValue({ + ok: true, + params: { agentId: 'codex', model: 'gpt-5.4-mini' } + }) + getStagedCommitContextMock.mockResolvedValue(null) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:generateCommitMessage')!(null, { + worktreePath: WORKTREE_FEATURE_PATH + }) + ).resolves.toEqual({ success: false, error: 'No staged changes to summarize.' }) + + expect(generateCommitMessageFromContextMock).not.toHaveBeenCalled() + }) + + it('sanitizes local staged-context read failures before returning to the renderer', async () => { + resolveCommitMessageSettingsMock.mockReturnValue({ + ok: true, + params: { agentId: 'codex', model: 'gpt-5.4-mini' } + }) + getStagedCommitContextMock.mockRejectedValue(new Error('fatal: /secret/repo failed')) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:generateCommitMessage')!(null, { + worktreePath: WORKTREE_FEATURE_PATH + }) + ).resolves.toEqual({ success: false, error: 'Failed to read staged changes.' }) + + expect(generateCommitMessageFromContextMock).not.toHaveBeenCalled() + }) + + it('sanitizes SSH staged-context read failures before returning to the renderer', async () => { + resolveCommitMessageSettingsMock.mockReturnValue({ + ok: true, + params: { agentId: 'codex', model: 'gpt-5.4-mini' } + }) + getSshGitProviderMock.mockReturnValue({ + getStagedCommitContext: vi.fn().mockRejectedValue(new Error('fatal: /remote/secret failed')) + }) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:generateCommitMessage')!(null, { + worktreePath: '/remote/repo', + connectionId: 'conn-1' + }) + ).resolves.toEqual({ success: false, error: 'Failed to read staged changes.' }) + + expect(generateCommitMessageFromContextMock).not.toHaveBeenCalled() + }) + it('routes ssh git:commit through the SSH provider instead of local commitChanges', async () => { const sshCommitMock = vi.fn().mockResolvedValue({ success: true }) getSshGitProviderMock.mockReturnValue({ commit: sshCommitMock }) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 817668dec74..f35adbca549 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -37,9 +37,16 @@ import { bulkUnstageFiles, bulkDiscardChanges, discardChanges, + getStagedCommitContext, getBranchCompare, getBranchDiff } from '../git/status' +import { + cancelGenerateCommitMessageLocal, + generateCommitMessageFromContext, + resolveCommitMessageSettings, + type GenerateCommitMessageResult +} from '../text-generation/commit-message-text-generation' import { getUpstreamStatus } from '../git/upstream' import { gitFetch, gitPull, gitPush } from '../git/remote' import { assertGitPushTargetShape } from '../../shared/git-push-target-validation' @@ -59,6 +66,8 @@ import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './mar import { checkRgAvailable } from './rg-availability' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import { applyClaudeEnvPatch } from '../claude-accounts/environment' // Why: Monaco has large-file optimizations like VS Code; blocking at 5MB makes // ordinary JSON/log files inaccessible before the editor can degrade features. @@ -84,6 +93,56 @@ const PREVIEWABLE_BINARY_MIME_TYPES: Record = { '.pdf': 'application/pdf' } +export type CommitMessageAgentEnvironmentResolvers = { + prepareForCodexLaunch?: () => string | null + prepareForClaudeLaunch?: () => Promise +} + +function cloneProcessEnv(): Record { + const env: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + env[key] = value + } + } + return env +} + +async function prepareLocalCommitMessageAgentEnv( + agentId: string, + resolvers: CommitMessageAgentEnvironmentResolvers | undefined +): Promise<{ ok: true; env?: NodeJS.ProcessEnv } | { ok: false; error: string }> { + if (!resolvers) { + return { ok: true } + } + + try { + if (agentId === 'codex' && resolvers.prepareForCodexLaunch) { + const codexHomePath = resolvers.prepareForCodexLaunch() + return { + ok: true, + env: codexHomePath ? { ...cloneProcessEnv(), CODEX_HOME: codexHomePath } : undefined + } + } + + if (agentId === 'claude' && resolvers.prepareForClaudeLaunch) { + const preparation = await resolvers.prepareForClaudeLaunch() + const env = applyClaudeEnvPatch(cloneProcessEnv(), preparation.envPatch, { + stripAuthEnv: preparation.stripAuthEnv + }) + return { ok: true, env } + } + } catch (error) { + console.error('[filesystem] Failed to prepare commit message agent environment:', error) + return { + ok: false, + error: 'Failed to prepare the selected agent account for commit message generation.' + } + } + + return { ok: true } +} + /** * Check if a buffer appears to be binary (contains null bytes in first 8KB). */ @@ -129,7 +188,10 @@ async function isDirectoryEntry( } } -export function registerFilesystemHandlers(store: Store): void { +export function registerFilesystemHandlers( + store: Store, + commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers +): void { const activeTextSearches = new Map() // ─── Filesystem ───────────────────────────────────────── @@ -557,6 +619,93 @@ export function registerFilesystemHandlers(store: Store): void { } ) + ipcMain.handle( + 'git:generateCommitMessage', + async ( + _event, + args: { + worktreePath: string + connectionId?: string + } + ): Promise => { + const resolvedSettings = resolveCommitMessageSettings(store.getSettings()) + if (!resolvedSettings.ok) { + return { success: false, error: resolvedSettings.error } + } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return { + success: false, + error: `No git provider for connection "${args.connectionId}"` + } + } + let context + try { + context = await provider.getStagedCommitContext(args.worktreePath) + } catch (error) { + console.error('[filesystem] Failed to read remote staged commit context:', error) + return { + success: false, + error: 'Failed to read staged changes.' + } + } + if (!context) { + return { success: false, error: 'No staged changes to summarize.' } + } + return generateCommitMessageFromContext(context, resolvedSettings.params, { + kind: 'remote', + cwd: args.worktreePath, + execute: (plan, cwd, timeoutMs) => + provider.executeCommitMessagePlan(plan, cwd, timeoutMs), + missingBinaryLocation: 'remote PATH' + }) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + let context + try { + context = await getStagedCommitContext(worktreePath) + } catch (error) { + console.error('[filesystem] Failed to read staged commit context:', error) + return { + success: false, + error: 'Failed to read staged changes.' + } + } + if (!context) { + return { success: false, error: 'No staged changes to summarize.' } + } + const localEnv = await prepareLocalCommitMessageAgentEnv( + resolvedSettings.params.agentId, + commitMessageAgentEnv + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return generateCommitMessageFromContext(context, resolvedSettings.params, { + kind: 'local', + cwd: worktreePath, + ...(localEnv.env ? { env: localEnv.env } : {}) + }) + } + ) + + ipcMain.handle( + 'git:cancelGenerateCommitMessage', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return + } + await provider.cancelGenerateCommitMessage(args.worktreePath) + return + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + cancelGenerateCommitMessageLocal(worktreePath) + } + ) + ipcMain.handle( 'git:branchCompare', async ( diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 74c08a40f69..2bfb4c71d86 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -4,7 +4,10 @@ import { registerPreflightHandlers } from './preflight' import type { Store } from '../persistence' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { StatsCollector } from '../stats/collector' -import { registerFilesystemHandlers } from './filesystem' +import { + registerFilesystemHandlers, + type CommitMessageAgentEnvironmentResolvers +} from './filesystem' import { registerFilesystemWatcherHandlers } from './filesystem-watcher' import { registerClaudeUsageHandlers } from './claude-usage' import { registerCodexUsageHandlers } from './codex-usage' @@ -65,7 +68,8 @@ export function registerCoreHandlers( claudeAccounts: ClaudeAccountService, rateLimits: RateLimitService, mainWindowWebContentsId: number | null = null, - automations?: AutomationService + automations?: AutomationService, + commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers ): void { // Why: on macOS the app can stay alive after all windows close, then // openMainWindow() is called again on 'activate'. ipcMain.handle() throws @@ -118,7 +122,11 @@ export function registerCoreHandlers( registerSessionHandlers(store) registerUIHandlers(store) registerWorkspaceSpaceHandlers(store) - registerFilesystemHandlers(store) + if (commitMessageAgentEnv) { + registerFilesystemHandlers(store, commitMessageAgentEnv) + } else { + registerFilesystemHandlers(store) + } registerFilesystemWatcherHandlers() registerRuntimeHandlers(runtime) registerRuntimeEnvironmentHandlers() diff --git a/src/main/providers/ssh-git-dispatch.ts b/src/main/providers/ssh-git-dispatch.ts index b4e462a3af1..74afe7700fc 100644 --- a/src/main/providers/ssh-git-dispatch.ts +++ b/src/main/providers/ssh-git-dispatch.ts @@ -1,8 +1,8 @@ -import type { IGitProvider } from './types' +import type { SshGitProvider } from './ssh-git-provider' -const sshProviders = new Map() +const sshProviders = new Map() -export function registerSshGitProvider(connectionId: string, provider: IGitProvider): void { +export function registerSshGitProvider(connectionId: string, provider: SshGitProvider): void { sshProviders.set(connectionId, provider) } @@ -10,6 +10,6 @@ export function unregisterSshGitProvider(connectionId: string): void { sshProviders.delete(connectionId) } -export function getSshGitProvider(connectionId: string): IGitProvider | undefined { +export function getSshGitProvider(connectionId: string): SshGitProvider | undefined { return sshProviders.get(connectionId) } diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 5643c3c2d29..636fa229d60 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -54,6 +54,82 @@ describe('SshGitProvider', () => { expect(result).toEqual(commitResult) }) + it('getStagedCommitContext reads branch, staged summary, and staged patch remotely', async () => { + mux.request.mockImplementation(async (method, payload) => { + expect(method).toBe('git.exec') + if (payload.args[1] === '--show-current') { + return { stdout: 'feature/ai-commit\n' } + } + if (payload.args[2] === '--name-status') { + return { stdout: 'M\tREADME.md\n' } + } + if (payload.args[2] === '--patch') { + return { stdout: 'diff --git a/README.md b/README.md\n+hello' } + } + throw new Error(`unexpected args: ${payload.args.join(' ')}`) + }) + + const result = await provider.getStagedCommitContext('/home/user/repo') + + expect(result).toEqual({ + branch: 'feature/ai-commit', + stagedSummary: 'M\tREADME.md', + stagedPatch: 'diff --git a/README.md b/README.md\n+hello' + }) + expect(mux.request).toHaveBeenCalledWith('git.exec', { + args: ['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff'], + cwd: '/home/user/repo' + }) + }) + + it('getStagedCommitContext returns null when nothing is staged', async () => { + mux.request.mockImplementation(async (_method, payload) => { + if (payload.args[1] === '--show-current') { + return { stdout: 'main\n' } + } + return { stdout: '' } + }) + + await expect(provider.getStagedCommitContext('/home/user/repo')).resolves.toBeNull() + expect(mux.request).toHaveBeenCalledTimes(2) + }) + + it('executeCommitMessagePlan delegates the prepared plan to the relay', async () => { + const execResult = { + stdout: 'Update docs', + stderr: '', + exitCode: 0, + timedOut: false + } + mux.request.mockResolvedValue(execResult) + + const result = await provider.executeCommitMessagePlan( + { + binary: 'codex', + args: ['exec', 'PROMPT'], + stdinPayload: null, + label: 'Codex' + }, + '/home/user/repo', + 60_000 + ) + + expect(mux.request).toHaveBeenCalledWith('agent.execNonInteractive', { + binary: 'codex', + args: ['exec', 'PROMPT'], + cwd: '/home/user/repo', + stdin: null, + timeoutMs: 60_000 + }) + expect(result).toEqual(execResult) + }) + + it('cancelGenerateCommitMessage sends best-effort relay cancellation', async () => { + await provider.cancelGenerateCommitMessage('/home/user/repo') + + expect(mux.request).toHaveBeenCalledWith('agent.cancelExec', { cwd: '/home/user/repo' }) + }) + it('getDiff sends git.diff request', async () => { const diffResult = { kind: 'text', originalContent: '', modifiedContent: 'hello' } mux.request.mockResolvedValue(diffResult) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 45dec93c79b..e73e92f7658 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -1,3 +1,7 @@ +/* eslint-disable max-lines -- Why: this provider mirrors IGitProvider one + method per RPC call (~16 methods). Splitting it would only add + indirection — every method is a 1:1 forwarder to a relay RPC plus a + small amount of param plumbing. */ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import type { IGitProvider } from './types' import hostedGitInfo from 'hosted-git-info' @@ -10,6 +14,9 @@ import type { GitUpstreamStatus, GitWorktreeInfo } from '../../shared/types' +import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' +import type { CommitMessagePlan } from '../../shared/commit-message-plan' +import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation' export class SshGitProvider implements IGitProvider { private connectionId: string @@ -38,6 +45,54 @@ export class SshGitProvider implements IGitProvider { })) as { success: boolean; error?: string } } + async getStagedCommitContext(worktreePath: string): Promise { + const branchPromise = this.exec(['branch', '--show-current'], worktreePath).catch(() => ({ + stdout: '' + })) + const [branchResult, summaryResult] = await Promise.all([ + branchPromise, + this.exec(['diff', '--cached', '--name-status'], worktreePath) + ]) + const stagedSummary = summaryResult.stdout.trim() + if (!stagedSummary) { + return null + } + const { stdout: stagedPatch } = await this.exec( + ['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff'], + worktreePath + ) + return { + branch: branchResult.stdout.trim() || null, + stagedSummary, + stagedPatch + } + } + + async executeCommitMessagePlan( + plan: CommitMessagePlan, + cwd: string, + timeoutMs: number + ): Promise { + return (await this.mux.request('agent.execNonInteractive', { + binary: plan.binary, + args: plan.args, + cwd, + stdin: plan.stdinPayload, + timeoutMs + })) as RemoteCommitMessageExecResult + } + + async cancelGenerateCommitMessage(worktreePath: string): Promise { + // Why: best-effort — the relay returns `{canceled: false}` when there is + // nothing in flight. Callers should not block UI updates on this. + try { + await this.mux.request('agent.cancelExec', { cwd: worktreePath }) + } catch { + // Swallow: cancellation is a fire-and-forget user intent. The pending + // generateCommitMessage promise will still resolve with the kill result. + } + } + async getDiff( worktreePath: string, filePath: string, diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 3e091ea1f76..da8e8758ac6 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -11,6 +11,7 @@ import type { SearchOptions, SearchResult } from '../../shared/types' +import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' // ─── PTY Provider ─────────────────────────────────────────────────── @@ -143,6 +144,7 @@ export type IFilesystemProvider = { export type IGitProvider = { getStatus(worktreePath: string): Promise commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }> + getStagedCommitContext(worktreePath: string): Promise getDiff( worktreePath: string, filePath: string, diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts new file mode 100644 index 00000000000..77665b8991b --- /dev/null +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -0,0 +1,473 @@ +/* eslint-disable max-lines -- Why: local/remote generation, cancellation, and + env propagation share subprocess mocks; splitting would obscure the + cross-path invariants these tests protect. */ +import { spawn } from 'child_process' +import type * as ChildProcess from 'child_process' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import { + applyOrcaAttribution, + generateCommitMessageFromContext, + resolveCommitMessageSettings +} from './commit-message-text-generation' + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + spawn: vi.fn(actual.spawn) + } +}) + +const spawnMock = vi.mocked(spawn) + +function withPlatform(platform: NodeJS.Platform, fn: () => T): T { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return fn() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } +} + +beforeEach(() => { + spawnMock.mockClear() +}) + +describe('resolveCommitMessageSettings', () => { + it('falls back to the agent default model when a persisted model is stale', () => { + const settings = getDefaultSettings('/tmp') + settings.enableGitHubAttribution = true + settings.commitMessageAi = { + enabled: true, + agentId: 'codex', + selectedModelByAgent: { codex: 'retired-model' }, + selectedThinkingByModel: {}, + customPrompt: 'Use Conventional Commits.', + customAgentCommand: '' + } + + const result = resolveCommitMessageSettings(settings) + + expect(result).toEqual({ + ok: true, + params: { + agentId: 'codex', + model: 'gpt-5.4-mini', + thinkingLevel: 'low', + customPrompt: 'Use Conventional Commits.', + attributionEnabled: true + } + }) + }) + + it('falls back to the model default thinking level when a persisted level is stale', () => { + const settings = getDefaultSettings('/tmp') + settings.commitMessageAi = { + enabled: true, + agentId: 'codex', + selectedModelByAgent: { codex: 'gpt-5.5' }, + selectedThinkingByModel: { 'gpt-5.5': 'turbo' }, + customPrompt: '', + customAgentCommand: '' + } + + const result = resolveCommitMessageSettings(settings) + + expect(result).toMatchObject({ + ok: true, + params: { + agentId: 'codex', + model: 'gpt-5.5', + thinkingLevel: 'low' + } + }) + }) + + it('passes the per-agent command override into non-interactive planning', () => { + const settings = getDefaultSettings('/tmp') + settings.agentCmdOverrides.codex = 'npx codex' + settings.commitMessageAi = { + enabled: true, + agentId: 'codex', + selectedModelByAgent: { codex: 'gpt-5.4-mini' }, + selectedThinkingByModel: {}, + customPrompt: '', + customAgentCommand: '' + } + + const result = resolveCommitMessageSettings(settings) + + expect(result).toMatchObject({ + ok: true, + params: { + agentId: 'codex', + agentCommandOverride: 'npx codex' + } + }) + }) + + it('requires a non-empty custom command for custom agents', () => { + const settings = getDefaultSettings('/tmp') + settings.commitMessageAi = { + enabled: true, + agentId: 'custom', + selectedModelByAgent: {}, + selectedThinkingByModel: {}, + customPrompt: '', + customAgentCommand: ' ' + } + + expect(resolveCommitMessageSettings(settings)).toEqual({ + ok: false, + error: 'Custom command is empty. Add one in Settings -> Git -> AI Commit Messages.' + }) + }) +}) + +describe('generateCommitMessageFromContext', () => { + it('uses a prepared remote execution plan instead of running git on the remote side', async () => { + const result = await generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent --message {prompt}' + }, + { + kind: 'remote', + cwd: '/repo', + missingBinaryLocation: 'remote PATH', + execute: async (plan, cwd, timeoutMs) => { + expect(cwd).toBe('/repo') + expect(timeoutMs).toBe(60_000) + expect(plan.binary).toBe('agent') + expect(plan.args).toHaveLength(2) + expect(plan.args[0]).toBe('--message') + expect(plan.args[1]).toContain('Staged files:\nM\tREADME.md') + return { + stdout: 'Add README note.\n', + stderr: '', + exitCode: 0, + timedOut: false + } + } + } + ) + + expect(result).toEqual({ + success: true, + message: 'Add README note', + agentLabel: 'agent' + }) + }) + + it('does not fall back to raw agent stdout or stderr on failures', async () => { + const result = await generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'remote', + cwd: '/repo', + missingBinaryLocation: 'remote PATH', + execute: async () => ({ + stdout: 'You are generating a single git commit message for /secret/repo', + stderr: 'raw failure output with /secret/repo', + exitCode: 1, + timedOut: false + }) + } + ) + + expect(result).toEqual({ + success: false, + error: 'agent failed. Check the agent CLI configuration and try again.' + }) + }) + + it('does not expose extracted agent error details to the renderer', async () => { + const result = await generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'remote', + cwd: '/repo', + missingBinaryLocation: 'remote PATH', + execute: async () => ({ + stdout: 'ERROR: fatal: /secret/repo/config failed', + stderr: '', + exitCode: 1, + timedOut: false + }) + } + ) + + expect(result).toEqual({ + success: false, + error: 'agent failed. Check the agent CLI configuration and try again.' + }) + }) + + it('preserves the structured subject and body when formatting the final response', async () => { + const result = await generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent', + attributionEnabled: true + }, + { + kind: 'remote', + cwd: '/repo', + missingBinaryLocation: 'remote PATH', + execute: async () => ({ + stdout: 'Update README.\n\n- Explain the generated commit-message flow\n', + stderr: '', + exitCode: 0, + timedOut: false + }) + } + ) + + expect(result).toEqual({ + success: true, + message: + 'Update README\n\n- Explain the generated commit-message flow\n\nCo-authored-by: Orca ', + agentLabel: 'agent' + }) + }) + + it('sanitizes remote execution transport failures', async () => { + const result = await generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'remote', + cwd: '/repo', + missingBinaryLocation: 'remote PATH', + execute: async () => { + throw new Error('relay disconnected while reading /secret/repo') + } + } + ) + + expect(result).toEqual({ + success: false, + error: + 'agent could not be reached on the remote PATH. Try again after the SSH connection recovers.' + }) + }) + + it('caps local agent output before buffering unbounded data', async () => { + const listeners = new Map void>() + const child = { + pid: 123, + kill: vi.fn(), + stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, + stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, + stdin: { end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)) + } + spawnMock.mockReturnValue(child as never) + + const pending = generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'local', + cwd: '/repo' + } + ) + + listeners.get('stdout:data')?.(Buffer.alloc(4 * 1024 * 1024 + 1)) + listeners.get('close')?.(null) + + await expect(pending).resolves.toEqual({ + success: false, + error: 'agent failed. Check the agent CLI configuration and try again.' + }) + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('passes prepared provider environment to local agent subprocesses', async () => { + const listeners = new Map void>() + const child = { + pid: 123, + kill: vi.fn(), + stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, + stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, + stdin: { end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)) + } + spawnMock.mockReturnValue(child as never) + + const pending = generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'orca-test-agent-nope' + }, + { + kind: 'local', + cwd: '/repo', + env: { ...process.env, CODEX_HOME: '/managed/codex-home' } + } + ) + + listeners.get('stdout:data')?.(Buffer.from('Add README note\n')) + listeners.get('close')?.(0) + + await expect(pending).resolves.toMatchObject({ + success: true, + message: 'Add README note' + }) + expect(spawnMock).toHaveBeenCalledWith( + 'orca-test-agent-nope', + [], + expect.objectContaining({ + env: expect.objectContaining({ CODEX_HOME: '/managed/codex-home' }) + }) + ) + }) + + it('routes Windows batch-script agent commands through cmd.exe', async () => { + const originalComSpec = process.env.ComSpec + process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe' + try { + await withPlatform('win32', async () => { + const listeners = new Map void>() + const child = { + pid: 123, + kill: vi.fn(), + stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, + stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, + stdin: { end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)) + } + spawnMock.mockReturnValue(child as never) + + const pending = generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'C:/tools/agent.cmd' + }, + { + kind: 'local', + cwd: 'C:\\repo' + } + ) + + listeners.get('stdout:data')?.(Buffer.from('Update README\n')) + listeners.get('close')?.(0) + + await expect(pending).resolves.toMatchObject({ + success: true, + message: 'Update README' + }) + expect(spawnMock).toHaveBeenCalledWith( + 'C:\\Windows\\System32\\cmd.exe', + ['/d', '/s', '/c', '"C:/tools/agent.cmd"'], + expect.objectContaining({ + cwd: 'C:\\repo', + windowsHide: true + }) + ) + }) + } finally { + if (originalComSpec === undefined) { + delete process.env.ComSpec + } else { + process.env.ComSpec = originalComSpec + } + } + }) + + it('rejects unsafe argv prompts for Windows batch-script agent commands', async () => { + await withPlatform('win32', async () => { + const result = await generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello & goodbye' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'C:/tools/agent.cmd {prompt}' + }, + { + kind: 'local', + cwd: 'C:\\repo' + } + ) + + expect(result).toEqual({ + success: false, + error: + 'C:/tools/agent.cmd cannot be run as a Windows batch command with the prompt in argv. Remove {prompt} so Orca sends the prompt on stdin.' + }) + expect(spawnMock).not.toHaveBeenCalled() + }) + }) +}) + +describe('applyOrcaAttribution', () => { + it('does not duplicate the Orca trailer', () => { + const message = applyOrcaAttribution('Update docs', true) + + expect(applyOrcaAttribution(message, true)).toBe(message) + }) +}) diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts new file mode 100644 index 00000000000..e056270f8ca --- /dev/null +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -0,0 +1,450 @@ +/* eslint-disable max-lines -- Why: local and SSH generation share cancellation, + spawn failure handling, and output normalization; keeping them together + prevents those paths from drifting. */ +import { exec, spawn, type ChildProcess } from 'child_process' +import type { GlobalSettings, TuiAgent } from '../../shared/types' +import { + buildCommitMessagePrompt, + splitGeneratedCommitMessage, + type CommitMessageDraftContext, + type GeneratedCommitMessage +} from '../../shared/commit-message-generation' +import { + cleanGeneratedCommitMessage, + extractAgentErrorMessage +} from '../../shared/commit-message-prompt' +import { + CUSTOM_AGENT_ID, + DEFAULT_COMMIT_MESSAGE_AGENT_ID, + getCommitMessageAgentSpec, + getCommitMessageModel, + isCustomAgentId +} from '../../shared/commit-message-agent-spec' +import { + planCommitMessageGeneration, + type CommitMessagePlan +} from '../../shared/commit-message-plan' +import { ORCA_GIT_COMMIT_TRAILER } from '../../shared/orca-attribution' +import { resolveCliCommand } from '../codex-cli/command' +import { + getSpawnArgsForWindows, + UnsafeWindowsBatchArgumentsError, + WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR +} from '../win32-utils' + +const GENERATION_TIMEOUT_MS = 60_000 +const MAX_AGENT_OUTPUT_BYTES = 4 * 1024 * 1024 + +export type GenerateCommitMessageParams = { + agentId: TuiAgent | 'custom' + model: string + thinkingLevel?: string + customPrompt?: string + customAgentCommand?: string + agentCommandOverride?: string + /** When true, append `Co-authored-by: Orca ...` after the cleaned message. */ + attributionEnabled?: boolean +} + +export type GenerateCommitMessageResult = + | { success: true; message: string; agentLabel?: string } + | { success: false; error: string; canceled?: boolean } + +export type RemoteCommitMessageExecResult = { + stdout: string + stderr: string + exitCode: number | null + timedOut: boolean + canceled?: boolean + spawnError?: string +} + +export type CommitMessageGenerationTarget = + | { kind: 'local'; cwd: string; env?: NodeJS.ProcessEnv } + | { + kind: 'remote' + cwd: string + execute: ( + plan: CommitMessagePlan, + cwd: string, + timeoutMs: number + ) => Promise + missingBinaryLocation: string + } + +type ResolveCommitMessageSettingsResult = + | { ok: true; params: GenerateCommitMessageParams } + | { ok: false; error: string } + +type InternalCommitMessageGenerationResult = + | { success: true; commitMessage: GeneratedCommitMessage; agentLabel?: string } + | { success: false; error: string; canceled?: boolean } + +/** Appends the Orca trailer if the message does not already include it. */ +export function applyOrcaAttribution(message: string, enabled: boolean): string { + if (!enabled) { + // Why: trim trailing whitespace even on the no-attribution path so a + // stray "\n" from the agent's output never reaches the textarea as a + // visible blank line. + return message.replace(/\s+$/, '') + } + const stripped = message.replace(/\s+$/, '') + if (stripped.includes(ORCA_GIT_COMMIT_TRAILER)) { + return stripped + } + // Why: a blank line separates the trailer block from the body so `git + // interpret-trailers` and most parsers treat it as a real trailer instead + // of a paragraph continuation. + return `${stripped}\n\n${ORCA_GIT_COMMIT_TRAILER}` +} + +export function resolveCommitMessageSettings( + settings: GlobalSettings +): ResolveCommitMessageSettingsResult { + const config = settings.commitMessageAi + if (!config?.enabled || !config.agentId) { + return { ok: false, error: 'Enable AI commit messages and choose an agent in Settings -> Git.' } + } + + if (isCustomAgentId(config.agentId)) { + const customAgentCommand = config.customAgentCommand.trim() + if (!customAgentCommand) { + return { + ok: false, + error: 'Custom command is empty. Add one in Settings -> Git -> AI Commit Messages.' + } + } + return { + ok: true, + params: { + agentId: CUSTOM_AGENT_ID, + model: '', + customPrompt: config.customPrompt, + customAgentCommand, + attributionEnabled: settings.enableGitHubAttribution === true + } + } + } + + const agentId = config.agentId ?? DEFAULT_COMMIT_MESSAGE_AGENT_ID + const spec = getCommitMessageAgentSpec(agentId) + if (!spec) { + return { ok: false, error: `Agent "${agentId}" does not support AI commit messages.` } + } + + const persistedModelId = config.selectedModelByAgent[agentId] ?? spec.defaultModelId + const model = + getCommitMessageModel(agentId, persistedModelId) ?? + getCommitMessageModel(agentId, spec.defaultModelId) + if (!model) { + return { ok: false, error: `No model is available for ${spec.label}.` } + } + + const persistedThinking = config.selectedThinkingByModel[model.id] + const thinkingLevel = model.thinkingLevels?.some((level) => level.id === persistedThinking) + ? persistedThinking + : model.defaultThinkingLevel + + const agentCommandOverride = settings.agentCmdOverrides?.[agentId]?.trim() + return { + ok: true, + params: { + agentId, + model: model.id, + thinkingLevel, + customPrompt: config.customPrompt, + ...(agentCommandOverride ? { agentCommandOverride } : {}), + attributionEnabled: settings.enableGitHubAttribution === true + } + } +} + +function sanitizeAgentFailureDetail(detail: string | null): string | null { + const trimmed = detail?.replace(/\p{Cc}+/gu, ' ').trim() + if (!trimmed) { + return null + } + return trimmed.length > 240 ? `${trimmed.slice(0, 240).trimEnd()}...` : trimmed +} + +function userFacingAgentFailure(label: string): string { + return `${label} failed. Check the agent CLI configuration and try again.` +} + +function userFacingUnsafeWindowsBatchArgs(label: string): string { + return `${label} cannot be run as a Windows batch command with the prompt in argv. Remove {prompt} so Orca sends the prompt on stdin.` +} + +// Why: on Windows, npm-installed CLIs like `claude` and `codex` are usually +// `.cmd` shims. We route those through cmd.exe so Node can launch them, and +// `child.kill()` would only terminate the wrapper. `taskkill /T /F` walks the +// process tree from the wrapper PID and force-kills every descendant, which is +// what users expect when they hit "stop generating". +function killProcessTree(child: ChildProcess): void { + const pid = child.pid + if (!pid) { + return + } + if (process.platform === 'win32') { + exec(`taskkill /pid ${pid} /T /F`, () => { + // Best-effort; the spawn's `close` listener fires once the tree exits. + }) + return + } + try { + child.kill('SIGKILL') + } catch { + // The child may have already exited between the in-flight check and the + // kill - that race is benign and can be ignored. + } +} + +// Keying by `local:${cwd}` keeps local cancellation independent from any SSH +// worktree with the same remote path. +const cancelTokensByLane = new Map void>() + +function localLaneKey(cwd: string): string { + return `local:${cwd}` +} + +export function cancelGenerateCommitMessageLocal(cwd: string): void { + cancelTokensByLane.get(localLaneKey(cwd))?.() +} + +async function runLocalPlan( + plan: CommitMessagePlan, + cwd: string, + env: NodeJS.ProcessEnv | undefined +): Promise { + const { binary, args, stdinPayload, label } = plan + return new Promise((resolve) => { + let child: ChildProcess + try { + const spawnEnv = env ?? process.env + const resolvedBinary = + process.platform === 'win32' + ? resolveCliCommand(binary, { pathEnv: spawnEnv.PATH ?? spawnEnv.Path ?? null }) + : binary + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedBinary, args) + child = spawn(spawnCmd, spawnArgs, { + cwd, + env: spawnEnv, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + }) + } catch (error) { + if (error instanceof UnsafeWindowsBatchArgumentsError) { + resolve({ + success: false, + error: userFacingUnsafeWindowsBatchArgs(label) + }) + return + } + console.error('[commit-message] Failed to spawn local generator:', error) + resolve({ + success: false, + error: `${label} could not be started. Check the agent command in Settings and try again.` + }) + return + } + + let stdout = '' + let stderr = '' + let stdoutBytes = 0 + let stderrBytes = 0 + let outputLimitExceeded = false + let settled = false + let canceledByUser = false + const laneKey = localLaneKey(cwd) + const finalize = (result: InternalCommitMessageGenerationResult): void => { + if (settled) { + return + } + settled = true + cancelTokensByLane.delete(laneKey) + resolve(result) + } + + cancelTokensByLane.set(laneKey, () => { + canceledByUser = true + killProcessTree(child) + }) + + const timer = setTimeout(() => { + killProcessTree(child) + finalize({ + success: false, + error: `Generation timed out after ${GENERATION_TIMEOUT_MS / 1000}s.` + }) + }, GENERATION_TIMEOUT_MS) + + child.stdout?.on('data', (chunk: Buffer) => { + stdoutBytes += chunk.byteLength + if (stdoutBytes > MAX_AGENT_OUTPUT_BYTES) { + outputLimitExceeded = true + killProcessTree(child) + return + } + stdout += chunk.toString('utf-8') + }) + child.stderr?.on('data', (chunk: Buffer) => { + stderrBytes += chunk.byteLength + if (stderrBytes > MAX_AGENT_OUTPUT_BYTES) { + outputLimitExceeded = true + killProcessTree(child) + return + } + stderr += chunk.toString('utf-8') + }) + child.on('error', (error) => { + clearTimeout(timer) + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') { + finalize({ + success: false, + error: `${binary} not found on PATH. Install ${label} to use AI commit messages.` + }) + return + } + console.error('[commit-message] Local generator failed after spawn:', error) + finalize({ + success: false, + error: `${label} failed to start. Check the agent command in Settings and try again.` + }) + }) + child.on('close', (code) => { + clearTimeout(timer) + if (canceledByUser) { + finalize({ success: false, error: 'Generation canceled.', canceled: true }) + return + } + if (outputLimitExceeded) { + finalize({ success: false, error: userFacingAgentFailure(label) }) + return + } + finalizeFromAgentOutput({ code, stdout, stderr, label, finalize }) + }) + + child.stdin?.end(stdinPayload ?? undefined) + }) +} + +function finalizeFromAgentOutput(args: { + code: number | null + stdout: string + stderr: string + label: string + finalize: (result: InternalCommitMessageGenerationResult) => void +}): void { + const { code, stdout, stderr, label, finalize } = args + if (code !== 0) { + const safeDetail = sanitizeAgentFailureDetail(extractAgentErrorMessage(stdout, stderr)) + console.error('[commit-message] Generator failed:', { + label, + exitCode: code, + safeDetail, + stdout, + stderr + }) + finalize({ success: false, error: userFacingAgentFailure(label) }) + return + } + const cleaned = cleanGeneratedCommitMessage(stdout) + if (!cleaned) { + finalize({ success: false, error: `${label} returned an empty message.` }) + return + } + const commitMessage = splitGeneratedCommitMessage(cleaned) + finalize({ + success: true, + commitMessage, + agentLabel: label + }) +} + +async function runRemotePlan( + plan: CommitMessagePlan, + target: Extract +): Promise { + const { binary, label } = plan + let result: RemoteCommitMessageExecResult + try { + result = await target.execute(plan, target.cwd, GENERATION_TIMEOUT_MS) + } catch (error) { + console.error('[commit-message] Remote generator request failed:', error) + return { + success: false, + error: `${label} could not be reached on the ${target.missingBinaryLocation}. Try again after the SSH connection recovers.` + } + } + if (result.spawnError) { + if (result.spawnError === WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR) { + return { + success: false, + error: userFacingUnsafeWindowsBatchArgs(label) + } + } + if (/ENOENT/i.test(result.spawnError)) { + return { + success: false, + error: `${binary} not found on the ${target.missingBinaryLocation}. Install ${label} there.` + } + } + console.error('[commit-message] Remote generator spawn failed:', result.spawnError) + return { + success: false, + error: `${label} could not be started on the ${target.missingBinaryLocation}. Check the agent command there and try again.` + } + } + if (result.canceled) { + return { success: false, error: 'Generation canceled.', canceled: true } + } + if (result.timedOut) { + return { + success: false, + error: `Generation timed out after ${GENERATION_TIMEOUT_MS / 1000}s.` + } + } + + return new Promise((resolve) => { + finalizeFromAgentOutput({ + code: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + label, + finalize: resolve + }) + }) +} + +function formatCommitMessageGenerationResult( + result: InternalCommitMessageGenerationResult, + attributionEnabled: boolean +): GenerateCommitMessageResult { + if (!result.success) { + return result + } + return { + success: true, + message: applyOrcaAttribution(result.commitMessage.message, attributionEnabled), + agentLabel: result.agentLabel + } +} + +export async function generateCommitMessageFromContext( + context: CommitMessageDraftContext, + params: GenerateCommitMessageParams, + target: CommitMessageGenerationTarget +): Promise { + const prompt = buildCommitMessagePrompt(context, params.customPrompt ?? '') + const planned = planCommitMessageGeneration(params, prompt) + if (!planned.ok) { + return { success: false, error: planned.error } + } + + const internalResult = + target.kind === 'remote' + ? await runRemotePlan(planned.plan, target) + : await runLocalPlan(planned.plan, target.cwd, target.env) + return formatCommitMessageGenerationResult(internalResult, params.attributionEnabled === true) +} diff --git a/src/main/win32-utils.test.ts b/src/main/win32-utils.test.ts index 621bfaaf7bb..cf4efb4cd52 100644 --- a/src/main/win32-utils.test.ts +++ b/src/main/win32-utils.test.ts @@ -44,9 +44,9 @@ describe('getSpawnArgsForWindows', () => { '--foo' ]) expect(spawnCmd).toBe('C:\\Windows\\System32\\cmd.exe') - // Why: /d disables AutoRun; /c runs the given command and exits. Args - // follow the batch path literally without re-escaping. - expect(spawnArgs).toEqual(['/d', '/c', 'C:\\tools\\codex.cmd', 'login', '--foo']) + // Why: /d disables AutoRun; /s preserves quoted command-line parsing; + // /c runs the quoted batch command and exits. + expect(spawnArgs).toEqual(['/d', '/s', '/c', '"C:\\tools\\codex.cmd" "login" "--foo"']) }) } finally { if (originalComSpec === undefined) { @@ -72,6 +72,14 @@ describe('getSpawnArgsForWindows', () => { expect(spawnArgs).toEqual(['login']) }) }) + + it('rejects unsafe args for .cmd scripts on win32', () => { + withPlatform('win32', () => { + expect(() => getSpawnArgsForWindows('C:\\tools\\agent.cmd', ['hello & goodbye'])).toThrow( + 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' + ) + }) + }) }) describe('isPermissionError', () => { diff --git a/src/main/win32-utils.ts b/src/main/win32-utils.ts index 4465fa22349..edddf2eefd5 100644 --- a/src/main/win32-utils.ts +++ b/src/main/win32-utils.ts @@ -22,6 +22,26 @@ export function isWindowsBatchScript(commandPath: string): boolean { return process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandPath) } +export const WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR = 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' + +export class UnsafeWindowsBatchArgumentsError extends Error { + constructor() { + super(WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR) + this.name = 'UnsafeWindowsBatchArgumentsError' + } +} + +function hasUnsafeWindowsBatchSyntax(value: string): boolean { + return /[&|<>^"%!\r\n]/.test(value) +} + +function quoteWindowsBatchToken(value: string): string { + if (hasUnsafeWindowsBatchSyntax(value)) { + throw new UnsafeWindowsBatchArgumentsError() + } + return `"${value}"` +} + /** Check whether an error is a Windows permission error (EACCES or EPERM). */ export function isPermissionError(error: unknown): boolean { return ( @@ -103,18 +123,17 @@ export function grantDirAcl(dirPath: string, options?: { recursive?: boolean }): * Why /d: disables per-machine/user AutoRun registry commands so a background * spawn cannot inherit surprising side effects from the user's shell config. * - * SAFETY: when the .cmd/.bat branch is taken, cmd.exe re-parses the combined - * command line, so callers MUST only pass trusted/literal args. An arg - * containing `&`, `|`, `^`, `<`, `>`, or unbalanced `"` can escape the - * intended command and execute arbitrary cmd.exe syntax. Do not feed - * user-supplied strings through this helper without first validating them. + * SAFETY: when the .cmd/.bat branch is taken, cmd.exe re-parses the command + * line. Args with cmd metacharacters are rejected instead of escaped because + * the agent prompt may contain arbitrary staged diff text. */ export function getSpawnArgsForWindows( command: string, args: string[] ): { spawnCmd: string; spawnArgs: string[] } { if (isWindowsBatchScript(command)) { - return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/c', command, ...args] } + const commandLine = [command, ...args].map(quoteWindowsBatchToken).join(' ') + return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/s', '/c', commandLine] } } return { spawnCmd: command, spawnArgs: args } } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 73322f995ab..9eb7f202d3f 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1214,6 +1214,17 @@ export type PreloadApi = { message: string connectionId?: string }) => Promise<{ success: boolean; error?: string }> + generateCommitMessage: (args: { + worktreePath: string + connectionId?: string + }) => Promise< + | { success: true; message: string; agentLabel?: string } + | { success: false; error: string; canceled?: boolean } + > + cancelGenerateCommitMessage: (args: { + worktreePath: string + connectionId?: string + }) => Promise stage: (args: { worktreePath: string filePath: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 5e987664eab..d26874832be 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1807,6 +1807,14 @@ const api = { message: string connectionId?: string }): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('git:commit', args), + generateCommitMessage: (args: { + worktreePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:generateCommitMessage', args), + cancelGenerateCommitMessage: (args: { + worktreePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:cancelGenerateCommitMessage', args), stage: (args: { worktreePath: string filePath: string diff --git a/src/relay/agent-exec-handler.test.ts b/src/relay/agent-exec-handler.test.ts new file mode 100644 index 00000000000..18a9a5966c9 --- /dev/null +++ b/src/relay/agent-exec-handler.test.ts @@ -0,0 +1,259 @@ +import { EventEmitter } from 'events' +import { exec, spawn } from 'child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ChildProcess from 'child_process' +import type { MethodHandler } from './dispatcher' +import { AgentExecHandler } from './agent-exec-handler' + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + exec: vi.fn(), + spawn: vi.fn() + } +}) + +const spawnMock = vi.mocked(spawn) +const execMock = vi.mocked(exec) + +function withPlatform(platform: NodeJS.Platform, fn: () => T): T { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return fn() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } +} + +type FakeChild = EventEmitter & { + pid: number + kill: ReturnType + stdout: EventEmitter + stderr: EventEmitter + stdin: { end: ReturnType } +} + +function createFakeChild(): FakeChild { + return Object.assign(new EventEmitter(), { + pid: 12345, + kill: vi.fn(), + stdout: new EventEmitter(), + stderr: new EventEmitter(), + stdin: { end: vi.fn() } + }) +} + +function createHandlers(): Map { + const handlers = new Map() + new AgentExecHandler({ + onRequest: (method: string, handler: MethodHandler): void => { + handlers.set(method, handler) + } + } as never) + return handlers +} + +describe('AgentExecHandler', () => { + beforeEach(() => { + spawnMock.mockReset() + execMock.mockReset() + }) + + it('executes a non-interactive command with captured output and stdin', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child as never) + const handlers = createHandlers() + + const pending = handlers.get('agent.execNonInteractive')!( + { + binary: 'agent', + args: ['--flag', 42], + cwd: '/repo', + stdin: 'PROMPT', + timeoutMs: 5_000 + }, + { isStale: () => false } + ) + + child.stdout.emit('data', Buffer.from('message')) + child.stderr.emit('data', Buffer.from('warning')) + child.emit('close', 0) + + await expect(pending).resolves.toEqual({ + stdout: 'message', + stderr: 'warning', + exitCode: 0, + timedOut: false, + canceled: false + }) + expect(spawnMock).toHaveBeenCalledWith('agent', ['--flag', '42'], { + cwd: '/repo', + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + }) + expect(child.stdin.end).toHaveBeenCalledWith('PROMPT') + }) + + it('merges caller-supplied provider environment into the spawned command environment', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child as never) + const handlers = createHandlers() + + const pending = handlers.get('agent.execNonInteractive')!( + { + binary: 'codex', + args: ['exec'], + cwd: '/repo', + stdin: 'PROMPT', + timeoutMs: 5_000, + env: { + CODEX_HOME: '/managed/codex-home', + PATH: '/managed/bin' + } + }, + { isStale: () => false } + ) + + child.emit('close', 0) + + await expect(pending).resolves.toMatchObject({ + exitCode: 0, + timedOut: false + }) + expect(spawnMock).toHaveBeenCalledWith('codex', ['exec'], { + cwd: '/repo', + env: expect.objectContaining({ + ...process.env, + CODEX_HOME: '/managed/codex-home', + PATH: '/managed/bin' + }), + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + }) + }) + + it('resolves bare Windows agent commands to batch shims before spawning', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'orca-agent-exec-')) + const originalComSpec = process.env.ComSpec + process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe' + try { + await withPlatform('win32', async () => { + const codexShim = join(tempDir, 'codex.cmd') + writeFileSync(codexShim, '@echo off\r\n') + const child = createFakeChild() + spawnMock.mockReturnValue(child as never) + const handlers = createHandlers() + + const pending = handlers.get('agent.execNonInteractive')!( + { + binary: 'codex', + args: ['exec', '-s', 'read-only'], + cwd: 'C:\\repo', + stdin: 'PROMPT', + timeoutMs: 5_000, + env: { PATH: tempDir } + }, + { isStale: () => false } + ) + + child.emit('close', 0) + + await expect(pending).resolves.toMatchObject({ + exitCode: 0, + timedOut: false + }) + expect(spawnMock).toHaveBeenCalledWith( + 'C:\\Windows\\System32\\cmd.exe', + ['/d', '/s', '/c', `"${codexShim}" "exec" "-s" "read-only"`], + { + cwd: 'C:\\repo', + env: expect.objectContaining({ PATH: tempDir }), + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + } + ) + }) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + if (originalComSpec === undefined) { + delete process.env.ComSpec + } else { + process.env.ComSpec = originalComSpec + } + } + }) + + it('rejects unsafe args when routing Windows batch shims through cmd.exe', async () => { + await withPlatform('win32', async () => { + const handlers = createHandlers() + + const result = await handlers.get('agent.execNonInteractive')!( + { + binary: 'C:\\tools\\agent.cmd', + args: ['hello & goodbye'], + cwd: 'C:\\repo', + stdin: null, + timeoutMs: 5_000 + }, + { isStale: () => false } + ) + + expect(result).toEqual({ + stdout: '', + stderr: '', + exitCode: null, + timedOut: false, + spawnError: 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' + }) + expect(spawnMock).not.toHaveBeenCalled() + }) + }) + + it('cancels the in-flight command for the requested cwd', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child as never) + const handlers = createHandlers() + + const pending = handlers.get('agent.execNonInteractive')!( + { + binary: 'agent', + args: [], + cwd: '/repo', + stdin: null, + timeoutMs: 5_000 + }, + { isStale: () => false } + ) + + await expect( + handlers.get('agent.cancelExec')!({ cwd: '/repo' }, { isStale: () => false }) + ).resolves.toEqual({ canceled: true }) + + if (process.platform === 'win32') { + expect(execMock).toHaveBeenCalledWith('taskkill /pid 12345 /T /F', expect.any(Function)) + } else { + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + } + + child.emit('close', null) + await expect(pending).resolves.toMatchObject({ + exitCode: null, + timedOut: false, + canceled: true + }) + }) + + it('reports when cancellation has no matching in-flight command', async () => { + const handlers = createHandlers() + + await expect( + handlers.get('agent.cancelExec')!({ cwd: '/repo' }, { isStale: () => false }) + ).resolves.toEqual({ canceled: false }) + }) +}) diff --git a/src/relay/agent-exec-handler.ts b/src/relay/agent-exec-handler.ts new file mode 100644 index 00000000000..38dc75601a4 --- /dev/null +++ b/src/relay/agent-exec-handler.ts @@ -0,0 +1,254 @@ +import { exec, spawn, type ChildProcess } from 'child_process' +import { existsSync } from 'fs' +import { delimiter, join } from 'path' +import type { RelayDispatcher } from './dispatcher' + +const DEFAULT_TIMEOUT_MS = 60_000 +const MAX_TIMEOUT_MS = 5 * 60 * 1000 +const MAX_OUTPUT_BYTES = 4 * 1024 * 1024 +const WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR = 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' + +function getCmdExePath(): string { + return process.env.ComSpec || `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\cmd.exe` +} + +function isWindowsBatchScript(commandPath: string): boolean { + return process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandPath) +} + +function hasUnsafeWindowsBatchSyntax(value: string): boolean { + return /[&|<>^"%!\r\n]/.test(value) +} + +function quoteWindowsBatchToken(value: string): string { + if (hasUnsafeWindowsBatchSyntax(value)) { + throw new Error(WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR) + } + return `"${value}"` +} + +function resolveWindowsCommand(binary: string, env: NodeJS.ProcessEnv): string { + if (process.platform !== 'win32') { + return binary + } + if (/[\\/]/.test(binary) || /\.[a-z0-9]+$/i.test(binary)) { + return binary + } + + const pathEnv = env.PATH ?? env.Path + if (!pathEnv) { + return binary + } + const names = [`${binary}.cmd`, `${binary}.exe`, `${binary}.bat`, binary] + for (const directory of pathEnv.split(delimiter).filter(Boolean)) { + for (const name of names) { + const candidate = join(directory, name) + if (existsSync(candidate)) { + return candidate + } + } + } + return binary +} + +function getWindowsSafeSpawn( + binary: string, + args: string[], + env: NodeJS.ProcessEnv +): { spawnCmd: string; spawnArgs: string[] } { + const resolvedBinary = resolveWindowsCommand(binary, env) + if (!isWindowsBatchScript(resolvedBinary)) { + return { spawnCmd: resolvedBinary, spawnArgs: args } + } + const commandLine = [resolvedBinary, ...args].map(quoteWindowsBatchToken).join(' ') + return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/s', '/c', commandLine] } +} + +// Why: mirrors src/main/text-generation/commit-message-text-generation.ts. On +// Windows, npm-installed CLIs like `claude`/`codex` are usually `.cmd` shims. +// We route those through cmd.exe so Node can launch them, and taskkill is +// needed to terminate the whole wrapper + node.exe process tree. Kept +// duplicated rather than imported because the relay ships to remote hosts. +function killProcessTree(child: ChildProcess): void { + const pid = child.pid + if (!pid) { + return + } + if (process.platform === 'win32') { + exec(`taskkill /pid ${pid} /T /F`, () => { + // Best-effort; the spawn's `close` listener fires once the tree exits. + }) + return + } + try { + child.kill('SIGKILL') + } catch { + // Child may already have exited between the kill request and now. + } +} + +type ExecParams = { + binary: unknown + args: unknown + cwd: unknown + stdin: unknown + timeoutMs: unknown + env: unknown +} + +type CancelParams = { + cwd: unknown +} + +type ExecResult = { + stdout: string + stderr: string + exitCode: number | null + timedOut: boolean + /** Set when the user canceled the exec via `agent.cancelExec`. */ + canceled?: boolean + /** Set when the binary could not be spawned (e.g. ENOENT). */ + spawnError?: string +} + +/** + * Non-interactive subprocess exec on the remote host. Used by the AI commit + * message generator to spawn agent CLIs (claude, codex, …) with the staged + * diff piped via stdin and the output captured to stdout. Distinct from + * `pty.spawn` because we want no terminal allocation, no escape sequences, + * and a clean exit code instead of an interactive session. + */ +export class AgentExecHandler { + // Why: a single in-flight exec per cwd is enough — the renderer prevents + // double-clicks. Keying by cwd lets `agent.cancelExec({cwd})` find the + // child without the client having to track relay-internal request ids. + private inFlightByCwd = new Map void }>() + + constructor(dispatcher: RelayDispatcher) { + dispatcher.onRequest('agent.execNonInteractive', (p) => this.exec(p as ExecParams)) + dispatcher.onRequest('agent.cancelExec', (p) => this.cancel(p as CancelParams)) + } + + private async cancel(params: CancelParams): Promise<{ canceled: boolean }> { + const cwd = typeof params.cwd === 'string' ? params.cwd : '' + const entry = this.inFlightByCwd.get(cwd) + if (!entry) { + return { canceled: false } + } + entry.markCanceled() + killProcessTree(entry.child) + return { canceled: true } + } + + private async exec(params: ExecParams): Promise { + const binary = typeof params.binary === 'string' ? params.binary : '' + if (!binary) { + throw new Error('agent.execNonInteractive: binary is required') + } + const args = Array.isArray(params.args) ? params.args.map((a) => String(a)) : [] + const cwd = typeof params.cwd === 'string' && params.cwd.length > 0 ? params.cwd : undefined + const stdinPayload = typeof params.stdin === 'string' ? params.stdin : null + const requestedTimeout = + typeof params.timeoutMs === 'number' ? params.timeoutMs : DEFAULT_TIMEOUT_MS + const timeoutMs = Math.max(1_000, Math.min(MAX_TIMEOUT_MS, requestedTimeout)) + const extraEnv = + params.env && typeof params.env === 'object' && !Array.isArray(params.env) + ? (params.env as Record) + : null + const spawnEnv = extraEnv ? { ...process.env, ...extraEnv } : process.env + + return new Promise((resolve) => { + let child + try { + const { spawnCmd, spawnArgs } = getWindowsSafeSpawn(binary, args, spawnEnv) + child = spawn(spawnCmd, spawnArgs, { + cwd, + env: spawnEnv, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + }) + } catch (error) { + resolve({ + stdout: '', + stderr: '', + exitCode: null, + timedOut: false, + spawnError: error instanceof Error ? error.message : String(error) + }) + return + } + + let stdout = '' + let stderr = '' + let stdoutBytes = 0 + let stderrBytes = 0 + let timedOut = false + let canceled = false + let settled = false + const laneKey = typeof cwd === 'string' ? cwd : '' + const finish = (result: ExecResult): void => { + if (settled) { + return + } + settled = true + if (laneKey) { + this.inFlightByCwd.delete(laneKey) + } + resolve(result) + } + if (laneKey) { + this.inFlightByCwd.set(laneKey, { + child, + markCanceled: () => { + canceled = true + } + }) + } + + const timer = setTimeout(() => { + timedOut = true + // Why: tree-kill because some CLIs trap SIGTERM and continue streaming; + // also Windows wraps `.cmd` shims in cmd.exe, so the immediate child + // is not the real node.exe process. + killProcessTree(child) + }, timeoutMs) + + child.stdout?.on('data', (chunk: Buffer) => { + stdoutBytes += chunk.byteLength + if (stdoutBytes > MAX_OUTPUT_BYTES) { + killProcessTree(child) + return + } + stdout += chunk.toString('utf-8') + }) + child.stderr?.on('data', (chunk: Buffer) => { + stderrBytes += chunk.byteLength + if (stderrBytes > MAX_OUTPUT_BYTES) { + killProcessTree(child) + return + } + stderr += chunk.toString('utf-8') + }) + child.on('error', (error) => { + clearTimeout(timer) + finish({ + stdout, + stderr, + exitCode: null, + timedOut, + spawnError: error.message + }) + }) + child.on('close', (code) => { + clearTimeout(timer) + finish({ stdout, stderr, exitCode: code, timedOut, canceled }) + }) + + if (stdinPayload !== null) { + child.stdin?.end(stdinPayload) + } else { + child.stdin?.end() + } + }) + } +} diff --git a/src/relay/git-exec-validator.test.ts b/src/relay/git-exec-validator.test.ts index 54039ebd7cb..b808e692b8a 100644 --- a/src/relay/git-exec-validator.test.ts +++ b/src/relay/git-exec-validator.test.ts @@ -23,6 +23,8 @@ describe('validateGitExecArgs', () => { [['symbolic-ref', 'HEAD']], [['symbolic-ref', '--short', 'HEAD']], [['merge-base', 'main', 'HEAD']], + [['diff', '--cached', '--name-status']], + [['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff']], [['ls-files', '--error-unmatch', 'foo.txt']], [['config', '--get', 'user.name']], [['config', '--get-all', 'remote.origin.url']], @@ -189,4 +191,19 @@ describe('validateGitExecArgs', () => { expectBlocked(['symbolic-ref', '--delete=HEAD'], 'git symbolic-ref write operations') }) }) + + describe('git diff', () => { + it('rejects unstaged diff reads', () => { + expectBlocked(['diff', '--name-status'], 'restricted to staged changes') + }) + + it('rejects arbitrary refs and pathspecs', () => { + expectBlocked(['diff', '--cached', 'HEAD~1'], 'git diff flag not allowed') + expectBlocked(['diff', '--cached', '--', 'src/file.ts'], 'git diff flag not allowed') + }) + + it('rejects no-index reads', () => { + expectBlocked(['diff', '--cached', '--no-index', '/etc/passwd'], 'git diff flag not allowed') + }) + }) }) diff --git a/src/relay/git-exec-validator.ts b/src/relay/git-exec-validator.ts index b881fbb6a26..0c8df69a4da 100644 --- a/src/relay/git-exec-validator.ts +++ b/src/relay/git-exec-validator.ts @@ -16,6 +16,7 @@ const ALLOWED_GIT_SUBCOMMANDS = new Set([ 'remote', 'symbolic-ref', 'merge-base', + 'diff', 'ls-files', 'for-each-ref', 'config' @@ -69,6 +70,15 @@ const REMOTE_WRITE_SUBCOMMANDS = new Set([ 'update' ]) const SYMBOLIC_REF_WRITE_FLAGS = new Set(['-d', '--delete', '-m']) +const DIFF_ALLOWED_FLAGS = new Set([ + '--cached', + '--staged', + '--name-status', + '--patch', + '--minimal', + '--no-color', + '--no-ext-diff' +]) // Why: git accepts --flag=value compound syntax (e.g. --file=/etc/passwd), // which bypasses exact-match Set.has() checks. This helper catches both forms. @@ -133,4 +143,16 @@ export function validateGitExecArgs(args: string[]): void { throw new Error('git symbolic-ref write operations are not allowed via exec') } } + if (subcommand === 'diff') { + // Why: SSH commit-message generation only needs read-only staged diffs. + // Keep this narrow so `git.exec` cannot become a general file reader via + // arbitrary revisions, pathspecs, or --no-index. + if (!restArgs.some((a) => a === '--cached' || a === '--staged')) { + throw new Error('git diff via exec is restricted to staged changes') + } + const unsupportedArg = restArgs.find((a) => !DIFF_ALLOWED_FLAGS.has(a)) + if (unsupportedArg) { + throw new Error(`git diff flag not allowed via exec: ${unsupportedArg}`) + } + } } diff --git a/src/relay/relay.ts b/src/relay/relay.ts index b1bc2df32a5..b221c712554 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -5,6 +5,10 @@ topological order. Splitting by line count would scatter ordered side- effects across modules and obscure the lifecycle. */ +/* eslint-disable max-lines -- Why: the relay entrypoint owns process startup, + daemon reconnect, and handler registration. Splitting the orchestration + would hide the startup order, which is the important invariant here. */ + // Orca Relay — lightweight daemon deployed to remote hosts. // Communicates over stdin/stdout using the framed JSON-RPC protocol. // The Electron app (client) deploys this script via SCP and launches @@ -28,6 +32,7 @@ import { FsHandler } from './fs-handler' import { GitHandler } from './git-handler' import { PreflightHandler } from './preflight-handler' import { PortScanHandler } from './port-scan-handler' +import { AgentExecHandler } from './agent-exec-handler' import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server' import { PluginOverlayManager } from './plugin-overlay' import { @@ -232,6 +237,9 @@ async function main(): Promise { const _portScanHandler = new PortScanHandler(dispatcher) void _portScanHandler + const _agentExecHandler = new AgentExecHandler(dispatcher) + void _agentExecHandler + // ── Agent-hook server ───────────────────────────────────────────── // Why: hosts a loopback HTTP receiver inside the relay process so agent // CLIs running in remote PTYs can post hook events without leaving the diff --git a/src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx index 0d84e6517ce..bf85b036506 100644 --- a/src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx +++ b/src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx @@ -85,11 +85,19 @@ function baseProps(overrides: Partial = {}) { commitMessage: 'feat: add commit area', commitError: null as string | null, isCommitting: inputs.isCommitting, + aiEnabled: false, + aiAgentConfigured: false, + isGenerating: false, + generateError: null as string | null, + stagedCount: inputs.stagedCount, + hasUnresolvedConflicts: inputs.hasUnresolvedConflicts, isRemoteOperationActive: inputs.isRemoteOperationActive, inFlightRemoteOpKind: inputs.inFlightRemoteOpKind ?? null, primaryAction: resolvePrimaryAction(inputs), dropdownItems: resolveDropdownItems(inputs), onCommitMessageChange: vi.fn(), + onGenerate: vi.fn(), + onCancelGenerate: vi.fn(), onPrimaryAction: vi.fn(), onDropdownAction: vi.fn() as (kind: DropdownActionKind) => void } diff --git a/src/renderer/src/components/right-sidebar/CommitArea.generate.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.generate.test.tsx new file mode 100644 index 00000000000..cce1ecae339 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/CommitArea.generate.test.tsx @@ -0,0 +1,213 @@ +import { describe, expect, it, vi } from 'vitest' +import { CommitArea } from './SourceControl' +import { Button } from '@/components/ui/button' +import { resolvePrimaryAction, type PrimaryActionInputs } from './source-control-primary-action' +import { resolveDropdownItems, type DropdownActionKind } from './source-control-dropdown-items' + +type ReactElementLike = { + type: unknown + props: Record +} + +function visit(node: unknown, cb: (node: ReactElementLike) => void): void { + if (node == null || typeof node === 'string' || typeof node === 'number') { + return + } + if (Array.isArray(node)) { + node.forEach((entry) => visit(entry, cb)) + return + } + const element = node as ReactElementLike + cb(element) + if (element.props?.children) { + visit(element.props.children, cb) + } +} + +function findNativeButtonByAriaLabel(node: unknown, ariaLabel: string): ReactElementLike { + let found: ReactElementLike | null = null + visit(node, (entry) => { + if (entry.type === 'button' && entry.props['aria-label'] === ariaLabel) { + found = entry + } + }) + if (!found) { + throw new Error(`button not found: ${ariaLabel}`) + } + return found +} + +function hasNativeButtonByAriaLabel(node: unknown, ariaLabel: string): boolean { + let found = false + visit(node, (entry) => { + if (entry.type === 'button' && entry.props['aria-label'] === ariaLabel) { + found = true + } + }) + return found +} + +function findTextarea(node: unknown): ReactElementLike { + let found: ReactElementLike | null = null + visit(node, (entry) => { + if (entry.type === 'textarea') { + found = entry + } + }) + if (!found) { + throw new Error('textarea not found') + } + return found +} + +function hasText(node: unknown, text: string): boolean { + let found = false + const walk = (value: unknown): void => { + if (typeof value === 'string') { + if (value.includes(text)) { + found = true + } + return + } + if (Array.isArray(value)) { + value.forEach(walk) + return + } + const element = value as ReactElementLike | null + if (element && typeof element === 'object' && 'props' in element) { + walk(element.props?.children) + } + } + visit(node, (entry) => { + walk(entry.props?.children) + }) + return found +} + +function buildInputs(overrides: Partial = {}): PrimaryActionInputs { + return { + stagedCount: 1, + hasUnstagedChanges: false, + hasMessage: true, + hasUnresolvedConflicts: false, + isCommitting: false, + isRemoteOperationActive: false, + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + ...overrides + } +} + +function baseProps(overrides: Partial = {}) { + const inputs = buildInputs(overrides) + return { + commitMessage: 'feat: add commit area', + commitError: null as string | null, + isCommitting: inputs.isCommitting, + aiEnabled: false, + aiAgentConfigured: false, + isGenerating: false, + generateError: null as string | null, + stagedCount: inputs.stagedCount, + hasUnresolvedConflicts: inputs.hasUnresolvedConflicts, + isRemoteOperationActive: inputs.isRemoteOperationActive, + inFlightRemoteOpKind: inputs.inFlightRemoteOpKind ?? null, + primaryAction: resolvePrimaryAction(inputs), + dropdownItems: resolveDropdownItems(inputs), + onCommitMessageChange: vi.fn(), + onGenerate: vi.fn(), + onCancelGenerate: vi.fn(), + onPrimaryAction: vi.fn(), + onDropdownAction: vi.fn() as (kind: DropdownActionKind) => void + } +} + +describe('CommitArea AI generation', () => { + it('does not render the AI generate affordance when the feature is disabled', () => { + const element = CommitArea(baseProps()) + expect(hasNativeButtonByAriaLabel(element, 'Generate commit message with AI')).toBe(false) + }) + + it('enables AI generation only when an agent is configured, changes are staged, and the message is empty', () => { + const onGenerate = vi.fn() + const props = baseProps({ hasMessage: false }) + const element = CommitArea({ + ...props, + commitMessage: '', + aiEnabled: true, + aiAgentConfigured: true, + onGenerate + }) + + const button = findNativeButtonByAriaLabel(element, 'Generate commit message with AI') + expect(button.props.disabled).toBe(false) + ;(button.props.onClick as () => void)() + expect(onGenerate).toHaveBeenCalledTimes(1) + }) + + it('disables AI generation when the textarea already has user text', () => { + const element = CommitArea({ + ...baseProps(), + aiEnabled: true, + aiAgentConfigured: true + }) + + const button = findNativeButtonByAriaLabel(element, 'Generate commit message with AI') + expect(button.props.disabled).toBe(true) + expect(button.props.title).toBe('Clear the message to regenerate.') + }) + + it('disables AI generation until the configured agent can actually run', () => { + const props = baseProps({ hasMessage: false }) + const element = CommitArea({ + ...props, + commitMessage: '', + aiEnabled: true, + aiAgentConfigured: false + }) + + const button = findNativeButtonByAriaLabel(element, 'Generate commit message with AI') + expect(button.props.disabled).toBe(true) + expect(button.props.title).toBe('Pick an agent in Settings → AI Commit Messages.') + }) + + it('turns the generating icon into a stop affordance', () => { + const onCancelGenerate = vi.fn() + const props = baseProps({ hasMessage: false }) + const element = CommitArea({ + ...props, + commitMessage: '', + aiEnabled: true, + aiAgentConfigured: true, + isGenerating: true, + onCancelGenerate + }) + + const button = findNativeButtonByAriaLabel(element, 'Stop generating commit message') + expect(button.props.title).toBe('Stop generating') + ;(button.props.onClick as () => void)() + expect(onCancelGenerate).toHaveBeenCalledTimes(1) + }) + + it('shows generation errors separately from commit errors and links them to the textarea', () => { + const element = CommitArea({ + ...baseProps(), + commitError: null, + generateError: 'No staged changes to summarize.' + }) + + expect(hasText(element, 'No staged changes to summarize.')).toBe(true) + expect(findTextarea(element).props['aria-describedby']).toBe('commit-area-generate-error') + }) + + it('continues to render the split commit button alongside generation controls', () => { + const element = CommitArea({ ...baseProps(), aiEnabled: true, aiAgentConfigured: true }) + let primaryFound = false + visit(element, (entry) => { + if (entry.type === Button) { + primaryFound = true + } + }) + + expect(primaryFound).toBe(true) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx index 1ae4ef8fc95..4cf98fceffc 100644 --- a/src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx +++ b/src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx @@ -73,11 +73,19 @@ function baseProps(overrides: Partial = {}) { commitMessage: 'feat: add commit area', commitError: null as string | null, isCommitting: inputs.isCommitting, + aiEnabled: false, + aiAgentConfigured: false, + isGenerating: false, + generateError: null as string | null, + stagedCount: inputs.stagedCount, + hasUnresolvedConflicts: inputs.hasUnresolvedConflicts, isRemoteOperationActive: inputs.isRemoteOperationActive, inFlightRemoteOpKind: inputs.inFlightRemoteOpKind ?? null, primaryAction: resolvePrimaryAction(inputs), dropdownItems: resolveDropdownItems(inputs), onCommitMessageChange: vi.fn(), + onGenerate: vi.fn(), + onCancelGenerate: vi.fn(), onPrimaryAction: vi.fn(), onDropdownAction: vi.fn() as (kind: DropdownActionKind) => void } diff --git a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx index cf4d95bcddd..cf37df70c2c 100644 --- a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx +++ b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: these CommitArea regression tests share + element-tree helpers that keep the assertions independent from a DOM test + harness; splitting the remaining cases would mostly duplicate setup. */ import { describe, expect, it, vi } from 'vitest' import { Check, RefreshCw } from 'lucide-react' import { CommitArea } from './SourceControl' @@ -123,11 +126,19 @@ function baseProps(overrides: Partial = {}) { commitMessage: 'feat: add commit area', commitError: null as string | null, isCommitting: inputs.isCommitting, + aiEnabled: false, + aiAgentConfigured: false, + isGenerating: false, + generateError: null as string | null, + stagedCount: inputs.stagedCount, + hasUnresolvedConflicts: inputs.hasUnresolvedConflicts, isRemoteOperationActive: inputs.isRemoteOperationActive, inFlightRemoteOpKind: inputs.inFlightRemoteOpKind ?? null, primaryAction: resolvePrimaryAction(inputs), dropdownItems: resolveDropdownItems(inputs), onCommitMessageChange: vi.fn(), + onGenerate: vi.fn(), + onCancelGenerate: vi.fn(), onPrimaryAction: vi.fn(), onDropdownAction: vi.fn() as (kind: DropdownActionKind) => void } diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index a3aeea40cce..296e13dd75f 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -9,6 +9,8 @@ import { Plus, RefreshCw, Settings2, + Sparkles, + Square, Undo2, FileEdit, FileMinus, @@ -338,6 +340,16 @@ function SourceControlInner(): React.JSX.Element { {} ) const isCommitting = commitInFlightByWorktree[activeWorktreeId ?? ''] ?? false + // Why: parallel state to commit. Same per-worktree shape so navigating between + // worktrees mid-generation never silently cancels the in-flight request. + const generateInFlightRef = useRef>({}) + const [generateInFlightByWorktree, setGenerateInFlightByWorktree] = useState< + Record + >({}) + const [generateErrors, setGenerateErrors] = useState>({}) + const isGenerating = generateInFlightByWorktree[activeWorktreeId ?? ''] ?? false + const generateError = generateErrors[activeWorktreeId ?? ''] ?? null + const commitMessageAi = useAppStore((s) => s.settings?.commitMessageAi) const filterInputRef = useRef(null) const commitMessage = readCommitDraftForWorktree(commitDrafts, activeWorktreeId) const commitError = commitErrors[activeWorktreeId ?? ''] ?? null @@ -572,12 +584,19 @@ function SourceControlInner(): React.JSX.Element { setCommitDrafts((prev) => pruneRecord(prev)) setCommitErrors((prev) => pruneRecord(prev)) setCommitInFlightByWorktree((prev) => pruneRecord(prev)) + setGenerateInFlightByWorktree((prev) => pruneRecord(prev)) + setGenerateErrors((prev) => pruneRecord(prev)) // Refs don't need setState — mutate in place to drop stale keys. for (const key of Object.keys(commitInFlightRef.current)) { if (!worktreeMap.has(key)) { delete commitInFlightRef.current[key] } } + for (const key of Object.keys(generateInFlightRef.current)) { + if (!worktreeMap.has(key)) { + delete generateInFlightRef.current[key] + } + } }, [worktreeMap]) // Why: the sidebar no longer uses key={activeWorktreeId} to force a full @@ -703,6 +722,92 @@ function SourceControlInner(): React.JSX.Element { worktreePath ]) + const handleGenerate = useCallback(async (): Promise => { + if (!activeWorktreeId || !worktreePath) { + return + } + if (generateInFlightRef.current[activeWorktreeId]) { + return + } + if (!commitMessageAi?.enabled || !commitMessageAi.agentId) { + return + } + + if (commitMessageAi.agentId === 'custom') { + const command = commitMessageAi.customAgentCommand?.trim() ?? '' + if (!command) { + setGenerateErrors((prev) => ({ + ...prev, + [activeWorktreeId]: + 'Custom command is empty. Add one in Settings → Git → AI Commit Messages.' + })) + return + } + } + + generateInFlightRef.current[activeWorktreeId] = true + const connectionId = getConnectionId(activeWorktreeId) ?? undefined + setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true })) + setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) + try { + const result = (await window.api.git.generateCommitMessage({ + worktreePath, + connectionId + })) as + | { success: true; message: string; agentLabel?: string } + | { success: false; error: string; canceled?: boolean } + + if (!result.success) { + // Why: cancellation is a deliberate user action, not a failure to + // surface. Clear any prior error and stay quiet. + if (result.canceled) { + setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) + return + } + setGenerateErrors((prev) => ({ + ...prev, + [activeWorktreeId]: result.error + })) + return + } + + // Why: race protection — the user may have started typing into the + // textarea while the agent was running. In that case we silently drop + // the generated message rather than overwrite their in-progress edits. + setCommitDrafts((prev) => { + const current = prev[activeWorktreeId] + if (current && current.length > 0) { + return prev + } + return writeCommitDraftForWorktree(prev, activeWorktreeId, result.message) + }) + setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) + } catch (error) { + setGenerateErrors((prev) => ({ + ...prev, + [activeWorktreeId]: + error instanceof Error ? error.message : 'Failed to generate commit message' + })) + } finally { + setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false })) + generateInFlightRef.current[activeWorktreeId] = false + } + }, [activeWorktreeId, commitMessageAi, worktreePath]) + + const handleCancelGenerate = useCallback((): void => { + if (!activeWorktreeId || !worktreePath) { + return + } + if (!generateInFlightRef.current[activeWorktreeId]) { + return + } + const connectionId = getConnectionId(activeWorktreeId) ?? undefined + // Why: fire-and-forget — the in-flight generateCommitMessage promise + // resolves with `{canceled: true}` once the kill propagates, which is + // where the spinner is cleared. Awaiting here would just delay UI feedback. + void window.api.git.cancelGenerateCommitMessage({ worktreePath, connectionId }) + }, [activeWorktreeId, worktreePath]) + // Why: a single dispatcher for every remote-only action the split button or // chevron dropdown can trigger. Keeps the error-swallow pattern in one // place — store slices already surface actionable toasts, so additional @@ -1873,6 +1978,20 @@ function SourceControlInner(): React.JSX.Element { commitMessage={commitMessage} commitError={commitError} isCommitting={isCommitting} + aiEnabled={commitMessageAi?.enabled === true} + aiAgentConfigured={ + commitMessageAi?.enabled === true && + commitMessageAi.agentId !== null && + // Why: 'custom' is configured only once the user types a command. + // Without this guard, Generate would spawn an empty command and + // fail with a confusing error. + (commitMessageAi.agentId !== 'custom' || + (commitMessageAi.customAgentCommand ?? '').trim().length > 0) + } + isGenerating={isGenerating} + generateError={generateError} + stagedCount={grouped.staged.length} + hasUnresolvedConflicts={unresolvedConflicts.length > 0} isRemoteOperationActive={isRemoteOperationActive} inFlightRemoteOpKind={inFlightRemoteOpKind} primaryAction={primaryAction} @@ -1885,6 +2004,10 @@ function SourceControlInner(): React.JSX.Element { writeCommitDraftForWorktree(prev, activeWorktreeId, value) ) }} + onGenerate={() => { + void handleGenerate() + }} + onCancelGenerate={handleCancelGenerate} onPrimaryAction={handlePrimaryClick} onDropdownAction={handleActionInvoke} /> @@ -2184,11 +2307,19 @@ type CommitAreaProps = { commitMessage: string commitError: string | null isCommitting: boolean + aiEnabled: boolean + aiAgentConfigured: boolean + isGenerating: boolean + generateError: string | null + stagedCount: number + hasUnresolvedConflicts: boolean isRemoteOperationActive: boolean inFlightRemoteOpKind: RemoteOpKind | null primaryAction: PrimaryAction dropdownItems: DropdownEntry[] onCommitMessageChange: (message: string) => void + onGenerate: () => void + onCancelGenerate: () => void onPrimaryAction: () => void onDropdownAction: (kind: DropdownActionKind) => void } @@ -2197,11 +2328,19 @@ export function CommitArea({ commitMessage, commitError, isCommitting, + aiEnabled, + aiAgentConfigured, + isGenerating, + generateError, + stagedCount, + hasUnresolvedConflicts, isRemoteOperationActive, inFlightRemoteOpKind, primaryAction, dropdownItems, onCommitMessageChange, + onGenerate, + onCancelGenerate, onPrimaryAction, onDropdownAction }: CommitAreaProps): React.JSX.Element { @@ -2239,23 +2378,90 @@ export function CommitArea({ // title attribute carry the meaning for assistive tech. const PrimaryIcon = PRIMARY_ICONS[primaryAction.kind] + const hasMessage = commitMessage.trim().length > 0 + + // Why: only render the Generate button when the user has opted into the + // feature. Mounting a perma-disabled button would leak space and add noise + // for users who never plan to use AI commit messages. + const showGenerate = aiEnabled + let generateDisabledReason: string | undefined + if (isGenerating) { + generateDisabledReason = 'Generating commit message…' + } else if (isCommitting) { + generateDisabledReason = 'Commit in progress…' + } else if (!aiAgentConfigured) { + generateDisabledReason = 'Pick an agent in Settings → AI Commit Messages.' + } else if (stagedCount === 0) { + generateDisabledReason = 'Stage at least one file to generate a message.' + } else if (hasMessage) { + generateDisabledReason = 'Clear the message to regenerate.' + } + const isGenerateDisabled = + !aiAgentConfigured || + isGenerating || + isCommitting || + stagedCount === 0 || + hasMessage || + hasUnresolvedConflicts + return (
-