Fix Remote Host downloads and agent status parity (#6436)
* Fix remote host downloads and agent status parity Co-authored-by: Orca <help@stably.ai> * Address remote download review comments Co-authored-by: Orca <help@stably.ai> * Avoid inefficient SSH chunk fallback Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
d5b1ca7fcd
commit
4904ffda9a
|
|
@ -299,6 +299,8 @@ describe('registerFilesystemHandlers', () => {
|
|||
buffer.fill(0x61)
|
||||
return { bytesRead: buffer.length, buffer }
|
||||
}),
|
||||
write: vi.fn().mockResolvedValue(undefined),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn()
|
||||
})
|
||||
lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
|
|
@ -485,6 +487,71 @@ describe('registerFilesystemHandlers', () => {
|
|||
expect(rmMock).not.toHaveBeenCalledWith(tempPath, expect.anything())
|
||||
})
|
||||
|
||||
it('streams runtime download chunks to a temp sibling then promotes on finish', async () => {
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const close = vi.fn().mockResolvedValue(undefined)
|
||||
openMock.mockResolvedValue({ writeFile, close })
|
||||
showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' })
|
||||
statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
const started = await handlers.get('fs:startDownloadedFile')!(
|
||||
{ sender: {} },
|
||||
{ suggestedName: 'report.pdf' }
|
||||
)
|
||||
expect(started).toMatchObject({
|
||||
canceled: false,
|
||||
destinationPath: '/downloads/report.pdf'
|
||||
})
|
||||
if (!started || typeof started !== 'object' || !('transferId' in started)) {
|
||||
throw new Error('download did not start')
|
||||
}
|
||||
const transferId = started.transferId
|
||||
|
||||
await expect(
|
||||
handlers.get('fs:appendDownloadedFileChunk')!(null, {
|
||||
transferId,
|
||||
contentBase64: Buffer.from('hello').toString('base64')
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
await expect(handlers.get('fs:finishDownloadedFile')!(null, { transferId })).resolves.toEqual({
|
||||
canceled: false,
|
||||
destinationPath: '/downloads/report.pdf'
|
||||
})
|
||||
|
||||
const tempPath = openMock.mock.calls[0][0]
|
||||
expect(path.dirname(tempPath)).toBe(path.normalize('/downloads'))
|
||||
expect(openMock).toHaveBeenCalledWith(tempPath, 'wx')
|
||||
expect(writeFile).toHaveBeenCalledWith(Buffer.from('hello'))
|
||||
expect(close).toHaveBeenCalled()
|
||||
expect(renameMock).toHaveBeenCalledWith(tempPath, '/downloads/report.pdf')
|
||||
})
|
||||
|
||||
it('cleans up a runtime download temp file on cancel', async () => {
|
||||
const close = vi.fn().mockResolvedValue(undefined)
|
||||
openMock.mockResolvedValue({ writeFile: vi.fn(), close })
|
||||
showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' })
|
||||
statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
const started = await handlers.get('fs:startDownloadedFile')!(
|
||||
{ sender: {} },
|
||||
{ suggestedName: 'report.pdf' }
|
||||
)
|
||||
if (!started || typeof started !== 'object' || !('transferId' in started)) {
|
||||
throw new Error('download did not start')
|
||||
}
|
||||
const tempPath = openMock.mock.calls[0][0]
|
||||
|
||||
await expect(
|
||||
handlers.get('fs:cancelDownloadedFile')!(null, { transferId: started.transferId })
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(close).toHaveBeenCalled()
|
||||
expect(rmMock).toHaveBeenCalledWith(tempPath, { force: true })
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans up the temp sibling when remote download transfer fails', async () => {
|
||||
const provider = {
|
||||
stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/* eslint-disable max-lines */
|
||||
import { BrowserWindow, dialog, ipcMain, shell } from 'electron'
|
||||
import { readdir, readFile, writeFile, stat, lstat, open, rename, rm } from 'fs/promises'
|
||||
import type { FileHandle } from 'fs/promises'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { dirname, extname, join, resolve } from 'path'
|
||||
import type { ChildProcess } from 'child_process'
|
||||
|
|
@ -163,6 +164,24 @@ function sanitizeSaveDialogFilename(remoteBasename: string): string {
|
|||
return sanitized
|
||||
}
|
||||
|
||||
function decodeDownloadedFileContent(content: string, encoding: 'utf8' | 'base64'): Buffer {
|
||||
if (encoding === 'base64') {
|
||||
return Buffer.from(content, 'base64')
|
||||
}
|
||||
return Buffer.from(content, 'utf8')
|
||||
}
|
||||
|
||||
type DownloadSession = {
|
||||
destinationPath: string
|
||||
tempPath: string
|
||||
destinationExisted: boolean
|
||||
handle: FileHandle
|
||||
cleanupTimer: ReturnType<typeof setTimeout>
|
||||
senderId: number
|
||||
}
|
||||
|
||||
const DOWNLOAD_SESSION_TTL_MS = 30 * 60 * 1000
|
||||
|
||||
function createSiblingTransferPath(destinationPath: string, suffix: string): string {
|
||||
return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`)
|
||||
}
|
||||
|
|
@ -430,6 +449,32 @@ export function registerFilesystemHandlers(
|
|||
commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers
|
||||
): void {
|
||||
const activeTextSearches = new Map<string, ChildProcess>()
|
||||
const downloadSessions = new Map<string, DownloadSession>()
|
||||
|
||||
async function closeDownloadSession(
|
||||
transferId: string,
|
||||
cleanupTemp: boolean
|
||||
): Promise<DownloadSession | null> {
|
||||
const session = downloadSessions.get(transferId)
|
||||
if (!session) {
|
||||
return null
|
||||
}
|
||||
downloadSessions.delete(transferId)
|
||||
clearTimeout(session.cleanupTimer)
|
||||
await session.handle.close().catch(() => {})
|
||||
if (cleanupTemp) {
|
||||
await cleanupLocalTransferPath(session.tempPath)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
function cleanupDownloadSessionsForSender(senderId: number): void {
|
||||
for (const [transferId, session] of Array.from(downloadSessions)) {
|
||||
if (session.senderId === senderId) {
|
||||
void closeDownloadSession(transferId, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Filesystem ─────────────────────────────────────────
|
||||
ipcMain.handle(
|
||||
|
|
@ -552,6 +597,153 @@ export function registerFilesystemHandlers(
|
|||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'fs:saveDownloadedFile',
|
||||
async (
|
||||
event,
|
||||
args: { suggestedName?: string; content?: string; encoding?: 'utf8' | 'base64' }
|
||||
): Promise<DownloadFileResult> => {
|
||||
const suggestedName = sanitizeSaveDialogFilename(
|
||||
validateRequiredString(args?.suggestedName, 'suggestedName')
|
||||
)
|
||||
if (typeof args?.content !== 'string') {
|
||||
throw new Error('content is required')
|
||||
}
|
||||
const content = args.content
|
||||
const encoding = args?.encoding === 'base64' ? 'base64' : 'utf8'
|
||||
const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined
|
||||
const dialogResult = parentWindow
|
||||
? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName })
|
||||
: await dialog.showSaveDialog({ defaultPath: suggestedName })
|
||||
if (dialogResult.canceled || !dialogResult.filePath) {
|
||||
return { canceled: true }
|
||||
}
|
||||
|
||||
const destinationPath = dialogResult.filePath
|
||||
const { existed } = await inspectDownloadDestination(destinationPath)
|
||||
const tempPath = createSiblingTransferPath(destinationPath, 'download')
|
||||
let promoted = false
|
||||
try {
|
||||
await writeFile(tempPath, decodeDownloadedFileContent(content, encoding))
|
||||
await promoteDownloadedFile(tempPath, destinationPath, existed)
|
||||
promoted = true
|
||||
return { canceled: false, destinationPath }
|
||||
} finally {
|
||||
if (!promoted) {
|
||||
await cleanupLocalTransferPath(tempPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'fs:startDownloadedFile',
|
||||
async (
|
||||
event,
|
||||
args: { suggestedName?: string }
|
||||
): Promise<
|
||||
| { canceled: true }
|
||||
| {
|
||||
canceled: false
|
||||
transferId: string
|
||||
destinationPath: string
|
||||
}
|
||||
> => {
|
||||
const suggestedName = sanitizeSaveDialogFilename(
|
||||
validateRequiredString(args?.suggestedName, 'suggestedName')
|
||||
)
|
||||
const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined
|
||||
const dialogResult = parentWindow
|
||||
? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName })
|
||||
: await dialog.showSaveDialog({ defaultPath: suggestedName })
|
||||
if (dialogResult.canceled || !dialogResult.filePath) {
|
||||
return { canceled: true }
|
||||
}
|
||||
|
||||
const destinationPath = dialogResult.filePath
|
||||
const { existed } = await inspectDownloadDestination(destinationPath)
|
||||
const tempPath = createSiblingTransferPath(destinationPath, 'download')
|
||||
const transferId = randomUUID()
|
||||
try {
|
||||
const handle = await open(tempPath, 'wx')
|
||||
const senderId = typeof event.sender.id === 'number' ? event.sender.id : Number.NaN
|
||||
const cleanupTimer = setTimeout(() => {
|
||||
void closeDownloadSession(transferId, true)
|
||||
}, DOWNLOAD_SESSION_TTL_MS)
|
||||
if (typeof cleanupTimer.unref === 'function') {
|
||||
cleanupTimer.unref()
|
||||
}
|
||||
downloadSessions.set(transferId, {
|
||||
destinationPath,
|
||||
tempPath,
|
||||
destinationExisted: existed,
|
||||
handle,
|
||||
cleanupTimer,
|
||||
senderId
|
||||
})
|
||||
event.sender.once?.('destroyed', () => cleanupDownloadSessionsForSender(senderId))
|
||||
return { canceled: false, transferId, destinationPath }
|
||||
} catch (error) {
|
||||
await cleanupLocalTransferPath(tempPath)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'fs:appendDownloadedFileChunk',
|
||||
async (
|
||||
_event,
|
||||
args: { transferId?: string; contentBase64?: string }
|
||||
): Promise<{ ok: true }> => {
|
||||
const transferId = validateRequiredString(args?.transferId, 'transferId')
|
||||
const contentBase64 = validateRequiredString(args?.contentBase64, 'contentBase64')
|
||||
const session = downloadSessions.get(transferId)
|
||||
if (!session) {
|
||||
throw new Error('Download session not found')
|
||||
}
|
||||
await session.handle.writeFile(Buffer.from(contentBase64, 'base64'))
|
||||
return { ok: true }
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'fs:finishDownloadedFile',
|
||||
async (
|
||||
_event,
|
||||
args: { transferId?: string }
|
||||
): Promise<{ canceled: false; destinationPath: string }> => {
|
||||
const transferId = validateRequiredString(args?.transferId, 'transferId')
|
||||
const session = await closeDownloadSession(transferId, false)
|
||||
if (!session) {
|
||||
throw new Error('Download session not found')
|
||||
}
|
||||
let promoted = false
|
||||
try {
|
||||
await promoteDownloadedFile(
|
||||
session.tempPath,
|
||||
session.destinationPath,
|
||||
session.destinationExisted
|
||||
)
|
||||
promoted = true
|
||||
return { canceled: false, destinationPath: session.destinationPath }
|
||||
} finally {
|
||||
if (!promoted) {
|
||||
await cleanupLocalTransferPath(session.tempPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'fs:cancelDownloadedFile',
|
||||
async (_event, args: { transferId?: string }): Promise<{ ok: true }> => {
|
||||
const transferId = validateRequiredString(args?.transferId, 'transferId')
|
||||
await closeDownloadSession(transferId, true)
|
||||
return { ok: true }
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'fs:listMarkdownDocuments',
|
||||
async (
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
import type {
|
||||
RuntimeFileListResult,
|
||||
RuntimeFileOpenResult,
|
||||
RuntimeFileReadChunkResult,
|
||||
RuntimeFilePreviewResult,
|
||||
RuntimeFileReadResult,
|
||||
RuntimeTerminalPathResolution
|
||||
|
|
@ -469,6 +470,45 @@ export class RuntimeFileCommands {
|
|||
return { content: buffer.toString('utf-8'), isBinary: false }
|
||||
}
|
||||
|
||||
async readFileExplorerChunk(
|
||||
worktreeSelector: string,
|
||||
relativePath: string,
|
||||
offset: number,
|
||||
length: number
|
||||
): Promise<RuntimeFileReadChunkResult> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
const fileStat = await provider.stat(target.path)
|
||||
if (fileStat.type === 'directory') {
|
||||
throw new Error('Cannot download a directory')
|
||||
}
|
||||
throw new Error('SSH runtime chunked download is unavailable; use the SSH download path')
|
||||
}
|
||||
|
||||
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
||||
const fileStats = await stat(filePath)
|
||||
if (fileStats.isDirectory()) {
|
||||
throw new Error('Cannot download a directory')
|
||||
}
|
||||
const handle = await open(filePath, 'r')
|
||||
try {
|
||||
const buffer = Buffer.alloc(Math.min(length, Math.max(0, fileStats.size - offset)))
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset)
|
||||
const chunk = buffer.subarray(0, bytesRead)
|
||||
return {
|
||||
contentBase64: chunk.toString('base64'),
|
||||
bytesRead,
|
||||
eof: offset + bytesRead >= fileStats.size
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
async writeFileExplorerFile(
|
||||
worktreeSelector: string,
|
||||
relativePath: string,
|
||||
|
|
|
|||
|
|
@ -4655,6 +4655,8 @@ export class OrcaRuntimeService {
|
|||
this.fileCommands.watchFileExplorer.bind(this.fileCommands)
|
||||
readFileExplorerPreview: RuntimeFileCommands['readFileExplorerPreview'] =
|
||||
this.fileCommands.readFileExplorerPreview.bind(this.fileCommands)
|
||||
readFileExplorerChunk: RuntimeFileCommands['readFileExplorerChunk'] =
|
||||
this.fileCommands.readFileExplorerChunk.bind(this.fileCommands)
|
||||
writeFileExplorerFile: RuntimeFileCommands['writeFileExplorerFile'] =
|
||||
this.fileCommands.writeFileExplorerFile.bind(this.fileCommands)
|
||||
writeFileExplorerFileBase64: RuntimeFileCommands['writeFileExplorerFileBase64'] =
|
||||
|
|
|
|||
|
|
@ -338,6 +338,33 @@ describe('file RPC methods', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('reads a file chunk for a selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
readFileExplorerChunk: vi.fn().mockResolvedValue({
|
||||
contentBase64: 'YWJj',
|
||||
bytesRead: 3,
|
||||
eof: true
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.readChunk', {
|
||||
worktree: 'id:wt-1',
|
||||
relativePath: 'archive.zip',
|
||||
offset: 0,
|
||||
length: 1024
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.readFileExplorerChunk).toHaveBeenCalledWith('id:wt-1', 'archive.zip', 0, 1024)
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { contentBase64: 'YWJj', bytesRead: 3, eof: true }
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a file explorer directory for a selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
|
|
|
|||
|
|
@ -78,6 +78,15 @@ const FileWriteBase64Chunk = FileWriteBase64.extend({
|
|||
append: z.boolean().optional()
|
||||
})
|
||||
|
||||
const FileReadChunk = FileOpen.extend({
|
||||
offset: z.number().int().nonnegative(),
|
||||
length: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(512 * 1024)
|
||||
})
|
||||
|
||||
const FileRename = WorktreeSelector.extend({
|
||||
oldRelativePath: z
|
||||
.unknown()
|
||||
|
|
@ -175,6 +184,17 @@ export const FILE_METHODS: RpcAnyMethod[] = [
|
|||
handler: async (params, { runtime }) =>
|
||||
runtime.readFileExplorerPreview(params.worktree, params.relativePath)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.readChunk',
|
||||
params: FileReadChunk,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.readFileExplorerChunk(
|
||||
params.worktree,
|
||||
params.relativePath,
|
||||
params.offset,
|
||||
params.length
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.readDir',
|
||||
params: FileTreePath,
|
||||
|
|
|
|||
|
|
@ -323,6 +323,27 @@ function appendPendingMultiplexOutput(
|
|||
stream.pendingOutputOverflowed ||= trimmed.overflowed
|
||||
}
|
||||
|
||||
function getOutputAfterSnapshotSeq(
|
||||
chunk: TerminalOutputChunk,
|
||||
snapshotSeq: number | undefined
|
||||
): string | null {
|
||||
if (
|
||||
typeof snapshotSeq !== 'number' ||
|
||||
typeof chunk.meta?.seq !== 'number' ||
|
||||
typeof chunk.meta.rawLength !== 'number'
|
||||
) {
|
||||
return chunk.data
|
||||
}
|
||||
if (chunk.meta.seq <= snapshotSeq) {
|
||||
return null
|
||||
}
|
||||
const chunkStartSeq = chunk.meta.seq - chunk.meta.rawLength
|
||||
if (chunkStartSeq >= snapshotSeq) {
|
||||
return chunk.data
|
||||
}
|
||||
return chunk.data.slice(snapshotSeq - chunkStartSeq)
|
||||
}
|
||||
|
||||
function trimPendingOutputToBudget(
|
||||
pendingOutput: TerminalOutputChunk[],
|
||||
pendingOutputBytes: number
|
||||
|
|
@ -1489,7 +1510,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
const size = runtime.getTerminalSize(ptyId)
|
||||
const displayMode = runtime.getMobileDisplayMode(ptyId)
|
||||
const layoutSeq = runtime.getLayout(ptyId)?.seq
|
||||
const snapshotSeq = serialized?.seq ?? layoutSeq
|
||||
const snapshotFrameSeq = serialized?.seq ?? layoutSeq
|
||||
const snapshotOutputSeq = serialized?.seq
|
||||
if (!isMobile) {
|
||||
const fitOverride = runtime.getTerminalFitOverride(ptyId)
|
||||
emit({
|
||||
|
|
@ -1520,7 +1542,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
cols: serialized?.cols ?? size?.cols ?? 80,
|
||||
rows: serialized?.rows ?? size?.rows ?? 24,
|
||||
displayMode,
|
||||
seq: snapshotSeq,
|
||||
seq: snapshotFrameSeq,
|
||||
truncated: serialized ? read.truncated : isTerminalReadPayloadIncomplete(read),
|
||||
truncatedByByteBudget: serialized?.truncatedByByteBudget,
|
||||
source: serialized?.source,
|
||||
|
|
@ -1532,7 +1554,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
stream.lastResizeCols = serialized?.cols ?? size?.cols
|
||||
stream.buffering = false
|
||||
for (const chunk of stream.pendingOutput.splice(0)) {
|
||||
stream.outputBatcher.push(chunk.data, chunk.meta)
|
||||
const uncoveredData = getOutputAfterSnapshotSeq(chunk, snapshotOutputSeq)
|
||||
if (uncoveredData) {
|
||||
stream.outputBatcher.push(uncoveredData, chunk.meta)
|
||||
}
|
||||
}
|
||||
stream.pendingOutputBytes = 0
|
||||
stream.pendingOutputOverflowed = false
|
||||
|
|
@ -1778,16 +1803,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
.catch(() => runtime.cleanupSubscription(subscriptionId))
|
||||
const sendFrame = (
|
||||
opcode: TerminalStreamOpcode,
|
||||
payload: Uint8Array<ArrayBufferLike> = new Uint8Array()
|
||||
payload: Uint8Array<ArrayBufferLike> = new Uint8Array(),
|
||||
frameSeq = cursor++
|
||||
): void => {
|
||||
if (closed || !sendBinary) {
|
||||
return
|
||||
}
|
||||
sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: cursor++, payload }))
|
||||
sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: frameSeq, payload }))
|
||||
}
|
||||
outputBatcher = createTerminalOutputBatcher((data) => {
|
||||
for (const chunk of iterateTerminalOutputFrameChunks(data)) {
|
||||
sendFrame(TerminalStreamOpcode.Output, chunk.bytes)
|
||||
outputBatcher = createTerminalOutputBatcher((data, meta) => {
|
||||
for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) {
|
||||
sendFrame(TerminalStreamOpcode.Output, chunk.bytes, chunk.seq)
|
||||
}
|
||||
})
|
||||
unregisterBinaryHandler =
|
||||
|
|
@ -1842,7 +1868,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
return
|
||||
}
|
||||
|
||||
unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => {
|
||||
unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
|
|
@ -1854,13 +1880,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
const measurement = measureTerminalStreamByteLength(data, {
|
||||
stopAfterBytes: remainingBudget
|
||||
})
|
||||
pendingOutput.push({ data, bytes: measurement.byteLength })
|
||||
pendingOutput.push({ data, bytes: measurement.byteLength, meta })
|
||||
pendingOutputBytes += measurement.byteLength
|
||||
const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes)
|
||||
pendingOutputBytes = trimmed.bytes
|
||||
return
|
||||
}
|
||||
outputBatcher?.push(data)
|
||||
outputBatcher?.push(data, meta)
|
||||
})
|
||||
|
||||
const read = await runtime.readTerminal(params.terminal)
|
||||
|
|
@ -1874,7 +1900,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
// the mobile client's stale-event filter knows the high-water mark.
|
||||
// Undefined when the PTY has never transitioned (filter is fail-open).
|
||||
// See docs/mobile-terminal-layout-state-machine.md.
|
||||
const seq = runtime.getLayout(ptyId)?.seq
|
||||
const layoutSeq = runtime.getLayout(ptyId)?.seq
|
||||
const snapshotFrameSeq = serialized?.seq ?? layoutSeq
|
||||
const snapshotOutputSeq = serialized?.seq
|
||||
emit({
|
||||
type: 'subscribed',
|
||||
streamId,
|
||||
|
|
@ -1883,14 +1911,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
cols: serialized?.cols ?? size?.cols,
|
||||
rows: serialized?.rows ?? size?.rows,
|
||||
displayMode,
|
||||
seq
|
||||
seq: layoutSeq
|
||||
})
|
||||
const snapshotStats = sendSnapshotFrames(sendFrame, {
|
||||
kind: 'scrollback',
|
||||
cols: serialized?.cols ?? size?.cols ?? 80,
|
||||
rows: serialized?.rows ?? size?.rows ?? 24,
|
||||
displayMode,
|
||||
seq,
|
||||
seq: snapshotFrameSeq,
|
||||
truncated: serialized ? read.truncated : isTerminalReadPayloadIncomplete(read),
|
||||
truncatedByByteBudget: serialized?.truncatedByByteBudget,
|
||||
oscLinks: serialized?.oscLinks,
|
||||
|
|
@ -1910,7 +1938,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
lastResizeCols = serialized?.cols ?? size?.cols
|
||||
buffering = false
|
||||
for (const item of pendingOutput.splice(0)) {
|
||||
outputBatcher.push(item.data, item.meta)
|
||||
const uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq)
|
||||
if (uncoveredData) {
|
||||
outputBatcher.push(uncoveredData, item.meta)
|
||||
}
|
||||
}
|
||||
pendingOutputBytes = 0
|
||||
outputBatcher.flush()
|
||||
|
|
|
|||
|
|
@ -1026,6 +1026,275 @@ describe('terminal multiplex RPC', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('flushes output buffered during initial multiplex snapshot once', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const dataListenerRef: {
|
||||
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
} = {}
|
||||
let resolveSnapshot: (value: { data: string; cols: number; rows: number }) => void = () => {}
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn(
|
||||
() =>
|
||||
new Promise<{ data: string; cols: number; rows: number }>((resolve) => {
|
||||
resolveSnapshot = resolve
|
||||
})
|
||||
),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn((_: string, listener) => {
|
||||
dataListenerRef.current = listener
|
||||
return vi.fn()
|
||||
}),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
const cleanup = cleanups.get(id)
|
||||
cleanups.delete(id)
|
||||
cleanup?.()
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
|
||||
updateDesktopViewport: vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.subscribe', {
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
capabilities: { terminalBinaryStream: 1 }
|
||||
}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-buffered-output-on-subscribe',
|
||||
sendBinary: (bytes) => binaryFrames.push(bytes),
|
||||
registerBinaryStreamHandler: vi.fn(() => vi.fn())
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined())
|
||||
dataListenerRef.current?.('starting shell\r\n', { seq: 16, rawLength: 16 })
|
||||
resolveSnapshot({ data: '', cols: 120, rows: 40 })
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
|
||||
)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
|
||||
const output = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.join('')
|
||||
expect(output).toBe('starting shell\r\n')
|
||||
|
||||
runtime.cleanupSubscription('terminal-1:desktop-1')
|
||||
await dispatchPromise
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('drops buffered multiplex output already covered by the initial snapshot seq', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const dataListenerRef: {
|
||||
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
} = {}
|
||||
let resolveSnapshot: (value: {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq: number
|
||||
}) => void = () => {}
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn(
|
||||
() =>
|
||||
new Promise<{ data: string; cols: number; rows: number; seq: number }>((resolve) => {
|
||||
resolveSnapshot = resolve
|
||||
})
|
||||
),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn((_: string, listener) => {
|
||||
dataListenerRef.current = listener
|
||||
return vi.fn()
|
||||
}),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
const cleanup = cleanups.get(id)
|
||||
cleanups.delete(id)
|
||||
cleanup?.()
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
|
||||
updateDesktopViewport: vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.subscribe', {
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
capabilities: { terminalBinaryStream: 1 }
|
||||
}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-buffered-output-covered-by-snapshot',
|
||||
sendBinary: (bytes) => binaryFrames.push(bytes),
|
||||
registerBinaryStreamHandler: vi.fn(() => vi.fn())
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined())
|
||||
const startupLine = 'starting shell\r\n'
|
||||
dataListenerRef.current?.(startupLine, {
|
||||
seq: startupLine.length,
|
||||
rawLength: startupLine.length
|
||||
})
|
||||
resolveSnapshot({
|
||||
data: startupLine,
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
seq: startupLine.length
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
|
||||
)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
|
||||
const outputFrames = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
const snapshotStart = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart)
|
||||
expect(snapshotStart && decodeTerminalStreamJson(snapshotStart.payload)).toMatchObject({
|
||||
seq: startupLine.length
|
||||
})
|
||||
expect(outputFrames).toHaveLength(0)
|
||||
|
||||
runtime.cleanupSubscription('terminal-1:desktop-1')
|
||||
await dispatchPromise
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('replays only buffered multiplex output not covered by the initial snapshot seq', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const dataListenerRef: {
|
||||
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
} = {}
|
||||
let resolveSnapshot: (value: {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq: number
|
||||
}) => void = () => {}
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn(
|
||||
() =>
|
||||
new Promise<{ data: string; cols: number; rows: number; seq: number }>((resolve) => {
|
||||
resolveSnapshot = resolve
|
||||
})
|
||||
),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn((_: string, listener) => {
|
||||
dataListenerRef.current = listener
|
||||
return vi.fn()
|
||||
}),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
const cleanup = cleanups.get(id)
|
||||
cleanups.delete(id)
|
||||
cleanup?.()
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
|
||||
updateDesktopViewport: vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.subscribe', {
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
capabilities: { terminalBinaryStream: 1 }
|
||||
}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-buffered-output-partially-covered-by-snapshot',
|
||||
sendBinary: (bytes) => binaryFrames.push(bytes),
|
||||
registerBinaryStreamHandler: vi.fn(() => vi.fn())
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined())
|
||||
const buffered = 'hello world'
|
||||
dataListenerRef.current?.(buffered, {
|
||||
seq: buffered.length,
|
||||
rawLength: buffered.length
|
||||
})
|
||||
resolveSnapshot({
|
||||
data: 'hello',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
seq: 'hello'.length
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
|
||||
)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
|
||||
const output = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.join('')
|
||||
expect(output).toBe(' world')
|
||||
|
||||
runtime.cleanupSubscription('terminal-1:desktop-1')
|
||||
await dispatchPromise
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('settles mobile multiplex PTY waits when the stream signal aborts before PTY spawn', async () => {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
|||
'files.open',
|
||||
'files.openDiff',
|
||||
'files.read',
|
||||
'files.readChunk',
|
||||
'files.readPreview',
|
||||
'files.resolveTerminalPath',
|
||||
'git.abortMerge',
|
||||
|
|
|
|||
|
|
@ -2007,6 +2007,24 @@ export type PreloadApi = {
|
|||
filePath: string
|
||||
connectionId: string
|
||||
}) => Promise<{ canceled: true } | { canceled: false; destinationPath: string }>
|
||||
saveDownloadedFile: (args: {
|
||||
suggestedName: string
|
||||
content: string
|
||||
encoding: 'utf8' | 'base64'
|
||||
}) => Promise<{ canceled: true } | { canceled: false; destinationPath: string }>
|
||||
startDownloadedFile: (args: {
|
||||
suggestedName: string
|
||||
}) => Promise<
|
||||
{ canceled: true } | { canceled: false; transferId: string; destinationPath: string }
|
||||
>
|
||||
appendDownloadedFileChunk: (args: {
|
||||
transferId: string
|
||||
contentBase64: string
|
||||
}) => Promise<{ ok: true }>
|
||||
finishDownloadedFile: (args: {
|
||||
transferId: string
|
||||
}) => Promise<{ canceled: false; destinationPath: string }>
|
||||
cancelDownloadedFile: (args: { transferId: string }) => Promise<{ ok: true }>
|
||||
listMarkdownDocuments: (args: {
|
||||
rootPath: string
|
||||
connectionId?: string
|
||||
|
|
|
|||
|
|
@ -2404,6 +2404,27 @@ const api = {
|
|||
connectionId: string
|
||||
}): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> =>
|
||||
ipcRenderer.invoke('fs:downloadFile', args),
|
||||
saveDownloadedFile: (args: {
|
||||
suggestedName: string
|
||||
content: string
|
||||
encoding: 'utf8' | 'base64'
|
||||
}): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> =>
|
||||
ipcRenderer.invoke('fs:saveDownloadedFile', args),
|
||||
startDownloadedFile: (args: {
|
||||
suggestedName: string
|
||||
}): Promise<
|
||||
{ canceled: true } | { canceled: false; transferId: string; destinationPath: string }
|
||||
> => ipcRenderer.invoke('fs:startDownloadedFile', args),
|
||||
appendDownloadedFileChunk: (args: {
|
||||
transferId: string
|
||||
contentBase64: string
|
||||
}): Promise<{ ok: true }> => ipcRenderer.invoke('fs:appendDownloadedFileChunk', args),
|
||||
finishDownloadedFile: (args: {
|
||||
transferId: string
|
||||
}): Promise<{ canceled: false; destinationPath: string }> =>
|
||||
ipcRenderer.invoke('fs:finishDownloadedFile', args),
|
||||
cancelDownloadedFile: (args: { transferId: string }): Promise<{ ok: true }> =>
|
||||
ipcRenderer.invoke('fs:cancelDownloadedFile', args),
|
||||
listMarkdownDocuments: (args: {
|
||||
rootPath: string
|
||||
connectionId?: string
|
||||
|
|
|
|||
|
|
@ -23,8 +23,10 @@ import {
|
|||
import { FileExplorerVirtualRows } from './FileExplorerVirtualRows'
|
||||
import type { TreeNode } from './file-explorer-types'
|
||||
import { createFileExplorerRowProjection } from './file-explorer-row-projection'
|
||||
import type * as RuntimeFileClient from '@/runtime/runtime-file-client'
|
||||
|
||||
const { toastErrorMock, toastSuccessMock } = vi.hoisted(() => ({
|
||||
const { downloadRuntimeFileMock, toastErrorMock, toastSuccessMock } = vi.hoisted(() => ({
|
||||
downloadRuntimeFileMock: vi.fn(),
|
||||
toastErrorMock: vi.fn(),
|
||||
toastSuccessMock: vi.fn()
|
||||
}))
|
||||
|
|
@ -36,6 +38,14 @@ vi.mock('sonner', () => ({
|
|||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-file-client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof RuntimeFileClient>()
|
||||
return {
|
||||
...actual,
|
||||
downloadRuntimeFile: downloadRuntimeFileMock
|
||||
}
|
||||
})
|
||||
|
||||
type ReactElementLike = {
|
||||
type: unknown
|
||||
props: Record<string, unknown>
|
||||
|
|
@ -565,15 +575,24 @@ describe('FileExplorerRow collapse folder action', () => {
|
|||
).toBe(false)
|
||||
})
|
||||
|
||||
it('shows remote download only for desktop SSH file-like rows', () => {
|
||||
it('shows remote download only for desktop SSH or Remote Host file-like rows', () => {
|
||||
const runtimeContext = {
|
||||
settings: { activeRuntimeEnvironmentId: 'runtime-1' },
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo'
|
||||
}
|
||||
|
||||
expect(shouldShowRemoteDownloadAction(fileNode, 'ssh-1')).toBe(true)
|
||||
expect(shouldShowRemoteDownloadAction({ ...fileNode, isSymlink: true }, 'ssh-1')).toBe(true)
|
||||
expect(shouldShowRemoteDownloadAction(fileNode, null, runtimeContext)).toBe(true)
|
||||
expect(shouldShowRemoteDownloadAction(fileNode, null)).toBe(false)
|
||||
expect(shouldShowRemoteDownloadAction(directoryNode, 'ssh-1')).toBe(false)
|
||||
expect(shouldShowRemoteDownloadAction(directoryNode, null, runtimeContext)).toBe(false)
|
||||
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
|
||||
expect(shouldShowRemoteDownloadAction(fileNode, 'ssh-1')).toBe(false)
|
||||
expect(shouldShowRemoteDownloadAction(fileNode, null, runtimeContext)).toBe(false)
|
||||
})
|
||||
|
||||
it('shows OS file copy for single local rows and SSH file rows on desktop', () => {
|
||||
|
|
@ -677,6 +696,43 @@ describe('FileExplorerRow collapse folder action', () => {
|
|||
expect(toastErrorMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('downloads Remote Host rows through the runtime download path', async () => {
|
||||
const runtimeContext = {
|
||||
settings: { activeRuntimeEnvironmentId: 'runtime-1' },
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo'
|
||||
}
|
||||
downloadRuntimeFileMock.mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
destinationPath: '/downloads/index.ts'
|
||||
})
|
||||
const openPath = vi.fn().mockResolvedValue(undefined)
|
||||
;(
|
||||
globalThis as unknown as {
|
||||
window: {
|
||||
api: {
|
||||
shell: { openPath: typeof openPath }
|
||||
}
|
||||
}
|
||||
}
|
||||
).window = { api: { shell: { openPath } } }
|
||||
|
||||
await downloadRemoteFile(fileNode, runtimeContext)
|
||||
|
||||
expect(downloadRuntimeFileMock).toHaveBeenCalledWith(
|
||||
runtimeContext,
|
||||
'/repo/src/index.ts',
|
||||
'index.ts'
|
||||
)
|
||||
expect(toastSuccessMock).toHaveBeenCalledWith("Downloaded 'index.ts'", {
|
||||
action: {
|
||||
label: 'Open',
|
||||
onClick: expect.any(Function)
|
||||
}
|
||||
})
|
||||
expect(toastErrorMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a failure toast when remote download fails', async () => {
|
||||
const downloadFile = vi.fn().mockRejectedValue(new Error('Remote connection dropped'))
|
||||
;(
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import { useFileExplorerVisibleRowProjection } from './useFileExplorerVisibleRow
|
|||
import { translate } from '@/i18n/i18n'
|
||||
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/components/tab-bar/SortableTab'
|
||||
import type { RightSidebarExplorerView } from '../../../../shared/types'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
|
||||
function FileExplorerFiles(): React.JSX.Element {
|
||||
const explorerView = useAppStore((s) => s.rightSidebarExplorerView)
|
||||
|
|
@ -78,6 +79,9 @@ function FileExplorerFiles(): React.JSX.Element {
|
|||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const activeWorktree = useActiveWorktree()
|
||||
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
|
||||
const activeRuntimeEnvironmentId = useAppStore((s) =>
|
||||
getRuntimeEnvironmentIdForWorktree(s, activeWorktreeId)
|
||||
)
|
||||
const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration)
|
||||
const expandedDirs = useAppStore((s) => s.expandedDirs)
|
||||
const collapseAllDirs = useAppStore((s) => s.collapseAllDirs)
|
||||
|
|
@ -99,6 +103,18 @@ function FileExplorerFiles(): React.JSX.Element {
|
|||
const toggleShowDotfilesForWorktree = useAppStore((s) => s.toggleShowDotfilesForWorktree)
|
||||
|
||||
const worktreePath = activeWorktree?.path ?? null
|
||||
const runtimeDownloadContext = useMemo(
|
||||
() =>
|
||||
activeRuntimeEnvironmentId && activeWorktreeId && worktreePath
|
||||
? {
|
||||
settings: { activeRuntimeEnvironmentId },
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId: activeRepo?.connectionId ?? undefined
|
||||
}
|
||||
: null,
|
||||
[activeRepo?.connectionId, activeRuntimeEnvironmentId, activeWorktreeId, worktreePath]
|
||||
)
|
||||
const isFilesViewActive = explorerView === 'files'
|
||||
const visibleFilesWorktreePath = getVisibleFileExplorerWorktreePath({
|
||||
explorerView,
|
||||
|
|
@ -716,6 +732,7 @@ function FileExplorerFiles(): React.JSX.Element {
|
|||
flashingPath={flashingPath}
|
||||
deleteShortcutLabel={deleteShortcutLabel}
|
||||
connectionId={activeRepo?.connectionId ?? null}
|
||||
runtimeDownloadContext={runtimeDownloadContext}
|
||||
onClick={handleRowClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenuSelect={preserveSelectionForContextMenu}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/loc
|
|||
import { translate } from '@/i18n/i18n'
|
||||
import { extractIpcErrorMessage } from '@/lib/ipc-error'
|
||||
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/components/tab-bar/SortableTab'
|
||||
import { downloadRuntimeFile, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const isLinux = navigator.userAgent.includes('Linux')
|
||||
|
|
@ -270,6 +271,7 @@ type FileExplorerRowProps = {
|
|||
isIgnored: boolean
|
||||
deleteShortcutLabel: string
|
||||
connectionId?: string | null
|
||||
runtimeDownloadContext?: RuntimeFileOperationArgs | null
|
||||
canCollapseFolderSubtree: boolean
|
||||
targetDir: string
|
||||
targetDepth: number
|
||||
|
|
@ -304,12 +306,13 @@ export function shouldShowFindInFolderAction(node: TreeNode): boolean {
|
|||
|
||||
export function shouldShowRemoteDownloadAction(
|
||||
node: TreeNode,
|
||||
connectionId?: string | null
|
||||
connectionId?: string | null,
|
||||
runtimeDownloadContext?: RuntimeFileOperationArgs | null
|
||||
): boolean {
|
||||
// Why: Desktop-only because download depends on Electron's native save dialog.
|
||||
return (
|
||||
!node.isDirectory &&
|
||||
Boolean(connectionId) &&
|
||||
Boolean(connectionId || runtimeDownloadContext) &&
|
||||
(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ !== true
|
||||
)
|
||||
}
|
||||
|
|
@ -328,9 +331,18 @@ export function shouldShowCopyFileAction(
|
|||
)
|
||||
}
|
||||
|
||||
export async function downloadRemoteFile(node: TreeNode, connectionId: string): Promise<void> {
|
||||
export async function downloadRemoteFile(
|
||||
node: TreeNode,
|
||||
connectionIdOrRuntimeContext: string | RuntimeFileOperationArgs
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await window.api.fs.downloadFile({ filePath: node.path, connectionId })
|
||||
const result =
|
||||
typeof connectionIdOrRuntimeContext === 'string'
|
||||
? await window.api.fs.downloadFile({
|
||||
filePath: node.path,
|
||||
connectionId: connectionIdOrRuntimeContext
|
||||
})
|
||||
: await downloadRuntimeFile(connectionIdOrRuntimeContext, node.path, node.name)
|
||||
// Why: Suppress toasts when the user cancels the native save dialog per design.
|
||||
if (result.canceled) {
|
||||
return
|
||||
|
|
@ -396,6 +408,7 @@ export function FileExplorerRow({
|
|||
isIgnored,
|
||||
deleteShortcutLabel,
|
||||
connectionId,
|
||||
runtimeDownloadContext,
|
||||
canCollapseFolderSubtree,
|
||||
targetDir,
|
||||
targetDepth,
|
||||
|
|
@ -426,7 +439,11 @@ export function FileExplorerRow({
|
|||
const findInFolderShortcutLabel = useShortcutLabel('sidebar.search.toggle')
|
||||
const FileIcon = getFileTypeIcon(node.relativePath || node.name)
|
||||
const rowDropDir = node.isDirectory ? node.path : targetDir
|
||||
const showRemoteDownloadAction = shouldShowRemoteDownloadAction(node, connectionId)
|
||||
const showRemoteDownloadAction = shouldShowRemoteDownloadAction(
|
||||
node,
|
||||
connectionId,
|
||||
runtimeDownloadContext
|
||||
)
|
||||
const showCopyFileAction = shouldShowCopyFileAction(node, connectionId, selectionSize)
|
||||
const { setRowDragNode, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } =
|
||||
useFileExplorerRowDrag({
|
||||
|
|
@ -450,11 +467,12 @@ export function FileExplorerRow({
|
|||
}
|
||||
}, [activeWorktreeId, node.path])
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!connectionId) {
|
||||
const downloadTarget = connectionId || runtimeDownloadContext
|
||||
if (!downloadTarget) {
|
||||
return
|
||||
}
|
||||
void downloadRemoteFile(node, connectionId)
|
||||
}, [connectionId, node])
|
||||
void downloadRemoteFile(node, downloadTarget)
|
||||
}, [connectionId, node, runtimeDownloadContext])
|
||||
const handleCopyFile = useCallback(() => {
|
||||
void copyFileToOsClipboard(node, connectionId)
|
||||
}, [connectionId, node])
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { FileExplorerRow, InlineInputRow, type InlineInput } from './FileExplore
|
|||
import { shouldShowIgnoredDecoration, STATUS_COLORS } from './status-display'
|
||||
import type { DirCache, TreeNode } from './file-explorer-types'
|
||||
import type { FileExplorerRowProjection } from './file-explorer-row-projection'
|
||||
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
|
||||
|
||||
type FileExplorerVirtualRowsProps = {
|
||||
virtualizer: Virtualizer<HTMLDivElement, Element>
|
||||
|
|
@ -26,6 +27,7 @@ type FileExplorerVirtualRowsProps = {
|
|||
flashingPath: string | null
|
||||
deleteShortcutLabel: string
|
||||
connectionId?: string | null
|
||||
runtimeDownloadContext?: RuntimeFileOperationArgs | null
|
||||
onClick: (node: TreeNode, event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
onDoubleClick: (node: TreeNode) => void
|
||||
onContextMenuSelect: (node: TreeNode) => void
|
||||
|
|
@ -68,6 +70,7 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re
|
|||
flashingPath,
|
||||
deleteShortcutLabel,
|
||||
connectionId,
|
||||
runtimeDownloadContext,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onContextMenuSelect,
|
||||
|
|
@ -168,6 +171,7 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re
|
|||
isIgnored={isIgnored}
|
||||
deleteShortcutLabel={deleteShortcutLabel}
|
||||
connectionId={connectionId}
|
||||
runtimeDownloadContext={runtimeDownloadContext}
|
||||
canCollapseFolderSubtree={canCollapseFolderSubtree}
|
||||
targetDir={n.isDirectory ? n.path : dirname(n.path)}
|
||||
targetDepth={n.isDirectory ? n.depth + 1 : n.depth}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
copyRuntimePath,
|
||||
createRuntimePath,
|
||||
deleteRuntimePath,
|
||||
downloadRuntimeFile,
|
||||
getRuntimeFileReadScope,
|
||||
importExternalPathsToRuntime,
|
||||
listRuntimeFiles,
|
||||
|
|
@ -36,6 +37,12 @@ const fsDeletePath = vi.fn()
|
|||
const fsStat = vi.fn()
|
||||
const fsPathExists = vi.fn()
|
||||
const fsSearch = vi.fn()
|
||||
const fsDownloadFile = vi.fn()
|
||||
const fsSaveDownloadedFile = vi.fn()
|
||||
const fsStartDownloadedFile = vi.fn()
|
||||
const fsAppendDownloadedFileChunk = vi.fn()
|
||||
const fsFinishDownloadedFile = vi.fn()
|
||||
const fsCancelDownloadedFile = vi.fn()
|
||||
const fsImportExternalPaths = vi.fn()
|
||||
const fsStageExternalPathsForRuntimeUpload = vi.fn()
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
|
|
@ -56,6 +63,12 @@ beforeEach(() => {
|
|||
fsStat.mockReset()
|
||||
fsPathExists.mockReset()
|
||||
fsSearch.mockReset()
|
||||
fsDownloadFile.mockReset()
|
||||
fsSaveDownloadedFile.mockReset()
|
||||
fsStartDownloadedFile.mockReset()
|
||||
fsAppendDownloadedFileChunk.mockReset()
|
||||
fsFinishDownloadedFile.mockReset()
|
||||
fsCancelDownloadedFile.mockReset()
|
||||
fsImportExternalPaths.mockReset()
|
||||
fsStageExternalPathsForRuntimeUpload.mockReset()
|
||||
runtimeEnvironmentCall.mockReset()
|
||||
|
|
@ -89,6 +102,12 @@ beforeEach(() => {
|
|||
stat: fsStat,
|
||||
pathExists: fsPathExists,
|
||||
search: fsSearch,
|
||||
downloadFile: fsDownloadFile,
|
||||
saveDownloadedFile: fsSaveDownloadedFile,
|
||||
startDownloadedFile: fsStartDownloadedFile,
|
||||
appendDownloadedFileChunk: fsAppendDownloadedFileChunk,
|
||||
finishDownloadedFile: fsFinishDownloadedFile,
|
||||
cancelDownloadedFile: fsCancelDownloadedFile,
|
||||
importExternalPaths: fsImportExternalPaths,
|
||||
stageExternalPathsForRuntimeUpload: fsStageExternalPathsForRuntimeUpload
|
||||
},
|
||||
|
|
@ -348,6 +367,96 @@ describe('runtime file client', () => {
|
|||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('downloads remote runtime files in chunks instead of using preview content', async () => {
|
||||
fsStartDownloadedFile.mockResolvedValue({
|
||||
canceled: false,
|
||||
transferId: 'download-1',
|
||||
destinationPath: '/downloads/archive.zip'
|
||||
})
|
||||
fsAppendDownloadedFileChunk.mockResolvedValue({ ok: true })
|
||||
fsFinishDownloadedFile.mockResolvedValue({
|
||||
canceled: false,
|
||||
destinationPath: '/downloads/archive.zip'
|
||||
})
|
||||
runtimeEnvironmentCall
|
||||
.mockResolvedValueOnce({
|
||||
id: 'chunk-1',
|
||||
ok: true,
|
||||
result: { contentBase64: 'YWJj', bytesRead: 3, eof: false },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'chunk-2',
|
||||
ok: true,
|
||||
result: { contentBase64: 'ZA==', bytesRead: 1, eof: true },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
|
||||
await expect(
|
||||
downloadRuntimeFile(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' },
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/remote/repo'
|
||||
},
|
||||
'/remote/repo/archive.zip',
|
||||
'archive.zip'
|
||||
)
|
||||
).resolves.toEqual({ canceled: false, destinationPath: '/downloads/archive.zip' })
|
||||
|
||||
expect(fsStartDownloadedFile).toHaveBeenCalledWith({ suggestedName: 'archive.zip' })
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, {
|
||||
selector: 'env-1',
|
||||
method: 'files.readChunk',
|
||||
params: {
|
||||
worktree: 'id:wt-1',
|
||||
relativePath: 'archive.zip',
|
||||
offset: 0,
|
||||
length: 384 * 1024
|
||||
},
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, {
|
||||
selector: 'env-1',
|
||||
method: 'files.readChunk',
|
||||
params: {
|
||||
worktree: 'id:wt-1',
|
||||
relativePath: 'archive.zip',
|
||||
offset: 3,
|
||||
length: 384 * 1024
|
||||
},
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
expect(fsAppendDownloadedFileChunk).toHaveBeenCalledTimes(2)
|
||||
expect(fsFinishDownloadedFile).toHaveBeenCalledWith({ transferId: 'download-1' })
|
||||
expect(fsCancelDownloadedFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels the local temp download when a remote chunk fails', async () => {
|
||||
fsStartDownloadedFile.mockResolvedValue({
|
||||
canceled: false,
|
||||
transferId: 'download-1',
|
||||
destinationPath: '/downloads/archive.zip'
|
||||
})
|
||||
fsCancelDownloadedFile.mockResolvedValue({ ok: true })
|
||||
runtimeEnvironmentCall.mockRejectedValueOnce(new Error('connection dropped'))
|
||||
|
||||
await expect(
|
||||
downloadRuntimeFile(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' },
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/remote/repo'
|
||||
},
|
||||
'/remote/repo/archive.zip',
|
||||
'archive.zip'
|
||||
)
|
||||
).rejects.toThrow('connection dropped')
|
||||
|
||||
expect(fsCancelDownloadedFile).toHaveBeenCalledWith({ transferId: 'download-1' })
|
||||
expect(fsFinishDownloadedFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes root directory reads with an empty relative path', async () => {
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-1',
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ import type {
|
|||
SearchOptions,
|
||||
SearchResult
|
||||
} from '../../../shared/types'
|
||||
import type { RuntimeFilePreviewResult, RuntimeFileReadResult } from '../../../shared/runtime-types'
|
||||
import type {
|
||||
RuntimeFilePreviewResult,
|
||||
RuntimeFileReadChunkResult,
|
||||
RuntimeFileReadResult
|
||||
} from '../../../shared/runtime-types'
|
||||
import {
|
||||
callRuntimeRpc,
|
||||
getActiveRuntimeTarget,
|
||||
|
|
@ -49,6 +53,10 @@ export type RuntimeFileOperationArgs = {
|
|||
connectionId?: string
|
||||
}
|
||||
|
||||
export type RuntimeFileDownloadResult =
|
||||
| { canceled: true }
|
||||
| { canceled: false; destinationPath: string }
|
||||
|
||||
type StagedRuntimeImportSource =
|
||||
| {
|
||||
sourcePath: string
|
||||
|
|
@ -93,6 +101,7 @@ type RuntimeFileWatchEvent =
|
|||
| { type: 'end' }
|
||||
|
||||
const REMOTE_UPLOAD_BASE64_CHUNK_CHARS = 512 * 1024
|
||||
const REMOTE_DOWNLOAD_CHUNK_BYTES = 384 * 1024
|
||||
|
||||
type RuntimeFileWatchListener = {
|
||||
onPayload: (payload: FsChangedPayload) => void
|
||||
|
|
@ -179,6 +188,71 @@ export async function readRuntimeFilePreview(
|
|||
)
|
||||
}
|
||||
|
||||
export async function downloadRuntimeFile(
|
||||
context: RuntimeFileOperationArgs,
|
||||
filePath: string,
|
||||
suggestedName: string
|
||||
): Promise<RuntimeFileDownloadResult> {
|
||||
const remoteArgs = getRemoteFileArgs(context, filePath)
|
||||
if (!remoteArgs) {
|
||||
if (hasRemoteRuntimeOwner(context)) {
|
||||
throw new Error('Remote file is outside the owning runtime worktree')
|
||||
}
|
||||
if (context.connectionId) {
|
||||
return window.api.fs.downloadFile({ filePath, connectionId: context.connectionId })
|
||||
}
|
||||
const result = await readRuntimeFilePreview(context, filePath)
|
||||
return window.api.fs.saveDownloadedFile({
|
||||
suggestedName,
|
||||
content: result.content,
|
||||
encoding: result.isBinary ? 'base64' : 'utf8'
|
||||
})
|
||||
}
|
||||
|
||||
const download = await window.api.fs.startDownloadedFile({ suggestedName })
|
||||
if (download.canceled) {
|
||||
return download
|
||||
}
|
||||
|
||||
let finished = false
|
||||
try {
|
||||
let offset = 0
|
||||
for (;;) {
|
||||
const chunk = await callRuntimeRpc<RuntimeFileReadChunkResult>(
|
||||
remoteArgs.target,
|
||||
'files.readChunk',
|
||||
{
|
||||
worktree: remoteArgs.worktreeSelector,
|
||||
relativePath: remoteArgs.relativePath,
|
||||
offset,
|
||||
length: REMOTE_DOWNLOAD_CHUNK_BYTES
|
||||
},
|
||||
{ timeoutMs: 60_000 }
|
||||
)
|
||||
if (chunk.bytesRead > 0) {
|
||||
await window.api.fs.appendDownloadedFileChunk({
|
||||
transferId: download.transferId,
|
||||
contentBase64: chunk.contentBase64
|
||||
})
|
||||
}
|
||||
offset += chunk.bytesRead
|
||||
if (chunk.eof) {
|
||||
break
|
||||
}
|
||||
if (chunk.bytesRead <= 0) {
|
||||
throw new Error('Remote download stalled before reaching EOF')
|
||||
}
|
||||
}
|
||||
const result = await window.api.fs.finishDownloadedFile({ transferId: download.transferId })
|
||||
finished = true
|
||||
return result
|
||||
} finally {
|
||||
if (!finished) {
|
||||
await window.api.fs.cancelDownloadedFile({ transferId: download.transferId }).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function readRuntimeDirectory(
|
||||
context: RuntimeFileOperationArgs,
|
||||
dirPath: string
|
||||
|
|
|
|||
|
|
@ -1274,6 +1274,21 @@ function createFileApi(): NonNullable<Partial<PreloadApi>['fs']> {
|
|||
downloadFile: async () => {
|
||||
throw new Error('Remote file download is unavailable in paired web clients.')
|
||||
},
|
||||
saveDownloadedFile: async () => {
|
||||
throw new Error('Remote file download is unavailable in paired web clients.')
|
||||
},
|
||||
startDownloadedFile: async () => {
|
||||
throw new Error('Remote file download is unavailable in paired web clients.')
|
||||
},
|
||||
appendDownloadedFileChunk: async () => {
|
||||
throw new Error('Remote file download is unavailable in paired web clients.')
|
||||
},
|
||||
finishDownloadedFile: async () => {
|
||||
throw new Error('Remote file download is unavailable in paired web clients.')
|
||||
},
|
||||
cancelDownloadedFile: async () => {
|
||||
throw new Error('Remote file download is unavailable in paired web clients.')
|
||||
},
|
||||
listMarkdownDocuments: async ({ rootPath }) => {
|
||||
const file = await resolveRuntimeFilePath(rootPath)
|
||||
return callRuntimeResult('files.listMarkdownDocuments', {
|
||||
|
|
|
|||
|
|
@ -77,3 +77,25 @@ describe('MiMo title detection', () => {
|
|||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('Pi-compatible title detection', () => {
|
||||
it.each([
|
||||
['\u280b OMP', 'OMP', 'working'],
|
||||
['OMP ready', 'OMP', 'idle'],
|
||||
['OMP - action required', 'OMP', 'permission'],
|
||||
['\u280b Pi', 'Pi', 'working'],
|
||||
['Pi ready', 'Pi', 'idle'],
|
||||
['Pi - action required', 'Pi', 'permission']
|
||||
] as const)('classifies synthesized %s', (title, expectedLabel, expectedStatus) => {
|
||||
expect(getAgentLabel(title)).toBe(expectedLabel)
|
||||
expect(detectAgentStatusFromTitle(title)).toBe(expectedStatus)
|
||||
})
|
||||
|
||||
it.each(['~/omp/working', 'omp-harness ready', '~/pi/working', 'pi-scratch ready'])(
|
||||
'does not classify path or hyphen false positive %s',
|
||||
(title) => {
|
||||
expect(getAgentLabel(title)).toBeNull()
|
||||
expect(detectAgentStatusFromTitle(title)).toBeNull()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ import {
|
|||
titleHasAgentName,
|
||||
titleHasAnyLegacyAgentName
|
||||
} from './agent-name-token-match'
|
||||
import {
|
||||
getPiCompatibleSyntheticAgentLabel,
|
||||
getPiCompatibleSyntheticAgentStatus
|
||||
} from './pi-compatible-synthetic-title'
|
||||
|
||||
// Re-export so existing `agent-detection` importers keep working.
|
||||
export { AGENT_NAMES, titleHasAgentName } from './agent-name-token-match'
|
||||
|
|
@ -319,6 +323,12 @@ export function getAgentLabel(title: string): string | null {
|
|||
if (isGeminiTerminalTitle(title)) {
|
||||
return 'Gemini CLI'
|
||||
}
|
||||
// Why: Pi-compatible synthetic titles can carry braille spinners, which the
|
||||
// generic agent-title heuristics would otherwise claim first.
|
||||
const piCompatibleSyntheticAgentLabel = getPiCompatibleSyntheticAgentLabel(title)
|
||||
if (piCompatibleSyntheticAgentLabel) {
|
||||
return piCompatibleSyntheticAgentLabel
|
||||
}
|
||||
// Why: Pi working titles include a braille spinner prefix, which would be
|
||||
// mistaken for Claude Code if we checked `isClaudeAgent` first.
|
||||
if (isPiAgentTitle(title)) {
|
||||
|
|
@ -417,6 +427,13 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null {
|
|||
return 'idle'
|
||||
}
|
||||
|
||||
// Why: resolve synthetic Pi/OMP permission/idle labels before the broader
|
||||
// Pi and braille-spinner checks below.
|
||||
const piCompatibleSyntheticAgentStatus = getPiCompatibleSyntheticAgentStatus(title)
|
||||
if (piCompatibleSyntheticAgentStatus) {
|
||||
return piCompatibleSyntheticAgentStatus
|
||||
}
|
||||
|
||||
// Claude Code uses ✳ prefix for idle — must check before braille/agent-name
|
||||
// because the title text is the task description, not "Claude Code".
|
||||
if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
export type PiCompatibleSyntheticAgentLabel = 'Pi' | 'OMP'
|
||||
export type PiCompatibleSyntheticAgentStatus = 'working' | 'permission' | 'idle'
|
||||
|
||||
const PI_COMPATIBLE_SYNTHETIC_TITLE_RE =
|
||||
/^\s*(?:[\u2800-\u28ff]\s+)?(pi|omp)(?:\s+-\s+action required|\s+(?:ready|idle|done))?\s*$/i
|
||||
const PI_COMPATIBLE_IDLE_RE = /(?<![\w./\\-])(?:ready|idle|done)(?![\w-])/i
|
||||
|
||||
function containsBrailleSpinner(title: string): boolean {
|
||||
for (const char of title) {
|
||||
const codePoint = char.codePointAt(0)
|
||||
if (codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function getPiCompatibleSyntheticAgentLabel(
|
||||
title: string
|
||||
): PiCompatibleSyntheticAgentLabel | null {
|
||||
const match = PI_COMPATIBLE_SYNTHETIC_TITLE_RE.exec(title)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return match[1].toLowerCase() === 'omp' ? 'OMP' : 'Pi'
|
||||
}
|
||||
|
||||
export function getPiCompatibleSyntheticAgentStatus(
|
||||
title: string
|
||||
): PiCompatibleSyntheticAgentStatus | null {
|
||||
if (!getPiCompatibleSyntheticAgentLabel(title)) {
|
||||
return null
|
||||
}
|
||||
if (containsBrailleSpinner(title)) {
|
||||
return 'working'
|
||||
}
|
||||
const lower = title.toLowerCase()
|
||||
if (
|
||||
lower.includes('action required') ||
|
||||
lower.includes('permission') ||
|
||||
lower.includes('waiting')
|
||||
) {
|
||||
return 'permission'
|
||||
}
|
||||
if (PI_COMPATIBLE_IDLE_RE.test(title)) {
|
||||
return 'idle'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
@ -327,6 +327,12 @@ export type RuntimeFilePreviewResult = {
|
|||
mimeType?: string
|
||||
}
|
||||
|
||||
export type RuntimeFileReadChunkResult = {
|
||||
contentBase64: string
|
||||
bytesRead: number
|
||||
eof: boolean
|
||||
}
|
||||
|
||||
export type RuntimeTerminalSummary = {
|
||||
handle: string
|
||||
ptyId: string | null
|
||||
|
|
|
|||
|
|
@ -20,4 +20,16 @@ describe('synthetic agent titles', () => {
|
|||
expect(getSyntheticAgentTerminalTitle('devin', 'waiting')).toBe('Devin - action required')
|
||||
expect(shouldDriveSyntheticAgentTitleFromHook('devin', 'working')).toBe(true)
|
||||
})
|
||||
|
||||
it('provides Pi-compatible OMP titles for hook-driven status updates', () => {
|
||||
expect(getSyntheticAgentTerminalTitle('omp', 'done')).toBe('OMP ready')
|
||||
expect(getSyntheticAgentTerminalTitle('omp', 'waiting')).toBe('OMP - action required')
|
||||
expect(shouldDriveSyntheticAgentTitleFromHook('omp', 'working')).toBe(true)
|
||||
})
|
||||
|
||||
it('provides Pi titles for hook-driven status updates', () => {
|
||||
expect(getSyntheticAgentTerminalTitle('pi', 'done')).toBe('Pi ready')
|
||||
expect(getSyntheticAgentTerminalTitle('pi', 'waiting')).toBe('Pi - action required')
|
||||
expect(shouldDriveSyntheticAgentTitleFromHook('pi', 'working')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -26,6 +26,16 @@ export const SYNTHETIC_AGENT_TITLE_PROFILES: Record<string, SyntheticAgentTitleP
|
|||
permissionLabel: 'OpenCode - action required',
|
||||
idleLabel: 'OpenCode ready'
|
||||
},
|
||||
pi: {
|
||||
workingLabel: 'Pi',
|
||||
permissionLabel: 'Pi - action required',
|
||||
idleLabel: 'Pi ready'
|
||||
},
|
||||
omp: {
|
||||
workingLabel: 'OMP',
|
||||
permissionLabel: 'OMP - action required',
|
||||
idleLabel: 'OMP ready'
|
||||
},
|
||||
droid: {
|
||||
workingLabel: 'Droid',
|
||||
permissionLabel: 'Droid - action required',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager,
|
||||
waitForTerminalOutput
|
||||
} from './helpers/terminal'
|
||||
import {
|
||||
cleanupDockerSshRelayTarget,
|
||||
DOCKER_SSH_RELAY_REMOTE_REPO_PATH,
|
||||
startDockerSshRelayTarget,
|
||||
type DockerSshRelayTarget
|
||||
} from './helpers/docker-ssh-relay-target'
|
||||
|
||||
const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
|
||||
|
||||
type ConnectedDockerRemote = {
|
||||
targetId: string
|
||||
repoId: string
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
type RuntimeTerminalStatus = {
|
||||
isRunningAgent: boolean
|
||||
status: string | null
|
||||
}
|
||||
|
||||
type RuntimeTerminalSummary = {
|
||||
handle: string
|
||||
ptyId: string | null
|
||||
title: string | null
|
||||
}
|
||||
|
||||
async function connectDockerRemote(
|
||||
page: Page,
|
||||
target: DockerSshRelayTarget
|
||||
): Promise<ConnectedDockerRemote> {
|
||||
return await page.evaluate(
|
||||
async ({ target, remotePath }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Store unavailable')
|
||||
}
|
||||
const credentialUnsub = window.api.ssh.onCredentialRequest((request) => {
|
||||
void window.api.ssh.submitCredential({ requestId: request.requestId, value: null })
|
||||
})
|
||||
try {
|
||||
const createdTarget = await window.api.ssh.addTarget({
|
||||
target: {
|
||||
label: `Docker SSH Pi-Compatible Agent ${Date.now()}`,
|
||||
host: '127.0.0.1',
|
||||
port: target.port,
|
||||
username: 'root',
|
||||
identityFile: target.identityFile,
|
||||
identitiesOnly: true,
|
||||
relayGracePeriodSeconds: 1
|
||||
}
|
||||
})
|
||||
const state = await window.api.ssh.connect({ targetId: createdTarget.id })
|
||||
if (!state || state.status !== 'connected') {
|
||||
throw new Error(`SSH target did not connect: ${JSON.stringify(state)}`)
|
||||
}
|
||||
store.getState().setSshConnectionState(createdTarget.id, state)
|
||||
const labels = new Map(store.getState().sshTargetLabels)
|
||||
labels.set(createdTarget.id, createdTarget.label)
|
||||
store.getState().setSshTargetLabels(labels)
|
||||
|
||||
const result = await window.api.repos.addRemote({
|
||||
connectionId: createdTarget.id,
|
||||
remotePath,
|
||||
displayName: 'Docker SSH Pi-Compatible Agent'
|
||||
})
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
await store.getState().fetchRepos()
|
||||
await store.getState().fetchWorktrees(result.repo.id)
|
||||
const worktree = (store.getState().worktreesByRepo[result.repo.id] ?? [])[0]
|
||||
if (!worktree) {
|
||||
throw new Error(`No remote worktree found for ${result.repo.path}`)
|
||||
}
|
||||
store.getState().setActiveWorktree(worktree.id)
|
||||
if ((store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) {
|
||||
store.getState().createTab(worktree.id)
|
||||
}
|
||||
store.getState().setActiveTabType('terminal')
|
||||
return {
|
||||
targetId: createdTarget.id,
|
||||
repoId: result.repo.id,
|
||||
worktreeId: worktree.id
|
||||
}
|
||||
} finally {
|
||||
credentialUnsub()
|
||||
}
|
||||
},
|
||||
{ target, remotePath: DOCKER_SSH_RELAY_REMOTE_REPO_PATH }
|
||||
)
|
||||
}
|
||||
|
||||
async function emitOscTitle(page: Page, ptyId: string, title: string): Promise<void> {
|
||||
await sendToTerminal(page, ptyId, `printf '\\033]0;${title}\\007'\r`)
|
||||
}
|
||||
|
||||
async function findTerminalByPtyId(page: Page, ptyId: string): Promise<string> {
|
||||
return page.evaluate(async (ptyId) => {
|
||||
const response = await window.api.runtime.call({
|
||||
method: 'terminal.list',
|
||||
params: { limit: 50 }
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const terminals = (response.result as { terminals: RuntimeTerminalSummary[] }).terminals
|
||||
const terminal = terminals.find((candidate) => candidate.ptyId === ptyId)
|
||||
if (!terminal) {
|
||||
throw new Error(
|
||||
`No runtime terminal for PTY ${ptyId}; terminals=${JSON.stringify(terminals)}`
|
||||
)
|
||||
}
|
||||
return terminal.handle
|
||||
}, ptyId)
|
||||
}
|
||||
|
||||
async function readTerminalAgentStatus(
|
||||
page: Page,
|
||||
terminalHandle: string
|
||||
): Promise<RuntimeTerminalStatus> {
|
||||
return page.evaluate(async (terminal) => {
|
||||
const response = await window.api.runtime.call({
|
||||
method: 'terminal.agentStatus',
|
||||
params: { terminal }
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
return (response.result as { agentStatus: RuntimeTerminalStatus }).agentStatus
|
||||
}, terminalHandle)
|
||||
}
|
||||
|
||||
test.describe('Docker SSH Pi-compatible agent titles', () => {
|
||||
test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH relay tests.')
|
||||
test.skip(process.platform === 'win32', 'Docker SSH relay tests use POSIX ssh tooling.')
|
||||
|
||||
test('classifies OMP and Pi title transitions from a remote terminal', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.slow()
|
||||
let target: DockerSshRelayTarget | null = null
|
||||
try {
|
||||
target = startDockerSshRelayTarget(testInfo)
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
const remote = await connectDockerRemote(orcaPage, target)
|
||||
await ensureTerminalVisible(orcaPage, 45_000)
|
||||
await waitForActiveTerminalManager(orcaPage, 60_000)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage, 60_000)
|
||||
const terminalHandle = await findTerminalByPtyId(orcaPage, ptyId)
|
||||
|
||||
const marker = `PI_COMPATIBLE_TITLE_READY_${Date.now()}`
|
||||
await sendToTerminal(orcaPage, ptyId, `printf '${marker}\\n'\r`)
|
||||
await waitForTerminalOutput(orcaPage, marker, 20_000, 60_000)
|
||||
|
||||
await emitOscTitle(orcaPage, ptyId, '\u280b OMP')
|
||||
await expect
|
||||
.poll(async () => readTerminalAgentStatus(orcaPage, terminalHandle), {
|
||||
timeout: 10_000,
|
||||
message: 'Remote OMP working title did not classify as an agent status'
|
||||
})
|
||||
.toMatchObject({ isRunningAgent: true, status: 'working' })
|
||||
|
||||
await emitOscTitle(orcaPage, ptyId, 'OMP ready')
|
||||
await expect
|
||||
.poll(async () => readTerminalAgentStatus(orcaPage, terminalHandle), {
|
||||
timeout: 10_000,
|
||||
message: 'Remote OMP ready title did not classify as idle'
|
||||
})
|
||||
.toMatchObject({ isRunningAgent: true, status: 'idle' })
|
||||
|
||||
await emitOscTitle(orcaPage, ptyId, '\u280b Pi')
|
||||
await expect
|
||||
.poll(async () => readTerminalAgentStatus(orcaPage, terminalHandle), {
|
||||
timeout: 10_000,
|
||||
message: 'Remote Pi working title did not classify as an agent status'
|
||||
})
|
||||
.toMatchObject({ isRunningAgent: true, status: 'working' })
|
||||
|
||||
testInfo.annotations.push({
|
||||
type: 'docker-ssh-pi-compatible-title',
|
||||
description: `target=${remote.targetId} repo=${remote.repoId} worktree=${remote.worktreeId} pty=${ptyId} terminal=${terminalHandle}`
|
||||
})
|
||||
} finally {
|
||||
cleanupDockerSshRelayTarget(target)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue