Merge branch 'nwparker/term-speed-2-wake-session-reregister' into nwparker/term-speed-2-subscribe-recovery-kind

This commit is contained in:
Neil
2026-06-16 15:23:12 -07:00
2135 changed files with 227928 additions and 33685 deletions
+236
View File
@@ -0,0 +1,236 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FsChangeEvent } from '../../shared/types'
type MockWorker = {
terminated: boolean
postedMessages: unknown[]
workerData: unknown
on(event: string, listener: (arg?: unknown) => void): MockWorker
once(event: string, listener: (arg?: unknown) => void): MockWorker
off(event: string, listener: (arg?: unknown) => void): MockWorker
postMessage(message: unknown): void
terminate(): Promise<number>
emit(event: string, arg?: unknown): void
listenerCount(event: string): number
}
const workerState = vi.hoisted(() => {
const instances: MockWorker[] = []
class MockWorkerImpl {
terminated = false
postedMessages: unknown[] = []
workerData: unknown
private listeners = new Map<string, { listener: (arg?: unknown) => void; once: boolean }[]>()
constructor(_workerPath: string, options: { workerData?: unknown }) {
this.workerData = options.workerData
instances.push(this as unknown as MockWorker)
}
on(event: string, listener: (arg?: unknown) => void): this {
const list = this.listeners.get(event) ?? []
list.push({ listener, once: false })
this.listeners.set(event, list)
return this
}
once(event: string, listener: (arg?: unknown) => void): this {
const list = this.listeners.get(event) ?? []
list.push({ listener, once: true })
this.listeners.set(event, list)
return this
}
off(event: string, listener: (arg?: unknown) => void): this {
const list = this.listeners.get(event) ?? []
this.listeners.set(
event,
list.filter((entry) => entry.listener !== listener)
)
return this
}
postMessage(message: unknown): void {
this.postedMessages.push(message)
}
async terminate(): Promise<number> {
this.terminated = true
return 0
}
emit(event: string, arg?: unknown): void {
const entries = this.listeners.get(event)?.slice() ?? []
for (const entry of entries) {
if (entry.once) {
this.off(event, entry.listener)
}
entry.listener(arg)
}
}
listenerCount(event: string): number {
return this.listeners.get(event)?.length ?? 0
}
}
return { instances, MockWorkerImpl }
})
vi.mock('electron', () => ({
app: { isPackaged: false }
}))
vi.mock('worker_threads', () => ({
Worker: workerState.MockWorkerImpl
}))
import { watchFileExplorerInWorker } from './file-watcher-host'
function lastWorker(): MockWorker {
const worker = workerState.instances.at(-1)
if (!worker) {
throw new Error('no worker spawned')
}
return worker
}
describe('watchFileExplorerInWorker', () => {
beforeEach(() => {
workerState.instances.length = 0
})
afterEach(() => {
vi.restoreAllMocks()
})
it('resolves to an unsubscribe fn once the worker reports ready', async () => {
const promise = watchFileExplorerInWorker('/repo', vi.fn())
const worker = lastWorker()
expect(worker.workerData).toMatchObject({ rootPath: '/repo' })
worker.emit('message', { type: 'ready' })
const dispose = await promise
expect(typeof dispose).toBe('function')
})
it('forwards worker events to the callback only after ready', async () => {
const onEvents = vi.fn<(events: FsChangeEvent[]) => void>()
const promise = watchFileExplorerInWorker('/repo', onEvents)
const worker = lastWorker()
worker.emit('message', { type: 'ready' })
await promise
const events: FsChangeEvent[] = [
{ kind: 'update', absolutePath: '/repo/a.txt', isDirectory: false }
]
worker.emit('message', { type: 'events', events })
expect(onEvents).toHaveBeenCalledWith(events)
})
it('rejects if the worker errors before the crawl goes live', async () => {
const promise = watchFileExplorerInWorker('/repo', vi.fn())
const worker = lastWorker()
worker.emit('message', { type: 'error', message: 'addon missing' })
await expect(promise).rejects.toThrow('addon missing')
expect(worker.terminated).toBe(true)
})
it('rejects if the worker exits before ready', async () => {
const promise = watchFileExplorerInWorker('/repo', vi.fn())
const worker = lastWorker()
worker.emit('exit', 1)
await expect(promise).rejects.toThrow(/exited before ready/)
})
it('emits an overflow if a live worker crashes', async () => {
const onEvents = vi.fn<(events: FsChangeEvent[]) => void>()
const promise = watchFileExplorerInWorker('/repo', onEvents)
const worker = lastWorker()
worker.emit('message', { type: 'ready' })
await promise
worker.emit('error', new Error('boom'))
expect(onEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: '/repo' }])
})
it('unsubscribes and waits for a clean worker exit without force-terminating', async () => {
const promise = watchFileExplorerInWorker('/repo', vi.fn())
const worker = lastWorker()
worker.emit('message', { type: 'ready' })
const dispose = await promise
const disposed = dispose()
expect(worker.postedMessages).toContainEqual({ type: 'unsubscribe' })
// The worker unsubscribes its native watcher, closes its port and exits on
// its own — no force terminate, which is what corrupts the native watcher.
worker.emit('exit', 0)
await disposed
expect(worker.terminated).toBe(false)
expect(worker.listenerCount('exit')).toBe(1)
// Idempotent: a second dispose does nothing further.
await dispose()
expect(
worker.postedMessages.filter((m) => (m as { type?: string }).type === 'unsubscribe')
).toHaveLength(1)
})
it('shares pending dispose work across racing callers', async () => {
const promise = watchFileExplorerInWorker('/repo', vi.fn())
const worker = lastWorker()
worker.emit('message', { type: 'ready' })
const dispose = await promise
const firstDispose = dispose()
const secondDispose = dispose()
expect(secondDispose).toBe(firstDispose)
expect(
worker.postedMessages.filter((m) => (m as { type?: string }).type === 'unsubscribe')
).toHaveLength(1)
worker.emit('exit', 0)
await Promise.all([firstDispose, secondDispose])
expect(worker.terminated).toBe(false)
})
it('force-terminates the worker only if it fails to exit within the timeout', async () => {
vi.useFakeTimers()
try {
const promise = watchFileExplorerInWorker('/repo', vi.fn())
const worker = lastWorker()
worker.emit('message', { type: 'ready' })
const dispose = await promise
const disposed = dispose()
expect(worker.postedMessages).toContainEqual({ type: 'unsubscribe' })
expect(worker.listenerCount('exit')).toBe(2)
// Worker is wedged and never emits exit: the backstop must terminate it.
await vi.advanceTimersByTimeAsync(10_000)
await disposed
expect(worker.terminated).toBe(true)
expect(worker.listenerCount('exit')).toBe(1)
} finally {
vi.useRealTimers()
}
})
it('stops forwarding events after dispose', async () => {
const onEvents = vi.fn<(events: FsChangeEvent[]) => void>()
const promise = watchFileExplorerInWorker('/repo', onEvents)
const worker = lastWorker()
worker.emit('message', { type: 'ready' })
const dispose = await promise
const disposed = dispose()
worker.emit('exit', 0)
await disposed
onEvents.mockClear()
worker.emit('message', {
type: 'events',
events: [{ kind: 'update', absolutePath: '/repo/a.txt' }]
})
expect(onEvents).not.toHaveBeenCalled()
})
})
+165
View File
@@ -0,0 +1,165 @@
// Why: spawns the file-watcher worker thread and adapts it to the synchronous
// `watchFileExplorer` contract (a promise that resolves to an unsubscribe fn
// once the recursive crawl is live). Running @parcel/watcher in the worker
// keeps its blocking initial crawl off the main process's libuv pool so a huge
// non-git tree can't wedge the `serve` runtime (issue #5308).
import { Worker } from 'worker_threads'
import { join } from 'path'
import { app } from 'electron'
import type { FsChangeEvent } from '../../shared/types'
import type { FileWatcherHostMessage, FileWatcherWorkerMessage } from './file-watcher-worker'
// Mirrors VS Code's predefined recursive-watch excludes: skip churny generated
// trees at crawl time so the watcher never traverses them.
const RUNTIME_FILE_WATCH_IGNORE = [
'.git',
'node_modules',
'dist',
'build',
'.next',
'.cache',
'__pycache__',
'target',
'.venv'
]
// Why: clean teardown is async (the worker awaits subscription.unsubscribe()
// before closing its port and exiting). Wait this long for the worker to exit on
// its own before force-terminating, so the native watcher thread isn't freed
// mid-flight.
const WORKER_TEARDOWN_TIMEOUT_MS = 5000
type WorkerExitWaitResult = 'exit' | 'timeout'
function getFileWatcherWorkerPath(): string {
if (app.isPackaged) {
return join(process.resourcesPath, 'app.asar', 'out', 'main', 'file-watcher-worker.js')
}
return join(__dirname, 'file-watcher-worker.js')
}
function waitForWorkerExit(worker: Worker, timeoutMs: number): Promise<WorkerExitWaitResult> {
return new Promise((resolve) => {
let settled = false
let timer: ReturnType<typeof setTimeout> | undefined
let onExit: (() => void) | undefined
const finish = (result: WorkerExitWaitResult): void => {
if (settled) {
return
}
settled = true
if (timer) {
clearTimeout(timer)
}
if (onExit) {
worker.off('exit', onExit)
}
resolve(result)
}
onExit = () => finish('exit')
worker.once('exit', onExit)
timer = setTimeout(() => finish('timeout'), timeoutMs)
})
}
/** Start a recursive file watch in a worker thread. Resolves to an unsubscribe
* function once the worker reports the crawl is live; rejects if the worker
* fails to start the watch. */
export function watchFileExplorerInWorker(
rootPath: string,
callback: (events: FsChangeEvent[]) => void
): Promise<() => Promise<void>> {
return new Promise((resolve, reject) => {
const worker = new Worker(getFileWatcherWorkerPath(), {
workerData: { rootPath, ignore: RUNTIME_FILE_WATCH_IGNORE }
})
let ready = false
let disposed = false
let exited = false
let disposePromise: Promise<void> | undefined
const runDispose = async (): Promise<void> => {
if (disposed) {
return
}
disposed = true
if (exited) {
return
}
// Ask the worker to unsubscribe its native watcher and exit on its own.
// Why: worker.terminate() force-frees the worker's V8 env while
// @parcel/watcher's native watch thread / inflight async work is still
// live, which faults inside napi (Watcher::findCallback,
// PromiseRunner::onWorkComplete). Only terminate as a backstop if the
// worker wedges and never exits.
try {
worker.postMessage({ type: 'unsubscribe' } satisfies FileWatcherHostMessage)
} catch {
// Worker already gone — the exit wait and timeout backstop cover it.
}
const exitResult = await waitForWorkerExit(worker, WORKER_TEARDOWN_TIMEOUT_MS)
if (exitResult === 'timeout' && !exited) {
await worker.terminate().then(
() => undefined,
() => undefined
)
}
}
// Why: racing dispose callers must share the same worker-exit drain instead
// of letting later calls resolve while teardown is still in flight.
const dispose = (): Promise<void> => {
disposePromise ??= runDispose()
return disposePromise
}
worker.on('message', (message: FileWatcherWorkerMessage) => {
if (message.type === 'ready') {
ready = true
resolve(dispose)
return
}
if (message.type === 'events') {
if (!disposed) {
callback(message.events)
}
return
}
if (message.type === 'error') {
if (!ready) {
// The crawl never went live — fail the watch so the caller knows.
disposed = true
void worker.terminate()
reject(new Error(message.message))
return
}
// Already live: a mid-stream watcher error. Tell the renderer to
// refresh; the worker also emits an overflow event alongside this.
console.error('[runtime-files.watch] worker error', { rootPath, error: message.message })
}
})
worker.on('error', (err) => {
if (!ready) {
disposed = true
reject(err)
return
}
// A live worker crashed: surface an overflow so the renderer re-reads,
// rather than silently going stale.
console.error('[runtime-files.watch] worker crashed', { rootPath, err })
if (!disposed) {
callback([{ kind: 'overflow', absolutePath: rootPath }])
}
})
worker.on('exit', (code) => {
exited = true
if (!ready && !disposed) {
disposed = true
reject(new Error(`file watcher worker exited before ready (code ${code})`))
}
})
})
}
+140
View File
@@ -0,0 +1,140 @@
// Why: on Linux/Windows @parcel/watcher uses a brute-force backend that
// recursively walks the whole tree on a libuv threadpool thread before
// subscribe() resolves. On a huge tree backed by slow storage (a home dir on
// NFS opened as a worktree) that crawl can run for minutes. Running it here, in
// a dedicated worker thread, keeps it off the main/`serve` process's libuv pool
// so it can never starve static-asset serving, RPC crypto, or other clients
// (issue #5308). The worker owns the subscribe, the per-event stat fanout, and
// the event batching; the main thread only relays results.
import { stat } from 'fs/promises'
import { parentPort, workerData } from 'worker_threads'
import type * as ParcelWatcher from '@parcel/watcher'
import type { FsChangeEvent } from '../../shared/types'
const RUNTIME_FILE_WATCH_EVENT_STAT_LIMIT = 200
const RUNTIME_FILE_WATCH_STAT_CONCURRENCY = 8
type FileWatcherWorkerData = {
rootPath: string
ignore: string[]
}
// Messages the worker sends back to the host.
export type FileWatcherWorkerMessage =
| { type: 'ready' }
| { type: 'events'; events: FsChangeEvent[] }
| { type: 'error'; message: string }
// Messages the host sends to the worker.
export type FileWatcherHostMessage = { type: 'unsubscribe' }
const data = workerData as FileWatcherWorkerData
if (!parentPort) {
throw new Error('File watcher worker must run with a parent port.')
}
const port = parentPort
/** Report a watcher failure to the host and ask the renderer to refresh from
* scratch (the overflow event), so a mid-stream error never leaves the
* explorer silently stale. */
function reportWatchError(err: unknown): void {
port.postMessage({
type: 'error',
message: err instanceof Error ? err.message : String(err)
} satisfies FileWatcherWorkerMessage)
port.postMessage({
type: 'events',
events: [{ kind: 'overflow', absolutePath: data.rootPath }]
} satisfies FileWatcherWorkerMessage)
}
/** Run an async mapper over items with a bounded number in flight at once, so a
* large batch can't occupy every libuv threadpool thread in this worker. */
async function mapWithConcurrency<T, R>(
items: readonly T[],
limit: number,
mapper: (item: T) => Promise<R>
): Promise<R[]> {
const results = Array.from<R>({ length: items.length })
let cursor = 0
const worker = async (): Promise<void> => {
while (cursor < items.length) {
const index = cursor++
results[index] = await mapper(items[index])
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
return results
}
async function main(): Promise<void> {
let watcher: typeof ParcelWatcher
try {
watcher = await import('@parcel/watcher')
} catch (err) {
port.postMessage({
type: 'error',
message: err instanceof Error ? err.message : String(err)
} satisfies FileWatcherWorkerMessage)
return
}
const subscription = await watcher.subscribe(
data.rootPath,
(err, events) => {
if (err) {
reportWatchError(err)
return
}
// Why: large watcher batches usually mean a generated directory or branch
// switch. Avoid stat fanout and ask the renderer to refresh.
if (events.length > RUNTIME_FILE_WATCH_EVENT_STAT_LIMIT) {
port.postMessage({
type: 'events',
events: [{ kind: 'overflow', absolutePath: data.rootPath }]
} satisfies FileWatcherWorkerMessage)
return
}
void mapWithConcurrency(
events,
RUNTIME_FILE_WATCH_STAT_CONCURRENCY,
async (event): Promise<FsChangeEvent> => {
let isDirectory = false
try {
isDirectory = (await stat(event.path)).isDirectory()
} catch {
isDirectory = false
}
return { kind: event.type, absolutePath: event.path, isDirectory }
}
)
.then((mapped) => {
port.postMessage({ type: 'events', events: mapped } satisfies FileWatcherWorkerMessage)
})
// Why: without this, a throwing postMessage / stat becomes an unhandled
// rejection that crashes the worker silently. Surface it instead.
.catch((err: unknown) => reportWatchError(err))
},
{ ignore: data.ignore }
)
// The crawl finished and the subscription is live.
port.postMessage({ type: 'ready' } satisfies FileWatcherWorkerMessage)
port.on('message', (message: FileWatcherHostMessage) => {
if (message.type === 'unsubscribe') {
void subscription.unsubscribe().finally(() => {
port.close()
})
}
})
}
void main().catch((err: unknown) => {
port.postMessage({
type: 'error',
message: err instanceof Error ? err.message : String(err)
} satisfies FileWatcherWorkerMessage)
})
@@ -14,6 +14,18 @@ const MOBILE_DYNAMIC_RPC_METHODS = [
'gitlab.updateMR'
]
const MOBILE_STREAMING_CLEANUP_RPC_METHODS = [
// Why: shared-control unsubscribe methods are sent from generated cleanup
// paths, so literal mobile source scanning cannot discover every one.
'accounts.unsubscribe',
'browser.screencast.unsubscribe',
'notifications.unsubscribe',
'runtime.clientEvents.unsubscribe',
'session.tabs.unsubscribe',
'session.tabs.unsubscribeAll',
'terminal.unsubscribe'
]
function listSourceFiles(root: string): string[] {
const entries = readdirSync(root)
const files: string[] = []
@@ -88,4 +100,11 @@ describe('mobile RPC allowlist', () => {
expect(missing).toEqual([])
})
it('allows every cleanup RPC for mobile streaming subscriptions', () => {
const allowed = mobileRpcAllowlist()
const missing = MOBILE_STREAMING_CLEANUP_RPC_METHODS.filter((method) => !allowed.has(method))
expect(missing).toEqual([])
})
})
+7 -3
View File
@@ -1306,6 +1306,7 @@ export class RuntimeBrowserCommands {
worktree?: string
profileId?: string
waitForRegistration?: boolean
activate?: boolean
}): Promise<{ browserPageId: string }> {
const url = params.url ?? 'about:blank'
const worktreeId = params.worktree
@@ -1320,7 +1321,8 @@ export class RuntimeBrowserCommands {
const { browserPageId } = await this.createBrowserTabInRenderer(
url,
worktreeId,
params.profileId
params.profileId,
params.activate
)
// Why: the renderer creates the Zustand tab immediately, but the webview must
@@ -1700,7 +1702,8 @@ export class RuntimeBrowserCommands {
private async createBrowserTabInRenderer(
url: string,
worktreeId?: string,
profileId?: string
profileId?: string,
activate?: boolean
): Promise<{ browserPageId: string }> {
const win = this.host.getAuthoritativeWindow()
const requestId = randomUUID()
@@ -1731,7 +1734,8 @@ export class RuntimeBrowserCommands {
requestId,
url,
worktreeId,
sessionProfileId: profileId
sessionProfileId: profileId,
activate
})
})
@@ -2,15 +2,14 @@ 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'
import type { FsChangeEvent } from '../../shared/types'
const { resolveAuthorizedPathMock, statMock, subscribeParcelWatcherMock, watchMock } = vi.hoisted(
() => ({
resolveAuthorizedPathMock: vi.fn(),
statMock: vi.fn(),
subscribeParcelWatcherMock: vi.fn(),
watchMock: vi.fn()
})
)
const { resolveAuthorizedPathMock, statMock, watchMock, watchInWorkerMock } = vi.hoisted(() => ({
resolveAuthorizedPathMock: vi.fn(),
statMock: vi.fn(),
watchMock: vi.fn(),
watchInWorkerMock: vi.fn()
}))
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof Fs>('fs')
@@ -28,8 +27,9 @@ vi.mock('fs/promises', async () => {
}
})
vi.mock('@parcel/watcher', () => ({
subscribe: subscribeParcelWatcherMock
// The local (non-Windows, non-SSH) watch path now delegates to a worker thread.
vi.mock('./file-watcher-host', () => ({
watchFileExplorerInWorker: watchInWorkerMock
}))
vi.mock('../ipc/filesystem-auth', async () => {
@@ -65,8 +65,8 @@ describe('RuntimeFileCommands file watching', () => {
vi.useFakeTimers()
resolveAuthorizedPathMock.mockReset()
statMock.mockReset()
subscribeParcelWatcherMock.mockReset()
watchMock.mockReset()
watchInWorkerMock.mockReset()
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
@@ -103,6 +103,8 @@ describe('RuntimeFileCommands file watching', () => {
const unsubscribe = await commands.watchFileExplorer('id:wt-1', onEvents)
expect(watchMock).toHaveBeenCalledWith('C:\\repo', { recursive: true }, expect.any(Function))
// Windows path does not go through the worker.
expect(watchInWorkerMock).not.toHaveBeenCalled()
const emit = listener as (() => void) | null
expect(emit).not.toBeNull()
@@ -119,17 +121,59 @@ describe('RuntimeFileCommands file watching', () => {
expect(close).toHaveBeenCalledTimes(1)
})
it('tracks native Parcel watcher unsubscribe work so shutdown can await it', async () => {
// Issue #5308: the local recursive watch runs in a worker thread so
// @parcel/watcher's blocking initial crawl can't starve the serve runtime.
it('delegates local recursive watching to the worker thread', async () => {
resolveAuthorizedPathMock.mockResolvedValue('/home5/Brian')
statMock.mockResolvedValue({ isDirectory: () => true })
const captured: { cb?: (events: FsChangeEvent[]) => void } = {}
const workerDispose = vi.fn()
watchInWorkerMock.mockImplementation((_rootPath, cb) => {
captured.cb = cb
return Promise.resolve(workerDispose)
})
const onEvents = vi.fn()
const { commands } = createRuntimeFileCommands('/home5/Brian')
const unsubscribe = await commands.watchFileExplorer('id:wt-1', onEvents)
expect(watchInWorkerMock).toHaveBeenCalledWith('/home5/Brian', expect.any(Function))
// Events surfaced by the worker reach the caller.
captured.cb?.([{ kind: 'update', absolutePath: '/home5/Brian/a.txt', isDirectory: false }])
expect(onEvents).toHaveBeenCalledWith([
{ kind: 'update', absolutePath: '/home5/Brian/a.txt', isDirectory: false }
])
// Unsubscribe tears the worker down (dispose runs on the shutdown-drain
// microtask, so await the drain before asserting).
unsubscribe()
await awaitRuntimeFileWatcherUnsubscribes()
expect(workerDispose).toHaveBeenCalledTimes(1)
})
it('propagates a worker watch failure to the caller', async () => {
resolveAuthorizedPathMock.mockResolvedValue('/repo')
statMock.mockResolvedValue({ isDirectory: () => true })
let resolveUnsubscribe: () => void = () => {}
const unsubscribeMock = vi.fn(
watchInWorkerMock.mockRejectedValue(new Error('worker_failed'))
const { commands } = createRuntimeFileCommands('/repo')
await expect(commands.watchFileExplorer('id:wt-1', vi.fn())).rejects.toThrow('worker_failed')
})
it('tracks worker unsubscribe work so shutdown can await it', async () => {
resolveAuthorizedPathMock.mockResolvedValue('/repo')
statMock.mockResolvedValue({ isDirectory: () => true })
let resolveDispose: () => void = () => {}
const disposeMock = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveUnsubscribe = resolve
resolveDispose = resolve
})
)
subscribeParcelWatcherMock.mockResolvedValue({ unsubscribe: unsubscribeMock })
watchInWorkerMock.mockResolvedValue(disposeMock)
const { commands } = createRuntimeFileCommands('/repo')
const unsubscribe = await commands.watchFileExplorer('id:wt-1', vi.fn())
@@ -141,10 +185,10 @@ describe('RuntimeFileCommands file watching', () => {
})
await Promise.resolve()
expect(unsubscribeMock).toHaveBeenCalledTimes(1)
expect(disposeMock).toHaveBeenCalledTimes(1)
expect(drained).toBe(false)
resolveUnsubscribe()
resolveDispose()
await drainPromise
expect(drained).toBe(true)
})
+156 -57
View File
@@ -14,7 +14,7 @@ const {
renameMock,
resolveAuthorizedPathMock,
statMock,
subscribeParcelWatcherMock,
watchInWorkerMock,
checkRgAvailableMock,
wslAwareSpawnMock,
watchMock
@@ -25,7 +25,7 @@ const {
renameMock: vi.fn(),
resolveAuthorizedPathMock: vi.fn(),
statMock: vi.fn(),
subscribeParcelWatcherMock: vi.fn(),
watchInWorkerMock: vi.fn(),
wslAwareSpawnMock: vi.fn(),
watchMock: vi.fn()
}))
@@ -49,8 +49,8 @@ vi.mock('fs/promises', async () => {
}
})
vi.mock('@parcel/watcher', () => ({
subscribe: subscribeParcelWatcherMock
vi.mock('./file-watcher-host', () => ({
watchFileExplorerInWorker: watchInWorkerMock
}))
vi.mock('../ipc/filesystem-auth', async () => {
@@ -160,7 +160,7 @@ describe('RuntimeFileCommands', () => {
renameMock.mockReset()
resolveAuthorizedPathMock.mockReset()
statMock.mockReset()
subscribeParcelWatcherMock.mockReset()
watchInWorkerMock.mockReset()
watchMock.mockReset()
checkRgAvailableMock.mockReset()
wslAwareSpawnMock.mockReset()
@@ -223,6 +223,41 @@ describe('RuntimeFileCommands', () => {
})
})
it('opens previewable images through the renderer host as an image tab', async () => {
const openFile = vi.fn()
const { commands } = createRuntimeFileCommands({ openFile })
const result = await commands.openMobileFile('id:wt-1', 'assets/logo.png')
expect(openFile).toHaveBeenCalledWith(
'wt-1',
'/repo/assets/logo.png',
'assets/logo.png',
undefined
)
expect(result).toEqual({
worktree: 'wt-1',
relativePath: 'assets/logo.png',
kind: 'image',
opened: true
})
})
it('leaves non-previewable binaries unavailable on mobile', async () => {
const openFile = vi.fn()
const { commands } = createRuntimeFileCommands({ openFile })
const result = await commands.openMobileFile('id:wt-1', 'dist/bundle.zip')
expect(openFile).not.toHaveBeenCalled()
expect(result).toEqual({
worktree: 'wt-1',
relativePath: 'dist/bundle.zip',
kind: 'binary',
opened: false
})
})
it('does not follow symlinks when reading runtime-local file explorer dirs', async () => {
const { commands } = createRuntimeFileCommands()
resolveAuthorizedPathMock.mockResolvedValue('/repo')
@@ -364,64 +399,19 @@ describe('RuntimeFileCommands', () => {
expect(close).toHaveBeenCalledTimes(1)
})
it('tracks native Parcel watcher unsubscribe work so shutdown can await it', async () => {
it('delegates local recursive watching to the worker thread', async () => {
resolveAuthorizedPathMock.mockResolvedValue('/repo')
statMock.mockResolvedValue({ isDirectory: () => true })
let resolveUnsubscribe: () => void = () => {}
const unsubscribeMock = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveUnsubscribe = resolve
})
)
subscribeParcelWatcherMock.mockResolvedValue({ unsubscribe: unsubscribeMock })
const dispose = vi.fn()
watchInWorkerMock.mockResolvedValue(dispose)
const { commands } = createRuntimeFileCommands()
const unsubscribe = await commands.watchFileExplorer('id:wt-1', vi.fn())
expect(watchInWorkerMock).toHaveBeenCalledWith('/repo', expect.any(Function))
unsubscribe()
let drained = false
const drainPromise = awaitRuntimeFileWatcherUnsubscribes().then(() => {
drained = true
})
await Promise.resolve()
expect(unsubscribeMock).toHaveBeenCalledTimes(1)
expect(drained).toBe(false)
resolveUnsubscribe()
await drainPromise
expect(drained).toBe(true)
})
it('collapses large Parcel watcher batches to an overflow refresh', async () => {
resolveAuthorizedPathMock.mockResolvedValue('/repo')
statMock.mockResolvedValue({ isDirectory: () => true })
type ParcelCallback = (err: Error | null, events: { type: 'create'; path: string }[]) => void
const parcelCallbackRef: { current: ParcelCallback | null } = { current: null }
subscribeParcelWatcherMock.mockImplementation(async (_rootPath, callback) => {
parcelCallbackRef.current = callback as ParcelCallback
return { unsubscribe: vi.fn() }
})
const { commands } = createRuntimeFileCommands()
const onEvents = vi.fn()
await commands.watchFileExplorer('id:wt-1', onEvents)
statMock.mockClear()
if (!parcelCallbackRef.current) {
throw new Error('Parcel watcher callback was not registered')
}
parcelCallbackRef.current(
null,
Array.from({ length: 201 }, (_, index) => ({
type: 'create',
path: `/repo/generated-${index}.txt`
}))
)
await Promise.resolve()
expect(statMock).not.toHaveBeenCalled()
expect(onEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: '/repo' }])
await awaitRuntimeFileWatcherUnsubscribes()
expect(dispose).toHaveBeenCalledTimes(1)
})
it('settles and detaches runtime rg searches when timeout kill is ignored', async () => {
@@ -456,4 +446,113 @@ describe('RuntimeFileCommands', () => {
expect(child.listenerCount('error')).toBe(0)
expect(child.listenerCount('close')).toBe(0)
})
describe('resolveTerminalPath', () => {
function statAsFile() {
resolveAuthorizedPathMock.mockImplementation(async (p: string) => p)
statMock.mockResolvedValue({ isDirectory: () => false })
}
it('resolves an absolute path inside the worktree to a relative path', async () => {
const { commands } = createRuntimeFileCommands({ path: '/repo' })
statAsFile()
const result = await commands.resolveTerminalPath('id:wt-1', '/repo/src/index.ts')
expect(result).toEqual({
worktree: 'wt-1',
relativePath: 'src/index.ts',
absolutePath: '/repo/src/index.ts',
exists: true,
isDirectory: false
})
})
it('resolves a relative path against the provided cwd', async () => {
const { commands } = createRuntimeFileCommands({ path: '/repo' })
statAsFile()
const result = await commands.resolveTerminalPath('id:wt-1', 'index.ts', '/repo/src')
expect(result).toMatchObject({ relativePath: 'src/index.ts', exists: true })
})
it('resolves a relative path against the worktree root when no cwd is given', async () => {
const { commands } = createRuntimeFileCommands({ path: '/repo' })
statAsFile()
const result = await commands.resolveTerminalPath('id:wt-1', 'docs/readme.md')
expect(result).toMatchObject({ relativePath: 'docs/readme.md', exists: true })
})
it('reports a directory', async () => {
const { commands } = createRuntimeFileCommands({ path: '/repo' })
resolveAuthorizedPathMock.mockImplementation(async (p: string) => p)
statMock.mockResolvedValue({ isDirectory: () => true })
const result = await commands.resolveTerminalPath('id:wt-1', '/repo/src')
expect(result).toMatchObject({ relativePath: 'src', isDirectory: true, exists: true })
})
it('returns null relativePath for a path outside the worktree', async () => {
const { commands } = createRuntimeFileCommands({ path: '/repo' })
const result = await commands.resolveTerminalPath('id:wt-1', '/etc/passwd')
expect(result).toEqual({
worktree: 'wt-1',
relativePath: null,
absolutePath: null,
exists: false,
isDirectory: false
})
expect(statMock).not.toHaveBeenCalled()
})
it('reports a nonexistent in-worktree path as not existing', async () => {
const { commands } = createRuntimeFileCommands({ path: '/repo' })
resolveAuthorizedPathMock.mockImplementation(async (p: string) => p)
statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
const result = await commands.resolveTerminalPath('id:wt-1', 'src/missing.ts')
expect(result).toMatchObject({ relativePath: 'src/missing.ts', exists: false })
})
it('does not expand ~/ on a remote worktree (home is unknown)', async () => {
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
const stat = vi.fn()
vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat } as never)
const result = await commands.resolveTerminalPath('id:wt-1', '~/notes.md')
expect(result).toMatchObject({ relativePath: null, exists: false })
expect(stat).not.toHaveBeenCalled()
})
it('reports a missing remote file as not existing', async () => {
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
const stat = vi.fn().mockRejectedValue(new Error('ENOENT: no such file'))
vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat } as never)
const result = await commands.resolveTerminalPath('id:wt-1', 'src/missing.ts')
expect(result).toMatchObject({ relativePath: 'src/missing.ts', exists: false })
})
it('rethrows a remote transport error instead of reporting not-found', async () => {
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
const stat = vi.fn().mockRejectedValue(new Error('Remote connection dropped'))
vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat } as never)
await expect(commands.resolveTerminalPath('id:wt-1', 'src/x.ts')).rejects.toThrow(
'Remote connection dropped'
)
})
})
})
+133 -53
View File
@@ -14,6 +14,7 @@ import {
stat,
writeFile
} from 'fs/promises'
import { homedir } from 'os'
import { basename, dirname, extname, join } from 'path'
import type {
DirEntry,
@@ -24,12 +25,19 @@ import type {
SearchResult,
Worktree
} from '../../shared/types'
import {
isRuntimePathAbsolute,
relativePathInsideRoot,
resolveRuntimePath
} from '../../shared/cross-platform-path'
import type {
RuntimeFileListResult,
RuntimeFileOpenResult,
RuntimeFilePreviewResult,
RuntimeFileReadResult
RuntimeFileReadResult,
RuntimeTerminalPathResolution
} from '../../shared/runtime-types'
import { watchFileExplorerInWorker } from './file-watcher-host'
import { wslAwareSpawn } from '../git/runner'
import { parseWslPath, toWindowsWslPath } from '../wsl'
import { isENOENT, resolveAuthorizedPath } from '../ipc/filesystem-auth'
@@ -60,7 +68,6 @@ 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 RUNTIME_FILE_WATCH_EVENT_STAT_LIMIT = 200
// Why: runtime files.watch subscriptions are cleaned up through synchronous RPC
// callbacks. Track native Parcel unsubscribe work so app shutdown can drain it.
const pendingRuntimeFileWatcherUnsubscribes = new Set<Promise<void>>()
@@ -80,6 +87,28 @@ const MOBILE_BINARY_EXTENSIONS = new Set([
'.webp',
'.zip'
])
// Raster image extensions the mobile client can render from a base64 data URI
// via files.readPreview. Mirrors mobile's classifyMobileArtifact image set;
// SVG/PDF are intentionally excluded (RN <Image> can't decode those data URIs).
const MOBILE_PREVIEWABLE_IMAGE_EXTENSIONS = new Set([
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.bmp',
'.ico'
])
function isMobilePreviewableImagePath(relativePath: string): boolean {
const basename = basenameFromRelativePath(relativePath)
const dotIndex = basename.lastIndexOf('.')
if (dotIndex <= 0) {
return false
}
return MOBILE_PREVIEWABLE_IMAGE_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase())
}
const RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
@@ -175,11 +204,15 @@ export class RuntimeFileCommands {
if (!isSafeMobileRelativePath(relativePath)) {
throw new Error('invalid_relative_path')
}
const kind = isMobileBinaryPath(relativePath)
? 'binary'
: isMobileMarkdownPath(relativePath)
? 'markdown'
: 'text'
// Previewable images open like text (the mobile viewer renders them via
// files.readPreview); other binaries stay unavailable on mobile.
const kind = isMobilePreviewableImagePath(relativePath)
? 'image'
: isMobileBinaryPath(relativePath)
? 'binary'
: isMobileMarkdownPath(relativePath)
? 'markdown'
: 'text'
if (kind === 'binary') {
return { worktree: worktree.id, relativePath, kind, opened: false }
}
@@ -243,6 +276,95 @@ export class RuntimeFileCommands {
}
}
// Resolves a path tapped in the mobile terminal (absolute, relative, or ~/…)
// to a worktree-relative path the file RPCs can open, plus existence.
// Relative paths resolve against `cwd` when the caller supplies it, else
// against the worktree root. NOTE: the mobile tap path does not yet forward a
// cwd, so a token relative to a subdirectory currently resolves against the
// root and may miss — absolute and root-relative paths always resolve.
// (Threading the terminal's tracked cwd is a follow-up.)
async resolveTerminalPath(
worktreeSelector: string,
pathText: string,
cwd?: string | null
): Promise<RuntimeTerminalPathResolution> {
const store = this.host.requireStore()
const worktree = await this.host.resolveWorktreeSelector(worktreeSelector)
const repo = store.getRepo(worktree.repoId)
const connectionId = repo?.connectionId ?? undefined
const base = cwd && cwd.trim().length > 0 ? cwd : worktree.path
const empty: RuntimeTerminalPathResolution = {
worktree: worktree.id,
relativePath: null,
absolutePath: null,
exists: false,
isDirectory: false
}
// `~/…` is home-relative. The local home is known (os.homedir); the remote
// home is not, so don't guess — a tapped `~/…` on a remote worktree would
// mis-resolve under cwd/worktree-root, so treat it as not-openable instead.
const isTilde = pathText.startsWith('~/') || pathText.startsWith('~\\')
if (isTilde && connectionId) {
return empty
}
const expanded = isTilde ? resolveRuntimePath(homedir(), pathText.slice(2)) : pathText
const absolutePath = isRuntimePathAbsolute(expanded)
? expanded
: resolveRuntimePath(base, expanded)
const relativePath = relativePathInsideRoot(worktree.path, absolutePath)
// Outside the worktree, or not a safe relative path → not openable here.
if (relativePath === null || relativePath === '' || !isSafeMobileRelativePath(relativePath)) {
return empty
}
try {
const stats = connectionId
? await this.statRemoteTerminalPath(absolutePath, connectionId)
: await stat(await resolveAuthorizedPath(absolutePath, store))
return {
worktree: worktree.id,
relativePath,
absolutePath,
exists: true,
isDirectory: stats.isDirectory()
}
} catch (error) {
// A genuine "not found" → the path simply doesn't exist (report it, not an
// error). Transport/permission/provider failures must surface so a remote
// session doesn't silently report every tapped path as missing.
if (
isENOENT(error) ||
(connectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error))
) {
return { ...empty, relativePath, absolutePath }
}
throw error
}
}
// A remote stat failure that means "the file isn't there" vs a transport /
// permission / provider error. The mux drops the ErrnoException `code`, so the
// message is the only signal — match the not-found shapes the relay surfaces.
private static isRemoteNotFoundErrorMessage(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /\bENOENT\b|no such file|not found|does not exist/i.test(message)
}
private async statRemoteTerminalPath(
absolutePath: string,
connectionId: string
): Promise<{ isDirectory: () => boolean }> {
const provider = getSshFilesystemProvider(connectionId)
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
const stats = await provider.stat(absolutePath)
return { isDirectory: () => stats.type === 'directory' }
}
async readFileExplorerDir(worktreeSelector: string, relativePath: string): Promise<DirEntry[]> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
@@ -294,53 +416,11 @@ export class RuntimeFileCommands {
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
}
// Why: large watcher batches usually mean a generated directory or
// branch switch. Avoid stat fanout and ask the renderer to refresh.
if (events.length > RUNTIME_FILE_WATCH_EVENT_STAT_LIMIT) {
callback([{ kind: 'overflow', absolutePath: rootPath }])
return
}
void Promise.all(
events.map(async (event): Promise<FsChangeEvent> => {
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'
]
}
)
// Why: the watcher runs in a worker thread so @parcel/watcher's blocking
// recursive crawl can't starve the main/`serve` process (issue #5308).
const dispose = await watchFileExplorerInWorker(rootPath, callback)
return () => {
trackRuntimeFileWatcherUnsubscribe(rootPath, () => subscription.unsubscribe())
trackRuntimeFileWatcherUnsubscribe(rootPath, dispose)
}
}
+79
View File
@@ -11,6 +11,8 @@ import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runt
const mocks = vi.hoisted(() => ({
abortMerge: vi.fn(),
abortRebase: vi.fn(),
checkoutBranch: vi.fn(),
listLocalBranches: vi.fn(),
getStagedCommitContext: vi.fn(),
getPullRequestDraftContext: vi.fn(),
generateCommitMessageFromContext: vi.fn(),
@@ -26,6 +28,11 @@ vi.mock('../git/status', async () => ({
getStagedCommitContext: mocks.getStagedCommitContext
}))
vi.mock('../git/checkout', () => ({
checkoutBranch: mocks.checkoutBranch,
listLocalBranches: mocks.listLocalBranches
}))
vi.mock('../text-generation/commit-message-text-generation', async () => ({
...(await vi.importActual<typeof CommitMessageTextGenerationModule>(
'../text-generation/commit-message-text-generation'
@@ -80,6 +87,8 @@ describe('RuntimeGitCommands', () => {
mocks.generatePullRequestFieldsFromContext.mockReset()
mocks.resolveCommitMessageSettings.mockReset()
mocks.getSshGitProvider.mockReset()
mocks.checkoutBranch.mockReset()
mocks.listLocalBranches.mockReset()
})
afterEach(() => {
@@ -144,6 +153,76 @@ describe('RuntimeGitCommands', () => {
expect(mocks.abortRebase).not.toHaveBeenCalled()
})
it('checks out a local branch through the resolved worktree', async () => {
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
tempDirs.push(worktreePath)
const commands = makeCommands(worktreePath)
mocks.checkoutBranch.mockResolvedValue(undefined)
await expect(commands.checkoutRuntimeGitBranch('id:wt-1', 'feature/x')).resolves.toEqual({
ok: true,
branch: 'feature/x'
})
expect(mocks.checkoutBranch).toHaveBeenCalledWith(worktreePath, 'feature/x')
})
it('checks out a remote branch through the SSH git provider', async () => {
const provider = { checkoutBranch: vi.fn().mockResolvedValue(undefined) }
mocks.getSshGitProvider.mockReturnValue(provider)
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({
worktree: makeWorktree('/remote/repo'),
connectionId: 'conn-1'
}),
getRuntimeSettings: () => ({}) as GlobalSettings
})
await expect(commands.checkoutRuntimeGitBranch('id:wt-1', 'feature/x')).resolves.toEqual({
ok: true,
branch: 'feature/x'
})
expect(provider.checkoutBranch).toHaveBeenCalledWith('/remote/repo', 'feature/x')
expect(mocks.checkoutBranch).not.toHaveBeenCalled()
})
it('lists local branches through the resolved worktree', async () => {
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
tempDirs.push(worktreePath)
const commands = makeCommands(worktreePath)
mocks.listLocalBranches.mockResolvedValue({ current: 'main', branches: ['main', 'feature/x'] })
await expect(commands.listRuntimeGitLocalBranches('id:wt-1')).resolves.toEqual({
current: 'main',
branches: ['main', 'feature/x']
})
expect(mocks.listLocalBranches).toHaveBeenCalledWith(worktreePath)
})
it('lists remote local branches through the SSH git provider', async () => {
const provider = {
listLocalBranches: vi.fn().mockResolvedValue({ current: 'main', branches: ['main'] })
}
mocks.getSshGitProvider.mockReturnValue(provider)
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({
worktree: makeWorktree('/remote/repo'),
connectionId: 'conn-1'
}),
getRuntimeSettings: () => ({}) as GlobalSettings
})
await expect(commands.listRuntimeGitLocalBranches('id:wt-1')).resolves.toEqual({
current: 'main',
branches: ['main']
})
expect(provider.listLocalBranches).toHaveBeenCalledWith('/remote/repo')
expect(mocks.listLocalBranches).not.toHaveBeenCalled()
})
it('rejects slash-only git mutation paths before they can target the worktree root', async () => {
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
tempDirs.push(worktreePath)
+65 -1
View File
@@ -4,6 +4,8 @@ import type {
GitCommitCompareResult,
GitConflictOperation,
GitDiffResult,
GitForkSyncExpectedUpstream,
GitForkSyncResult,
GitPushTarget,
GitStatusResult,
GitUpstreamStatus,
@@ -21,7 +23,7 @@ import {
type ResolvedSourceControlAiGenerationParams
} from '../../shared/source-control-ai'
import type { SourceControlAiOperation } from '../../shared/source-control-ai-types'
import { getRemoteFileUrl } from '../git/repo'
import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo'
import {
abortMerge,
abortRebase,
@@ -41,9 +43,12 @@ import {
stageFile,
unstageFile
} from '../git/status'
import { checkoutBranch, listLocalBranches } from '../git/checkout'
import type { RuntimeGitCheckoutResult, RuntimeGitLocalBranches } from '../../shared/runtime-types'
import { getHistory as getGitHistory } from '../git/history'
import { getUpstreamStatus } from '../git/upstream'
import { gitFastForward, gitFetch, gitPull, gitPullRebaseFromBase, gitPush } from '../git/remote'
import { gitSyncForkDefaultBranch } from '../git/fork-sync'
import {
getSshGitProvider,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
@@ -210,6 +215,35 @@ export class RuntimeGitCommands {
return { ok: true }
}
async checkoutRuntimeGitBranch(
worktreeSelector: string,
branch: string
): Promise<RuntimeGitCheckoutResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
await provider.checkoutBranch(target.worktree.path, branch)
return { ok: true, branch }
}
await checkoutBranch(target.worktree.path, branch)
return { ok: true, branch }
}
async listRuntimeGitLocalBranches(worktreeSelector: string): Promise<RuntimeGitLocalBranches> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.listLocalBranches(target.worktree.path)
}
return listLocalBranches(target.worktree.path)
}
async getRuntimeGitDiff(
worktreeSelector: string,
filePath: string,
@@ -290,6 +324,21 @@ export class RuntimeGitCommands {
return { ok: true }
}
async syncRuntimeGitForkDefaultBranch(
worktreeSelector: string,
expectedUpstream: GitForkSyncExpectedUpstream
): Promise<GitForkSyncResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.syncForkDefaultBranch(target.worktree.path, expectedUpstream)
}
return gitSyncForkDefaultBranch(target.worktree.path, expectedUpstream)
}
async pullRuntimeGit(
worktreeSelector: string,
pushTarget?: GitPushTarget
@@ -774,4 +823,19 @@ export class RuntimeGitCommands {
}
return getRemoteFileUrl(target.worktree.path, normalizedRelativePath, line)
}
async getRuntimeGitRemoteCommitUrl(
worktreeSelector: string,
sha: string
): Promise<string | null> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.getRemoteCommitUrl(target.worktree.path, sha)
}
return getRemoteCommitUrl(target.worktree.path, sha)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,7 @@ function makeSummary(
): RuntimeTerminalSummary {
return {
handle,
ptyId: opts.ptyId ?? handle,
worktreeId: opts.worktreeId ?? 'wt_default',
worktreePath: opts.worktreePath ?? '/tmp/wt',
branch: opts.branch ?? 'main',
@@ -6,6 +6,7 @@ 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 { RemoteRuntimeSharedControlConnection } from '../../shared/remote-runtime-shared-control-connection'
import { subscribeRemoteRuntimeRequest } from '../../shared/remote-runtime-client'
import type {
RuntimeClientEvent,
@@ -13,6 +14,7 @@ import type {
} from '../../shared/runtime-client-events'
import type { OrcaRuntimeService } from './orca-runtime'
import { OrcaRuntimeRpcServer } from './runtime-rpc'
import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version'
describe('remote runtime request connection integration', () => {
it('fetches repos through the real E2EE WebSocket runtime', async () => {
@@ -224,9 +226,258 @@ describe('remote runtime request connection integration', () => {
rmSync(userDataPath, { recursive: true, force: true })
}
})
it('multiplexes shared-control calls and passive subscriptions through the real runtime', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-shared-control-'))
const repoPath = join(userDataPath, 'repo')
const repo: Repo = {
id: 'repo-1',
path: repoPath,
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
hookSettings: getDefaultRepoHookSettings(),
worktreeBaseRef: 'main',
kind: 'git'
}
const worktrees: unknown[] = [
{
id: 'repo-1::main',
repoId: repo.id,
path: repoPath,
branch: 'main',
displayName: 'repo',
isMainWorktree: true
}
]
const clientEventListeners = new Set<(event: RuntimeClientEvent) => void>()
const accountsListeners = new Set<(snapshot: unknown) => void>()
const notificationListeners = new Set<(event: unknown) => void>()
const sessionTabListeners = new Set<(snapshot: unknown) => void>()
const subscriptionCleanups = new Map<string, () => void>()
const sessionTabSnapshot = {
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
const runtime = {
getRuntimeId: () => 'runtime-test',
getStartedAt: () => 1,
getStatus: () => ({
runtimeId: 'runtime-test',
startedAt: 1,
version: '1.0.0',
protocolVersion: 1,
minCompatibleDesktopVersion: '1.0.0',
minCompatibleMobileVersion: '1.0.0',
capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY]
}),
cleanupSubscriptionsForConnection: (connectionId: string) => {
for (const [id, cleanup] of Array.from(subscriptionCleanups)) {
if (id.includes(connectionId)) {
cleanup()
subscriptionCleanups.delete(id)
}
}
},
registerSubscriptionCleanup: (id: string, cleanup: () => void) => {
subscriptionCleanups.set(id, cleanup)
},
cleanupSubscription: (id: string) => {
subscriptionCleanups.get(id)?.()
subscriptionCleanups.delete(id)
},
cleanupSubscriptionsByPrefix: (prefix: string) => {
for (const [id, cleanup] of Array.from(subscriptionCleanups)) {
if (id.startsWith(prefix)) {
cleanup()
subscriptionCleanups.delete(id)
}
}
},
cancelMobileDictationForConnection: () => {},
onClientDisconnected: () => {},
onClientEvent: (listener: (event: RuntimeClientEvent) => void) => {
clientEventListeners.add(listener)
return () => clientEventListeners.delete(listener)
},
getAccountsSnapshot: () => ({ claude: null, codex: null }),
refreshAccountsForMobile: async () => {
for (const listener of accountsListeners) {
listener({ claude: null, codex: null })
}
},
onAccountsChanged: (listener: (snapshot: unknown) => void) => {
accountsListeners.add(listener)
return () => accountsListeners.delete(listener)
},
onNotificationDispatched: (listener: (event: unknown) => void) => {
notificationListeners.add(listener)
return () => notificationListeners.delete(listener)
},
listMobileSessionTabs: () => sessionTabSnapshot,
listAllMobileSessionTabs: () => [sessionTabSnapshot],
onMobileSessionTabsChanged: (listener: (snapshot: unknown) => void) => {
sessionTabListeners.add(listener)
return () => sessionTabListeners.delete(listener)
},
watchFileExplorer: async () => () => {},
listRepos: () => [repo],
listDetectedManagedWorktrees: () => ({
repoId: repo.id,
authoritative: true,
source: 'git',
worktrees
}),
createManagedWorktree: ({ name }: { name?: string }) => {
const worktree = {
id: `repo-1::${name || 'created'}`,
repoId: repo.id,
path: join(userDataPath, name || 'created'),
branch: name || 'created',
displayName: name || 'created',
isMainWorktree: false
}
worktrees.push(worktree)
for (const listener of clientEventListeners) {
listener({ type: 'worktreesChanged', repoId: repo.id })
}
return { worktree }
}
} 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 events: RuntimeClientEventStreamMessage[] = []
const shared = new RemoteRuntimeSharedControlConnection(pairing)
const subscription = await shared.subscribe<RuntimeClientEventStreamMessage>(
'runtime.clientEvents.subscribe',
undefined,
1000,
{
onResponse: (response) => {
if (response.ok) {
events.push(response.result)
}
},
onError: (error) => {
throw error
}
}
)
try {
await waitFor(() => events.some((event) => event.type === 'ready'))
await expect(shared.request('status.get', undefined, 1000)).resolves.toMatchObject({
ok: true,
result: { capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] }
})
await expect(shared.request('repo.list', undefined, 1000)).resolves.toMatchObject({
ok: true,
result: { repos: [repo] }
})
await expect(
shared.request('worktree.create', { repo: repo.id, name: 'shared-created' }, 1000)
).resolves.toMatchObject({
ok: true,
result: { worktree: { id: 'repo-1::shared-created' } }
})
await waitFor(() =>
events.some((event) => event.type === 'worktreesChanged' && event.repoId === repo.id)
)
const mixedEvents: unknown[] = []
const mixedMethods = [
['runtime.clientEvents.subscribe', undefined],
['session.tabs.subscribe', { worktree: 'id:wt-1' }],
['accounts.subscribe', undefined],
['notifications.subscribe', undefined],
['files.watch', { worktree: 'id:wt-1' }]
] as const
const mixedSubscriptions = await Promise.all(
Array.from({ length: 30 }, (_value, index) => {
const [method, params] = mixedMethods[index % mixedMethods.length]!
return shared.subscribe(method, params, 1000, {
onResponse: (response) => {
if (response.ok) {
mixedEvents.push(response.result)
}
},
onError: (error) => {
throw error
}
})
})
)
await waitFor(
() => subscriptionCleanups.size >= mixedSubscriptions.length + 1,
5000,
() => `cleanup count ${subscriptionCleanups.size}, event count ${mixedEvents.length}`
)
expect(mixedEvents.length).toBeGreaterThan(0)
expect(
(server as unknown as { wsConnectionIds: Map<unknown, unknown> }).wsConnectionIds.size
).toBe(1)
for (const mixed of mixedSubscriptions) {
mixed.close()
}
const extraSubscriptions = await Promise.all(
Array.from({ length: 30 }, () =>
shared.subscribe<RuntimeClientEventStreamMessage>(
'runtime.clientEvents.subscribe',
undefined,
1000,
{
onResponse: () => {},
onError: (error) => {
throw error
}
}
)
)
)
expect(
(server as unknown as { wsConnectionIds: Map<unknown, unknown> }).wsConnectionIds.size
).toBe(1)
for (const extra of extraSubscriptions) {
extra.close()
}
} finally {
subscription.close()
shared.close()
}
} finally {
await server.stop()
rmSync(userDataPath, { recursive: true, force: true })
}
}, 10_000)
})
async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise<void> {
async function waitFor(
predicate: () => boolean,
timeoutMs = 1000,
describeTimeout?: () => string
): Promise<void> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (predicate()) {
@@ -234,5 +485,7 @@ async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise<void
}
await new Promise((resolve) => setTimeout(resolve, 10))
}
throw new Error('Timed out waiting for condition')
throw new Error(
`Timed out waiting for condition${describeTimeout ? `: ${describeTimeout()}` : ''}`
)
}
+3
View File
@@ -53,6 +53,9 @@ export type RpcContext = {
// server reap all subscriptions for a closing socket, even when other
// sockets for the same deviceToken stay alive (multi-screen mobile).
connectionId?: string
// Why: shared-control multiplexes many logical streams over one socket. Some
// handlers need the frame id to register cleanup at logical-stream granularity.
requestId?: string
// Why: WebSocket RPCs authenticate by mobile device token. State-owning
// handlers use this to clean up when that paired device disconnects.
clientId?: string
+2
View File
@@ -121,6 +121,7 @@ export class RpcDispatcher {
const result = await method.handler(parsedParams.value, {
runtime: this.runtime,
signal: options?.signal,
requestId: request.id,
connectionId: options?.connectionId,
clientId: options?.clientId,
sendBinary: options?.sendBinary,
@@ -153,6 +154,7 @@ export class RpcDispatcher {
{
runtime: this.runtime,
signal: options?.signal,
requestId: request.id,
connectionId: options?.connectionId,
clientId: options?.clientId,
sendBinary: options?.sendBinary,
+16
View File
@@ -5,6 +5,7 @@
import type { RpcEnvelopeMeta, RpcFailure, RpcSuccess } from './core'
import { computerUseErrorRecoveryData } from '../../../shared/computer-use-error-recovery'
import { COMPUTER_ERROR_CODES } from '../../../shared/runtime-types'
import { LINEAR_ERROR_CODES } from '../../../shared/linear-agent-access'
export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess {
return {
@@ -49,6 +50,7 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
])
const COMPUTER_PASSTHROUGH_CODES: ReadonlySet<string> = new Set(Object.values(COMPUTER_ERROR_CODES))
const LINEAR_PASSTHROUGH_CODES: ReadonlySet<string> = new Set(LINEAR_ERROR_CODES)
export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure {
const message = error instanceof Error ? error.message : String(error)
@@ -75,6 +77,20 @@ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknow
(error as { data?: unknown }).data
)
}
if (
error instanceof Error &&
'code' in error &&
typeof (error as { code: unknown }).code === 'string' &&
LINEAR_PASSTHROUGH_CODES.has((error as { code: string }).code)
) {
return errorResponse(
id,
meta,
(error as { code: string }).code,
message,
(error as { data?: unknown }).data
)
}
if (RUNTIME_PASSTHROUGH_CODES.has(message)) {
return errorResponse(id, meta, message, message)
}
@@ -30,6 +30,23 @@ describe('automation RPC methods', () => {
prompt: 'Review changes',
precheck: { command: 'test -f ready', timeoutSeconds: 30 },
agentId: 'codex',
runContext: {
kind: 'workspace-run',
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
projectHostSetupId: 'setup-gpu',
repoId: 'repo-gpu',
path: '/srv/orca'
},
sourceContext: {
kind: 'task-source',
provider: 'github',
projectId: 'github:stablyai/orca',
hostId: 'local',
projectHostSetupId: 'setup-local',
repoId: 'repo-local',
providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }
},
repo: 'repo-1',
reuseSession: true,
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
@@ -59,6 +76,8 @@ describe('automation RPC methods', () => {
prompt: 'Review changes',
precheck: { command: 'test -f ready', timeoutSeconds: 30 },
agentId: 'codex',
runContext: expect.objectContaining({ hostId: 'runtime:gpu' }),
sourceContext: expect.objectContaining({ hostId: 'local' }),
repo: 'repo-1',
reuseSession: true
})
@@ -4,6 +4,8 @@ import {
MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS,
normalizeAutomationPrecheckTimeoutSeconds
} from '../../../../shared/automation-precheck'
import { normalizeExecutionHostId } from '../../../../shared/execution-host'
import type { TaskProviderIdentity as SharedTaskProviderIdentity } from '../../../../shared/task-source-context'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import { defineMethod, type RpcMethod } from '../core'
import {
@@ -20,6 +22,14 @@ const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, {
})
const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional()
const ExecutionHostId = requiredString('Missing host id').transform((value, ctx) => {
const hostId = normalizeExecutionHostId(value)
if (!hostId) {
ctx.addIssue({ code: 'custom', message: 'Invalid host id' })
return z.NEVER
}
return hostId
})
const AutomationSchedule = requiredString('Missing trigger').refine(isValidAutomationSchedule, {
message: 'Invalid automation trigger'
@@ -43,6 +53,43 @@ const OptionalNullablePlainString = z
.pipe(z.union([z.string(), z.null(), z.undefined()]))
.optional()
const TaskProviderIdentity = z
.custom<SharedTaskProviderIdentity>(
(value) =>
value !== null &&
typeof value === 'object' &&
'provider' in value &&
['github', 'gitlab', 'linear', 'jira'].includes(String(value.provider))
)
.optional()
.nullable()
const TaskSourceContext = z
.object({
kind: z.literal('task-source'),
provider: z.enum(['github', 'gitlab', 'linear', 'jira']),
projectId: requiredString('Missing source project id'),
hostId: ExecutionHostId,
projectHostSetupId: OptionalNullablePlainString,
repoId: OptionalNullablePlainString,
providerIdentity: TaskProviderIdentity,
accountLabel: OptionalNullablePlainString
})
.optional()
.nullable()
const WorkspaceRunContext = z
.object({
kind: z.literal('workspace-run'),
projectId: requiredString('Missing run project id'),
hostId: ExecutionHostId,
projectHostSetupId: requiredString('Missing project host setup id'),
repoId: requiredString('Missing repo id'),
path: requiredString('Missing run path')
})
.optional()
.nullable()
const AutomationId = z.object({
id: requiredString('Missing automation id')
})
@@ -56,6 +103,8 @@ const AutomationCreate = z.object({
prompt: requiredString('Missing automation prompt'),
precheck: AutomationPrecheck,
agentId: TuiAgent,
runContext: WorkspaceRunContext,
sourceContext: TaskSourceContext,
repo: OptionalString,
workspace: OptionalString,
workspaceMode: AutomationWorkspaceMode,
@@ -73,6 +122,8 @@ const AutomationUpdateFields = z.object({
prompt: OptionalString,
precheck: AutomationPrecheck,
agentId: TuiAgent.optional(),
runContext: WorkspaceRunContext,
sourceContext: TaskSourceContext,
repo: OptionalString,
workspace: OptionalString,
workspaceMode: AutomationWorkspaceMode,
@@ -118,7 +118,9 @@ export const TabCreate = z.object({
url: OptionalString,
worktree: OptionalString,
profileId: OptionalString,
waitForRegistration: z.boolean().optional()
waitForRegistration: z.boolean().optional(),
// User-initiated opens focus the tab; agent/automation opens stay background.
activate: z.boolean().optional()
})
export const TabShow = z.object({
@@ -153,9 +155,7 @@ export const ProfileCreate = z.object({
scope: z.enum(['isolated', 'imported'])
})
export const ProfileDelete = z.object({
profileId: requiredString('Missing required --profile')
})
export const ProfileDelete = z.object({ profileId: requiredString('Missing required --profile') })
export const ProfileImportFromBrowser = z.object({
profileId: requiredString('Missing required --profile'),
+21 -1
View File
@@ -1,7 +1,15 @@
import { defineStreamingMethod, type RpcAnyMethod } from '../core'
import { z } from 'zod'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
let clientEventSubscriptionSeq = 0
const ClientEventsUnsubscribeParams = z.object({
subscriptionId: z
.unknown()
.transform((value) => (typeof value === 'string' && value.length > 0 ? value : ''))
.pipe(z.string().min(1, 'Missing subscriptionId'))
})
export const CLIENT_EVENT_METHODS: readonly RpcAnyMethod[] = [
defineStreamingMethod({
name: 'runtime.clientEvents.subscribe',
@@ -27,5 +35,17 @@ export const CLIENT_EVENT_METHODS: readonly RpcAnyMethod[] = [
emit({ type: 'ready', subscriptionId })
})
}
}),
defineMethod({
name: 'runtime.clientEvents.unsubscribe',
params: ClientEventsUnsubscribeParams,
handler: async (params, { runtime, connectionId }) => {
const expectedPrefix = `runtime-client-events-${connectionId ?? 'inproc'}-`
if (!params.subscriptionId.startsWith(expectedPrefix)) {
return { unsubscribed: false }
}
runtime.cleanupSubscription(params.subscriptionId)
return { unsubscribed: true }
}
})
]
@@ -129,6 +129,7 @@ describe('client UI RPC methods', () => {
...getDefaultUIState(),
rightSidebarOpen: false,
rightSidebarTab: 'checks',
rightSidebarExplorerView: 'search',
showActiveOnly: true,
filterRepoIds: ['repo-1']
}
@@ -142,6 +143,7 @@ describe('client UI RPC methods', () => {
makeRequest('ui.set', {
rightSidebarOpen: false,
rightSidebarTab: 'checks',
rightSidebarExplorerView: 'search',
showActiveOnly: true,
hideSleepingWorkspaces: true,
filterRepoIds: ['repo-1']
@@ -151,6 +153,7 @@ describe('client UI RPC methods', () => {
expect(runtime.updateUIState).toHaveBeenCalledWith({
rightSidebarOpen: false,
rightSidebarTab: 'checks',
rightSidebarExplorerView: 'search',
showActiveOnly: true,
hideSleepingWorkspaces: true,
filterRepoIds: ['repo-1']
+22 -1
View File
@@ -4,6 +4,10 @@ import {
type FeatureInteractionId
} from '../../../../shared/feature-interactions'
import { isFeatureTipId } from '../../../../shared/feature-tips'
import {
normalizeTuiAgentArgsRecord,
normalizeTuiAgentEnvRecord
} from '../../../../shared/tui-agent-launch-defaults'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import { isTaskProvider } from '../../../../shared/task-providers'
import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection'
@@ -114,6 +118,14 @@ const SettingsUpdate = z
.unknown()
.transform((value) => normalizeDisabledTuiAgents(value))
.optional(),
agentDefaultArgs: z
.unknown()
.transform((value) => normalizeTuiAgentArgsRecord(value))
.optional(),
agentDefaultEnv: z
.unknown()
.transform((value) => normalizeTuiAgentEnvRecord(value))
.optional(),
defaultTaskSource: TaskProviderParam.optional(),
visibleTaskProviders: z.array(TaskProviderParam).optional(),
defaultTaskViewPreset: z
@@ -133,15 +145,23 @@ const UiUpdate = z
lastActiveWorktreeId: NullableString.optional(),
sidebarWidth: z.number().finite().optional(),
rightSidebarOpen: z.boolean().optional(),
rightSidebarTab: z.enum(['explorer', 'search', 'source-control', 'checks', 'ports']).optional(),
rightSidebarTab: z
.enum(['explorer', 'search', 'vault', 'source-control', 'checks', 'ports'])
.optional(),
rightSidebarExplorerView: z.enum(['files', 'search']).optional(),
rightSidebarWidth: z.number().finite().optional(),
markdownTocPanelWidth: z.number().finite().optional(),
groupBy: z.enum(['none', 'workspace-status', 'repo', 'pr-status']).optional(),
showWorkspaceLineage: z.boolean().optional(),
sortBy: z.enum(['name', 'smart', 'recent', 'repo', 'manual']).optional(),
projectOrderBy: z.enum(['manual', 'recent']).optional(),
showActiveOnly: z.boolean().optional(),
hideSleepingWorkspaces: z.boolean().optional(),
showSleepingWorkspaces: z.boolean().optional(),
showInactiveWorkspaces: z.boolean().optional(),
workspaceHostScope: z.string().optional(),
visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(),
workspaceHostOrder: z.array(z.string()).optional(),
hideDefaultBranchWorkspace: z.boolean().optional(),
filterRepoIds: StringArray.optional(),
collapsedGroups: StringArray.optional(),
@@ -190,6 +210,7 @@ const UiUpdate = z
starNagCompleted: z.boolean().optional(),
trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(),
setupScriptPromptDismissedRepoIds: StringArray.optional(),
projectOrderManualDefaultNoticeDismissed: z.boolean().optional(),
usageEmptyStateDismissed: z.boolean().optional(),
petVisible: z.boolean().optional(),
petId: z.string().optional(),
@@ -284,6 +284,37 @@ describe('file RPC methods', () => {
})
})
it('resolves a tapped terminal path for a selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
resolveTerminalPath: vi.fn().mockResolvedValue({
worktree: 'wt-1',
relativePath: 'src/index.ts',
exists: true,
isDirectory: false
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('files.resolveTerminalPath', {
worktree: 'id:wt-1',
pathText: '/repo/src/index.ts',
cwd: '/repo'
})
)
expect(runtime.resolveTerminalPath).toHaveBeenCalledWith(
'id:wt-1',
'/repo/src/index.ts',
'/repo'
)
expect(response).toMatchObject({
ok: true,
result: { relativePath: 'src/index.ts', exists: true, isDirectory: false }
})
})
it('reads a preview file for a selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
+18
View File
@@ -26,6 +26,18 @@ const FileOpen = WorktreeSelector.extend({
.pipe(z.string().min(1, 'Missing relative path'))
})
const ResolveTerminalPath = WorktreeSelector.extend({
pathText: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing path text')),
cwd: z
.unknown()
.transform((v) => (typeof v === 'string' && v.length > 0 ? v : null))
.nullable()
.optional()
})
const FileOpenDiff = FileOpen.extend({
staged: z.boolean().optional()
})
@@ -151,6 +163,12 @@ export const FILE_METHODS: RpcAnyMethod[] = [
handler: async (params, { runtime }) =>
runtime.readMobileFile(params.worktree, params.relativePath)
}),
defineMethod({
name: 'files.resolveTerminalPath',
params: ResolveTerminalPath,
handler: async (params, { runtime }) =>
runtime.resolveTerminalPath(params.worktree, params.pathText, params.cwd ?? null)
}),
defineMethod({
name: 'files.readPreview',
params: FileOpen,
@@ -0,0 +1,98 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
const FolderWorkspaceLinkedTask = z
.object({
provider: z.enum(['github', 'gitlab', 'linear', 'jira']),
type: z.enum(['issue', 'pr', 'mr']),
number: z.number().finite(),
title: requiredString('Missing linked task title'),
url: requiredString('Missing linked task URL'),
linearIdentifier: OptionalString,
jiraIdentifier: OptionalString,
repoId: OptionalString
})
.nullable()
const FolderWorkspaceCreate = z.object({
projectGroupId: requiredString('Missing project group id'),
name: OptionalString,
folderPath: OptionalString.nullable().optional(),
connectionId: OptionalString.nullable().optional(),
linkedTask: FolderWorkspaceLinkedTask.optional(),
createdWithAgent: z.string().refine(isTuiAgent).optional(),
pendingFirstAgentMessageRename: z.boolean().optional()
})
const FolderWorkspaceUpdate = z.object({
folderWorkspaceId: requiredString('Missing folder workspace id'),
updates: z.object({
name: OptionalString,
folderPath: OptionalString,
linkedTask: FolderWorkspaceLinkedTask.optional(),
comment: z.string().optional(),
isArchived: z.boolean().optional(),
isUnread: z.boolean().optional(),
isPinned: z.boolean().optional(),
sortOrder: OptionalFiniteNumber,
manualOrder: OptionalFiniteNumber,
workspaceStatus: OptionalString,
createdWithAgent: z.string().refine(isTuiAgent).optional(),
pendingFirstAgentMessageRename: z.boolean().optional(),
firstAgentMessageRenameError: z.string().nullable().optional(),
lastActivityAt: OptionalFiniteNumber
})
})
const FolderWorkspaceSelector = z.object({
folderWorkspaceId: requiredString('Missing folder workspace id')
})
const FolderWorkspacePathStatus = z.discriminatedUnion('scope', [
z.object({
scope: z.literal('folder-workspace'),
folderWorkspaceId: requiredString('Missing folder workspace id')
}),
z.object({
scope: z.literal('project-group'),
projectGroupId: requiredString('Missing project group id')
})
])
export const FOLDER_WORKSPACE_METHODS: RpcMethod[] = [
defineMethod({
name: 'folderWorkspace.list',
params: null,
handler: (_params, { runtime }) => ({
folderWorkspaces: runtime.listFolderWorkspaces()
})
}),
defineMethod({
name: 'folderWorkspace.create',
params: FolderWorkspaceCreate,
handler: async (params, { runtime }) => ({
folderWorkspace: await runtime.createFolderWorkspace(params)
})
}),
defineMethod({
name: 'folderWorkspace.update',
params: FolderWorkspaceUpdate,
handler: async (params, { runtime }) => ({
folderWorkspace: await runtime.updateFolderWorkspace(params.folderWorkspaceId, params.updates)
})
}),
defineMethod({
name: 'folderWorkspace.delete',
params: FolderWorkspaceSelector,
handler: async (params, { runtime }) => runtime.deleteFolderWorkspace(params.folderWorkspaceId)
}),
defineMethod({
name: 'folderWorkspace.getPathStatus',
params: FolderWorkspacePathStatus,
handler: async (params, { runtime }) => ({
status: await runtime.getFolderWorkspacePathStatus(params)
})
})
]
@@ -196,6 +196,13 @@ export const GitTargetedRemote = WorktreeSelector.extend({
pushTarget: GitPushTargetParam.optional()
})
export const GitForkSync = WorktreeSelector.extend({
expectedUpstream: z.object({
owner: z.string().trim().min(1),
repo: z.string().trim().min(1)
})
})
export const GitRebaseFromBase = WorktreeSelector.extend({
baseRef: z
.unknown()
@@ -208,6 +215,19 @@ export const GitRebaseFromBase = WorktreeSelector.extend({
)
})
export const GitCheckout = WorktreeSelector.extend({
branch: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(
z
.string()
.min(1, 'Missing branch')
// Why: never let a branch arg be parsed as a git flag (arg injection).
.refine((value) => !value.startsWith('-'), 'Branch must not start with -')
)
})
export const GitRemoteFileUrl = WorktreeSelector.extend({
relativePath: z
.unknown()
@@ -215,3 +235,10 @@ export const GitRemoteFileUrl = WorktreeSelector.extend({
.pipe(z.string().min(1, 'Missing relative path')),
line: z.number().int().min(1)
})
export const GitRemoteCommitUrl = WorktreeSelector.extend({
sha: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(FullGitObjectId)
})
+151 -1
View File
@@ -200,9 +200,11 @@ describe('git RPC methods', () => {
abortRuntimeGitMerge: vi.fn().mockResolvedValue({ ok: true }),
abortRuntimeGitRebase: vi.fn().mockResolvedValue({ ok: true }),
pushRuntimeGit: vi.fn().mockResolvedValue({ ok: true }),
getRuntimeGitRemoteFileUrl: vi.fn().mockResolvedValue('https://example.com/file#L3')
getRuntimeGitRemoteFileUrl: vi.fn().mockResolvedValue('https://example.com/file#L3'),
getRuntimeGitRemoteCommitUrl: vi.fn().mockResolvedValue('https://example.com/commit/abc')
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
const commitOid = '0123456789abcdef0123456789abcdef01234567'
await dispatcher.dispatch(
makeRequest('git.commit', { worktree: 'id:wt-1', message: 'feat: test' })
@@ -234,6 +236,12 @@ describe('git RPC methods', () => {
line: 3
})
)
const commitUrlResponse = await dispatcher.dispatch(
makeRequest('git.remoteCommitUrl', {
worktree: 'id:wt-1',
sha: commitOid
})
)
expect(runtime.commitRuntimeGit).toHaveBeenCalledWith('id:wt-1', 'feat: test')
expect(runtime.generateRuntimeCommitMessage).toHaveBeenCalledWith('id:wt-1')
@@ -250,6 +258,26 @@ describe('git RPC methods', () => {
undefined
)
expect(response).toMatchObject({ ok: true, result: 'https://example.com/file#L3' })
expect(runtime.getRuntimeGitRemoteCommitUrl).toHaveBeenCalledWith('id:wt-1', commitOid)
expect(commitUrlResponse).toMatchObject({ ok: true, result: 'https://example.com/commit/abc' })
})
it('rejects remote commit URL requests without a full git object id', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getRuntimeGitRemoteCommitUrl: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
const response = await dispatcher.dispatch(
makeRequest('git.remoteCommitUrl', {
worktree: 'id:wt-1',
sha: 'abc123'
})
)
expect(response.ok).toBe(false)
expect(runtime.getRuntimeGitRemoteCommitUrl).not.toHaveBeenCalled()
})
it('forwards force-with-lease push mode to the runtime', async () => {
@@ -304,6 +332,78 @@ describe('git RPC methods', () => {
expect(runtime.fetchRuntimeGit).toHaveBeenCalledWith('id:wt-1', pushTarget)
})
it('forwards fork sync requests to the runtime', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
syncRuntimeGitForkDefaultBranch: vi.fn().mockResolvedValue({
status: 'up-to-date',
originRemote: 'origin',
upstreamRemote: 'upstream',
branchName: 'main',
ahead: 0,
behind: 0
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
const response = await dispatcher.dispatch(
makeRequest('git.forkSync', {
worktree: 'id:wt-1',
expectedUpstream: { owner: 'stablyai', repo: 'orca' }
})
)
expect(runtime.syncRuntimeGitForkDefaultBranch).toHaveBeenCalledWith('id:wt-1', {
owner: 'stablyai',
repo: 'orca'
})
expect(response).toMatchObject({
ok: true,
result: { status: 'up-to-date', branchName: 'main', ahead: 0, behind: 0 }
})
})
it('rejects blank fork sync expected upstream fields before calling the runtime', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
syncRuntimeGitForkDefaultBranch: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
const response = await dispatcher.dispatch(
makeRequest('git.forkSync', {
worktree: 'id:wt-1',
expectedUpstream: { owner: ' ', repo: 'orca' }
})
)
expect(response.ok).toBe(false)
expect(response).toMatchObject({
error: expect.objectContaining({ code: 'invalid_argument' })
})
expect(runtime.syncRuntimeGitForkDefaultBranch).not.toHaveBeenCalled()
})
it('rejects missing fork sync expected upstream before calling the runtime', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
syncRuntimeGitForkDefaultBranch: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
const response = await dispatcher.dispatch(
makeRequest('git.forkSync', {
worktree: 'id:wt-1'
})
)
expect(response.ok).toBe(false)
expect(response).toMatchObject({
error: expect.objectContaining({ code: 'invalid_argument' })
})
expect(runtime.syncRuntimeGitForkDefaultBranch).not.toHaveBeenCalled()
})
it('forwards fast-forward push target to the runtime', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
@@ -504,4 +604,54 @@ describe('git RPC methods', () => {
expect(response.ok).toBe(false)
expect(runtime.getRuntimeGitHistory).not.toHaveBeenCalled()
})
it('checks out a branch', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
checkoutRuntimeGitBranch: vi.fn().mockResolvedValue({ ok: true, branch: 'feature/x' })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
const response = await dispatcher.dispatch(
makeRequest('git.checkout', { worktree: 'id:wt-1', branch: 'feature/x' })
)
expect(runtime.checkoutRuntimeGitBranch).toHaveBeenCalledWith('id:wt-1', 'feature/x')
expect(response).toMatchObject({ ok: true, result: { ok: true, branch: 'feature/x' } })
})
it('rejects a checkout branch that starts with a dash', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
checkoutRuntimeGitBranch: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
const response = await dispatcher.dispatch(
makeRequest('git.checkout', { worktree: 'id:wt-1', branch: '--force' })
)
expect(response.ok).toBe(false)
expect(runtime.checkoutRuntimeGitBranch).not.toHaveBeenCalled()
})
it('lists local branches', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
listRuntimeGitLocalBranches: vi
.fn()
.mockResolvedValue({ current: 'main', branches: ['main', 'feature/x'] })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
const response = await dispatcher.dispatch(
makeRequest('git.localBranches', { worktree: 'id:wt-1' })
)
expect(runtime.listRuntimeGitLocalBranches).toHaveBeenCalledWith('id:wt-1')
expect(response).toMatchObject({
ok: true,
result: { current: 'main', branches: ['main', 'feature/x'] }
})
})
})
+26
View File
@@ -7,17 +7,20 @@ import {
GitBranchDiff,
GitBulkPaths,
GitCheckIgnored,
GitCheckout,
GitCommit,
GitCommitCompare,
GitCommitDiff,
GitDiscoverCommitMessageModels,
GitDiff,
GitFilePath,
GitForkSync,
GitGenerateCommitMessage,
GitGeneratePullRequestFields,
GitHistory,
GitPush,
GitRebaseFromBase,
GitRemoteCommitUrl,
GitRemoteFileUrl,
GitStatusParams,
GitTargetedRemote,
@@ -119,6 +122,17 @@ export const GIT_METHODS: RpcMethod[] = [
params: WorktreeSelector,
handler: async (params, { runtime }) => runtime.abortRuntimeGitRebase(params.worktree)
}),
defineMethod({
name: 'git.checkout',
params: GitCheckout,
handler: async (params, { runtime }) =>
runtime.checkoutRuntimeGitBranch(params.worktree, params.branch)
}),
defineMethod({
name: 'git.localBranches',
params: WorktreeSelector,
handler: async (params, { runtime }) => runtime.listRuntimeGitLocalBranches(params.worktree)
}),
defineMethod({
name: 'git.diff',
params: GitDiff,
@@ -158,6 +172,12 @@ export const GIT_METHODS: RpcMethod[] = [
? runtime.fetchRuntimeGit(params.worktree)
: runtime.fetchRuntimeGit(params.worktree, params.pushTarget)
}),
defineMethod({
name: 'git.forkSync',
params: GitForkSync,
handler: async (params, { runtime }) =>
runtime.syncRuntimeGitForkDefaultBranch(params.worktree, params.expectedUpstream)
}),
defineMethod({
name: 'git.pull',
params: GitTargetedRemote,
@@ -314,5 +334,11 @@ export const GIT_METHODS: RpcMethod[] = [
params: GitRemoteFileUrl,
handler: async (params, { runtime }) =>
runtime.getRuntimeGitRemoteFileUrl(params.worktree, params.relativePath, params.line)
}),
defineMethod({
name: 'git.remoteCommitUrl',
params: GitRemoteCommitUrl,
handler: async (params, { runtime }) =>
runtime.getRuntimeGitRemoteCommitUrl(params.worktree, params.sha)
})
]
+2 -1
View File
@@ -383,11 +383,12 @@ describe('github RPC methods', () => {
repo: 'repo-1',
prNumber: 7,
enabled: true,
method: 'squash',
prRepo: { owner: 'acme', repo: 'widgets' }
})
)
expect(runtime.setRepoPRAutoMerge).toHaveBeenCalledWith('repo-1', 7, true, {
expect(runtime.setRepoPRAutoMerge).toHaveBeenCalledWith('repo-1', 7, true, 'squash', {
owner: 'acme',
repo: 'widgets'
})
+2
View File
@@ -126,6 +126,7 @@ const MergePr = RepoSelector.extend({
const SetPrAutoMerge = RepoSelector.extend({
prNumber: z.number().int().positive(),
enabled: z.boolean(),
method: z.enum(['merge', 'squash', 'rebase']).optional(),
prRepo: SlugRepo.nullable().optional()
})
@@ -463,6 +464,7 @@ export const GITHUB_METHODS: RpcMethod[] = [
params.repo,
params.prNumber,
params.enabled,
params.method,
params.prRepo ?? null
)
}),
+2
View File
@@ -21,6 +21,7 @@ import { GITHUB_METHODS } from './github'
import { GITLAB_METHODS } from './gitlab'
import { HOSTED_REVIEW_METHODS } from './hosted-review'
import { LINEAR_METHODS } from './linear'
import { LINEAR_AGENT_ACCESS_METHODS } from './linear-agent-access'
import { JIRA_METHODS } from './jira'
import { SSH_METHODS } from './ssh'
import { SPEECH_METHODS } from './speech'
@@ -58,6 +59,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...GITLAB_METHODS,
...HOSTED_REVIEW_METHODS,
...LINEAR_METHODS,
...LINEAR_AGENT_ACCESS_METHODS,
...JIRA_METHODS,
...SSH_METHODS,
...SPEECH_METHODS,
@@ -0,0 +1,651 @@
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import { OrcaRuntimeService } from '../../orca-runtime'
import { LinearWriteFailure } from '../../../linear/issues'
import { sanitizeLinearErrorMessage } from '../../../linear/issue-context-errors'
import { LINEAR_AGENT_ACCESS_METHODS } from './linear-agent-access'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('Linear agent access RPC methods', () => {
it('routes agent write methods to the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
linearIssueSetState: vi.fn().mockResolvedValue({ ok: true }),
linearTeamListForAgents: vi.fn().mockResolvedValue({ ok: true }),
linearTeamMembersForAgents: vi.fn().mockResolvedValue({ ok: true }),
linearTeamStatesForAgents: vi.fn().mockResolvedValue({ ok: true }),
linearTeamLabelsForAgents: vi.fn().mockResolvedValue({ ok: true }),
linearIssueListForAgents: vi.fn().mockResolvedValue({ ok: true }),
linearProjectListForAgents: vi.fn().mockResolvedValue({ ok: true }),
linearIssueUpdateTask: vi.fn().mockResolvedValue({ ok: true }),
linearIssueAddComment: vi.fn().mockResolvedValue({ ok: true }),
linearIssueAttachLink: vi.fn().mockResolvedValue({ ok: true }),
linearIssueCreate: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_AGENT_ACCESS_METHODS })
const setStateResponse = await dispatcher.dispatch(
makeRequest('linear.issueSetState', {
input: 'ENG-1',
to: 'In Review',
workspaceId: 'workspace-1'
})
)
const teamListResponse = await dispatcher.dispatch(
makeRequest('linear.agentTeamList', { workspaceId: 'all' })
)
const teamMembersResponse = await dispatcher.dispatch(
makeRequest('linear.agentTeamMembers', { teamInput: 'ENG', workspaceId: 'workspace-1' })
)
const teamStatesResponse = await dispatcher.dispatch(
makeRequest('linear.agentTeamStates', { teamInput: 'ENG', workspaceId: 'workspace-1' })
)
const teamLabelsResponse = await dispatcher.dispatch(
makeRequest('linear.agentTeamLabels', { teamInput: 'ENG', workspaceId: 'workspace-1' })
)
const issueListResponse = await dispatcher.dispatch(
makeRequest('linear.agentIssueList', {
filter: 'open',
teamInput: 'ENG',
limit: 10,
workspaceId: 'workspace-1'
})
)
const projectListResponse = await dispatcher.dispatch(
makeRequest('linear.agentProjectList', {
query: 'launch',
limit: 10,
workspaceId: 'all'
})
)
const taskUpdateResponse = await dispatcher.dispatch(
makeRequest('linear.issueUpdateTask', {
input: 'ENG-1',
operation: 'dueDate',
dueDate: '2026-06-30',
workspaceId: 'workspace-1'
})
)
const commentResponse = await dispatcher.dispatch(
makeRequest('linear.issueAddComment', {
input: 'ENG-1',
body: 'Done',
replyTo: 'comment-1',
writeId: '11111111-1111-4111-8111-111111111111',
workspaceId: 'workspace-1'
})
)
const attachResponse = await dispatcher.dispatch(
makeRequest('linear.issueAttachLink', {
input: 'ENG-1',
url: 'https://example.com/review/1',
title: 'Review',
writeId: '22222222-2222-4222-8222-222222222222',
workspaceId: 'workspace-1'
})
)
const createResponse = await dispatcher.dispatch(
makeRequest('linear.issueCreate', {
title: 'Follow up',
body: 'Details',
teamInput: 'ENG',
projectInput: 'project-1',
priority: 2,
parentInput: 'ENG-1',
writeId: '33333333-3333-4333-8333-333333333333',
workspaceId: 'workspace-1'
})
)
expect(setStateResponse.ok).toBe(true)
expect(teamListResponse.ok).toBe(true)
expect(teamMembersResponse.ok).toBe(true)
expect(teamStatesResponse.ok).toBe(true)
expect(teamLabelsResponse.ok).toBe(true)
expect(issueListResponse.ok).toBe(true)
expect(projectListResponse.ok).toBe(true)
expect(taskUpdateResponse.ok).toBe(true)
expect(commentResponse.ok).toBe(true)
expect(attachResponse.ok).toBe(true)
expect(createResponse.ok).toBe(true)
expect(runtime.linearIssueSetState).toHaveBeenCalledWith({
input: 'ENG-1',
to: 'In Review',
workspaceId: 'workspace-1'
})
expect(runtime.linearTeamListForAgents).toHaveBeenCalledWith({ workspaceId: 'all' })
expect(runtime.linearTeamMembersForAgents).toHaveBeenCalledWith({
teamInput: 'ENG',
workspaceId: 'workspace-1'
})
expect(runtime.linearTeamStatesForAgents).toHaveBeenCalledWith({
teamInput: 'ENG',
workspaceId: 'workspace-1'
})
expect(runtime.linearTeamLabelsForAgents).toHaveBeenCalledWith({
teamInput: 'ENG',
workspaceId: 'workspace-1'
})
expect(runtime.linearIssueListForAgents).toHaveBeenCalledWith({
filter: 'open',
teamInput: 'ENG',
limit: 10,
workspaceId: 'workspace-1'
})
expect(runtime.linearProjectListForAgents).toHaveBeenCalledWith({
query: 'launch',
limit: 10,
workspaceId: 'all'
})
expect(runtime.linearIssueUpdateTask).toHaveBeenCalledWith({
input: 'ENG-1',
operation: 'dueDate',
dueDate: '2026-06-30',
workspaceId: 'workspace-1'
})
expect(runtime.linearIssueAddComment).toHaveBeenCalledWith({
input: 'ENG-1',
body: 'Done',
replyTo: 'comment-1',
writeId: '11111111-1111-4111-8111-111111111111',
workspaceId: 'workspace-1'
})
expect(runtime.linearIssueAttachLink).toHaveBeenCalledWith({
input: 'ENG-1',
url: 'https://example.com/review/1',
title: 'Review',
writeId: '22222222-2222-4222-8222-222222222222',
workspaceId: 'workspace-1'
})
expect(runtime.linearIssueCreate).toHaveBeenCalledWith({
title: 'Follow up',
body: 'Details',
teamInput: 'ENG',
projectInput: 'project-1',
priority: 2,
parentInput: 'ENG-1',
writeId: '33333333-3333-4333-8333-333333333333',
workspaceId: 'workspace-1'
})
})
it('rejects malformed write ids before the runtime is called', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
linearIssueAddComment: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_AGENT_ACCESS_METHODS })
const response = await dispatcher.dispatch(
makeRequest('linear.issueAddComment', {
input: 'ENG-1',
body: 'Done',
writeId: 'not-a-uuid'
})
)
expect(response.ok).toBe(false)
expect(response.ok === false ? response.error.code : '').toBe('linear_invalid_write_id')
expect(runtime.linearIssueAddComment).not.toHaveBeenCalled()
})
it('rejects workspace all for direct write RPC calls', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
linearIssueSetState: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_AGENT_ACCESS_METHODS })
const response = await dispatcher.dispatch(
makeRequest('linear.issueSetState', {
input: 'ENG-1',
to: 'In Review',
workspaceId: 'all'
})
)
expect(response.ok).toBe(false)
expect(response.ok === false ? response.error.message : '').toContain(
'--workspace all is not valid for Linear writes'
)
expect(runtime.linearIssueSetState).not.toHaveBeenCalled()
})
it('rejects workspace all for non-list team discovery RPC calls', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
linearTeamMembersForAgents: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_AGENT_ACCESS_METHODS })
const response = await dispatcher.dispatch(
makeRequest('linear.agentTeamMembers', {
teamInput: 'ENG',
workspaceId: 'all'
})
)
expect(response.ok).toBe(false)
expect(response.ok === false ? response.error.message : '').toContain(
'--workspace all is only valid for team list'
)
expect(runtime.linearTeamMembersForAgents).not.toHaveBeenCalled()
})
it('rejects invalid due dates before the runtime is called', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
linearIssueUpdateTask: vi.fn(),
linearIssueCreate: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_AGENT_ACCESS_METHODS })
const updateResponse = await dispatcher.dispatch(
makeRequest('linear.issueUpdateTask', {
input: 'ENG-1',
operation: 'dueDate',
dueDate: 'tomorrow',
workspaceId: 'workspace-1'
})
)
const createResponse = await dispatcher.dispatch(
makeRequest('linear.issueCreate', {
title: 'Follow up',
dueDate: 'June 30',
workspaceId: 'workspace-1'
})
)
expect(updateResponse.ok).toBe(false)
expect(updateResponse.ok === false ? updateResponse.error.message : '').toContain(
'Linear due dates must use YYYY-MM-DD'
)
expect(createResponse.ok).toBe(false)
expect(createResponse.ok === false ? createResponse.error.message : '').toContain(
'Linear due dates must use YYYY-MM-DD'
)
expect(runtime.linearIssueUpdateTask).not.toHaveBeenCalled()
expect(runtime.linearIssueCreate).not.toHaveBeenCalled()
})
})
type LinearWriteRunner = {
runLinearAgentWrite<T>(
write: (signal: AbortSignal) => Promise<T>,
unconfirmed: (cause?: string) => Error
): Promise<T>
}
type LinearUnconfirmedBuilder = {
linearCreateStyleUnconfirmed(
verb: 'comment' | 'attach' | 'create',
writeId: string,
target: unknown,
extra?: unknown
): Error & { data?: { cause?: string; nextSteps?: string[] } }
resolveLinearAgentState(input: string, states: unknown[]): unknown | null
linearCreatedIssueMatchesIntent(issue: unknown, intent: unknown): boolean
notifyLinearLinkedIssueUpdated(workspaceId: string, identifier: string): Promise<void>
listResolvedWorktrees(): Promise<unknown[]>
}
type LinearRetryLookupTester = {
getMatchingLinearCommentWrite(
writeId: string,
issueId: string,
parentId: string | null,
workspaceId: string,
required: boolean
): Promise<unknown | null>
getMatchingLinearAttachmentWrite(
writeId: string,
issueId: string,
workspaceId: string,
required: boolean
): Promise<unknown | null>
getMatchingLinearCreatedIssue(
writeId: string,
teamId: string,
parentId: string | null,
workspaceId: string,
required: boolean,
intent?: unknown
): Promise<unknown | null>
refetchLinearCommentAfterDuplicate(
writeId: string,
issueId: string,
parentId: string | null,
workspaceId: string,
unconfirmed: () => Error
): Promise<unknown>
readLinearWriteLookup(lookup: () => Promise<unknown>): Promise<unknown>
}
describe('Linear agent write recovery helpers', () => {
it('keeps stable write failure codes while preserving the Linear provider message', async () => {
const runtime = new OrcaRuntimeService()
const runner = runtime as unknown as LinearWriteRunner
await expect(
runner.runLinearAgentWrite(
async () => {
throw new LinearWriteFailure(
'failed',
'Linear rejected the state transition because the issue is archived.'
)
},
() => Object.assign(new Error('should not be used'), { code: 'linear_write_unconfirmed' })
)
).rejects.toMatchObject({
code: 'linear_write_failed',
message: 'Linear rejected the state transition because the issue is archived.'
})
})
it('keeps pinned retry guidance for unconfirmed writes while adding sanitized cause text', async () => {
const runtime = new OrcaRuntimeService()
const runner = runtime as unknown as LinearWriteRunner
const builder = runtime as unknown as LinearUnconfirmedBuilder
const writeId = '11111111-1111-4111-8111-111111111111'
const target = {
workspaceId: 'workspace-1',
issue: { id: 'issue-1', identifier: 'ENG-123', url: 'https://example.invalid/ENG-123' }
}
await expect(
runner.runLinearAgentWrite(
async () => {
throw new LinearWriteFailure(
'unconfirmed',
'Linear write could not be confirmed.',
new Error('fetch failed: socket hang up Authorization: Bearer linear-secret-token')
)
},
(cause) =>
builder.linearCreateStyleUnconfirmed('comment', writeId, target, {
bodyRequired: true,
cause
})
)
).rejects.toMatchObject({
code: 'linear_write_unconfirmed',
data: {
cause: 'fetch failed: socket hang up Authorization: Bearer [REDACTED]',
nextSteps: [expect.stringContaining(`--write-id=${writeId}`)]
}
})
})
it('sanitizes Linear provider messages before they enter RPC error envelopes', () => {
const message = sanitizeLinearErrorMessage(
'Linear rejected mutation variables: {"body":"user comment payload","id":"issue-1"} headers: {Authorization: Bearer token-123}\n at handler (linear.ts:1:1)'
)
expect(message).toContain('Linear rejected mutation')
expect(message).toContain('variables: [REDACTED]')
expect(message).toContain('headers: [REDACTED]')
expect(message).not.toContain('user comment payload')
expect(message).not.toContain('token-123')
expect(message).not.toContain('at handler')
})
it('returns unconfirmed at the write deadline even when the request ignores abort', async () => {
vi.useFakeTimers()
try {
const runtime = new OrcaRuntimeService()
const write = vi.fn((_signal: AbortSignal) => new Promise<string>(() => undefined))
const unconfirmed = vi.fn(() =>
Object.assign(new Error('unconfirmed'), { code: 'linear_write_unconfirmed' })
)
const pending = (runtime as unknown as LinearWriteRunner).runLinearAgentWrite(
write,
unconfirmed
)
const rejection = expect(pending).rejects.toMatchObject({
code: 'linear_write_unconfirmed'
})
await vi.advanceTimersByTimeAsync(25_000)
await rejection
expect(unconfirmed).toHaveBeenCalledTimes(1)
expect(write.mock.calls[0]?.[0].aborted).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('keeps payload and destination details in pinned retries', () => {
const runtime = new OrcaRuntimeService()
const builder = runtime as unknown as LinearUnconfirmedBuilder
const writeId = '11111111-1111-4111-8111-111111111111'
const target = {
workspaceId: 'workspace-1',
issue: { id: 'issue-1', identifier: 'ENG-123', url: 'https://example.invalid/ENG-123' }
}
const parent = {
workspaceId: 'workspace-1',
issue: { id: 'issue-1', identifier: 'ENG-123', url: 'https://example.invalid/ENG-123' }
}
const comment = builder.linearCreateStyleUnconfirmed('comment', writeId, target, {
parentId: 'comment-root',
bodyRequired: true
})
const attach = builder.linearCreateStyleUnconfirmed('attach', writeId, target, {
title: 'Review link',
url: 'https://example.invalid/review/1'
})
const create = builder.linearCreateStyleUnconfirmed('create', writeId, null, {
parent,
team: { id: 'team-2', key: 'OTHER', name: 'Other', workspaceId: 'workspace-1' },
title: 'Follow up',
bodyRequired: true,
createFields: {
priority: 2,
estimate: 3,
dueDate: '2026-06-30',
projectId: 'project-1',
labelIds: ['label-1']
}
})
expect(comment.data?.nextSteps?.[0]).toContain('--body-file -')
expect(comment.data?.nextSteps?.[0]).toContain('--reply-to=comment-root')
expect(attach.data?.nextSteps?.[0]).toContain('--url URL_HERE')
expect(attach.data?.nextSteps?.[0]).toContain('--title TITLE_HERE')
expect(attach.data?.nextSteps?.[0]).toContain('Replace TITLE_HERE/URL_HERE')
expect(create.data?.nextSteps?.[0]).toContain('--title TITLE_HERE')
expect(create.data?.nextSteps?.[0]).toContain('--body-file -')
expect(create.data?.nextSteps?.[0]).toContain('--parent=ENG-123')
expect(create.data?.nextSteps?.[0]).toContain('--team=OTHER')
expect(create.data?.nextSteps?.[0]).toContain('--priority=high')
expect(create.data?.nextSteps?.[0]).toContain('--estimate=3')
expect(create.data?.nextSteps?.[0]).toContain('--due-date=2026-06-30')
expect(create.data?.nextSteps?.[0]).toContain('--project=project-1')
expect(create.data?.nextSteps?.[0]).toContain('--label=label-1')
expect(create.data?.nextSteps?.[0]).toContain('Replace TITLE_HERE')
})
it('requires created issue readback to match enriched field intent', () => {
const runtime = new OrcaRuntimeService()
const builder = runtime as unknown as LinearUnconfirmedBuilder
const issue = {
id: 'issue-2',
identifier: 'ENG-2',
title: 'Follow up',
url: 'https://example.invalid/ENG-2',
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
state: { id: 'state-review', name: 'In Review' },
parent: null,
project: { id: 'project-1', name: 'Launch' },
assignee: { id: 'user-1', displayName: 'Ada' },
priority: 2,
estimate: 3,
dueDate: '2026-06-30',
labels: [{ id: 'label-1', name: 'Bug' }],
labelIds: ['label-1']
}
expect(
builder.linearCreatedIssueMatchesIntent(issue, {
stateId: 'state-review',
assigneeId: 'user-1',
priority: 2,
estimate: 3,
dueDate: '2026-06-30',
projectId: 'project-1',
labelIds: ['label-1']
})
).toBe(true)
expect(
builder.linearCreatedIssueMatchesIntent(issue, {
stateId: 'state-review',
projectId: 'project-2'
})
).toBe(false)
})
it('resolves workflow states by UUID or case-insensitive exact name', () => {
const runtime = new OrcaRuntimeService()
const states = [
{ id: 'state-review', name: 'In Review', type: 'started' },
{ id: 'state-done', name: 'Done', type: 'completed' }
]
const builder = runtime as unknown as LinearUnconfirmedBuilder
expect(builder.resolveLinearAgentState('In Review', states)).toBe(states[0])
expect(builder.resolveLinearAgentState('in review', states)).toBe(states[0])
expect(builder.resolveLinearAgentState('STATE-REVIEW', states)).toBe(states[0])
expect(builder.resolveLinearAgentState('Review', states)).toBeNull()
})
it('deduplicates write-id lookups by relationship target without comparing payloads', async () => {
const runtime = new OrcaRuntimeService()
const tester = runtime as unknown as LinearRetryLookupTester
tester.readLinearWriteLookup = vi.fn(async () => ({
id: 'comment-1',
body: 'different retry body',
issue: { id: 'issue-1', identifier: 'ENG-1', url: 'https://example.invalid/ENG-1' },
parentId: 'comment-root',
threadRootId: 'comment-root',
url: null
}))
await expect(
tester.getMatchingLinearCommentWrite(
'11111111-1111-4111-8111-111111111111',
'issue-1',
'comment-root',
'workspace-1',
true
)
).resolves.toMatchObject({ id: 'comment-1' })
tester.readLinearWriteLookup = vi.fn(async () => ({
id: 'attachment-1',
title: 'Different title',
url: 'https://example.invalid/different',
issue: { id: 'issue-1', identifier: 'ENG-1', url: 'https://example.invalid/ENG-1' }
}))
await expect(
tester.getMatchingLinearAttachmentWrite(
'22222222-2222-4222-8222-222222222222',
'issue-1',
'workspace-1',
true
)
).resolves.toMatchObject({ id: 'attachment-1' })
tester.readLinearWriteLookup = vi.fn(async () => ({
id: 'issue-2',
identifier: 'ENG-2',
title: 'Different title',
description: 'Different body',
url: 'https://example.invalid/ENG-2',
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
state: null,
parent: { id: 'issue-1', identifier: 'ENG-1' },
project: { id: 'project-1', name: 'Launch' }
}))
await expect(
tester.getMatchingLinearCreatedIssue(
'33333333-3333-4333-8333-333333333333',
'team-1',
'issue-1',
'workspace-1',
true
)
).resolves.toMatchObject({ id: 'issue-2' })
await expect(
tester.getMatchingLinearCreatedIssue(
'33333333-3333-4333-8333-333333333333',
'team-1',
'issue-1',
'workspace-1',
true,
{ projectId: 'project-2' }
)
).rejects.toMatchObject({ code: 'linear_invalid_write_id' })
})
it('keeps the unconfirmed retry envelope when duplicate recovery lookup fails', async () => {
const runtime = new OrcaRuntimeService()
const tester = runtime as unknown as LinearRetryLookupTester
const unconfirmed = Object.assign(new Error('try pinned retry again'), {
code: 'linear_write_unconfirmed',
data: { writeId: '11111111-1111-4111-8111-111111111111' }
})
tester.readLinearWriteLookup = vi.fn(async () => {
throw Object.assign(new Error('socket reset during lookup'), {
code: 'linear_network_error'
})
})
await expect(
tester.refetchLinearCommentAfterDuplicate(
'11111111-1111-4111-8111-111111111111',
'issue-1',
null,
'workspace-1',
() => unconfirmed
)
).rejects.toBe(unconfirmed)
})
it('emits linked issue refresh events for matching workspace links', async () => {
const runtime = new OrcaRuntimeService()
const builder = runtime as unknown as LinearUnconfirmedBuilder
const events: unknown[] = []
runtime.onClientEvent((event) => events.push(event))
builder.listResolvedWorktrees = vi.fn(async () => [
{
id: 'worktree-1',
linkedLinearIssue: 'eng-123',
linkedLinearIssueWorkspaceId: 'workspace-1'
},
{
id: 'worktree-2',
linkedLinearIssue: 'ENG-123',
linkedLinearIssueWorkspaceId: 'workspace-2'
}
])
await builder.notifyLinearLinkedIssueUpdated('workspace-1', 'ENG-123')
expect(events).toEqual([
{
type: 'linearLinkedIssueUpdated',
worktreeId: 'worktree-1',
identifier: 'ENG-123',
workspaceId: 'workspace-1'
}
])
})
})
@@ -0,0 +1,215 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
import { linearError } from '../../../linear/issue-context-errors'
import { isLinearUuid } from '../../../../shared/linear-uuid'
const LINEAR_DUE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
const LinearDueDate = z.string().refine((value) => LINEAR_DUE_DATE_PATTERN.test(value), {
message: 'Linear due dates must use YYYY-MM-DD'
})
const OptionalLinearDueDate = LinearDueDate.optional()
const OptionalLinearDueDateOrClear = z.union([LinearDueDate, z.null()]).optional()
const AgentSearchIssues = z.object({
query: requiredString('Missing query'),
limit: OptionalFiniteNumber,
workspaceId: z.union([z.string(), z.literal('all')]).optional()
})
const LinearWorkspaceRead = z.object({
workspaceId: z.union([z.string(), z.literal('all')]).optional()
})
const LinearTeamLookup = z.object({
teamInput: requiredString('Missing team'),
workspaceId: OptionalString.refine((value) => value !== 'all', {
message: '--workspace all is only valid for team list'
})
})
const LinearIssueList = z.object({
filter: z.enum(['assigned', 'created', 'all', 'completed', 'open']).optional(),
teamInput: OptionalString,
limit: OptionalFiniteNumber,
workspaceId: z.union([z.string(), z.literal('all')]).optional()
})
const LinearProjectList = z.object({
query: OptionalString,
limit: OptionalFiniteNumber,
workspaceId: z.union([z.string(), z.literal('all')]).optional()
})
const LinearIncludeFlags = z.object({
comments: z.boolean(),
children: z.boolean(),
attachments: z.boolean(),
relations: z.boolean()
})
const LinearCurrentContext = z
.object({
worktreeId: OptionalString,
terminalHandle: OptionalString,
cwd: OptionalString,
remote: z.boolean().optional()
})
.optional()
const LinearWriteTarget = z.object({
input: OptionalString,
current: z.boolean().optional(),
workspaceId: OptionalString.refine((value) => value !== 'all', {
message: '--workspace all is not valid for Linear writes'
}),
context: LinearCurrentContext
})
const AgentIssueContext = z.object({
input: OptionalString,
current: z.boolean().optional(),
workspaceId: OptionalString,
include: LinearIncludeFlags,
depth: z.number().int().min(0).max(5),
context: LinearCurrentContext
})
const LinearIssueSetState = LinearWriteTarget.extend({
to: requiredString('Missing target state')
})
const LinearIssueUpdateTask = LinearWriteTarget.extend({
operation: z.enum(['assignee', 'priority', 'estimate', 'dueDate', 'labels']),
assigneeId: z.string().nullable().optional(),
assigneeMe: z.boolean().optional(),
priority: z.number().int().min(0).max(4).optional(),
estimate: z.number().int().min(0).nullable().optional(),
dueDate: OptionalLinearDueDateOrClear,
labelMode: z.enum(['add', 'remove', 'set']).optional(),
labels: z.array(z.string()).optional()
})
const LinearIssueAddComment = LinearWriteTarget.extend({
body: requiredString('Missing comment body'),
replyTo: OptionalString,
writeId: OptionalString
})
const LinearIssueAttachLink = LinearWriteTarget.extend({
url: requiredString('Missing attachment URL'),
title: OptionalString,
writeId: OptionalString
})
const LinearIssueCreate = z.object({
title: requiredString('Missing issue title'),
body: OptionalString,
teamInput: OptionalString,
teamKey: OptionalString,
state: OptionalString,
assignee: OptionalString,
priority: z.number().int().min(0).max(4).optional(),
estimate: z.number().int().min(0).optional(),
dueDate: OptionalLinearDueDate,
labels: z.array(z.string()).optional(),
projectInput: OptionalString,
parentInput: OptionalString,
parentCurrent: z.boolean().optional(),
workspaceId: OptionalString.refine((value) => value !== 'all', {
message: '--workspace all is not valid for Linear writes'
}),
writeId: OptionalString,
context: LinearCurrentContext
})
function parseLinearWriteId(writeId: string | undefined): string | undefined {
if (writeId === undefined) {
return undefined
}
if (!isLinearUuid(writeId)) {
throw linearError('linear_invalid_write_id', '--write-id must be a UUID')
}
return writeId
}
export const LINEAR_AGENT_ACCESS_METHODS: RpcMethod[] = [
defineMethod({
name: 'linear.agentSearchIssues',
params: AgentSearchIssues,
handler: async (params, { runtime }) =>
runtime.linearSearchForAgents({
query: params.query,
limit: params.limit,
workspaceId: params.workspaceId
})
}),
defineMethod({
name: 'linear.issueContext',
params: AgentIssueContext,
handler: async (params, { runtime }) => runtime.linearIssueContext(params)
}),
defineMethod({
name: 'linear.agentTeamList',
params: LinearWorkspaceRead,
handler: async (params, { runtime }) => runtime.linearTeamListForAgents(params)
}),
defineMethod({
name: 'linear.agentTeamMembers',
params: LinearTeamLookup,
handler: async (params, { runtime }) => runtime.linearTeamMembersForAgents(params)
}),
defineMethod({
name: 'linear.agentTeamStates',
params: LinearTeamLookup,
handler: async (params, { runtime }) => runtime.linearTeamStatesForAgents(params)
}),
defineMethod({
name: 'linear.agentTeamLabels',
params: LinearTeamLookup,
handler: async (params, { runtime }) => runtime.linearTeamLabelsForAgents(params)
}),
defineMethod({
name: 'linear.agentIssueList',
params: LinearIssueList,
handler: async (params, { runtime }) => runtime.linearIssueListForAgents(params)
}),
defineMethod({
name: 'linear.agentProjectList',
params: LinearProjectList,
handler: async (params, { runtime }) => runtime.linearProjectListForAgents(params)
}),
defineMethod({
name: 'linear.resolveCurrentIssue',
params: LinearCurrentContext,
handler: async (params, { runtime }) => runtime.linearResolveCurrentIssue(params)
}),
defineMethod({
name: 'linear.issueSetState',
params: LinearIssueSetState,
handler: async (params, { runtime }) => runtime.linearIssueSetState(params)
}),
defineMethod({
name: 'linear.issueUpdateTask',
params: LinearIssueUpdateTask,
handler: async (params, { runtime }) => runtime.linearIssueUpdateTask(params)
}),
defineMethod({
name: 'linear.issueAddComment',
params: LinearIssueAddComment,
handler: async (params, { runtime }) =>
runtime.linearIssueAddComment({ ...params, writeId: parseLinearWriteId(params.writeId) })
}),
defineMethod({
name: 'linear.issueAttachLink',
params: LinearIssueAttachLink,
handler: async (params, { runtime }) =>
runtime.linearIssueAttachLink({ ...params, writeId: parseLinearWriteId(params.writeId) })
}),
defineMethod({
name: 'linear.issueCreate',
params: LinearIssueCreate,
handler: async (params, { runtime }) =>
runtime.linearIssueCreate({ ...params, writeId: parseLinearWriteId(params.writeId) })
})
]
@@ -0,0 +1,246 @@
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from '../../orca-runtime'
import type * as LinearIssuesModule from '../../../linear/issues'
type LinearProjectResolverTester = {
resolveLinearCreateProject(
input: string,
team: { id: string; workspaceId: string }
): Promise<{
id: string
name: string
}>
readLinearProjectByIdForCreate(id: string, workspaceId: string): Promise<unknown | null>
readLinearProjectsForCreate(query: string, workspaceId: string): Promise<unknown[]>
readLinearProjectsByExactNameForCreate(name: string, workspaceId: string): Promise<unknown[]>
}
type LinearCreateTester = LinearProjectResolverTester & {
resolveLinearCreateTeam(
teamInput: string | undefined,
workspaceId: string | undefined,
parent: unknown
): Promise<{ id: string; key: string; name: string; workspaceId: string }>
}
describe('Linear agent project access helpers', () => {
it('resolves Linear projects by UUID before searching names', async () => {
const runtime = new OrcaRuntimeService()
const tester = runtime as unknown as LinearProjectResolverTester
const readById = vi.spyOn(tester, 'readLinearProjectByIdForCreate').mockResolvedValue({
id: '11111111-1111-4111-8111-111111111111',
name: 'Launch',
teams: [{ id: 'team-1', name: 'Engineering', key: 'ENG' }]
} as never)
const readByName = vi
.spyOn(tester, 'readLinearProjectsForCreate')
.mockResolvedValue([] as never)
await expect(
tester.resolveLinearCreateProject('11111111-1111-4111-8111-111111111111', {
id: 'team-1',
workspaceId: 'workspace-1'
})
).resolves.toMatchObject({ id: '11111111-1111-4111-8111-111111111111' })
expect(readById).toHaveBeenCalledWith('11111111-1111-4111-8111-111111111111', 'workspace-1')
expect(readByName).not.toHaveBeenCalled()
})
it('resolves Linear projects by trimmed case-insensitive exact name', async () => {
const runtime = new OrcaRuntimeService()
const tester = runtime as unknown as LinearProjectResolverTester
vi.spyOn(tester, 'readLinearProjectByIdForCreate').mockResolvedValue(null as never)
const readByName = vi.spyOn(tester, 'readLinearProjectsForCreate').mockResolvedValue([
{
id: 'project-1',
name: 'Launch',
teams: [{ id: 'team-1', name: 'Engineering', key: 'ENG' }]
}
] as never)
const readExactName = vi
.spyOn(tester, 'readLinearProjectsByExactNameForCreate')
.mockResolvedValue([
{
id: 'project-1',
name: 'Launch',
teams: [{ id: 'team-1', name: 'Engineering', key: 'ENG' }]
}
] as never)
await expect(
tester.resolveLinearCreateProject(' launch ', { id: 'team-1', workspaceId: 'workspace-1' })
).resolves.toMatchObject({ id: 'project-1' })
expect(readByName).toHaveBeenCalledWith('launch', 'workspace-1')
expect(readExactName).toHaveBeenCalledWith('launch', 'workspace-1')
})
it('resolves same-named Linear projects by target team compatibility', async () => {
const runtime = new OrcaRuntimeService()
const tester = runtime as unknown as LinearProjectResolverTester
vi.spyOn(tester, 'readLinearProjectByIdForCreate').mockResolvedValue(null as never)
vi.spyOn(tester, 'readLinearProjectsForCreate').mockResolvedValue([] as never)
vi.spyOn(tester, 'readLinearProjectsByExactNameForCreate').mockResolvedValue([
{
id: 'project-1',
name: 'Launch',
teams: [{ id: 'team-other', name: 'Other', key: 'OTH' }]
},
{
id: 'project-2',
name: 'launch',
teams: [{ id: 'team-1', name: 'Engineering', key: 'ENG' }]
}
] as never)
await expect(
tester.resolveLinearCreateProject('Launch', { id: 'team-1', workspaceId: 'workspace-1' })
).resolves.toMatchObject({ id: 'project-2' })
})
it('rejects ambiguous Linear project names with candidate ids', async () => {
const runtime = new OrcaRuntimeService()
const tester = runtime as unknown as LinearProjectResolverTester
vi.spyOn(tester, 'readLinearProjectByIdForCreate').mockResolvedValue(null as never)
vi.spyOn(tester, 'readLinearProjectsForCreate').mockResolvedValue([] as never)
vi.spyOn(tester, 'readLinearProjectsByExactNameForCreate').mockResolvedValue([
{
id: 'project-1',
name: 'Launch',
teams: [{ id: 'team-1', name: 'Engineering', key: 'ENG' }]
},
{
id: 'project-2',
name: 'launch',
teams: [{ id: 'team-1', name: 'Engineering', key: 'ENG' }]
}
] as never)
await expect(
tester.resolveLinearCreateProject('Launch', { id: 'team-1', workspaceId: 'workspace-1' })
).rejects.toMatchObject({
code: 'linear_invalid_project',
data: {
projects: [
{ id: 'project-1', name: 'Launch' },
{ id: 'project-2', name: 'launch' }
]
}
})
})
it('fails closed when Linear project team membership cannot be verified', async () => {
const runtime = new OrcaRuntimeService()
const tester = runtime as unknown as LinearProjectResolverTester
vi.spyOn(tester, 'readLinearProjectByIdForCreate').mockResolvedValue(null as never)
vi.spyOn(tester, 'readLinearProjectsForCreate').mockResolvedValue([] as never)
vi.spyOn(tester, 'readLinearProjectsByExactNameForCreate').mockResolvedValue([
{
id: 'project-1',
name: 'Launch'
}
] as never)
await expect(
tester.resolveLinearCreateProject('Launch', { id: 'team-1', workspaceId: 'workspace-1' })
).rejects.toMatchObject({
code: 'linear_invalid_project',
data: { project: { id: 'project-1', name: 'Launch', teams: [] } }
})
})
it('caps agent project lists globally and returns a narrow project DTO', async () => {
const runtime = new OrcaRuntimeService()
vi.spyOn(runtime, 'linearListProjects').mockResolvedValue({
items: [
{
id: 'project-1',
name: 'Launch',
url: 'https://linear.app/acme/project/launch',
workspaceId: 'workspace-1',
workspaceName: 'Acme',
content: 'internal notes',
description: 'roadmap',
teams: [{ id: 'team-1', name: 'Engineering', key: 'ENG' }]
},
{
id: 'project-2',
name: 'Follow-up',
workspaceId: 'workspace-2',
workspaceName: 'Beta'
}
],
hasMore: false
} as never)
const result = await runtime.linearProjectListForAgents({ limit: 1, workspaceId: 'all' })
expect(result.projects).toHaveLength(1)
expect(result.projects[0]).toMatchObject({
id: 'project-1',
name: 'Launch',
url: 'https://linear.app/acme/project/launch',
workspaceId: 'workspace-1',
workspaceName: 'Acme',
teams: [{ id: 'team-1', name: 'Engineering', key: 'ENG' }]
})
expect(result.projects[0]).not.toHaveProperty('content')
expect(result.projects[0]).not.toHaveProperty('description')
expect(result.meta).toMatchObject({ limit: 1, returned: 1, hasMore: true })
})
it('passes the resolved project id into agent issue create', async () => {
vi.resetModules()
const createIssueForAgent = vi.fn().mockResolvedValue({
id: 'issue-created',
identifier: 'ENG-123',
title: 'Follow up',
url: 'https://linear.app/acme/issue/ENG-123',
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
state: null,
parent: null,
project: { id: 'project-1', name: 'Launch' }
})
vi.doMock('../../../linear/issues', async (importOriginal) => {
const actual = await importOriginal<typeof LinearIssuesModule>()
return { ...actual, createIssueForAgent }
})
try {
const { OrcaRuntimeService: RuntimeService } = await import('../../orca-runtime')
const runtime = new RuntimeService()
const tester = runtime as unknown as LinearCreateTester
vi.spyOn(tester, 'resolveLinearCreateTeam').mockResolvedValue({
id: 'team-1',
key: 'ENG',
name: 'Engineering',
workspaceId: 'workspace-1'
})
vi.spyOn(tester, 'resolveLinearCreateProject').mockResolvedValue({
id: 'project-1',
name: 'Launch'
} as never)
const result = await runtime.linearIssueCreate({
title: 'Follow up',
teamInput: 'ENG',
projectInput: 'Launch',
writeId: '33333333-3333-4333-8333-333333333333'
})
expect(result.issue.project).toMatchObject({ id: 'project-1', name: 'Launch' })
expect(createIssueForAgent).toHaveBeenCalledWith(
'team-1',
'Follow up',
undefined,
'workspace-1',
expect.objectContaining({
id: '33333333-3333-4333-8333-333333333333',
parentId: null,
projectId: 'project-1'
})
)
} finally {
vi.doUnmock('../../../linear/issues')
vi.resetModules()
}
})
})
@@ -1,11 +1,16 @@
/* eslint-disable max-lines -- Why: orchestration tests share a mock runtime factory; splitting by method would duplicate 40 lines of setup per file without improving clarity. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ORCHESTRATION_METHODS } from './orchestration'
import { buildRegistry, type RpcContext } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { buildRegistry, type RpcContext, type RpcRequest } from '../core'
import { OrchestrationDb } from '../../orchestration/db'
import { OrcaRuntimeService } from '../../orca-runtime'
import type { RuntimeTerminalSummary } from '../../../../shared/runtime-types'
function lifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string {
return `${type} messages must be sent to a concrete coordinator terminal handle, not a group address.`
}
describe('orchestration RPC methods', () => {
let db: OrchestrationDb
let dbOpen = false
@@ -45,6 +50,10 @@ describe('orchestration RPC methods', () => {
return method.handler(parsed, ctx)
}
function makeRequest(method: string, params: Record<string, unknown>): RpcRequest {
return { id: 'req_1', authToken: 'token', method, params }
}
it('registers all expected methods', () => {
const registry = buildRegistry(ORCHESTRATION_METHODS)
expect(registry.size).toBe(16)
@@ -97,12 +106,77 @@ describe('orchestration RPC methods', () => {
expect(() => method.params!.parse({ to: 'b', subject: 'hi', priority: 'medium' })).toThrow()
})
it.each(['@all', '@idle', '@worktree:wt_1', '@codex', '@nobody'])(
'rejects worker_done to group recipient %s without inserting rows',
async (to) => {
setup()
const listTerminals = vi.spyOn(runtime, 'listTerminals')
await expect(
call('orchestration.send', {
from: 'term_worker',
to,
subject: 'done',
type: 'worker_done'
})
).rejects.toThrow(lifecycleGroupRecipientError('worker_done'))
expect(db.getInbox(100)).toHaveLength(0)
expect(listTerminals).not.toHaveBeenCalled()
}
)
it('rejects worker_done groups before terminal listing failures can win', async () => {
setup()
const listTerminals = vi
.spyOn(runtime, 'listTerminals')
.mockRejectedValue(new Error('terminal listing failed'))
await expect(
call('orchestration.send', {
from: 'term_worker',
to: '@all',
subject: 'done',
type: 'worker_done'
})
).rejects.toThrow(lifecycleGroupRecipientError('worker_done'))
expect(listTerminals).not.toHaveBeenCalled()
expect(db.getInbox(100)).toHaveLength(0)
})
it('returns invalid_argument for worker_done group sends through the dispatcher', async () => {
setup()
const dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS })
const listTerminals = vi.spyOn(runtime, 'listTerminals')
const response = await dispatcher.dispatch(
makeRequest('orchestration.send', {
from: 'term_worker',
to: '@all',
subject: 'done',
type: 'worker_done'
})
)
expect(response).toMatchObject({
ok: false,
error: {
code: 'invalid_argument',
message: lifecycleGroupRecipientError('worker_done')
}
})
expect(listTerminals).not.toHaveBeenCalled()
expect(db.getInbox(100)).toHaveLength(0)
})
function makeSummary(
handle: string,
opts: Partial<RuntimeTerminalSummary> = {}
): RuntimeTerminalSummary {
return {
handle,
ptyId: opts.ptyId ?? handle,
worktreeId: opts.worktreeId ?? 'wt_default',
worktreePath: opts.worktreePath ?? '/tmp/wt',
branch: opts.branch ?? 'main',
@@ -146,6 +220,55 @@ describe('orchestration RPC methods', () => {
expect(recipients).toEqual(['term_b', 'term_c'])
})
it('continues to fan out status messages to groups', async () => {
setupWithTerminals([makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')])
const result = (await call('orchestration.send', {
from: 'term_a',
to: '@all',
subject: 'status broadcast',
type: 'status'
})) as { messages: { to_handle: string; type: string }[]; recipients: number }
expect(result.recipients).toBe(2)
expect(result.messages.map((m) => m.to_handle).sort()).toEqual(['term_b', 'term_c'])
expect(result.messages.every((m) => m.type === 'status')).toBe(true)
})
it('rejects heartbeat group sends before inserting rows', async () => {
setup()
const listTerminals = vi.spyOn(runtime, 'listTerminals')
await expect(
call('orchestration.send', {
from: 'term_worker',
to: '@all',
subject: 'alive',
type: 'heartbeat',
payload: JSON.stringify({ taskId: 'task_1', dispatchId: 'ctx_1' })
})
).rejects.toThrow(lifecycleGroupRecipientError('heartbeat'))
expect(listTerminals).not.toHaveBeenCalled()
expect(db.getInbox(100)).toHaveLength(0)
})
it('continues to send worker_done to a concrete terminal handle', async () => {
setup()
const result = (await call('orchestration.send', {
from: 'term_worker',
to: 'term_coord',
subject: 'done',
type: 'worker_done',
payload: JSON.stringify({ taskId: 'task_1', dispatchId: 'ctx_1' })
})) as { message: { to_handle: string; type: string; payload: string | null } }
expect(result.message.to_handle).toBe('term_coord')
expect(result.message.type).toBe('worker_done')
expect(result.message.payload).toBe(JSON.stringify({ taskId: 'task_1', dispatchId: 'ctx_1' }))
})
it('fans out @idle to only idle agents', async () => {
setupWithTerminals([makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')], {
term_b: 'idle',
+42 -22
View File
@@ -28,28 +28,48 @@ const TASK_STATUSES: TaskStatus[] = [
'blocked'
]
const SendParams = z.object({
to: requiredString('Missing --to'),
subject: requiredString('Missing --subject'),
from: OptionalString,
body: OptionalString,
type: z
.enum([
'status',
'dispatch',
'worker_done',
'merge_ready',
'escalation',
'handoff',
'decision_gate',
'heartbeat'
])
.optional(),
priority: z.enum(['normal', 'high', 'urgent']).optional(),
threadId: OptionalString,
payload: OptionalString,
devMode: OptionalBoolean
})
function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string {
return `${type} messages must be sent to a concrete coordinator terminal handle, not a group address.`
}
const SendParams = z
.object({
to: requiredString('Missing --to'),
subject: requiredString('Missing --subject'),
from: OptionalString,
body: OptionalString,
type: z
.enum([
'status',
'dispatch',
'worker_done',
'merge_ready',
'escalation',
'handoff',
'decision_gate',
'heartbeat'
])
.optional(),
priority: z.enum(['normal', 'high', 'urgent']).optional(),
threadId: OptionalString,
payload: OptionalString,
devMode: OptionalBoolean
})
.superRefine((params, ctx) => {
if (
(params.type !== 'worker_done' && params.type !== 'heartbeat') ||
!isGroupAddress(params.to)
) {
return
}
// Why: dispatch lifecycle messages are authority/liveness signals for one
// coordinator. Fanout creates lifecycle mail in unrelated terminals.
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: getLifecycleGroupRecipientError(params.type),
path: ['to']
})
})
const CheckParams = z.object({
terminal: OptionalString,
@@ -0,0 +1,122 @@
import { z } from 'zod'
import { normalizeExecutionHostId } from '../../../../shared/execution-host'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalString, requiredString } from '../schemas'
const ProjectHostSetupExistingFolder = z.object({
projectId: requiredString('Missing project ID'),
hostId: requiredString('Missing host ID').transform((value, ctx) => {
const hostId = normalizeExecutionHostId(value)
if (!hostId) {
ctx.addIssue({ code: 'custom', message: 'Invalid host ID' })
return z.NEVER
}
return hostId
}),
path: requiredString('Missing project path'),
kind: z.enum(['git', 'folder']).optional(),
displayName: OptionalString,
setupMethod: z.enum(['imported-existing-folder', 'cloned']).optional()
})
const ProjectHostSetupClone = z.object({
projectId: requiredString('Missing project ID'),
hostId: requiredString('Missing host ID').transform((value, ctx) => {
const hostId = normalizeExecutionHostId(value)
if (!hostId) {
ctx.addIssue({ code: 'custom', message: 'Invalid host ID' })
return z.NEVER
}
return hostId
}),
url: requiredString('Missing clone URL'),
destination: requiredString('Missing clone destination'),
displayName: OptionalString
})
const ProjectHostSetupCreate = z.object({
projectId: requiredString('Missing project ID'),
hostId: requiredString('Missing host ID').transform((value, ctx) => {
const hostId = normalizeExecutionHostId(value)
if (!hostId) {
ctx.addIssue({ code: 'custom', message: 'Invalid host ID' })
return z.NEVER
}
return hostId
}),
setupId: OptionalString,
path: OptionalString,
kind: z.enum(['git', 'folder']).optional(),
displayName: OptionalString,
worktreeBasePath: OptionalString,
gitUsername: OptionalString,
setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(),
setupMethod: z.enum(['imported-existing-folder', 'cloned', 'provisioned']).optional()
})
const ProjectHostSetupUpdate = z.object({
setupId: requiredString('Missing setup ID'),
updates: z.object({
displayName: OptionalString,
path: OptionalString,
worktreeBasePath: OptionalString,
setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(),
setupMethod: z
.enum(['legacy-repo', 'imported-existing-folder', 'cloned', 'provisioned'])
.optional(),
gitUsername: OptionalString,
kind: z.enum(['git', 'folder']).optional()
})
})
const ProjectHostSetupDelete = z.object({
setupId: requiredString('Missing setup ID')
})
export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [
defineMethod({
name: 'project.list',
params: null,
handler: (_params, { runtime }) => ({ projects: runtime.listProjects() })
}),
defineMethod({
name: 'projectHostSetup.list',
params: null,
handler: (_params, { runtime }) => ({ setups: runtime.listProjectHostSetups() })
}),
defineMethod({
name: 'projectHostSetup.create',
params: ProjectHostSetupCreate,
handler: (params, { runtime }) => ({
result: runtime.createProjectHostSetup(params)
})
}),
defineMethod({
name: 'projectHostSetup.setupExistingFolder',
params: ProjectHostSetupExistingFolder,
handler: async (params, { runtime }) => ({
result: await runtime.setupProjectExistingFolder(params)
})
}),
defineMethod({
name: 'projectHostSetup.clone',
params: ProjectHostSetupClone,
handler: async (params, { runtime }) => ({
result: await runtime.setupProjectClone(params)
})
}),
defineMethod({
name: 'projectHostSetup.update',
params: ProjectHostSetupUpdate,
handler: (params, { runtime }) => ({
result: runtime.updateProjectHostSetup(params)
})
}),
defineMethod({
name: 'projectHostSetup.delete',
params: ProjectHostSetupDelete,
handler: (params, { runtime }) => ({
result: runtime.deleteProjectHostSetup(params)
})
})
]
+111 -1
View File
@@ -33,6 +33,22 @@ describe('repo RPC methods', () => {
})
})
it('reports runtime Git availability without exposing command details', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
isGitAvailable: vi.fn().mockResolvedValue(true)
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const response = await dispatcher.dispatch(makeRequest('repo.gitAvailable'))
expect(runtime.isGitAvailable).toHaveBeenCalled()
expect(response).toMatchObject({
ok: true,
result: { available: true }
})
})
it('clones a repo on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
@@ -226,6 +242,33 @@ describe('repo RPC methods', () => {
})
})
it('persists fork sync mode updates', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
updateRepo: vi.fn().mockResolvedValue({
id: 'repo-1',
path: '/srv/repo',
forkSyncMode: 'safe-auto'
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const response = await dispatcher.dispatch(
makeRequest('repo.update', {
repo: 'repo-1',
updates: { forkSyncMode: 'safe-auto' }
})
)
expect(runtime.updateRepo).toHaveBeenCalledWith('repo-1', {
forkSyncMode: 'safe-auto'
})
expect(response).toMatchObject({
ok: true,
result: { repo: { id: 'repo-1', forkSyncMode: 'safe-auto' } }
})
})
it('persists resolved GitHub upstream metadata updates', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
@@ -271,7 +314,29 @@ describe('repo RPC methods', () => {
createProjectGroup: vi.fn().mockResolvedValue(group),
updateProjectGroup: vi.fn().mockResolvedValue({ ...group, name: 'Core' }),
deleteProjectGroup: vi.fn().mockResolvedValue({ deleted: true }),
moveProjectToGroup: vi.fn().mockResolvedValue({ id: 'repo-1', projectGroupId: group.id })
moveProjectToGroup: vi.fn().mockResolvedValue({ id: 'repo-1', projectGroupId: group.id }),
listFolderWorkspaces: vi.fn().mockReturnValue([
{
id: 'folder-workspace-1',
projectGroupId: group.id,
name: 'Refund fix',
folderPath: '/srv/platform',
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 0,
createdAt: 1,
updatedAt: 1
}
]),
createFolderWorkspace: vi.fn().mockResolvedValue({ id: 'folder-workspace-2' }),
updateFolderWorkspace: vi.fn().mockResolvedValue({ id: 'folder-workspace-1', comment: 'x' }),
deleteFolderWorkspace: vi.fn().mockResolvedValue({ deleted: true }),
getFolderWorkspacePathStatus: vi
.fn()
.mockResolvedValue({ path: '/srv/platform', exists: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
@@ -297,6 +362,28 @@ describe('repo RPC methods', () => {
order: 2
})
)
const folderListResponse = await dispatcher.dispatch(makeRequest('folderWorkspace.list'))
await dispatcher.dispatch(
makeRequest('folderWorkspace.create', {
projectGroupId: group.id,
name: 'Refund fix'
})
)
await dispatcher.dispatch(
makeRequest('folderWorkspace.update', {
folderWorkspaceId: 'folder-workspace-1',
updates: { comment: 'x' }
})
)
await dispatcher.dispatch(
makeRequest('folderWorkspace.delete', { folderWorkspaceId: 'folder-workspace-1' })
)
const statusResponse = await dispatcher.dispatch(
makeRequest('folderWorkspace.getPathStatus', {
scope: 'folder-workspace',
folderWorkspaceId: 'folder-workspace-1'
})
)
expect(runtime.listProjectGroups).toHaveBeenCalled()
expect(runtime.createProjectGroup).toHaveBeenCalledWith({
@@ -310,10 +397,33 @@ describe('repo RPC methods', () => {
})
expect(runtime.deleteProjectGroup).toHaveBeenCalledWith(group.id)
expect(runtime.moveProjectToGroup).toHaveBeenCalledWith('repo-1', group.id, 2)
expect(runtime.listFolderWorkspaces).toHaveBeenCalled()
expect(runtime.createFolderWorkspace).toHaveBeenCalledWith({
projectGroupId: group.id,
name: 'Refund fix'
})
expect(runtime.updateFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1', {
comment: 'x'
})
expect(runtime.deleteFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1')
expect(runtime.getFolderWorkspacePathStatus).toHaveBeenCalledWith({
scope: 'folder-workspace',
folderWorkspaceId: 'folder-workspace-1'
})
expect(moveResponse).toMatchObject({
ok: true,
result: { repo: { id: 'repo-1', projectGroupId: group.id } }
})
expect(folderListResponse).toMatchObject({
ok: true,
result: {
folderWorkspaces: [expect.objectContaining({ id: 'folder-workspace-1' })]
}
})
expect(statusResponse).toMatchObject({
ok: true,
result: { status: { path: '/srv/platform', exists: true } }
})
})
it('allows separate nested-repo imports without a group name', async () => {
+11
View File
@@ -4,6 +4,8 @@ import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods'
import { FOLDER_WORKSPACE_METHODS } from './folder-workspace'
const RepoSelector = z.object({
repo: requiredString('Missing repo selector')
@@ -71,6 +73,7 @@ const RepoUpdate = RepoSelector.extend({
kind: z.enum(['git', 'folder']).optional(),
symlinkPaths: z.array(z.string()).optional(),
issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(),
forkSyncMode: z.enum(['ask', 'safe-auto', 'off']).optional(),
externalWorktreeVisibility: z.enum(['hide', 'show']).optional(),
externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(),
projectGroupId: OptionalString.nullable().optional(),
@@ -95,6 +98,7 @@ const RepoReorder = z.object({
const ProjectGroupCreate = z.object({
name: requiredString('Missing group name'),
parentPath: OptionalString,
connectionId: OptionalString.nullable().optional(),
parentGroupId: OptionalString.nullable().optional(),
createdFrom: z.enum(['manual', 'folder-scan', 'migration']).optional()
})
@@ -156,6 +160,7 @@ export const REPO_METHODS: RpcMethod[] = [
params: null,
handler: (_params, { runtime }) => ({ repos: runtime.listRepos() })
}),
...PROJECT_RUNTIME_METHODS,
defineMethod({
name: 'projectGroup.list',
params: null,
@@ -187,6 +192,7 @@ export const REPO_METHODS: RpcMethod[] = [
repo: await runtime.moveProjectToGroup(params.repo, params.groupId ?? null, params.order)
})
}),
...FOLDER_WORKSPACE_METHODS,
defineMethod({
name: 'projectGroup.scanNested',
params: ProjectGroupScanNested,
@@ -228,6 +234,11 @@ export const REPO_METHODS: RpcMethod[] = [
handler: async (params, { runtime }) =>
runtime.createRepo(params.parentPath, params.name, params.kind)
}),
defineMethod({
name: 'repo.gitAvailable',
params: null,
handler: async (_params, { runtime }) => ({ available: await runtime.isGitAvailable() })
}),
defineMethod({
name: 'repo.clone',
params: RepoClone,
@@ -232,7 +232,7 @@ describe('session tab RPC methods', () => {
})
expect(runtime.registerSubscriptionCleanup).toHaveBeenCalledWith(
'session.tabs:conn-1:*',
'session.tabs:conn-1:*:req-1',
expect.any(Function),
'conn-1'
)
@@ -249,6 +249,113 @@ describe('session tab RPC methods', () => {
])
})
it('keeps duplicate all-session-tab subscribers independent on one connection', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
listAllMobileSessionTabs: vi.fn(() => []),
onMobileSessionTabsChanged: vi.fn(() => vi.fn()),
registerSubscriptionCleanup: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
await dispatcher.dispatchStreaming(
{ ...makeRequest('session.tabs.subscribeAll'), id: 'sub-all-1' },
vi.fn(),
{ connectionId: 'conn-1' }
)
await dispatcher.dispatchStreaming(
{ ...makeRequest('session.tabs.subscribeAll'), id: 'sub-all-2' },
vi.fn(),
{ connectionId: 'conn-1' }
)
expect(runtime.registerSubscriptionCleanup).toHaveBeenCalledWith(
'session.tabs:conn-1:*:sub-all-1',
expect.any(Function),
'conn-1'
)
expect(runtime.registerSubscriptionCleanup).toHaveBeenCalledWith(
'session.tabs:conn-1:*:sub-all-2',
expect.any(Function),
'conn-1'
)
})
it('registers session tab subscription cleanup with the resolved worktree id', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
listMobileSessionTabs: vi.fn().mockResolvedValue({
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}),
onMobileSessionTabsChanged: vi.fn(() => vi.fn()),
registerSubscriptionCleanup: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
await dispatcher.dispatchStreaming(
makeRequest('session.tabs.subscribe', { worktree: 'id:wt-1' }),
vi.fn(),
{ connectionId: 'conn-1' }
)
expect(runtime.registerSubscriptionCleanup).toHaveBeenCalledWith(
'session.tabs:conn-1:wt-1:req-1',
expect.any(Function),
'conn-1'
)
expect(runtime.registerSubscriptionCleanup).not.toHaveBeenCalledWith(
'session.tabs:conn-1:id:wt-1',
expect.any(Function),
'conn-1'
)
})
it('keeps duplicate session tab subscribers for one worktree independent', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
listMobileSessionTabs: vi.fn().mockResolvedValue({
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}),
onMobileSessionTabsChanged: vi.fn(() => vi.fn()),
registerSubscriptionCleanup: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
await dispatcher.dispatchStreaming(
{ ...makeRequest('session.tabs.subscribe', { worktree: 'id:wt-1' }), id: 'sub-1' },
vi.fn(),
{ connectionId: 'conn-1' }
)
await dispatcher.dispatchStreaming(
{ ...makeRequest('session.tabs.subscribe', { worktree: 'wt-1' }), id: 'sub-2' },
vi.fn(),
{ connectionId: 'conn-1' }
)
expect(runtime.registerSubscriptionCleanup).toHaveBeenCalledWith(
'session.tabs:conn-1:wt-1:sub-1',
expect.any(Function),
'conn-1'
)
expect(runtime.registerSubscriptionCleanup).toHaveBeenCalledWith(
'session.tabs:conn-1:wt-1:sub-2',
expect.any(Function),
'conn-1'
)
})
it('unsubscribes a session tabs stream using the resolved worktree id and connection id', async () => {
const cleanupSubscription = vi.fn()
const runtime = {
@@ -262,7 +369,8 @@ describe('session tab RPC methods', () => {
activeTabType: null,
tabs: []
}),
cleanupSubscription
cleanupSubscription,
cleanupSubscriptionsByPrefix: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const messages: string[] = []
@@ -279,4 +387,53 @@ describe('session tab RPC methods', () => {
result: { unsubscribed: true }
})
})
it('unsubscribes one shared-control session tab stream by subscription id', async () => {
const cleanupSubscription = vi.fn()
const cleanupSubscriptionsByPrefix = vi.fn()
const runtime = {
getRuntimeId: () => 'test-runtime',
listMobileSessionTabs: vi.fn().mockResolvedValue({
worktree: 'wt-1',
publicationEpoch: 'test',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}),
cleanupSubscription,
cleanupSubscriptionsByPrefix
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
await dispatcher.dispatchStreaming(
makeRequest('session.tabs.unsubscribe', { worktree: 'id:wt-1', subscriptionId: 'sub-1' }),
vi.fn(),
{ connectionId: 'conn-1' }
)
expect(cleanupSubscription).toHaveBeenCalledWith('session.tabs:conn-1:wt-1:sub-1')
expect(cleanupSubscriptionsByPrefix).not.toHaveBeenCalled()
})
it('unsubscribes one shared-control all-session-tabs stream by subscription id', async () => {
const cleanupSubscription = vi.fn()
const cleanupSubscriptionsByPrefix = vi.fn()
const runtime = {
getRuntimeId: () => 'test-runtime',
cleanupSubscription,
cleanupSubscriptionsByPrefix
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
await dispatcher.dispatchStreaming(
makeRequest('session.tabs.unsubscribeAll', { subscriptionId: 'sub-all-1' }),
vi.fn(),
{ connectionId: 'conn-1' }
)
expect(cleanupSubscription).toHaveBeenCalledWith('session.tabs:conn-1:*:sub-all-1')
expect(cleanupSubscriptionsByPrefix).not.toHaveBeenCalled()
})
})
+54 -16
View File
@@ -10,6 +10,10 @@ const WorktreeTabSelector = z.object({
.pipe(z.string().min(1, 'Missing worktree selector'))
})
const SessionTabsUnsubscribe = WorktreeTabSelector.extend({
subscriptionId: z.string().min(1).optional()
})
const ActivateTab = WorktreeTabSelector.extend({
tabId: z
.unknown()
@@ -143,14 +147,20 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
defineStreamingMethod({
name: 'session.tabs.subscribe',
params: WorktreeTabSelector,
handler: async (params, { runtime, connectionId }, emit) => {
handler: async (params, { runtime, connectionId, requestId }, emit) => {
let subscribedWorktree: string | null = null
let unsubscribe = (): void => {}
let closed = false
// Why: initial list errors should return one RPC error, not a leaked
// subscription cleanup that later emits a stray end frame.
let initialized = false
const subscriptionId = `session.tabs:${connectionId ?? 'local'}:${params.worktree}`
const initial = await runtime.listMobileSessionTabs(params.worktree)
if (closed) {
return
}
subscribedWorktree = initial.worktree
const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:${subscribedWorktree}`
const subscriptionId = requestId ? `${cleanupPrefix}:${requestId}` : cleanupPrefix
// Why: shared-control can carry multiple subscribers for one worktree on
// one socket; include the RPC id so one subscriber cannot evict another.
runtime.registerSubscriptionCleanup(
subscriptionId,
() => {
@@ -162,46 +172,56 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
},
connectionId
)
const initial = await Promise.resolve(runtime.listMobileSessionTabs(params.worktree)).catch(
(error) => {
runtime.cleanupSubscription(subscriptionId)
throw error
}
)
if (closed) {
return
}
subscribedWorktree = initial.worktree
emit({ type: 'snapshot', ...initial })
initialized = true
if (closed) {
return
}
unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => {
if (snapshot.worktree === subscribedWorktree) {
emit({ type: 'updated', ...snapshot })
}
})
if (closed) {
unsubscribe()
}
}
}),
defineMethod({
name: 'session.tabs.unsubscribe',
params: WorktreeTabSelector,
params: SessionTabsUnsubscribe,
handler: async (params, { runtime, connectionId }) => {
const snapshot = await runtime.listMobileSessionTabs(params.worktree)
runtime.cleanupSubscription(`session.tabs:${connectionId ?? 'local'}:${params.worktree}`)
runtime.cleanupSubscription(`session.tabs:${connectionId ?? 'local'}:${snapshot.worktree}`)
const connection = connectionId ?? 'local'
if (params.subscriptionId) {
runtime.cleanupSubscription(
`session.tabs:${connection}:${snapshot.worktree}:${params.subscriptionId}`
)
return { unsubscribed: true }
}
runtime.cleanupSubscription(`session.tabs:${connection}:${params.worktree}`)
runtime.cleanupSubscription(`session.tabs:${connection}:${snapshot.worktree}`)
runtime.cleanupSubscriptionsByPrefix(`session.tabs:${connection}:${snapshot.worktree}:`)
return { unsubscribed: true }
}
}),
defineStreamingMethod({
name: 'session.tabs.subscribeAll',
params: null,
handler: async (_params, { runtime, connectionId }, emit) => {
handler: async (_params, { runtime, connectionId, requestId }, emit) => {
let unsubscribe = (): void => {}
let closed = false
// Why: initial listAll errors should return one RPC error, not a leaked
// subscription cleanup that later emits a stray end frame.
let initialized = false
const subscriptionId = `session.tabs:${connectionId ?? 'local'}:*`
const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*`
const subscriptionId = requestId ? `${cleanupPrefix}:${requestId}` : cleanupPrefix
// Why: shared-control can carry multiple all-tab subscribers on one
// socket; include the RPC id so closing one does not evict siblings.
runtime.registerSubscriptionCleanup(
subscriptionId,
() => {
@@ -235,6 +255,24 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
})
}
}),
defineMethod({
name: 'session.tabs.unsubscribeAll',
params: z
.object({
subscriptionId: z.string().min(1).optional()
})
.nullish(),
handler: async (params, { runtime, connectionId }) => {
const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*`
if (params?.subscriptionId) {
runtime.cleanupSubscription(`${cleanupPrefix}:${params.subscriptionId}`)
return { unsubscribed: true }
}
runtime.cleanupSubscription(cleanupPrefix)
runtime.cleanupSubscriptionsByPrefix(`${cleanupPrefix}:`)
return { unsubscribed: true }
}
}),
defineMethod({
name: 'markdown.readTab',
params: ActivateTab,
@@ -71,4 +71,51 @@ describe('speech RPC methods', () => {
expect(response).toMatchObject({ ok: false })
expect(runtime.feedMobileDictation).not.toHaveBeenCalled()
})
it('lists speech models', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
listMobileSpeechModels: vi
.fn()
.mockResolvedValue({ enabled: false, selectedModelId: '', models: [] })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SPEECH_METHODS })
const response = await dispatcher.dispatch(makeRequest('speech.models.list', null))
expect(runtime.listMobileSpeechModels).toHaveBeenCalled()
expect(response).toMatchObject({ ok: true, result: { enabled: false, models: [] } })
})
it('starts a model download', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
downloadMobileSpeechModel: vi.fn().mockResolvedValue({ started: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SPEECH_METHODS })
const response = await dispatcher.dispatch(
makeRequest('speech.models.download', { modelId: 'parakeet-tdt-0.6b-v3-int8' })
)
expect(runtime.downloadMobileSpeechModel).toHaveBeenCalledWith('parakeet-tdt-0.6b-v3-int8')
expect(response).toMatchObject({ ok: true, result: { started: true } })
})
it('configures dictation enable + model selection', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
configureMobileDictation: vi
.fn()
.mockResolvedValue({ enabled: true, selectedModelId: 'm1', models: [] })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SPEECH_METHODS })
const response = await dispatcher.dispatch(
makeRequest('speech.dictation.setup', { enabled: true, modelId: 'm1' })
)
expect(runtime.configureMobileDictation).toHaveBeenCalledWith({ enabled: true, modelId: 'm1' })
expect(response).toMatchObject({ ok: true, result: { enabled: true, selectedModelId: 'm1' } })
})
})
+30
View File
@@ -38,7 +38,37 @@ const DictationHandle = z.object({
dictationId: requiredString('Missing dictation ID')
})
const SpeechModelDownload = z.object({
modelId: requiredString('Missing model ID')
})
const DictationSetup = z.object({
enabled: z.boolean().optional(),
modelId: OptionalString,
dictationMode: z.enum(['toggle', 'hold']).optional()
})
export const SPEECH_METHODS: RpcMethod[] = [
defineMethod({
name: 'speech.models.list',
params: null,
handler: async (_params, { runtime }) => runtime.listMobileSpeechModels()
}),
defineMethod({
name: 'speech.models.download',
params: SpeechModelDownload,
handler: async (params, { runtime }) => runtime.downloadMobileSpeechModel(params.modelId)
}),
defineMethod({
name: 'speech.dictation.setup',
params: DictationSetup,
handler: async (params, { runtime }) =>
runtime.configureMobileDictation({
...(params.enabled !== undefined ? { enabled: params.enabled } : {}),
...(params.modelId !== undefined ? { modelId: params.modelId } : {}),
...(params.dictationMode !== undefined ? { dictationMode: params.dictationMode } : {})
})
}),
defineMethod({
name: 'speech.dictation.start',
params: DictationStart,
+19 -2
View File
@@ -450,7 +450,8 @@ const TerminalHandle = z.object({
const TerminalListParams = z.object({
worktree: OptionalString,
limit: OptionalFiniteNumber
limit: OptionalFiniteNumber,
requireFreshPtyLiveness: z.boolean().optional()
})
const TerminalResolveActive = z.object({
@@ -546,6 +547,11 @@ const TerminalStop = z.object({
worktree: requiredString('Missing worktree selector')
})
const TerminalStopExact = TerminalStop.extend({
expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1),
keepHistory: z.boolean().optional()
})
const AgentTeamsTmuxCompat = z.object({
teamId: requiredString('Missing agent team ID'),
token: requiredString('Missing agent team token'),
@@ -687,7 +693,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'terminal.list',
params: TerminalListParams,
handler: async (params, { runtime }) => runtime.listTerminals(params.worktree, params.limit)
handler: async (params, { runtime }) =>
runtime.listTerminals(params.worktree, params.limit, {
requireFreshPtyLiveness: params.requireFreshPtyLiveness
})
}),
defineMethod({
name: 'terminal.resolveActive',
@@ -817,6 +826,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
params: TerminalStop,
handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree)
}),
defineMethod({
name: 'terminal.stopExact',
params: TerminalStopExact,
handler: async (params, { runtime }) =>
runtime.stopExactTerminalsForWorktree(params.worktree, params.expectedPtyIds, {
keepHistory: params.keepHistory
})
}),
defineMethod({
name: 'terminal.resizeForClient',
params: TerminalResizeForClient,
@@ -59,8 +59,13 @@ export const WorktreeCreate = z
linkedIssue: TriStateLinkedIssue,
linkedPR: TriStateLinkedIssue,
linkedLinearIssue: z.string().optional(),
linkedLinearIssueWorkspaceId: z.union([z.string(), z.null()]).optional(),
linkedLinearIssueOrganizationUrlKey: z.union([z.string(), z.null()]).optional(),
linkedGitLabMR: TriStateLinkedIssue,
linkedGitLabIssue: TriStateLinkedIssue,
linkedBitbucketPR: TriStateLinkedIssue,
linkedAzureDevOpsPR: TriStateLinkedIssue,
linkedGiteaPR: TriStateLinkedIssue,
comment: OptionalString,
displayName: OptionalString,
telemetrySource: z
@@ -87,6 +92,8 @@ export const WorktreeCreate = z
.optional(),
runHooks: OptionalBoolean,
activate: OptionalBoolean,
parentWorkspace: OptionalString,
envParentWorkspace: OptionalString,
parentWorktree: OptionalString,
cwdParentWorktree: OptionalString,
noParent: OptionalBoolean,
@@ -123,10 +130,16 @@ export const WorktreeCreate = z
.optional()
})
.superRefine((params, ctx) => {
if (params.parentWorktree && params.noParent === true) {
if ((params.parentWorkspace || params.parentWorktree) && params.noParent === true) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Choose either --parent-worktree or --no-parent, not both.'
message: 'Choose either a parent workspace flag or --no-parent, not both.'
})
}
if (params.parentWorkspace && params.parentWorktree) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Choose either --parent-workspace or --parent-worktree, not both.'
})
}
if (params.startupPrompt !== undefined && params.startupAgent === undefined) {
@@ -153,8 +166,13 @@ export const WorktreeSet = WorktreeSelector.extend({
linkedIssue: TriStateLinkedIssue,
linkedPR: TriStateLinkedIssue,
linkedLinearIssue: z.union([z.string(), z.null()]).optional(),
linkedLinearIssueWorkspaceId: z.union([z.string(), z.null()]).optional(),
linkedLinearIssueOrganizationUrlKey: z.union([z.string(), z.null()]).optional(),
linkedGitLabMR: TriStateLinkedIssue,
linkedGitLabIssue: TriStateLinkedIssue,
linkedBitbucketPR: TriStateLinkedIssue,
linkedAzureDevOpsPR: TriStateLinkedIssue,
linkedGiteaPR: TriStateLinkedIssue,
isArchived: OptionalBoolean,
isUnread: OptionalBoolean,
isPinned: OptionalBoolean,
@@ -175,6 +193,7 @@ export const WorktreeSet = WorktreeSelector.extend({
})
.optional(),
diffComments: z.array(z.unknown()).optional(),
mobileDiffReview: z.unknown().optional(),
parentWorktree: OptionalString,
noParent: OptionalBoolean
}).superRefine((params, ctx) => {
+36 -3
View File
@@ -45,6 +45,8 @@ describe('worktree RPC methods', () => {
linkedIssue: 123,
linkedPR: 456,
linkedLinearIssue: undefined,
linkedLinearIssueWorkspaceId: undefined,
linkedLinearIssueOrganizationUrlKey: undefined,
linkedGitLabIssue: 789,
linkedGitLabMR: 321,
comment: undefined,
@@ -191,7 +193,9 @@ describe('worktree RPC methods', () => {
)
expect(response).toMatchObject({ ok: false })
expect(JSON.stringify(response)).toContain('Choose either --parent-worktree or --no-parent')
expect(JSON.stringify(response)).toContain(
'Choose either a parent workspace flag or --no-parent'
)
expect(runtime.createManagedWorktree).not.toHaveBeenCalled()
})
@@ -258,6 +262,33 @@ describe('worktree RPC methods', () => {
})
})
it('forwards Linear metadata through worktree.set', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
updateManagedWorktreeMeta: vi.fn().mockResolvedValue({ id: 'wt-1' })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('worktree.set', {
worktree: 'id:wt-1',
linkedLinearIssue: 'STA-335',
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: 'stably'
})
)
expect(response).toMatchObject({ ok: true })
expect(runtime.updateManagedWorktreeMeta).toHaveBeenCalledWith(
'id:wt-1',
expect.objectContaining({
linkedLinearIssue: 'STA-335',
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: 'stably'
})
)
})
it('rejects worktree.set when both parent and no-parent are supplied', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
@@ -292,14 +323,16 @@ describe('worktree RPC methods', () => {
}
const runtime = {
getRuntimeId: () => 'test-runtime',
listWorktreeLineage: vi.fn().mockResolvedValue(lineage)
listWorktreeLineage: vi.fn().mockResolvedValue(lineage),
listWorkspaceLineage: vi.fn().mockResolvedValue({})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
const response = await dispatcher.dispatch(makeRequest('worktree.lineageList'))
expect(runtime.listWorktreeLineage).toHaveBeenCalled()
expect(response).toMatchObject({ ok: true, result: { lineage } })
expect(runtime.listWorkspaceLineage).toHaveBeenCalled()
expect(response).toMatchObject({ ok: true, result: { lineage, workspaceLineage: {} } })
})
it('persists smart sort order on the runtime server', async () => {
+17 -1
View File
@@ -33,7 +33,10 @@ export const WORKTREE_METHODS: RpcMethod[] = [
defineMethod({
name: 'worktree.lineageList',
params: null,
handler: async (_params, { runtime }) => ({ lineage: await runtime.listWorktreeLineage() })
handler: async (_params, { runtime }) => ({
lineage: await runtime.listWorktreeLineage(),
workspaceLineage: await runtime.listWorkspaceLineage()
})
}),
defineMethod({
name: 'worktree.show',
@@ -64,8 +67,13 @@ export const WORKTREE_METHODS: RpcMethod[] = [
linkedIssue: params.linkedIssue,
linkedPR: params.linkedPR,
linkedLinearIssue: params.linkedLinearIssue,
linkedLinearIssueWorkspaceId: params.linkedLinearIssueWorkspaceId,
linkedLinearIssueOrganizationUrlKey: params.linkedLinearIssueOrganizationUrlKey,
linkedGitLabMR: params.linkedGitLabMR,
linkedGitLabIssue: params.linkedGitLabIssue,
linkedBitbucketPR: params.linkedBitbucketPR,
linkedAzureDevOpsPR: params.linkedAzureDevOpsPR,
linkedGiteaPR: params.linkedGiteaPR,
comment: params.comment,
displayName: params.displayName,
telemetrySource: params.telemetrySource,
@@ -87,6 +95,8 @@ export const WORKTREE_METHODS: RpcMethod[] = [
...(params.startupPrompt !== undefined ? { startupPrompt: params.startupPrompt } : {}),
startupDraft: params.startupDraft,
lineage: {
parentWorkspace: params.parentWorkspace,
envParentWorkspace: params.envParentWorkspace,
parentWorktree: params.parentWorktree,
...(params.cwdParentWorktree ? { cwdParentWorktree: params.cwdParentWorktree } : {}),
noParent: params.noParent === true,
@@ -115,8 +125,13 @@ export const WORKTREE_METHODS: RpcMethod[] = [
linkedIssue: params.linkedIssue,
linkedPR: params.linkedPR,
linkedLinearIssue: params.linkedLinearIssue,
linkedLinearIssueWorkspaceId: params.linkedLinearIssueWorkspaceId,
linkedLinearIssueOrganizationUrlKey: params.linkedLinearIssueOrganizationUrlKey,
linkedGitLabMR: params.linkedGitLabMR,
linkedGitLabIssue: params.linkedGitLabIssue,
linkedBitbucketPR: params.linkedBitbucketPR,
linkedAzureDevOpsPR: params.linkedAzureDevOpsPR,
linkedGiteaPR: params.linkedGiteaPR,
comment: params.comment,
isArchived: params.isArchived,
isUnread: params.isUnread,
@@ -132,6 +147,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [
workspaceStatus: params.workspaceStatus,
pushTarget: params.pushTarget,
diffComments: params.diffComments,
mobileDiffReview: params.mobileDiffReview,
lineage:
params.parentWorktree || params.noParent === true
? {
+6
View File
@@ -76,6 +76,12 @@ describe('RPC optional pipe schemas', () => {
telemetrySource: 'raw-source'
})
expectParses(methodParams(WORKTREE_METHODS, 'worktree.create'), { repo: 'repo-1' })
expectParses(methodParams(WORKTREE_METHODS, 'worktree.set'), {
worktree: 'id:wt-1',
linkedLinearIssue: 'STA-335',
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: 'stably'
})
expectParses(methodParams(WORKTREE_METHODS, 'worktree.prefetchCreateBase'), { repo: 'repo-1' })
})
})
+4 -1
View File
@@ -13,7 +13,10 @@ import type { RpcTransport } from './transport'
import { createStaticWebClientHandler } from './static-web-client-handler'
const MAX_WS_MESSAGE_BYTES = 1024 * 1024
const MAX_WS_CONNECTIONS = 32
// Why: desktop remote-host clients can legitimately hold many concurrent
// streams (session tabs, terminals, file watches, browser streams). Keep the
// cap high enough that leaked/stale streams do not starve short control RPCs.
const MAX_WS_CONNECTIONS = 128
const PRE_AUTH_TIMEOUT_MS = 10_000
type WebSocketMessagePayload = string | Uint8Array<ArrayBufferLike>
type WebSocketMessageHandler = {
+7 -2
View File
@@ -989,7 +989,9 @@ describe('OrcaRuntimeRpcServer', () => {
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_forbidden',
method: 'git.generateCommitMessage',
// files.delete is a real registered RPC intentionally kept off the
// mobile allowlist — mobile clients must never delete host files.
method: 'files.delete',
deviceToken: mobile.token,
params: { worktree: 'id:wt-1' }
}),
@@ -2154,7 +2156,10 @@ describe('OrcaRuntimeRpcServer', () => {
})
expect(listResponse).toMatchObject({
id: 'req_list',
ok: true
ok: true,
result: {
terminals: [expect.objectContaining({ ptyId: 'pty-1' })]
}
})
const handle = (
+28
View File
@@ -140,7 +140,11 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'browser.screencast.unsubscribe',
'browser.tabCreate',
'browser.viewport',
'clipboard.abortImageUpload',
'clipboard.appendImageUploadChunk',
'clipboard.commitImageUpload',
'clipboard.saveImageAsTempFile',
'clipboard.startImageUpload',
'diagnostics.memory',
'files.browseServerDir',
'files.createFile',
@@ -148,17 +152,30 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'files.open',
'files.openDiff',
'files.read',
'files.readPreview',
'files.resolveTerminalPath',
'git.abortMerge',
'git.abortRebase',
'git.bulkStage',
'git.bulkUnstage',
'git.branchCompare',
'git.branchDiff',
'git.cancelGenerateCommitMessage',
'git.cancelGeneratePullRequestFields',
'git.checkout',
'git.commit',
'git.commitCompare',
'git.commitDiff',
'git.discard',
'git.discoverCommitMessageModels',
'git.diff',
'git.fetch',
'git.forkSync',
'git.fastForward',
'git.generateCommitMessage',
'git.generatePullRequestFields',
'git.history',
'git.localBranches',
'git.pull',
'git.push',
'git.rebaseFromBase',
@@ -221,9 +238,14 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'host.pwsh.isAvailable',
'host.wsl.isAvailable',
'host.wsl.listDistros',
'hostedReview.create',
'hostedReview.getCreationEligibility',
'linear.getCustomView',
'linear.getIssue',
'linear.getProject',
'linear.agentSearchIssues',
'linear.issueContext',
'linear.resolveCurrentIssue',
'linear.addIssueComment',
'linear.connect',
'linear.createIssue',
@@ -251,6 +273,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'preflight.detectAgents',
'preflight.detectRemoteAgents',
'repo.baseRefDefault',
'repo.gitAvailable',
'repo.hooks',
'repo.list',
'repo.saveSparsePreset',
@@ -258,6 +281,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'repo.sparsePresets',
'repo.update',
'runtime.clientEvents.subscribe',
'runtime.clientEvents.unsubscribe',
'session.tabs.activate',
'session.tabs.close',
'session.tabs.createTerminal',
@@ -267,6 +291,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'session.tabs.subscribe',
'session.tabs.subscribeAll',
'session.tabs.unsubscribe',
'session.tabs.unsubscribeAll',
'settings.get',
'settings.update',
'ssh.connect',
@@ -274,7 +299,10 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'speech.dictation.cancel',
'speech.dictation.chunk',
'speech.dictation.finish',
'speech.dictation.setup',
'speech.dictation.start',
'speech.models.download',
'speech.models.list',
'stats.summary',
'status.get',
'agentTeams.prepareLaunch',