mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 16:17:51 +00:00
Merge branch 'nwparker/term-speed-2-model-view-contract-tests' into nwparker/term-speed-2-subscribe-overflow-snapshot
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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})`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
+4046
-311
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()}` : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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'] }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
]
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
]
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user