mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* feat(media): read remote media ranges through the paired runtime Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(media): stream remote media through orca-media grants Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(editor): play video and audio from remote runtime workspaces Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(media): surface update-required for remote media and cancel in-flight chunks Probe files.readChunk once per environment and pairing revision before minting a grant so older servers show the update message. Closing a media stream now aborts the in-flight remote chunk. Also validate the IPC address, fix extension-only names, and tighten the preview return type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(files): one classifier decides which viewer opens a file Local reads, files.readPreview on hosts, git blob previews and the desktop's remote binary handling each kept their own image/PDF extension table, and media had another. file-preview-kind.ts is now the one table: image, PDF, audio or video, with the MIME type. AVIF, which only local reads knew, previews on hosts and in git diffs too. * perf(runtime): give byte-range reads their own small call lane files.readChunk (media streams, paged files, downloads) now runs in a two-call lane per environment, so a playing video or a download never holds the slots terminal input and workspace calls use. * feat(files): stream previews from paired runtimes by byte range Generalises the remote media grant into one byte-range preview path: an orca-media:// grant keyed by environment, worktree, path and pairing revision, streamed over files.stat/readChunk. Audio and video use it now; the shared classifier decides which viewers do. - One files.stat at mint replaces the readChunk probe: it proves the host serves range reads and that the path is a file, so an old host or a missing file fails in the tab, not as a silent player error. - A burst of range requests shares one stat (reused for 1s), so seeking costs one round trip less on a slow link; later requests re-stat, so a growing recording keeps playing. - Removing a paired runtime revokes its grants; a re-paired one answers 410 and drops the grant. - Only readers that render bytes mint a stream; baseline checks don't. Co-authored-by: Sahil Sharma <128581847+SahilSharma0810@users.noreply.github.com> * fix(files): prove chunk reads at mint, and let a reload report the new size Hosts from v1.4.5 answer files.stat but only v1.4.111 and later answer files.readChunk, and no capability names it, so minting reads one byte, cached per pairing, and an older host fails in the tab with the update message. A re-mint seeds the grant with its fresh stat, so a reload never reuses an older size. A renderer gone mid-mint mints nothing. Preview chunks time out after 20s so a stalled one frees its lane sooner. Co-authored-by: Sahil Sharma <128581847+SahilSharma0810@users.noreply.github.com> * fix(files): resolve preview hosts in the pairing registry's own directory The remote preview reader looked environments up under the canonical user-data path, while the pairing IPC writes the registry under app.getPath('userData'), which can differ in casing on case-sensitive installs. Both now use one getter. A chunk-read verdict is cached only when a pairing revision pins it, so it can't outlive a re-pair. Co-authored-by: Sahil Sharma <128581847+SahilSharma0810@users.noreply.github.com> * fix(files): read the pairing registry from one directory everywhere Doc previews resolved paired servers under the canonical user-data path, the same casing mismatch the media reader had. They and every other main caller that names the registry directory inline now use getRuntimeEnvironmentRegistryRoot(), so it has one source. Co-authored-by: Sahil Sharma <128581847+SahilSharma0810@users.noreply.github.com> * fix(files): pin an unpinned preview grant to the pairing it was minted on A renderer that hadn't loaded the environment's pairing revision yet sent none, and every later chunk read went unguarded, so a re-pair under the same environment id would let the old URL read from the new host. Main now pins the registered revision at mint. Co-authored-by: Sahil Sharma <128581847+SahilSharma0810@users.noreply.github.com> * refactor(files): route the remaining pairing-registry readers through the one getter AI-vault host scans, the settings runtime-environment preference, skill capability checks and the mobile relay host list named the registry directory inline. They now use getRuntimeEnvironmentRegistryRoot() too; callers that also use the directory for other storage keep their own path. Co-authored-by: Sahil Sharma <128581847+SahilSharma0810@users.noreply.github.com> * refactor(files): write new pairings through the same registry root The ephemeral VM pairing writer named the registry directory inline; it now uses getRuntimeEnvironmentRegistryRoot() like every reader. Co-authored-by: Sahil Sharma <128581847+SahilSharma0810@users.noreply.github.com> --------- Co-authored-by: Sahil Sharma <128581847+SahilSharma0810@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
279 lines
9.3 KiB
TypeScript
279 lines
9.3 KiB
TypeScript
import { abortSignalReason } from './abort-signal-reason'
|
|
import { REMOTE_RUNTIME_MAX_PREPARED_RPC_BYTES } from './remote-runtime/remote-runtime-memory-limits'
|
|
|
|
const DEFAULT_REMOTE_RUNTIME_CALL_CONCURRENCY = 8
|
|
const DEFAULT_REMOTE_RUNTIME_BACKGROUND_CALL_CONCURRENCY = 2
|
|
const REMOTE_RUNTIME_BULK_READ_CONCURRENCY = 2
|
|
export const RUNTIME_RPC_MAX_QUEUED_CALLS_PER_SELECTOR = 256
|
|
export const RUNTIME_RPC_MAX_QUEUED_CALLS_TOTAL = 2_048
|
|
export const RUNTIME_RPC_QUEUE_OVERLOAD_CODE = 'runtime_rpc_queue_overloaded'
|
|
|
|
export class RuntimeRpcCallQueueOverloadError extends Error {
|
|
readonly code = RUNTIME_RPC_QUEUE_OVERLOAD_CODE
|
|
|
|
constructor(readonly scope: 'selector' | 'global' | 'memory') {
|
|
super('Remote runtime call queue is full; retry after current calls finish.')
|
|
this.name = 'RuntimeRpcCallQueueOverloadError'
|
|
}
|
|
}
|
|
|
|
type QueuedRuntimeCall<T> = {
|
|
background: boolean
|
|
retainedBytes: number
|
|
run: () => Promise<T>
|
|
resolve: (value: T) => void
|
|
reject: (error: unknown) => void
|
|
started: boolean
|
|
signal?: AbortSignal
|
|
onAbort?: () => void
|
|
}
|
|
|
|
type RuntimeCallQueue = {
|
|
concurrency: number
|
|
active: number
|
|
backgroundActive: number
|
|
foreground: (QueuedRuntimeCall<unknown> | undefined)[]
|
|
foregroundHead: number
|
|
background: (QueuedRuntimeCall<unknown> | undefined)[]
|
|
backgroundHead: number
|
|
}
|
|
|
|
export function isBackgroundRuntimeMethod(method: string): boolean {
|
|
return (
|
|
method === 'hostedReview.forBranch' ||
|
|
method === 'github.prForBranch' ||
|
|
method === 'github.listWorkItems' ||
|
|
method === 'github.countWorkItems' ||
|
|
method === 'git.status' ||
|
|
method === 'git.history' ||
|
|
method === 'git.conflictOperation' ||
|
|
method === 'git.branchCompare' ||
|
|
method === 'git.upstreamStatus' ||
|
|
method === 'worktree.prefetchCreateBase'
|
|
)
|
|
}
|
|
|
|
// Slow worktree removal/model listing waits get a separate lane so foreground refresh/send slots remain free;
|
|
// background slots belong to status.
|
|
function isLongWaitRuntimeMethod(method: string): boolean {
|
|
return method === 'worktree.rm' || method === 'agentSession.modelCatalog'
|
|
}
|
|
|
|
// Byte-range reads (media, paged files, downloads) are bulk: a playing video must not hold terminal or workspace slots.
|
|
function isBulkReadRuntimeMethod(method: string): boolean {
|
|
return method === 'files.readChunk'
|
|
}
|
|
|
|
export class RuntimeRpcCallQueuePool {
|
|
private readonly queues = new Map<string, RuntimeCallQueue>()
|
|
private queuedCallCount = 0
|
|
private retainedCallBytes = 0
|
|
|
|
constructor(
|
|
private readonly concurrency = DEFAULT_REMOTE_RUNTIME_CALL_CONCURRENCY,
|
|
private readonly backgroundConcurrency = DEFAULT_REMOTE_RUNTIME_BACKGROUND_CALL_CONCURRENCY,
|
|
private readonly maxQueuedPerSelector = RUNTIME_RPC_MAX_QUEUED_CALLS_PER_SELECTOR,
|
|
private readonly maxQueuedTotal = RUNTIME_RPC_MAX_QUEUED_CALLS_TOTAL,
|
|
private readonly maxRetainedBytes = REMOTE_RUNTIME_MAX_PREPARED_RPC_BYTES
|
|
) {}
|
|
|
|
enqueue<T>(
|
|
selector: string,
|
|
method: string,
|
|
run: () => Promise<T>,
|
|
retainedBytes = 0,
|
|
signal?: AbortSignal
|
|
): Promise<T> {
|
|
if (signal?.aborted) {
|
|
return Promise.reject(abortSignalReason(signal))
|
|
}
|
|
// Same concurrency bound, counted apart from the selector's other calls; global caps still apply.
|
|
const bulkRead = isBulkReadRuntimeMethod(method)
|
|
const queueKey = isLongWaitRuntimeMethod(method)
|
|
? `${selector}\u0000long-wait`
|
|
: bulkRead
|
|
? `${selector}\u0000bulk-read`
|
|
: selector
|
|
if (this.queuedCallCount >= this.maxQueuedTotal) {
|
|
return Promise.reject(new RuntimeRpcCallQueueOverloadError('global'))
|
|
}
|
|
const existingQueue = this.queues.get(queueKey)
|
|
if (existingQueue && this.queuedCount(existingQueue) >= this.maxQueuedPerSelector) {
|
|
return Promise.reject(new RuntimeRpcCallQueueOverloadError('selector'))
|
|
}
|
|
if (
|
|
!Number.isSafeInteger(retainedBytes) ||
|
|
retainedBytes < 0 ||
|
|
this.retainedCallBytes + retainedBytes > this.maxRetainedBytes
|
|
) {
|
|
return Promise.reject(new RuntimeRpcCallQueueOverloadError('memory'))
|
|
}
|
|
|
|
const queue = this.getQueue(
|
|
queueKey,
|
|
bulkRead ? Math.min(this.concurrency, REMOTE_RUNTIME_BULK_READ_CONCURRENCY) : this.concurrency
|
|
)
|
|
return new Promise<T>((resolve, reject) => {
|
|
const call: QueuedRuntimeCall<T> = {
|
|
background: isBackgroundRuntimeMethod(method),
|
|
retainedBytes,
|
|
run,
|
|
resolve,
|
|
reject,
|
|
started: false,
|
|
signal
|
|
}
|
|
const targetQueue = call.background ? queue.background : queue.foreground
|
|
targetQueue.push(call as QueuedRuntimeCall<unknown>)
|
|
this.queuedCallCount += 1
|
|
this.retainedCallBytes += retainedBytes
|
|
if (signal) {
|
|
call.onAbort = () => this.cancelQueuedCall(queueKey, queue, call)
|
|
signal.addEventListener('abort', call.onAbort, { once: true })
|
|
}
|
|
this.pump(queueKey, queue)
|
|
})
|
|
}
|
|
|
|
private getQueue(selector: string, concurrency: number): RuntimeCallQueue {
|
|
let queue = this.queues.get(selector)
|
|
if (!queue) {
|
|
queue = {
|
|
concurrency,
|
|
active: 0,
|
|
backgroundActive: 0,
|
|
foreground: [],
|
|
foregroundHead: 0,
|
|
background: [],
|
|
backgroundHead: 0
|
|
}
|
|
this.queues.set(selector, queue)
|
|
}
|
|
return queue
|
|
}
|
|
|
|
private pump(selector: string, queue: RuntimeCallQueue): void {
|
|
while (queue.active < queue.concurrency) {
|
|
let call = this.takeForeground(queue)
|
|
if (!call && queue.backgroundActive < this.backgroundConcurrency) {
|
|
call = this.takeBackground(queue)
|
|
}
|
|
if (!call) {
|
|
break
|
|
}
|
|
|
|
call.started = true
|
|
if (call.signal && call.onAbort) {
|
|
call.signal.removeEventListener('abort', call.onAbort)
|
|
}
|
|
|
|
queue.active += 1
|
|
if (call.background) {
|
|
queue.backgroundActive += 1
|
|
}
|
|
// Decorative status refreshes must not stampede capacity shared with streams and workspace actions.
|
|
let runPromise: Promise<unknown>
|
|
try {
|
|
runPromise = call.run()
|
|
} catch (error) {
|
|
// Start queued work immediately but route synchronous validation failures through cleanup.
|
|
runPromise = Promise.reject(error)
|
|
}
|
|
void runPromise.then(call.resolve, call.reject).finally(() => {
|
|
this.retainedCallBytes = Math.max(0, this.retainedCallBytes - call.retainedBytes)
|
|
queue.active = Math.max(0, queue.active - 1)
|
|
if (call.background) {
|
|
queue.backgroundActive = Math.max(0, queue.backgroundActive - 1)
|
|
}
|
|
if (queue.active === 0 && this.isEmpty(queue)) {
|
|
this.queues.delete(selector)
|
|
return
|
|
}
|
|
this.pump(selector, queue)
|
|
})
|
|
}
|
|
}
|
|
|
|
private cancelQueuedCall<T>(
|
|
selector: string,
|
|
queue: RuntimeCallQueue,
|
|
call: QueuedRuntimeCall<T>
|
|
): void {
|
|
if (call.started || !call.signal) {
|
|
return
|
|
}
|
|
const targetQueue = call.background ? queue.background : queue.foreground
|
|
const head = call.background ? queue.backgroundHead : queue.foregroundHead
|
|
const index = targetQueue.indexOf(call as QueuedRuntimeCall<unknown>, head)
|
|
if (index === -1) {
|
|
return
|
|
}
|
|
targetQueue.splice(index, 1)
|
|
this.queuedCallCount = Math.max(0, this.queuedCallCount - 1)
|
|
this.retainedCallBytes = Math.max(0, this.retainedCallBytes - call.retainedBytes)
|
|
call.reject(abortSignalReason(call.signal))
|
|
if (queue.active === 0 && this.isEmpty(queue)) {
|
|
this.queues.delete(selector)
|
|
return
|
|
}
|
|
this.pump(selector, queue)
|
|
}
|
|
|
|
private takeForeground(queue: RuntimeCallQueue): QueuedRuntimeCall<unknown> | undefined {
|
|
if (queue.foregroundHead >= queue.foreground.length) {
|
|
return undefined
|
|
}
|
|
const call = queue.foreground[queue.foregroundHead]
|
|
queue.foreground[queue.foregroundHead] = undefined
|
|
queue.foregroundHead += 1
|
|
this.queuedCallCount = Math.max(0, this.queuedCallCount - 1)
|
|
this.compactForeground(queue)
|
|
return call
|
|
}
|
|
|
|
private takeBackground(queue: RuntimeCallQueue): QueuedRuntimeCall<unknown> | undefined {
|
|
if (queue.backgroundHead >= queue.background.length) {
|
|
return undefined
|
|
}
|
|
const call = queue.background[queue.backgroundHead]
|
|
queue.background[queue.backgroundHead] = undefined
|
|
queue.backgroundHead += 1
|
|
this.queuedCallCount = Math.max(0, this.queuedCallCount - 1)
|
|
this.compactBackground(queue)
|
|
return call
|
|
}
|
|
|
|
private compactForeground(queue: RuntimeCallQueue): void {
|
|
if (queue.foregroundHead <= 32 || queue.foregroundHead * 2 < queue.foreground.length) {
|
|
return
|
|
}
|
|
// Head indexes avoid repeated shifts; compaction bounds the consumed prefix.
|
|
queue.foreground.splice(0, queue.foregroundHead)
|
|
queue.foregroundHead = 0
|
|
}
|
|
|
|
private compactBackground(queue: RuntimeCallQueue): void {
|
|
if (queue.backgroundHead <= 32 || queue.backgroundHead * 2 < queue.background.length) {
|
|
return
|
|
}
|
|
queue.background.splice(0, queue.backgroundHead)
|
|
queue.backgroundHead = 0
|
|
}
|
|
|
|
private isEmpty(queue: RuntimeCallQueue): boolean {
|
|
return (
|
|
queue.foregroundHead >= queue.foreground.length &&
|
|
queue.backgroundHead >= queue.background.length
|
|
)
|
|
}
|
|
|
|
private queuedCount(queue: RuntimeCallQueue): number {
|
|
return (
|
|
queue.foreground.length -
|
|
queue.foregroundHead +
|
|
queue.background.length -
|
|
queue.backgroundHead
|
|
)
|
|
}
|
|
}
|