mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 00:02:37 +00:00
Refactor runtime app architecture (#1878)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -67,6 +67,9 @@ design-docs/
|
||||
.context/
|
||||
.atl/
|
||||
|
||||
# Machine-local agent hook endpoint files may contain auth tokens.
|
||||
/agent-hooks/
|
||||
|
||||
# Local-only design/planning docs (not checked in)
|
||||
docs/*.md
|
||||
!docs/README*.md
|
||||
|
||||
@@ -39,6 +39,8 @@ module.exports = {
|
||||
// integration — dependencies inside the asar archive are invisible to
|
||||
// require(). Unpack CLI runtime deps so they resolve from
|
||||
// app.asar.unpacked/node_modules/.
|
||||
// Why: remote runtime connections use WebSocket + E2EE from the packaged CLI
|
||||
// before the GUI process starts, so those deps need the same treatment.
|
||||
// Why: sherpa-onnx native bindings (platform-specific subpackages) must be
|
||||
// unpacked because they ship .node addons + .dylib/.so files that cannot be
|
||||
// dlopen()'d from inside the asar archive.
|
||||
@@ -49,6 +51,8 @@ module.exports = {
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/chunks/**',
|
||||
'resources/**',
|
||||
'node_modules/ws/**',
|
||||
'node_modules/tweetnacl/**',
|
||||
'node_modules/zod/**',
|
||||
'node_modules/sherpa-onnx*/**'
|
||||
],
|
||||
|
||||
@@ -7,41 +7,41 @@ class AudioEngine {
|
||||
private var engineConfigChangeObserver: Any?
|
||||
private var sessionInterruptionObserver: Any?
|
||||
private var mediaServicesResetObserver: Any?
|
||||
|
||||
|
||||
public private(set) var voiceIOFormat: AVAudioFormat
|
||||
public private(set) var isRecording = false
|
||||
private var wasRecordingBeforeInterruption = false
|
||||
|
||||
|
||||
public var onMicDataCallback: ((Data) -> Void)?
|
||||
public var onInputVolumeCallback: ((Float) -> Void)?
|
||||
public var onOutputVolumeCallback: ((Float) -> Void)?
|
||||
public var onAudioInterruptionCallback: ((String) -> Void)?
|
||||
|
||||
|
||||
private var inputLevelTimer: Timer?
|
||||
private var outputLevelTimer: Timer?
|
||||
|
||||
|
||||
private var inputBuffer = [Float](repeating: 0, count: 2048)
|
||||
private var outputBuffer = [Float](repeating: 0, count: 2048)
|
||||
private var inputBufferIndex = 0
|
||||
private var outputBufferIndex = 0
|
||||
|
||||
|
||||
private var hasFirstInputBeenDiscarded = false
|
||||
private var discardRecording = false
|
||||
private var discardFirstInputMillis = 2000
|
||||
|
||||
|
||||
enum AudioEngineError: Error {
|
||||
case audioFormatError
|
||||
}
|
||||
|
||||
|
||||
init() throws {
|
||||
avAudioEngine.attach(speechPlayer)
|
||||
|
||||
|
||||
guard let format = AVAudioFormat(standardFormatWithSampleRate: 16000, channels: 1) else {
|
||||
throw AudioEngineError.audioFormatError
|
||||
}
|
||||
voiceIOFormat = format
|
||||
print("Voice IO format: \(String(describing: voiceIOFormat))")
|
||||
|
||||
|
||||
engineConfigChangeObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVAudioEngineConfigurationChange,
|
||||
object: avAudioEngine,
|
||||
@@ -60,12 +60,12 @@ class AudioEngine {
|
||||
queue: .main) { [weak self] _ in
|
||||
self?.handleMediaServicesWereReset()
|
||||
}
|
||||
|
||||
|
||||
self.setupAudioSession()
|
||||
self.setup()
|
||||
self.start()
|
||||
}
|
||||
|
||||
|
||||
deinit {
|
||||
if let observer = engineConfigChangeObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
@@ -77,29 +77,29 @@ class AudioEngine {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func setupAudioSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
|
||||
|
||||
do {
|
||||
try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP])
|
||||
} catch {
|
||||
print("Could not set the audio category: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
|
||||
do {
|
||||
try session.setPreferredSampleRate(voiceIOFormat.sampleRate)
|
||||
} catch {
|
||||
print("Could not set the preferred sample rate: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
|
||||
do {
|
||||
try session.setActive(true)
|
||||
} catch {
|
||||
print("Could not set the audio session as active")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func setup() {
|
||||
let input = avAudioEngine.inputNode
|
||||
do {
|
||||
@@ -108,15 +108,15 @@ class AudioEngine {
|
||||
print("Could not enable voice processing \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
avAudioEngine.inputNode.isVoiceProcessingInputMuted = !isRecording
|
||||
|
||||
|
||||
let output = avAudioEngine.outputNode
|
||||
let mainMixer = avAudioEngine.mainMixerNode
|
||||
|
||||
|
||||
avAudioEngine.connect(speechPlayer, to: mainMixer, format: voiceIOFormat)
|
||||
avAudioEngine.connect(mainMixer, to: output, format: voiceIOFormat)
|
||||
|
||||
|
||||
input.installTap(onBus: 0, bufferSize: 2048, format: voiceIOFormat) { [weak self] buffer, when in
|
||||
// We don't do any input processing (no volume calculation or passing mic data to the callback) if discardRecording == true
|
||||
// See comment in the playPCMData function
|
||||
@@ -125,48 +125,48 @@ class AudioEngine {
|
||||
self?.updateInputVolume()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
mainMixer.installTap(onBus: 0, bufferSize: 2048, format: voiceIOFormat) { [weak self] buffer, when in
|
||||
self?.processOutputBuffer(buffer)
|
||||
self?.updateOutputVolume()
|
||||
}
|
||||
|
||||
|
||||
avAudioEngine.prepare()
|
||||
}
|
||||
|
||||
|
||||
func processMicrophoneBuffer(_ buffer: AVAudioPCMBuffer) {
|
||||
guard let channelData = buffer.floatChannelData?[0] else {
|
||||
print("Error: Could not access channel data")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
let frameCount = Int(buffer.frameLength)
|
||||
var int16Samples = [Int16](repeating: 0, count: frameCount)
|
||||
|
||||
|
||||
// Convert float samples to Int16 and update input buffer for volume calculation
|
||||
for i in 0..<frameCount {
|
||||
let floatSample = max(-1.0, min(1.0, channelData[i]))
|
||||
int16Samples[i] = Int16(floatSample * Float(Int16.max))
|
||||
|
||||
|
||||
inputBuffer[inputBufferIndex] = floatSample
|
||||
inputBufferIndex = (inputBufferIndex + 1) % inputBuffer.count
|
||||
}
|
||||
|
||||
|
||||
// Create Data object from Int16 samples
|
||||
let data = Data(bytes: int16Samples, count: frameCount * MemoryLayout<Int16>.size)
|
||||
|
||||
|
||||
// Send the data to the callback
|
||||
onMicDataCallback?(data)
|
||||
}
|
||||
|
||||
|
||||
func processOutputBuffer(_ buffer: AVAudioPCMBuffer) {
|
||||
guard let channelData = buffer.floatChannelData?[0] else {
|
||||
print("Error: Could not access channel data")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
let frameCount = Int(buffer.frameLength)
|
||||
|
||||
|
||||
// Update output buffer for volume calculation
|
||||
for i in 0..<frameCount {
|
||||
let floatSample = max(-1.0, min(1.0, channelData[i]))
|
||||
@@ -174,7 +174,7 @@ class AudioEngine {
|
||||
outputBufferIndex = (outputBufferIndex + 1) % outputBuffer.count
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func start() {
|
||||
do {
|
||||
try avAudioEngine.start()
|
||||
@@ -182,7 +182,7 @@ class AudioEngine {
|
||||
print("Could not start audio engine: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func playPCMData(_ pcmData: Data) {
|
||||
// Looks like we don't get a proper AEC for the very first chunks of audio that we play.
|
||||
// To work around this, we will discard microphone input for the first few milliseconds.
|
||||
@@ -196,33 +196,33 @@ class AudioEngine {
|
||||
self.discardRecording = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard let buffer = createBuffer(from: pcmData) else {
|
||||
print("Failed to create audio buffer")
|
||||
return
|
||||
}
|
||||
speechPlayer.scheduleBuffer(buffer)
|
||||
|
||||
|
||||
if !speechPlayer.isPlaying {
|
||||
speechPlayer.play()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private func createBuffer(from data: Data) -> AVAudioPCMBuffer? {
|
||||
let frameCount = UInt32(data.count) / 2 // 16-bit input = 2 bytes per frame
|
||||
|
||||
|
||||
let format = AVAudioFormat(commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: 16000,
|
||||
channels: 1,
|
||||
interleaved: false)!
|
||||
|
||||
|
||||
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
buffer.frameLength = frameCount
|
||||
|
||||
|
||||
data.withUnsafeBytes { (rawBufferPointer: UnsafeRawBufferPointer) in
|
||||
if let sourcePtr = rawBufferPointer.baseAddress?.assumingMemoryBound(to: Int16.self),
|
||||
let destPtr = buffer.floatChannelData?[0] {
|
||||
@@ -231,15 +231,15 @@ class AudioEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
|
||||
func bypassVoiceProcessing(_ bypass: Bool) {
|
||||
let input = avAudioEngine.inputNode
|
||||
input.isVoiceProcessingBypassed = bypass
|
||||
}
|
||||
|
||||
|
||||
func toggleRecording(_ val: Bool) -> Bool {
|
||||
isRecording = val
|
||||
if !isRecording {
|
||||
@@ -251,10 +251,10 @@ class AudioEngine {
|
||||
avAudioEngine.inputNode.isVoiceProcessingInputMuted = false
|
||||
}
|
||||
print("Recording \(isRecording ? "started" : "stopped")")
|
||||
|
||||
|
||||
return isRecording
|
||||
}
|
||||
|
||||
|
||||
func stopRecordingAndPlayer(clearInterruptionResume: Bool = true){
|
||||
if clearInterruptionResume {
|
||||
wasRecordingBeforeInterruption = false
|
||||
@@ -268,7 +268,7 @@ class AudioEngine {
|
||||
speechPlayer.stop()
|
||||
updateOutputVolume()
|
||||
}
|
||||
|
||||
|
||||
func resumeRecordingAndPlayer(){
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
@@ -279,12 +279,12 @@ class AudioEngine {
|
||||
isRecording = toggleRecording(true)
|
||||
speechPlayer.play()
|
||||
}
|
||||
|
||||
|
||||
func tearDown() {
|
||||
stopRecordingAndPlayer()
|
||||
avAudioEngine.stop()
|
||||
}
|
||||
|
||||
|
||||
var isPlaying: Bool {
|
||||
return speechPlayer.isPlaying
|
||||
}
|
||||
@@ -308,13 +308,13 @@ class AudioEngine {
|
||||
print("Playback resumed")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func checkEngineIsRunning() {
|
||||
if !avAudioEngine.isRunning {
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func handleAudioSessionInterruption(_ notification: Notification) {
|
||||
guard let userInfo = notification.userInfo,
|
||||
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
@@ -343,39 +343,39 @@ class AudioEngine {
|
||||
fatalError("Unknown type: \(type)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func handleMediaServicesWereReset() {
|
||||
self.avAudioEngine.stop()
|
||||
self.setup()
|
||||
self.start()
|
||||
}
|
||||
|
||||
|
||||
private func updateInputVolume() {
|
||||
let volume = calculateRMSLevel(from: inputBuffer)
|
||||
onInputVolumeCallback?(volume)
|
||||
}
|
||||
|
||||
|
||||
private func updateOutputVolume() {
|
||||
let volume = calculateRMSLevel(from: outputBuffer)
|
||||
onOutputVolumeCallback?(volume)
|
||||
}
|
||||
|
||||
|
||||
private func calculateRMSLevel(from buffer: [Float]) -> Float {
|
||||
let epsilon: Float = 1e-5 // To avoid log(0)
|
||||
let rmsValue = sqrt(buffer.reduce(0) { $0 + $1 * $1 } / Float(buffer.count))
|
||||
|
||||
|
||||
// Convert to decibels
|
||||
let dbValue = 20 * log10(max(rmsValue, epsilon))
|
||||
|
||||
|
||||
// Normalize decibel value to 0-1 range
|
||||
// Assuming minimum audible is -60dB and maximum is 0dB
|
||||
let minDb: Float = -80.0
|
||||
let normalizedValue = max(0.0, min(1.0, (dbValue - minDb) / abs(minDb)))
|
||||
|
||||
|
||||
// Optional: Apply exponential factor to push smaller values down
|
||||
let expFactor: Float = 2.0 // Adjust this value to change the curve
|
||||
let adjustedValue = pow(normalizedValue, expFactor)
|
||||
|
||||
|
||||
return adjustedValue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,7 +267,14 @@ ws.on('open', () => {
|
||||
})
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const plaintext = decrypt(data.toString())
|
||||
let plaintext: string | null = null
|
||||
try {
|
||||
plaintext = decrypt(data.toString())
|
||||
} catch {
|
||||
// Plaintext handshake/control frames such as e2ee_ready are handled by the
|
||||
// connect flow above. The global listener only cares about encrypted RPC.
|
||||
return
|
||||
}
|
||||
if (!plaintext) return
|
||||
const response = JSON.parse(plaintext) as RpcResponse
|
||||
if (response._meta?.runtimeId) {
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
// Why: declares the mobile's pairing protocol version and the minimum
|
||||
// desktop version it can talk to. Duplicates the desktop's
|
||||
// Why: declares the mobile client's runtime protocol version and the minimum
|
||||
// server protocol it can talk to. Duplicates the desktop's
|
||||
// `src/shared/protocol-version.ts` because Metro/Expo doesn't resolve
|
||||
// outside `mobile/`. Manual sync is acceptable — these constants are
|
||||
// expected to bump less than once a quarter.
|
||||
//
|
||||
// Bump MOBILE_PROTOCOL_VERSION when:
|
||||
// - You change the meaning of an RPC mobile sends.
|
||||
// - You stop relying on a desktop-side feature in a way old desktops
|
||||
// - You stop relying on a server-side feature in a way old servers
|
||||
// would notice.
|
||||
// Do NOT bump for:
|
||||
// - Adding new optional fields to outbound requests.
|
||||
// - Reading new optional fields on incoming responses.
|
||||
//
|
||||
// Bump MIN_COMPATIBLE_DESKTOP_VERSION when mobile starts relying on a
|
||||
// desktop feature added at a specific desktop protocol version. This
|
||||
// triggers a hard-block screen for users paired to older desktops.
|
||||
// server feature added at a specific runtime protocol version. This
|
||||
// triggers a hard-block screen for users paired to older servers.
|
||||
|
||||
export const MOBILE_PROTOCOL_VERSION = 2
|
||||
export const MIN_COMPATIBLE_DESKTOP_VERSION = 2
|
||||
|
||||
+1
-1
@@ -88,8 +88,8 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"dompurify": "^3.4.2",
|
||||
"electron-updater": "^6.8.3",
|
||||
"entities": "^6.0.1",
|
||||
"emoji-picker-react": "^4.19.1",
|
||||
"entities": "^6.0.1",
|
||||
"github-slugger": "^2.0.0",
|
||||
"hosted-git-info": "^9.0.3",
|
||||
"html-to-image": "^1.11.13",
|
||||
|
||||
+2
-1
@@ -14,7 +14,7 @@ export type CommandSpec = {
|
||||
notes?: string[]
|
||||
}
|
||||
|
||||
export const GLOBAL_FLAGS = ['help', 'json']
|
||||
export const GLOBAL_FLAGS = ['help', 'json', 'pairing-code', 'environment']
|
||||
|
||||
export function parseArgs(argv: string[]): ParsedArgs {
|
||||
const commandPath: string[] = []
|
||||
@@ -93,6 +93,7 @@ export function isCommandGroup(commandPath: string[]): boolean {
|
||||
'storage',
|
||||
'orchestration',
|
||||
'computer',
|
||||
'environment',
|
||||
'note'
|
||||
].includes(commandPath[0])) ||
|
||||
(commandPath.length === 2 &&
|
||||
|
||||
@@ -14,6 +14,7 @@ import { BROWSER_ENV_HANDLERS } from './handlers/browser-env'
|
||||
import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage'
|
||||
import { ORCHESTRATION_HANDLERS } from './handlers/orchestration'
|
||||
import { COMPUTER_HANDLERS } from './handlers/computer'
|
||||
import { ENVIRONMENT_HANDLERS } from './handlers/environment'
|
||||
import { NOTE_HANDLERS } from './handlers/note'
|
||||
|
||||
export type HandlerContext = {
|
||||
@@ -42,6 +43,7 @@ function buildHandlers(): Map<string, CommandHandler> {
|
||||
BROWSER_STORAGE_HANDLERS,
|
||||
ORCHESTRATION_HANDLERS,
|
||||
COMPUTER_HANDLERS,
|
||||
ENVIRONMENT_HANDLERS,
|
||||
NOTE_HANDLERS
|
||||
]
|
||||
for (const group of groups) {
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
RuntimeWorktreePsResult,
|
||||
RuntimeWorktreeRecord
|
||||
} from '../shared/runtime-types'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
|
||||
import type { NoteListResult, NoteMutationResult, NoteShowResult } from '../shared/notes-types'
|
||||
import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
|
||||
@@ -101,6 +102,33 @@ export function formatStatus(status: CliStatusResult): string {
|
||||
return formatCliStatus(status)
|
||||
}
|
||||
|
||||
export function formatEnvironmentList(result: {
|
||||
environments: PublicKnownRuntimeEnvironment[]
|
||||
}): string {
|
||||
if (result.environments.length === 0) {
|
||||
return 'No saved environments.'
|
||||
}
|
||||
return result.environments
|
||||
.map(
|
||||
(environment) =>
|
||||
`${environment.id} ${environment.name} ${environment.endpoints[0]?.endpoint ?? 'no-endpoint'}`
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function formatEnvironment(environment: PublicKnownRuntimeEnvironment): string {
|
||||
return [
|
||||
`id: ${environment.id}`,
|
||||
`name: ${environment.name}`,
|
||||
`runtimeId: ${environment.runtimeId ?? 'unknown'}`,
|
||||
`lastUsedAt: ${environment.lastUsedAt ?? 'never'}`,
|
||||
`preferredEndpointId: ${environment.preferredEndpointId}`,
|
||||
...environment.endpoints.map(
|
||||
(endpoint) => `endpoint: ${endpoint.id} ${endpoint.kind} ${endpoint.endpoint}`
|
||||
)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function formatTerminalList(result: RuntimeTerminalListResult): string {
|
||||
if (result.terminals.length === 0) {
|
||||
return 'No live terminals.'
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { formatCliStatus, formatStatus, printResult } from '../format'
|
||||
import { RuntimeClientError, serveOrcaApp } from '../runtime-client'
|
||||
|
||||
export const CORE_HANDLERS: Record<string, CommandHandler> = {
|
||||
open: async ({ client, json }) => {
|
||||
const result = await client.openOrca()
|
||||
printResult(result, json, formatCliStatus)
|
||||
},
|
||||
serve: async ({ flags, json }) => {
|
||||
if (flags.get('no-pairing') === true && flags.get('mobile-pairing') === true) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --mobile-pairing or --no-pairing, not both.'
|
||||
)
|
||||
}
|
||||
const rawPort = flags.get('port')
|
||||
if (typeof rawPort === 'string') {
|
||||
const port = Number(rawPort)
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||
throw new RuntimeClientError('invalid_argument', `Invalid --port value: ${rawPort}`)
|
||||
}
|
||||
}
|
||||
const exitCode = await serveOrcaApp({
|
||||
json,
|
||||
port: typeof rawPort === 'string' ? rawPort : null,
|
||||
pairingAddress:
|
||||
typeof flags.get('pairing-address') === 'string'
|
||||
? (flags.get('pairing-address') as string)
|
||||
: null,
|
||||
noPairing: flags.get('no-pairing') === true,
|
||||
mobilePairing: flags.get('mobile-pairing') === true
|
||||
})
|
||||
process.exitCode = exitCode
|
||||
},
|
||||
status: async ({ client, json }) => {
|
||||
const result = await client.getCliStatus()
|
||||
if (!json && !result.result.runtime.reachable) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { formatEnvironment, formatEnvironmentList, printResult } from '../format'
|
||||
import { getDefaultUserDataPath } from '../runtime-client'
|
||||
import type { RuntimeRpcSuccess } from '../runtime-client'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { redactRuntimeEnvironment } from '../../shared/runtime-environments'
|
||||
import {
|
||||
addEnvironmentFromPairingCode,
|
||||
listEnvironments,
|
||||
removeEnvironment,
|
||||
resolveEnvironment,
|
||||
type EnvironmentAddResult,
|
||||
type EnvironmentRemoveResult
|
||||
} from '../runtime/environments'
|
||||
|
||||
export const ENVIRONMENT_HANDLERS: Record<string, CommandHandler> = {
|
||||
'environment add': async ({ flags, json }) => {
|
||||
const name = getRequiredStringFlag(flags, 'name')
|
||||
const pairingCode = getRequiredStringFlag(flags, 'pairing-code')
|
||||
const environment = redactRuntimeEnvironment(
|
||||
addEnvironmentFromPairingCode(getDefaultUserDataPath(), {
|
||||
name,
|
||||
pairingCode
|
||||
})
|
||||
)
|
||||
printResult(
|
||||
localSuccess({ environment }),
|
||||
json,
|
||||
(result: EnvironmentAddResult) =>
|
||||
`Saved environment ${result.environment.name} (${result.environment.id}).`
|
||||
)
|
||||
},
|
||||
'environment list': async ({ json }) => {
|
||||
const environments = listEnvironments(getDefaultUserDataPath()).map(redactRuntimeEnvironment)
|
||||
printResult(localSuccess({ environments }), json, formatEnvironmentList)
|
||||
},
|
||||
'environment show': async ({ flags, json }) => {
|
||||
const selector = getRequiredStringFlag(flags, 'environment')
|
||||
const environment = redactRuntimeEnvironment(
|
||||
resolveEnvironment(getDefaultUserDataPath(), selector)
|
||||
)
|
||||
printResult(localSuccess({ environment }), json, ({ environment: value }) =>
|
||||
formatEnvironment(value)
|
||||
)
|
||||
},
|
||||
'environment rm': async ({ flags, json }) => {
|
||||
const selector = getRequiredStringFlag(flags, 'environment')
|
||||
const removed = redactRuntimeEnvironment(removeEnvironment(getDefaultUserDataPath(), selector))
|
||||
printResult(
|
||||
localSuccess({ removed }),
|
||||
json,
|
||||
(result: EnvironmentRemoveResult) =>
|
||||
`Removed environment ${result.removed.name} (${result.removed.id}).`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getRequiredStringFlag(flags: Map<string, string | boolean>, name: string): string {
|
||||
const value = flags.get(name)
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new RuntimeClientError('invalid_argument', `Missing required --${name}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function localSuccess<TResult>(result: TResult): RuntimeRpcSuccess<TResult> {
|
||||
return {
|
||||
id: 'local',
|
||||
ok: true,
|
||||
result,
|
||||
_meta: {
|
||||
runtimeId: 'local'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,43 @@
|
||||
import { resolve as resolvePath } from 'path'
|
||||
import type { RuntimeRepoList, RuntimeRepoSearchRefs } from '../../shared/runtime-types'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { formatRepoList, formatRepoRefs, formatRepoShow, printResult } from '../format'
|
||||
import { getOptionalPositiveIntegerFlag, getRequiredStringFlag } from '../flags'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
|
||||
function isAbsoluteServerPath(value: string): boolean {
|
||||
return (
|
||||
value.startsWith('/') ||
|
||||
/^[A-Za-z]:[\\/]/.test(value) ||
|
||||
value.startsWith('\\\\') ||
|
||||
value.startsWith('//')
|
||||
)
|
||||
}
|
||||
|
||||
function resolveRepoAddPath(inputPath: string, cwd: string, isRemote: boolean): string {
|
||||
if (!isRemote) {
|
||||
return resolvePath(cwd, inputPath)
|
||||
}
|
||||
// Why: the local CLI cwd is unrelated to a paired runtime's filesystem.
|
||||
// Relative remote paths would silently target the wrong machine.
|
||||
if (!isAbsoluteServerPath(inputPath)) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Remote repo add requires --path to be an absolute path on the remote server.'
|
||||
)
|
||||
}
|
||||
return inputPath
|
||||
}
|
||||
|
||||
export const REPO_HANDLERS: Record<string, CommandHandler> = {
|
||||
'repo list': async ({ client, json }) => {
|
||||
const result = await client.call<RuntimeRepoList>('repo.list')
|
||||
printResult(result, json, formatRepoList)
|
||||
},
|
||||
'repo add': async ({ flags, client, json }) => {
|
||||
'repo add': async ({ flags, client, cwd, json }) => {
|
||||
const repoPath = getRequiredStringFlag(flags, 'path')
|
||||
const result = await client.call<{ repo: Record<string, unknown> }>('repo.add', {
|
||||
path: getRequiredStringFlag(flags, 'path')
|
||||
path: resolveRepoAddPath(repoPath, cwd, client.isRemote)
|
||||
})
|
||||
printResult(result, json, formatRepoShow)
|
||||
},
|
||||
|
||||
@@ -116,6 +116,12 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
|
||||
printResult(result, json, formatTerminalRename)
|
||||
},
|
||||
'terminal create': async ({ flags, client, cwd, json }) => {
|
||||
if (client.isRemote && !flags.has('worktree')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Remote terminal create requires --worktree because the client cwd cannot identify a server worktree.'
|
||||
)
|
||||
}
|
||||
const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', {
|
||||
worktree: await getBrowserWorktreeSelector(flags, cwd, client),
|
||||
command: getOptionalStringFlag(flags, 'command'),
|
||||
|
||||
@@ -8,8 +8,15 @@ Usage: orca <command> [options]
|
||||
|
||||
Startup:
|
||||
open Launch Orca and wait for the runtime to be reachable
|
||||
serve Start a headless Orca runtime server
|
||||
status Show app/runtime/graph readiness
|
||||
|
||||
Environments:
|
||||
environment add Save a remote Orca runtime from a pairing code
|
||||
environment list List saved remote Orca runtimes
|
||||
environment show Show one saved remote Orca runtime
|
||||
environment rm Remove a saved remote Orca runtime
|
||||
|
||||
Repos:
|
||||
repo list List repos registered in Orca
|
||||
repo add Add a project to Orca by filesystem path
|
||||
@@ -138,7 +145,12 @@ Browser Automation:
|
||||
|
||||
Common Commands:
|
||||
orca open [--json]
|
||||
orca serve [--port <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--json]
|
||||
orca status [--json]
|
||||
orca environment add --name <name> --pairing-code <code> [--json]
|
||||
orca environment list [--json]
|
||||
orca environment show --environment <selector> [--json]
|
||||
orca environment rm --environment <selector> [--json]
|
||||
orca worktree list [--repo <selector>] [--limit <n>] [--json]
|
||||
orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--run-hooks] [--activate] [--json]
|
||||
orca worktree show --worktree <selector> [--json]
|
||||
@@ -178,10 +190,13 @@ Wait Options:
|
||||
|
||||
Output Options:
|
||||
--json Emit machine-readable JSON instead of human text
|
||||
--pairing-code <code> Connect to a remote Orca runtime using an orca://pair#... code
|
||||
--environment <selector> Connect using a saved environment id or name
|
||||
--help Show this help message
|
||||
|
||||
Behavior:
|
||||
Most commands require a running Orca runtime. If Orca is not open yet, run \`orca open\` first.
|
||||
Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT.
|
||||
Use selectors for discovery and handles for repeated live terminal operations.
|
||||
|
||||
Browser Workflow:
|
||||
|
||||
+367
-2
@@ -2,13 +2,47 @@
|
||||
import path from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
const {
|
||||
callMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
addEnvironmentFromPairingCodeMock,
|
||||
listEnvironmentsMock
|
||||
} = vi.hoisted(() => ({
|
||||
callMock: vi.fn(),
|
||||
serveOrcaAppMock: vi.fn(),
|
||||
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
|
||||
addEnvironmentFromPairingCodeMock: vi.fn(),
|
||||
listEnvironmentsMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-client', () => {
|
||||
class RuntimeClient {
|
||||
readonly isRemote: boolean
|
||||
call = callMock
|
||||
getCliStatus = vi.fn()
|
||||
openOrca = vi.fn()
|
||||
|
||||
constructor(
|
||||
_userDataPath?: string,
|
||||
_requestTimeoutMs?: number,
|
||||
remotePairingCode?: string | null,
|
||||
environmentSelector?: string | null
|
||||
) {
|
||||
const effectivePairingCode =
|
||||
remotePairingCode === undefined
|
||||
? (process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING)
|
||||
: remotePairingCode
|
||||
const effectiveEnvironment =
|
||||
environmentSelector === undefined ? process.env.ORCA_ENVIRONMENT : environmentSelector
|
||||
if (effectivePairingCode && effectiveEnvironment) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --pairing-code or --environment, not both.'
|
||||
)
|
||||
}
|
||||
this.isRemote = Boolean(effectivePairingCode || effectiveEnvironment)
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeClientError extends Error {
|
||||
@@ -32,10 +66,19 @@ vi.mock('./runtime-client', () => {
|
||||
return {
|
||||
RuntimeClient,
|
||||
RuntimeClientError,
|
||||
RuntimeRpcFailureError
|
||||
RuntimeRpcFailureError,
|
||||
serveOrcaApp: serveOrcaAppMock,
|
||||
getDefaultUserDataPath: getDefaultUserDataPathMock
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./runtime/environments', () => ({
|
||||
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
|
||||
listEnvironments: listEnvironmentsMock,
|
||||
removeEnvironment: vi.fn(),
|
||||
resolveEnvironment: vi.fn()
|
||||
}))
|
||||
|
||||
import {
|
||||
buildCurrentWorktreeSelector,
|
||||
COMMAND_SPECS,
|
||||
@@ -58,9 +101,36 @@ describe('COMMAND_SPECS collision check', () => {
|
||||
describe('orca cli worktree awareness', () => {
|
||||
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
|
||||
const originalUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
const originalPairingCode = process.env.ORCA_PAIRING_CODE
|
||||
const originalRemotePairing = process.env.ORCA_REMOTE_PAIRING
|
||||
const originalEnvironment = process.env.ORCA_ENVIRONMENT
|
||||
|
||||
beforeEach(() => {
|
||||
callMock.mockReset()
|
||||
serveOrcaAppMock.mockReset()
|
||||
getDefaultUserDataPathMock.mockClear()
|
||||
addEnvironmentFromPairingCodeMock.mockReset()
|
||||
listEnvironmentsMock.mockReset()
|
||||
addEnvironmentFromPairingCodeMock.mockReturnValue({
|
||||
id: 'env-1',
|
||||
name: 'desk',
|
||||
createdAt: 100,
|
||||
updatedAt: 100,
|
||||
lastUsedAt: null,
|
||||
runtimeId: null,
|
||||
endpoints: [
|
||||
{
|
||||
id: 'ws-env-1',
|
||||
kind: 'websocket',
|
||||
label: 'WebSocket',
|
||||
endpoint: 'ws://127.0.0.1:6768',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'pk'
|
||||
}
|
||||
],
|
||||
preferredEndpointId: 'ws-env-1'
|
||||
})
|
||||
listEnvironmentsMock.mockReturnValue([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -75,6 +145,21 @@ describe('orca cli worktree awareness', () => {
|
||||
} else {
|
||||
process.env.ORCA_USER_DATA_PATH = originalUserDataPath
|
||||
}
|
||||
if (originalPairingCode === undefined) {
|
||||
delete process.env.ORCA_PAIRING_CODE
|
||||
} else {
|
||||
process.env.ORCA_PAIRING_CODE = originalPairingCode
|
||||
}
|
||||
if (originalRemotePairing === undefined) {
|
||||
delete process.env.ORCA_REMOTE_PAIRING
|
||||
} else {
|
||||
process.env.ORCA_REMOTE_PAIRING = originalRemotePairing
|
||||
}
|
||||
if (originalEnvironment === undefined) {
|
||||
delete process.env.ORCA_ENVIRONMENT
|
||||
} else {
|
||||
process.env.ORCA_ENVIRONMENT = originalEnvironment
|
||||
}
|
||||
})
|
||||
|
||||
it('builds the current worktree selector from cwd', () => {
|
||||
@@ -115,6 +200,25 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(logSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects remote `worktree current` without listing worktrees from client cwd', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(
|
||||
['worktree', 'current', '--pairing-code', 'remote-runtime', '--json'],
|
||||
'/tmp/repo/src'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
||||
'current is a local cwd shortcut and cannot be resolved against a remote runtime.'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
it('uses cwd when active is passed to worktree.set', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
@@ -171,6 +275,192 @@ describe('orca cli worktree awareness', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('starts a foreground headless server through `serve`', async () => {
|
||||
serveOrcaAppMock.mockResolvedValue(0)
|
||||
process.env.ORCA_ENVIRONMENT = 'stale-env'
|
||||
|
||||
await main(
|
||||
['serve', '--json', '--port', '6768', '--pairing-address', '100.64.1.20', '--no-pairing'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(serveOrcaAppMock).toHaveBeenCalledWith({
|
||||
json: true,
|
||||
port: '6768',
|
||||
pairingAddress: '100.64.1.20',
|
||||
noPairing: true,
|
||||
mobilePairing: false
|
||||
})
|
||||
})
|
||||
|
||||
it('starts a foreground headless server with mobile pairing enabled', async () => {
|
||||
serveOrcaAppMock.mockResolvedValue(0)
|
||||
|
||||
await main(
|
||||
['serve', '--pairing-address', '100.64.1.20', '--mobile-pairing', '--json'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(serveOrcaAppMock).toHaveBeenCalledWith({
|
||||
json: true,
|
||||
port: null,
|
||||
pairingAddress: '100.64.1.20',
|
||||
noPairing: false,
|
||||
mobilePairing: true
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects contradictory serve pairing flags', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(['serve', '--mobile-pairing', '--no-pairing', '--json'], '/tmp/repo')
|
||||
|
||||
expect(serveOrcaAppMock).not.toHaveBeenCalled()
|
||||
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
||||
'Use either --mobile-pairing or --no-pairing, not both.'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
it('rejects invalid serve ports before launching the app', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(['serve', '--port', 'not-a-port', '--json'], '/tmp/repo')
|
||||
|
||||
expect(serveOrcaAppMock).not.toHaveBeenCalled()
|
||||
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
||||
'Invalid --port value: not-a-port'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
it('lists saved environments even when ORCA_ENVIRONMENT is set', async () => {
|
||||
process.env.ORCA_ENVIRONMENT = 'stale-env'
|
||||
listEnvironmentsMock.mockReturnValue([addEnvironmentFromPairingCodeMock()])
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['environment', 'list', '--json'], '/tmp/repo')
|
||||
|
||||
expect(listEnvironmentsMock).toHaveBeenCalledWith('/tmp/orca-user-data')
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect(logSpy.mock.calls[0]?.[0]).not.toContain('token')
|
||||
expect(logSpy.mock.calls[0]?.[0]).not.toContain('publicKeyB64')
|
||||
})
|
||||
|
||||
it('adds saved environments even when ORCA_ENVIRONMENT is set', async () => {
|
||||
process.env.ORCA_ENVIRONMENT = 'stale-env'
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
['environment', 'add', '--name', 'desk', '--pairing-code', 'orca://pair#abc', '--json'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(addEnvironmentFromPairingCodeMock).toHaveBeenCalledWith('/tmp/orca-user-data', {
|
||||
name: 'desk',
|
||||
pairingCode: 'orca://pair#abc'
|
||||
})
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect(logSpy.mock.calls[0]?.[0]).not.toContain('token')
|
||||
expect(logSpy.mock.calls[0]?.[0]).not.toContain('publicKeyB64')
|
||||
})
|
||||
|
||||
it('resolves repo.add paths against the invoking cli cwd', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_repo_add', {
|
||||
repo: {
|
||||
id: 'repo-1',
|
||||
path: path.resolve('/tmp/repo/apps/web'),
|
||||
displayName: 'web'
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['repo', 'add', '--path', './apps/web', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('repo.add', {
|
||||
path: path.resolve('/tmp/repo/apps/web')
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects remote repo.add relative paths instead of resolving against client cwd', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(
|
||||
['repo', 'add', '--path', './apps/web', '--pairing-code', 'remote-runtime', '--json'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
||||
'Remote repo add requires --path to be an absolute path on the remote server.'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
it('sends remote repo.add absolute paths unchanged', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_repo_add', {
|
||||
repo: {
|
||||
id: 'repo-1',
|
||||
path: '/srv/orca/web',
|
||||
displayName: 'web'
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
['repo', 'add', '--path', '/srv/orca/web', '--pairing-code', 'remote-runtime', '--json'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('repo.add', {
|
||||
path: '/srv/orca/web'
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['C:\\repo', 'C:/repo', '\\\\server\\share\\repo', '//server/share/repo'])(
|
||||
'sends remote repo.add server absolute path %s unchanged',
|
||||
async (serverPath) => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_repo_add', {
|
||||
repo: {
|
||||
id: 'repo-1',
|
||||
path: serverPath,
|
||||
displayName: 'web'
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
['repo', 'add', '--path', serverPath, '--pairing-code', 'remote-runtime', '--json'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('repo.add', {
|
||||
path: serverPath
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('opts into setup and activation when worktree.create runs hooks', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
@@ -352,4 +642,79 @@ describe('orca cli worktree awareness', () => {
|
||||
limit: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects implicit remote terminal create instead of resolving from client cwd', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(
|
||||
['terminal', 'create', '--pairing-code', 'remote-runtime', '--json'],
|
||||
'/tmp/client/repo/src'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
||||
'Remote terminal create requires --worktree'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
it('sends explicit remote terminal create worktree selectors unchanged', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
worktreeId: 'repo-1::/srv/orca/feature',
|
||||
title: 'Server terminal'
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'terminal',
|
||||
'create',
|
||||
'--worktree',
|
||||
'id:repo-1::/srv/orca/feature',
|
||||
'--pairing-code',
|
||||
'remote-runtime',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/client/repo/src'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'id:repo-1::/srv/orca/feature',
|
||||
command: undefined,
|
||||
title: undefined,
|
||||
focus: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not resolve implicit remote browser targets from client cwd', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_tab_current', {
|
||||
tab: {
|
||||
browserPageId: 'page-1',
|
||||
index: 0,
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
active: true,
|
||||
worktreeId: 'repo-1::/srv/orca/feature'
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['tab', 'current', '--pairing-code', 'remote-runtime', '--json'], '/tmp/client/src')
|
||||
|
||||
expect(callMock).toHaveBeenCalledTimes(1)
|
||||
expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', { worktree: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
+13
-1
@@ -15,6 +15,10 @@ import { COMMAND_SPECS } from './specs'
|
||||
export { COMMAND_SPECS } from './specs'
|
||||
export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors'
|
||||
|
||||
function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
|
||||
return commandPath[0] === 'environment' || commandPath[0] === 'serve'
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise<void> {
|
||||
const parsed = parseArgs(argv)
|
||||
const helpPath = resolveHelpPath(parsed)
|
||||
@@ -40,7 +44,15 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P
|
||||
// lookup so users do not get misleading "Orca is not running" failures for
|
||||
// simple command typos or unsupported flags.
|
||||
validateCommandAndFlags(COMMAND_SPECS, parsed)
|
||||
const client = new RuntimeClient()
|
||||
const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath)
|
||||
const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code')
|
||||
const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment')
|
||||
const client = new RuntimeClient(
|
||||
undefined,
|
||||
undefined,
|
||||
typeof pairingCode === 'string' ? pairingCode : undefined,
|
||||
typeof environmentSelector === 'string' ? environmentSelector : undefined
|
||||
)
|
||||
await dispatch(parsed.commandPath, {
|
||||
flags: parsed.flags,
|
||||
client,
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
RuntimeClient,
|
||||
RuntimeClientError,
|
||||
RuntimeRpcFailureError,
|
||||
serveOrcaApp,
|
||||
getDefaultUserDataPath,
|
||||
type RuntimeRpcFailure,
|
||||
type RuntimeRpcResponse,
|
||||
|
||||
+133
-4
@@ -1,9 +1,17 @@
|
||||
import type { CliStatusResult } from '../../shared/runtime-types'
|
||||
import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types'
|
||||
import { parsePairingCode, type PairingOffer } from '../../shared/pairing'
|
||||
import { launchOrcaApp } from './launch'
|
||||
import { getDefaultUserDataPath, readMetadata } from './metadata'
|
||||
import { getCliStatus } from './status'
|
||||
import { sendRequest } from './transport'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
|
||||
import { sendWebSocketRequest } from './websocket-transport'
|
||||
import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environments'
|
||||
import { describeRuntimeCompatBlock, evaluateRuntimeCompat } from '../../shared/protocol-compat'
|
||||
import {
|
||||
MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
|
||||
RUNTIME_PROTOCOL_VERSION
|
||||
} from '../../shared/protocol-version'
|
||||
|
||||
// Why: for `orchestration.check --wait` the caller's method-level
|
||||
// `params.timeoutMs` is the inner waiter budget; we extend the client-side
|
||||
@@ -16,13 +24,27 @@ const LONG_POLL_CLIENT_GRACE_MS = 10_000
|
||||
export class RuntimeClient {
|
||||
private readonly userDataPath: string
|
||||
private readonly requestTimeoutMs: number
|
||||
private readonly remotePairing: PairingOffer | null
|
||||
private readonly environmentSelector: string | null
|
||||
private remoteCompatChecked = false
|
||||
|
||||
// Why: browser commands trigger first-time session init (agent-browser connect +
|
||||
// CDP proxy setup) which can take 15-30s. 60s accommodates cold start without
|
||||
// being so large that genuine hangs go unnoticed.
|
||||
constructor(userDataPath = getDefaultUserDataPath(), requestTimeoutMs = 60_000) {
|
||||
constructor(
|
||||
userDataPath = getDefaultUserDataPath(),
|
||||
requestTimeoutMs = 60_000,
|
||||
remotePairingCode = process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING ?? null,
|
||||
environmentSelector = process.env.ORCA_ENVIRONMENT ?? null
|
||||
) {
|
||||
this.userDataPath = userDataPath
|
||||
this.requestTimeoutMs = requestTimeoutMs
|
||||
this.environmentSelector = environmentSelector
|
||||
this.remotePairing = resolveRemotePairing(userDataPath, remotePairingCode, environmentSelector)
|
||||
}
|
||||
|
||||
get isRemote(): boolean {
|
||||
return this.remotePairing !== null
|
||||
}
|
||||
|
||||
async call<TResult>(
|
||||
@@ -32,10 +54,30 @@ export class RuntimeClient {
|
||||
timeoutMs?: number
|
||||
}
|
||||
): Promise<RuntimeRpcSuccess<TResult>> {
|
||||
const metadata = readMetadata(this.userDataPath)
|
||||
const effectiveTimeoutMs = options?.timeoutMs ?? this.resolveMethodTimeoutMs(method, params)
|
||||
if (this.remotePairing) {
|
||||
if (method !== 'status.get') {
|
||||
await this.ensureRemoteRuntimeCompatible(effectiveTimeoutMs)
|
||||
}
|
||||
const response = await sendWebSocketRequest<TResult>(
|
||||
this.remotePairing,
|
||||
method,
|
||||
params,
|
||||
effectiveTimeoutMs
|
||||
)
|
||||
if (response.ok === false) {
|
||||
throw new RuntimeRpcFailureError(response)
|
||||
}
|
||||
if (this.environmentSelector) {
|
||||
markEnvironmentUsed(this.userDataPath, this.environmentSelector, {
|
||||
runtimeId: response._meta.runtimeId
|
||||
})
|
||||
}
|
||||
return response
|
||||
}
|
||||
const metadata = readMetadata(this.userDataPath)
|
||||
const response = await sendRequest<TResult>(metadata, method, params, effectiveTimeoutMs)
|
||||
if (!response.ok) {
|
||||
if (response.ok === false) {
|
||||
throw new RuntimeRpcFailureError(response)
|
||||
}
|
||||
return response
|
||||
@@ -58,9 +100,69 @@ export class RuntimeClient {
|
||||
}
|
||||
|
||||
async getCliStatus(): Promise<RuntimeRpcSuccess<CliStatusResult>> {
|
||||
if (this.remotePairing) {
|
||||
const response = await this.call<RuntimeStatus>('status.get')
|
||||
this.assertRemoteRuntimeStatusCompatible(response.result)
|
||||
this.remoteCompatChecked = true
|
||||
const graphState = response.result.graphStatus
|
||||
return {
|
||||
id: response.id,
|
||||
ok: true,
|
||||
result: {
|
||||
app: {
|
||||
running: true,
|
||||
pid: null
|
||||
},
|
||||
runtime: {
|
||||
state: graphState === 'ready' ? 'ready' : 'graph_not_ready',
|
||||
reachable: true,
|
||||
runtimeId: response.result.runtimeId
|
||||
},
|
||||
graph: {
|
||||
state: graphState
|
||||
}
|
||||
},
|
||||
_meta: response._meta
|
||||
}
|
||||
}
|
||||
return getCliStatus(this.userDataPath)
|
||||
}
|
||||
|
||||
private async ensureRemoteRuntimeCompatible(timeoutMs: number): Promise<void> {
|
||||
if (!this.remotePairing || this.remoteCompatChecked) {
|
||||
return
|
||||
}
|
||||
const response = await sendWebSocketRequest<RuntimeStatus>(
|
||||
this.remotePairing,
|
||||
'status.get',
|
||||
undefined,
|
||||
timeoutMs
|
||||
)
|
||||
if (response.ok === false) {
|
||||
throw new RuntimeRpcFailureError(response)
|
||||
}
|
||||
this.assertRemoteRuntimeStatusCompatible(response.result)
|
||||
this.remoteCompatChecked = true
|
||||
if (this.environmentSelector) {
|
||||
markEnvironmentUsed(this.userDataPath, this.environmentSelector, {
|
||||
runtimeId: response._meta.runtimeId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private assertRemoteRuntimeStatusCompatible(status: RuntimeStatus): void {
|
||||
const verdict = evaluateRuntimeCompat({
|
||||
clientProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
|
||||
serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion,
|
||||
serverMinCompatibleClientProtocolVersion:
|
||||
status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion
|
||||
})
|
||||
if (verdict.kind === 'blocked') {
|
||||
throw new RuntimeClientError('incompatible_runtime', describeRuntimeCompatBlock(verdict))
|
||||
}
|
||||
}
|
||||
|
||||
async openOrca(timeoutMs = 15_000): Promise<RuntimeRpcSuccess<CliStatusResult>> {
|
||||
const initial = await this.getCliStatus()
|
||||
if (initial.result.runtime.reachable) {
|
||||
@@ -84,6 +186,33 @@ export class RuntimeClient {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRemotePairing(
|
||||
userDataPath: string,
|
||||
pairingCode: string | null,
|
||||
environmentSelector: string | null
|
||||
): PairingOffer | null {
|
||||
if (pairingCode && environmentSelector) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --pairing-code or --environment, not both.'
|
||||
)
|
||||
}
|
||||
if (environmentSelector) {
|
||||
return resolveEnvironmentPairingOffer(userDataPath, environmentSelector)
|
||||
}
|
||||
if (!pairingCode) {
|
||||
return null
|
||||
}
|
||||
const pairing = parsePairingCode(pairingCode)
|
||||
if (!pairing) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Invalid remote pairing code. Expected an orca://pair#... URL or bare pairing payload.'
|
||||
)
|
||||
}
|
||||
return pairing
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
@@ -1,62 +1,5 @@
|
||||
// Why: the Orca runtime is a separate process and may drift in version from
|
||||
// the CLI (older CLI talking to newer app, or vice versa during dev HMR). A
|
||||
// Zod schema at the decode boundary means a malformed frame surfaces as a
|
||||
// single legible error instead of a silent mis-typed access downstream.
|
||||
//
|
||||
// The envelope shape mirrors src/main/runtime/rpc/core.ts. `result` is left
|
||||
// unknown here — method-level types are checked by the caller via generics —
|
||||
// so only the frame is validated, not the payload.
|
||||
import { z } from 'zod'
|
||||
|
||||
const MetaSuccess = z.object({
|
||||
runtimeId: z.string()
|
||||
})
|
||||
|
||||
const MetaFailure = z
|
||||
.object({
|
||||
runtimeId: z.union([z.string(), z.null()])
|
||||
})
|
||||
.optional()
|
||||
|
||||
const Success = z.object({
|
||||
id: z.string(),
|
||||
ok: z.literal(true),
|
||||
result: z.unknown(),
|
||||
_meta: MetaSuccess
|
||||
})
|
||||
|
||||
const Failure = z.object({
|
||||
id: z.string(),
|
||||
ok: z.literal(false),
|
||||
error: z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
data: z.unknown().optional()
|
||||
}),
|
||||
_meta: MetaFailure
|
||||
})
|
||||
|
||||
// Why: transport-layer keepalive frame (server→client only). Not a terminal
|
||||
// frame — the client reads past it and keeps waiting for the real
|
||||
// success/failure. `id` and `_meta` are deliberately absent: keepalives carry
|
||||
// no method-level semantics and aren't tied to a particular request (one
|
||||
// connection handles one request today). See design doc §3.1.
|
||||
const Keepalive = z.object({
|
||||
_keepalive: z.literal(true)
|
||||
})
|
||||
|
||||
// Why: switched from z.discriminatedUnion('ok', …) to z.union because
|
||||
// keepalives have no `ok` field. Client code must branch on
|
||||
// `'_keepalive' in frame` before treating the frame as Success/Failure.
|
||||
export const RuntimeRpcEnvelopeSchema = z.union([Success, Failure, Keepalive])
|
||||
|
||||
export type RuntimeRpcKeepaliveFrame = z.infer<typeof Keepalive>
|
||||
|
||||
export function isKeepaliveFrame(frame: unknown): frame is RuntimeRpcKeepaliveFrame {
|
||||
return (
|
||||
typeof frame === 'object' &&
|
||||
frame !== null &&
|
||||
'_keepalive' in frame &&
|
||||
(frame as { _keepalive: unknown })._keepalive === true
|
||||
)
|
||||
}
|
||||
export {
|
||||
isKeepaliveFrame,
|
||||
RuntimeRpcEnvelopeSchema,
|
||||
type RuntimeRpcKeepaliveFrame
|
||||
} from '../../shared/runtime-rpc-envelope'
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { mkdtempSync, statSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { encodePairingOffer } from '../../shared/pairing'
|
||||
import {
|
||||
addEnvironmentFromPairingCode,
|
||||
getEnvironmentStorePath,
|
||||
listEnvironments,
|
||||
removeEnvironment,
|
||||
resolveEnvironmentPairingOffer
|
||||
} from './environments'
|
||||
|
||||
function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string {
|
||||
return encodePairingOffer({
|
||||
v: 2,
|
||||
endpoint,
|
||||
deviceToken: 'device-token',
|
||||
publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64')
|
||||
})
|
||||
}
|
||||
|
||||
describe('CLI runtime environments', () => {
|
||||
it('saves, resolves, and removes a paired environment', () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-env-store-'))
|
||||
const saved = addEnvironmentFromPairingCode(userDataPath, {
|
||||
name: 'workstation',
|
||||
pairingCode: pairingCode(),
|
||||
now: 100
|
||||
})
|
||||
|
||||
expect(listEnvironments(userDataPath)).toHaveLength(1)
|
||||
expect(resolveEnvironmentPairingOffer(userDataPath, 'workstation')).toMatchObject({
|
||||
endpoint: 'ws://127.0.0.1:6768',
|
||||
deviceToken: 'device-token'
|
||||
})
|
||||
expect(resolveEnvironmentPairingOffer(userDataPath, saved.id)).toMatchObject({
|
||||
endpoint: 'ws://127.0.0.1:6768'
|
||||
})
|
||||
expect((statSync(getEnvironmentStorePath(userDataPath)).mode & 0o777).toString(8)).toBe('600')
|
||||
|
||||
const removed = removeEnvironment(userDataPath, 'workstation')
|
||||
expect(removed.id).toBe(saved.id)
|
||||
expect(listEnvironments(userDataPath)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects an environment with the same name', () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-env-store-'))
|
||||
const first = addEnvironmentFromPairingCode(userDataPath, {
|
||||
name: 'workstation',
|
||||
pairingCode: pairingCode('ws://127.0.0.1:1111'),
|
||||
now: 100
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
addEnvironmentFromPairingCode(userDataPath, {
|
||||
name: 'workstation',
|
||||
pairingCode: pairingCode('ws://127.0.0.1:2222'),
|
||||
now: 200
|
||||
})
|
||||
).toThrow('A server named "workstation" already exists.')
|
||||
expect(listEnvironments(userDataPath)).toHaveLength(1)
|
||||
expect(resolveEnvironmentPairingOffer(userDataPath, 'workstation').endpoint).toBe(
|
||||
'ws://127.0.0.1:1111'
|
||||
)
|
||||
expect(listEnvironments(userDataPath)[0]?.id).toBe(first.id)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
addEnvironmentFromPairingCode as addEnvironmentFromPairingCodeInStore,
|
||||
getEnvironmentStorePath,
|
||||
listEnvironments,
|
||||
markEnvironmentUsed as markEnvironmentUsedInStore,
|
||||
removeEnvironment as removeEnvironmentFromStore,
|
||||
resolveEnvironment as resolveEnvironmentFromStore,
|
||||
resolveEnvironmentPairingOffer as resolveEnvironmentPairingOfferFromStore,
|
||||
RuntimeEnvironmentStoreError,
|
||||
type RuntimeEnvironmentStoreErrorCode
|
||||
} from '../../shared/runtime-environment-store'
|
||||
import type {
|
||||
KnownRuntimeEnvironment,
|
||||
PublicKnownRuntimeEnvironment
|
||||
} from '../../shared/runtime-environments'
|
||||
import type { PairingOffer } from '../../shared/pairing'
|
||||
import { RuntimeClientError } from './types'
|
||||
|
||||
export type EnvironmentAddResult = {
|
||||
environment: PublicKnownRuntimeEnvironment
|
||||
}
|
||||
|
||||
export type EnvironmentListResult = {
|
||||
environments: PublicKnownRuntimeEnvironment[]
|
||||
}
|
||||
|
||||
export type EnvironmentRemoveResult = {
|
||||
removed: PublicKnownRuntimeEnvironment
|
||||
}
|
||||
|
||||
export { getEnvironmentStorePath, listEnvironments }
|
||||
|
||||
export function addEnvironmentFromPairingCode(
|
||||
userDataPath: string,
|
||||
args: { name: string; pairingCode: string; now?: number }
|
||||
): KnownRuntimeEnvironment {
|
||||
return translateStoreError(() => addEnvironmentFromPairingCodeInStore(userDataPath, args))
|
||||
}
|
||||
|
||||
export function removeEnvironment(userDataPath: string, selector: string): KnownRuntimeEnvironment {
|
||||
return translateStoreError(() => removeEnvironmentFromStore(userDataPath, selector))
|
||||
}
|
||||
|
||||
export function resolveEnvironment(
|
||||
userDataPath: string,
|
||||
selector: string
|
||||
): KnownRuntimeEnvironment {
|
||||
return translateStoreError(() => resolveEnvironmentFromStore(userDataPath, selector))
|
||||
}
|
||||
|
||||
export function resolveEnvironmentPairingOffer(
|
||||
userDataPath: string,
|
||||
selector: string
|
||||
): PairingOffer {
|
||||
return translateStoreError(() => resolveEnvironmentPairingOfferFromStore(userDataPath, selector))
|
||||
}
|
||||
|
||||
export function markEnvironmentUsed(
|
||||
userDataPath: string,
|
||||
selector: string,
|
||||
args: { runtimeId?: string | null; now?: number } = {}
|
||||
): void {
|
||||
translateStoreError(() => markEnvironmentUsedInStore(userDataPath, selector, args))
|
||||
}
|
||||
|
||||
function translateStoreError<TResult>(fn: () => TResult): TResult {
|
||||
try {
|
||||
return fn()
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeEnvironmentStoreError) {
|
||||
throw new RuntimeClientError(toRuntimeClientErrorCode(error.code), error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function toRuntimeClientErrorCode(
|
||||
code: RuntimeEnvironmentStoreErrorCode
|
||||
): 'invalid_argument' | 'runtime_error' {
|
||||
return code
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { RuntimeClient } from './client'
|
||||
export { serveOrcaApp } from './launch'
|
||||
export { getDefaultUserDataPath } from './metadata'
|
||||
export {
|
||||
RuntimeClientError,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { resolve } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { spawnMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: spawnMock
|
||||
}))
|
||||
|
||||
import { serveOrcaApp } from './launch'
|
||||
|
||||
describe('serveOrcaApp', () => {
|
||||
beforeEach(() => {
|
||||
spawnMock.mockReset()
|
||||
process.env.ORCA_APP_EXECUTABLE = '/Applications/Orca.app/Contents/MacOS/Orca'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ORCA_APP_EXECUTABLE
|
||||
})
|
||||
|
||||
it('pins the Electron child cwd to the app root instead of the caller cwd', async () => {
|
||||
const child = {
|
||||
kill: vi.fn(),
|
||||
once: vi.fn(
|
||||
(event: string, handler: (code: number | null, signal: string | null) => void) => {
|
||||
if (event === 'exit') {
|
||||
queueMicrotask(() => handler(0, null))
|
||||
}
|
||||
return child
|
||||
}
|
||||
)
|
||||
}
|
||||
spawnMock.mockReturnValue(child)
|
||||
|
||||
await expect(serveOrcaApp({ json: true })).resolves.toBe(0)
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'/Applications/Orca.app/Contents/MacOS/Orca',
|
||||
['--serve', '--serve-json'],
|
||||
expect.objectContaining({
|
||||
cwd: resolve(__dirname, '../../..')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('passes mobile pairing through to the foreground server child', async () => {
|
||||
const child = {
|
||||
kill: vi.fn(),
|
||||
once: vi.fn(
|
||||
(event: string, handler: (code: number | null, signal: string | null) => void) => {
|
||||
if (event === 'exit') {
|
||||
queueMicrotask(() => handler(0, null))
|
||||
}
|
||||
return child
|
||||
}
|
||||
)
|
||||
}
|
||||
spawnMock.mockReturnValue(child)
|
||||
|
||||
await expect(
|
||||
serveOrcaApp({
|
||||
json: true,
|
||||
port: '6768',
|
||||
pairingAddress: '100.64.1.20',
|
||||
mobilePairing: true
|
||||
})
|
||||
).resolves.toBe(0)
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'/Applications/Orca.app/Contents/MacOS/Orca',
|
||||
[
|
||||
'--serve',
|
||||
'--serve-json',
|
||||
'--serve-port',
|
||||
'6768',
|
||||
'--serve-pairing-address',
|
||||
'100.64.1.20',
|
||||
'--serve-mobile-pairing'
|
||||
],
|
||||
expect.objectContaining({
|
||||
cwd: resolve(__dirname, '../../..')
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { spawn as spawnProcess } from 'child_process'
|
||||
import { dirname } from 'path'
|
||||
import { dirname, resolve } from 'path'
|
||||
import { RuntimeClientError } from './types'
|
||||
|
||||
export function launchOrcaApp(): void {
|
||||
@@ -53,6 +53,93 @@ export function launchOrcaApp(): void {
|
||||
)
|
||||
}
|
||||
|
||||
export function serveOrcaApp(
|
||||
args: {
|
||||
json?: boolean
|
||||
port?: string | null
|
||||
pairingAddress?: string | null
|
||||
noPairing?: boolean
|
||||
mobilePairing?: boolean
|
||||
} = {}
|
||||
): Promise<number> {
|
||||
const executable = resolveForegroundOrcaExecutable()
|
||||
const childArgs = ['--serve']
|
||||
if (args.json) {
|
||||
childArgs.push('--serve-json')
|
||||
}
|
||||
if (args.port) {
|
||||
childArgs.push('--serve-port', args.port)
|
||||
}
|
||||
if (args.pairingAddress) {
|
||||
childArgs.push('--serve-pairing-address', args.pairingAddress)
|
||||
}
|
||||
if (args.noPairing) {
|
||||
childArgs.push('--serve-no-pairing')
|
||||
}
|
||||
if (args.mobilePairing) {
|
||||
childArgs.push('--serve-mobile-pairing')
|
||||
}
|
||||
|
||||
const child = spawnProcess(executable, childArgs, {
|
||||
cwd: resolveAppRoot(),
|
||||
stdio: 'inherit',
|
||||
env: stripElectronRunAsNode(process.env)
|
||||
})
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let forceKillTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const forwardSignal = (signal: NodeJS.Signals): void => {
|
||||
child.kill(signal)
|
||||
forceKillTimer ??= setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
}, 5000)
|
||||
}
|
||||
const cleanup = (): void => {
|
||||
process.off('SIGINT', forwardSignal)
|
||||
process.off('SIGTERM', forwardSignal)
|
||||
if (forceKillTimer) {
|
||||
clearTimeout(forceKillTimer)
|
||||
forceKillTimer = null
|
||||
}
|
||||
}
|
||||
process.on('SIGINT', forwardSignal)
|
||||
process.on('SIGTERM', forwardSignal)
|
||||
child.once('error', (error) => {
|
||||
cleanup()
|
||||
reject(error)
|
||||
})
|
||||
child.once('exit', (code, signal) => {
|
||||
cleanup()
|
||||
if (typeof code === 'number') {
|
||||
resolve(code)
|
||||
return
|
||||
}
|
||||
reject(new RuntimeClientError('runtime_serve_failed', `Orca serve exited via ${signal}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function resolveAppRoot(): string {
|
||||
// Why: dev-mode resource resolution in the Electron child may consult
|
||||
// process.cwd(). Pin it to the app root so `orca serve` behaves the same
|
||||
// regardless of the shell directory it was launched from.
|
||||
return resolve(__dirname, '../../..')
|
||||
}
|
||||
|
||||
function resolveForegroundOrcaExecutable(): string {
|
||||
const overrideExecutable = process.env.ORCA_APP_EXECUTABLE
|
||||
if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) {
|
||||
return overrideExecutable
|
||||
}
|
||||
if (process.env.ELECTRON_RUN_AS_NODE === '1') {
|
||||
return process.execPath
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'runtime_serve_failed',
|
||||
'Could not determine how to start Orca server. Set ORCA_APP_EXECUTABLE to the Orca executable.'
|
||||
)
|
||||
}
|
||||
|
||||
function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const next = { ...env }
|
||||
delete next.ELECTRON_RUN_AS_NODE
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function getCliStatus(
|
||||
|
||||
try {
|
||||
const response = await sendRequest<RuntimeStatus>(metadata, 'status.get', undefined, 1000)
|
||||
if (!response.ok) {
|
||||
if (response.ok === false) {
|
||||
throw new RuntimeRpcFailureError(response)
|
||||
}
|
||||
const graphState = response.result.graphStatus
|
||||
|
||||
@@ -49,10 +49,10 @@ export async function sendRequest<TResult>(
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
socket.end()
|
||||
if (result.ok) {
|
||||
resolve(result.response)
|
||||
} else {
|
||||
if (result.ok === false) {
|
||||
reject(result.error)
|
||||
} else {
|
||||
resolve(result.response)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,10 @@
|
||||
// Why: the RPC envelope shape is the contract the CLI shares with the main
|
||||
// runtime. Keeping the types and error classes in one leaf module lets every
|
||||
// other runtime module depend on them without pulling in transport or launch
|
||||
// code.
|
||||
import type { RuntimeRpcFailure } from '../../shared/runtime-rpc-envelope'
|
||||
|
||||
export type RuntimeRpcSuccess<TResult> = {
|
||||
id: string
|
||||
ok: true
|
||||
result: TResult
|
||||
_meta: {
|
||||
runtimeId: string
|
||||
}
|
||||
}
|
||||
|
||||
export type RuntimeRpcFailure = {
|
||||
id: string
|
||||
ok: false
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
data?: unknown
|
||||
}
|
||||
_meta?: {
|
||||
runtimeId: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export type RuntimeRpcResponse<TResult> = RuntimeRpcSuccess<TResult> | RuntimeRpcFailure
|
||||
export type {
|
||||
RuntimeRpcFailure,
|
||||
RuntimeRpcResponse,
|
||||
RuntimeRpcSuccess
|
||||
} from '../../shared/runtime-rpc-envelope'
|
||||
|
||||
export class RuntimeClientError extends Error {
|
||||
readonly code: string
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { createServer, type Server } from 'http'
|
||||
import { mkdtempSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { WebSocketServer } from 'ws'
|
||||
import { encodePairingOffer, type PairingOffer } from '../../shared/pairing'
|
||||
import {
|
||||
decrypt,
|
||||
deriveSharedKey,
|
||||
encrypt,
|
||||
generateKeyPair,
|
||||
publicKeyToBase64
|
||||
} from '../../shared/e2ee-crypto'
|
||||
import { RuntimeClient } from './client'
|
||||
import { addEnvironmentFromPairingCode } from './environments'
|
||||
import { RuntimeClientError } from './types'
|
||||
import {
|
||||
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||||
RUNTIME_PROTOCOL_VERSION
|
||||
} from '../../shared/protocol-version'
|
||||
|
||||
type TestRuntime = {
|
||||
endpoint: string
|
||||
publicKeyB64: string
|
||||
deviceToken: string
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
describe('CLI remote WebSocket transport', () => {
|
||||
const servers: TestRuntime[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map((server) => server.close()))
|
||||
})
|
||||
|
||||
it('calls a remote runtime through a mobile pairing offer', async () => {
|
||||
const runtime = await startTestRuntime('runtime-ws-1')
|
||||
servers.push(runtime)
|
||||
|
||||
const pairingUrl = encodePairingOffer({
|
||||
v: 2,
|
||||
endpoint: runtime.endpoint,
|
||||
deviceToken: runtime.deviceToken,
|
||||
publicKeyB64: runtime.publicKeyB64
|
||||
})
|
||||
const client = new RuntimeClient('/tmp/unused', 5_000, pairingUrl)
|
||||
const response = await client.call<{ runtimeId: string }>('status.get')
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
expect(response.result.runtimeId).toBe('runtime-ws-1')
|
||||
})
|
||||
|
||||
it('rejects malformed remote pairing codes before local runtime lookup', () => {
|
||||
expect(() => new RuntimeClient('/tmp/unused', 5_000, 'not-a-pairing-code')).toThrow(
|
||||
RuntimeClientError
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a bare pairing payload as well as the orca URL wrapper', async () => {
|
||||
const runtime = await startTestRuntime('runtime-ws-2')
|
||||
servers.push(runtime)
|
||||
const offer: PairingOffer = {
|
||||
v: 2,
|
||||
endpoint: runtime.endpoint,
|
||||
deviceToken: runtime.deviceToken,
|
||||
publicKeyB64: runtime.publicKeyB64
|
||||
}
|
||||
const barePayload = encodePairingOffer(offer).split('#')[1]!
|
||||
|
||||
const client = new RuntimeClient('/tmp/unused', 5_000, barePayload)
|
||||
const status = await client.getCliStatus()
|
||||
|
||||
expect(status.result.runtime.reachable).toBe(true)
|
||||
expect(status.result.runtime.runtimeId).toBe('runtime-ws-2')
|
||||
})
|
||||
|
||||
it('connects through a saved environment selector', async () => {
|
||||
const runtime = await startTestRuntime('runtime-env-1')
|
||||
servers.push(runtime)
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-cli-env-'))
|
||||
addEnvironmentFromPairingCode(userDataPath, {
|
||||
name: 'remote-dev',
|
||||
pairingCode: encodePairingOffer({
|
||||
v: 2,
|
||||
endpoint: runtime.endpoint,
|
||||
deviceToken: runtime.deviceToken,
|
||||
publicKeyB64: runtime.publicKeyB64
|
||||
})
|
||||
})
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 5_000, null, 'remote-dev')
|
||||
const status = await client.getCliStatus()
|
||||
|
||||
expect(status.result.runtime.reachable).toBe(true)
|
||||
expect(status.result.runtime.runtimeId).toBe('runtime-env-1')
|
||||
})
|
||||
|
||||
it('blocks remote RPCs when the server protocol is too old', async () => {
|
||||
const runtime = await startTestRuntime('runtime-old', { runtimeProtocolVersion: 1 })
|
||||
servers.push(runtime)
|
||||
|
||||
const client = new RuntimeClient(
|
||||
'/tmp/unused',
|
||||
5_000,
|
||||
encodePairingOffer({
|
||||
v: 2,
|
||||
endpoint: runtime.endpoint,
|
||||
deviceToken: runtime.deviceToken,
|
||||
publicKeyB64: runtime.publicKeyB64
|
||||
})
|
||||
)
|
||||
|
||||
await expect(client.call('repo.list')).rejects.toMatchObject({
|
||||
code: 'incompatible_runtime',
|
||||
message: expect.stringContaining('server is too old')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
async function startTestRuntime(
|
||||
runtimeId: string,
|
||||
statusOverrides: {
|
||||
runtimeProtocolVersion?: number
|
||||
minCompatibleRuntimeClientVersion?: number
|
||||
} = {}
|
||||
): Promise<TestRuntime> {
|
||||
const serverKeyPair = generateKeyPair()
|
||||
const deviceToken = `token-${runtimeId}`
|
||||
const httpServer = createServer()
|
||||
const wss = new WebSocketServer({ server: httpServer })
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
let sharedKey: Uint8Array | null = null
|
||||
let authenticated = false
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const frame = data.toString()
|
||||
if (!sharedKey) {
|
||||
const hello = JSON.parse(frame) as { type?: string; publicKeyB64?: string }
|
||||
const clientPublicKey = Buffer.from(hello.publicKeyB64 ?? '', 'base64')
|
||||
sharedKey = deriveSharedKey(serverKeyPair.secretKey, clientPublicKey)
|
||||
ws.send(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
return
|
||||
}
|
||||
|
||||
const plaintext = decrypt(frame, sharedKey)
|
||||
if (!plaintext) {
|
||||
ws.close(4003, 'decrypt failed')
|
||||
return
|
||||
}
|
||||
if (!authenticated) {
|
||||
const auth = JSON.parse(plaintext) as { type?: string; deviceToken?: string }
|
||||
if (auth.type !== 'e2ee_auth' || auth.deviceToken !== deviceToken) {
|
||||
ws.send(encrypt(JSON.stringify({ type: 'e2ee_error' }), sharedKey))
|
||||
ws.close(4001, 'auth failed')
|
||||
return
|
||||
}
|
||||
authenticated = true
|
||||
ws.send(encrypt(JSON.stringify({ type: 'e2ee_authenticated' }), sharedKey))
|
||||
return
|
||||
}
|
||||
|
||||
const request = JSON.parse(plaintext) as { id: string; method: string }
|
||||
const response =
|
||||
request.method === 'status.get'
|
||||
? {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId,
|
||||
rendererGraphEpoch: 1,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: null,
|
||||
liveTabCount: 0,
|
||||
liveLeafCount: 0,
|
||||
runtimeProtocolVersion:
|
||||
statusOverrides.runtimeProtocolVersion ?? RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleRuntimeClientVersion:
|
||||
statusOverrides.minCompatibleRuntimeClientVersion ??
|
||||
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
|
||||
},
|
||||
_meta: { runtimeId }
|
||||
}
|
||||
: {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'Unknown method' },
|
||||
_meta: { runtimeId }
|
||||
}
|
||||
ws.send(encrypt(JSON.stringify(response), sharedKey))
|
||||
})
|
||||
})
|
||||
|
||||
await listen(httpServer)
|
||||
const address = httpServer.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Expected TCP test server')
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint: `ws://127.0.0.1:${address.port}`,
|
||||
publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey),
|
||||
deviceToken,
|
||||
close: async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve())
|
||||
for (const client of wss.clients) {
|
||||
client.close()
|
||||
}
|
||||
})
|
||||
await closeHttpServer(httpServer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listen(server: Server): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function closeHttpServer(server: Server): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { PairingOffer } from '../../shared/pairing'
|
||||
import {
|
||||
RemoteRuntimeClientError,
|
||||
sendRemoteRuntimeRequest
|
||||
} from '../../shared/remote-runtime-client'
|
||||
import { RuntimeClientError, type RuntimeRpcResponse } from './types'
|
||||
|
||||
export async function sendWebSocketRequest<TResult>(
|
||||
pairing: PairingOffer,
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number
|
||||
): Promise<RuntimeRpcResponse<TResult>> {
|
||||
try {
|
||||
return await sendRemoteRuntimeRequest<TResult>(pairing, method, params, timeoutMs)
|
||||
} catch (error) {
|
||||
if (error instanceof RemoteRuntimeClientError) {
|
||||
throw new RuntimeClientError(error.code, error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
+27
-2
@@ -1,5 +1,6 @@
|
||||
import { isAbsolute, relative, resolve as resolvePath } from 'path'
|
||||
import type { ComputerAppQuery, RuntimeWorktreeListResult } from '../shared/runtime-types'
|
||||
import { isPathInsideOrEqual } from '../shared/cross-platform-path'
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
import { getOptionalStringFlag, getRequiredStringFlag } from './flags'
|
||||
@@ -26,7 +27,22 @@ export function normalizeWorktreeSelector(selector: string, cwd: string): string
|
||||
return selector
|
||||
}
|
||||
|
||||
function assertLocalCwdWorktreeSelector(selector: string, client: RuntimeClient): void {
|
||||
if (!client.isRemote) {
|
||||
return
|
||||
}
|
||||
// Why: a paired CLI's cwd belongs to the client machine, not the runtime
|
||||
// server, so cwd-derived worktree selectors are only valid locally.
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as id:<id>, branch:<branch>, issue:<number>, or path:<absolute-server-path>.`
|
||||
)
|
||||
}
|
||||
|
||||
function isWithinPath(parentPath: string, childPath: string): boolean {
|
||||
if (isPathInsideOrEqual(parentPath, childPath)) {
|
||||
return true
|
||||
}
|
||||
const relativePath = relative(parentPath, childPath)
|
||||
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))
|
||||
}
|
||||
@@ -35,6 +51,8 @@ export async function resolveCurrentWorktreeSelector(
|
||||
cwd: string,
|
||||
client: RuntimeClient
|
||||
): Promise<string> {
|
||||
assertLocalCwdWorktreeSelector('current', client)
|
||||
|
||||
const currentPath = resolvePath(cwd)
|
||||
const worktrees = await client.call<RuntimeWorktreeListResult>('worktree.list', {
|
||||
limit: 10_000
|
||||
@@ -68,6 +86,7 @@ export async function getOptionalWorktreeSelector(
|
||||
return undefined
|
||||
}
|
||||
if (value === 'active' || value === 'current') {
|
||||
assertLocalCwdWorktreeSelector(value, client)
|
||||
return await resolveCurrentWorktreeSelector(cwd, client)
|
||||
}
|
||||
return normalizeWorktreeSelector(value, cwd)
|
||||
@@ -81,13 +100,14 @@ export async function getRequiredWorktreeSelector(
|
||||
): Promise<string> {
|
||||
const value = getRequiredStringFlag(flags, name)
|
||||
if (value === 'active' || value === 'current') {
|
||||
assertLocalCwdWorktreeSelector(value, client)
|
||||
return await resolveCurrentWorktreeSelector(cwd, client)
|
||||
}
|
||||
return normalizeWorktreeSelector(value, cwd)
|
||||
}
|
||||
|
||||
// Why: browser commands default to the current worktree (auto-resolve from cwd).
|
||||
// --worktree all bypasses filtering. Omitting --worktree auto-resolves.
|
||||
// Why: local browser commands default to the current worktree by auto-resolving
|
||||
// from cwd. Remote commands omit worktree so the runtime uses server-side focus.
|
||||
export async function getBrowserWorktreeSelector(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwd: string,
|
||||
@@ -99,10 +119,14 @@ export async function getBrowserWorktreeSelector(
|
||||
}
|
||||
if (value) {
|
||||
if (value === 'active' || value === 'current') {
|
||||
assertLocalCwdWorktreeSelector(value, client)
|
||||
return await resolveCurrentWorktreeSelector(cwd, client)
|
||||
}
|
||||
return normalizeWorktreeSelector(value, cwd)
|
||||
}
|
||||
if (client.isRemote) {
|
||||
return undefined
|
||||
}
|
||||
// Default: auto-resolve from cwd
|
||||
try {
|
||||
return await resolveCurrentWorktreeSelector(cwd, client)
|
||||
@@ -146,6 +170,7 @@ export async function getBrowserCommandTarget(
|
||||
return { page }
|
||||
}
|
||||
if (explicitWorktree === 'active' || explicitWorktree === 'current') {
|
||||
assertLocalCwdWorktreeSelector(explicitWorktree, client)
|
||||
return {
|
||||
page,
|
||||
worktree: await resolveCurrentWorktreeSelector(cwd, client)
|
||||
|
||||
@@ -9,6 +9,24 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
allowedFlags: [...GLOBAL_FLAGS],
|
||||
examples: ['orca open', 'orca open --json']
|
||||
},
|
||||
{
|
||||
path: ['serve'],
|
||||
summary: 'Start an Orca runtime server without opening a desktop window',
|
||||
usage:
|
||||
'orca serve [--port <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'port', 'pairing-address', 'mobile-pairing', 'no-pairing'],
|
||||
notes: [
|
||||
'Runs in the foreground and prints the runtime endpoint. Stop it with Ctrl+C.',
|
||||
'Use --pairing-address when clients should connect through a LAN, Tailscale, SSH-forward, or public tunnel address.',
|
||||
'Use --mobile-pairing to print a mobile-scoped pairing QR/link instead of the default runtime-environment pairing link.'
|
||||
],
|
||||
examples: [
|
||||
'orca serve',
|
||||
'orca serve --json',
|
||||
'orca serve --port 6768 --pairing-address 100.64.1.20',
|
||||
'orca serve --pairing-address 100.64.1.20 --mobile-pairing'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['status'],
|
||||
summary: 'Show app/runtime/graph readiness',
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { CommandSpec } from '../args'
|
||||
import { GLOBAL_FLAGS } from '../args'
|
||||
|
||||
export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['environment', 'add'],
|
||||
summary: 'Save a remote Orca runtime environment from a pairing code',
|
||||
usage: 'orca environment add --name <name> --pairing-code <code> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'name'],
|
||||
examples: ['orca environment add --name work-laptop --pairing-code orca://pair#...']
|
||||
},
|
||||
{
|
||||
path: ['environment', 'list'],
|
||||
summary: 'List saved Orca runtime environments',
|
||||
usage: 'orca environment list [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS]
|
||||
},
|
||||
{
|
||||
path: ['environment', 'show'],
|
||||
summary: 'Show one saved Orca runtime environment',
|
||||
usage: 'orca environment show --environment <selector> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS]
|
||||
},
|
||||
{
|
||||
path: ['environment', 'rm'],
|
||||
summary: 'Remove one saved Orca runtime environment',
|
||||
usage: 'orca environment rm --environment <selector> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS]
|
||||
}
|
||||
]
|
||||
@@ -4,6 +4,7 @@ import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic'
|
||||
import { CORE_COMMAND_SPECS } from './core'
|
||||
import { ORCHESTRATION_COMMAND_SPECS } from './orchestration'
|
||||
import { COMPUTER_COMMAND_SPECS } from './computer'
|
||||
import { ENVIRONMENT_COMMAND_SPECS } from './environment'
|
||||
import { NOTE_COMMAND_SPECS } from './note'
|
||||
|
||||
export const COMMAND_SPECS: CommandSpec[] = [
|
||||
@@ -12,5 +13,6 @@ export const COMMAND_SPECS: CommandSpec[] = [
|
||||
...BROWSER_ADVANCED_COMMAND_SPECS,
|
||||
...ORCHESTRATION_COMMAND_SPECS,
|
||||
...COMPUTER_COMMAND_SPECS,
|
||||
...ENVIRONMENT_COMMAND_SPECS,
|
||||
...NOTE_COMMAND_SPECS
|
||||
]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const builderConfig = require('../../../config/electron-builder.config.cjs') as {
|
||||
asarUnpack?: string[]
|
||||
}
|
||||
|
||||
describe('packaged CLI assets', () => {
|
||||
it('unpacks runtime dependencies used before Electron asar integration is available', () => {
|
||||
expect(builderConfig.asarUnpack).toEqual(
|
||||
expect.arrayContaining([
|
||||
'node_modules/ws/**',
|
||||
'node_modules/tweetnacl/**',
|
||||
'node_modules/zod/**'
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -220,10 +220,14 @@ describe('getDiff', () => {
|
||||
|
||||
const result = await getDiff('/repo', 'src/file.ts', false)
|
||||
|
||||
expect(gitExecFileAsyncBufferMock).toHaveBeenNthCalledWith(2, ['show', 'HEAD:src/file.ts'], {
|
||||
cwd: '/repo',
|
||||
maxBuffer: 10 * 1024 * 1024
|
||||
})
|
||||
expect(gitExecFileAsyncBufferMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
['show', '--end-of-options', 'HEAD:src/file.ts'],
|
||||
{
|
||||
cwd: '/repo',
|
||||
maxBuffer: 10 * 1024 * 1024
|
||||
}
|
||||
)
|
||||
expect(result.originalContent).toBe('head-content\n')
|
||||
expect(result.modifiedContent).toBe('working-tree-content')
|
||||
})
|
||||
|
||||
@@ -551,7 +551,7 @@ async function resolveCompareRef(worktreePath: string): Promise<string> {
|
||||
}
|
||||
|
||||
async function resolveRefOid(worktreePath: string, ref: string): Promise<string> {
|
||||
const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', ref], {
|
||||
const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', '--end-of-options', ref], {
|
||||
cwd: worktreePath
|
||||
})
|
||||
return stdout.trim()
|
||||
@@ -613,10 +613,13 @@ async function readGitBlobAtOidPath(
|
||||
filePath: string
|
||||
): Promise<GitBlobReadResult> {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsyncBuffer(['show', `${oid}:${filePath}`], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: MAX_GIT_SHOW_BYTES
|
||||
})
|
||||
const { stdout } = await gitExecFileAsyncBuffer(
|
||||
['show', '--end-of-options', `${oid}:${filePath}`],
|
||||
{
|
||||
cwd: worktreePath,
|
||||
maxBuffer: MAX_GIT_SHOW_BYTES
|
||||
}
|
||||
)
|
||||
|
||||
return { ...bufferToBlob(stdout, filePath), exists: true }
|
||||
} catch {
|
||||
|
||||
+174
-28
@@ -5,6 +5,7 @@
|
||||
import { grantDirAcl } from './win32-utils'
|
||||
import { app, BrowserWindow, nativeImage, nativeTheme } from 'electron'
|
||||
import { electronApp, is } from '@electron-toolkit/utils'
|
||||
import * as QRCode from 'qrcode'
|
||||
import devIcon from '../../resources/icon-dev.png?asset'
|
||||
import { Store, initDataPath } from './persistence'
|
||||
import { StatsCollector, initStatsPath } from './stats/collector'
|
||||
@@ -51,7 +52,12 @@ import { codexHookService } from './codex/hook-service'
|
||||
import { geminiHookService } from './gemini/hook-service'
|
||||
import { cursorHookService } from './cursor/hook-service'
|
||||
import { droidHookService } from './droid/hook-service'
|
||||
import { getPtyIdForPaneKey, registerPaneKeyTeardownListener, getLocalPtyProvider } from './ipc/pty'
|
||||
import {
|
||||
getPtyIdForPaneKey,
|
||||
registerPaneKeyTeardownListener,
|
||||
getLocalPtyProvider,
|
||||
registerHeadlessPtyRuntime
|
||||
} from './ipc/pty'
|
||||
import { AgentBrowserBridge } from './browser/agent-browser-bridge'
|
||||
import { browserManager } from './browser/browser-manager'
|
||||
import { setUnreadDockBadgeCount } from './dock/unread-badge'
|
||||
@@ -79,6 +85,7 @@ let disposeFeatureWallFirstAgentTour: (() => void) | null = null
|
||||
let watcherShutdownPromise: Promise<void> | null = null
|
||||
let watcherShutdownDone = false
|
||||
let automations: AutomationService | null = null
|
||||
const isServeMode = process.argv.includes('--serve')
|
||||
|
||||
installUncaughtPipeErrorGuard()
|
||||
// Why: propagate the Orca app version into `process.env` so PTY-env
|
||||
@@ -141,7 +148,8 @@ function focusExistingWindow(): void {
|
||||
// agent work, so that routing ambiguity is acceptable. Packaged Orca keeps
|
||||
// the lock to protect against the corruption documented in PR #1326 /
|
||||
// issue #1312.
|
||||
const hasSingleInstanceLock = is.dev ? true : acquireSingleInstanceLock(app, focusExistingWindow)
|
||||
const hasSingleInstanceLock =
|
||||
is.dev && !isServeMode ? true : acquireSingleInstanceLock(app, focusExistingWindow)
|
||||
if (!hasSingleInstanceLock) {
|
||||
if (is.dev) {
|
||||
// Why: packaged runs have no attached console, but dev runs do. Emit a
|
||||
@@ -160,8 +168,12 @@ if (!hasSingleInstanceLock) {
|
||||
// below happen — those handlers only fire after whenReady, which app.quit()
|
||||
// prevents from ever dispatching.
|
||||
if (hasSingleInstanceLock) {
|
||||
installDevParentDisconnectQuit(is.dev)
|
||||
installDevParentWatchdog(is.dev)
|
||||
// Why: dev parent shutdown coupling is only for electron-vite desktop runs.
|
||||
// `orca serve` may be launched through a CLI shim or background shell whose
|
||||
// parent lifetime is not the intended server lifetime.
|
||||
const shouldCoupleToDevParent = is.dev && !isServeMode
|
||||
installDevParentDisconnectQuit(shouldCoupleToDevParent)
|
||||
installDevParentWatchdog(shouldCoupleToDevParent)
|
||||
// Why: must run after configureDevUserDataPath (which redirects userData to
|
||||
// orca-dev in dev mode) but before app.setName('Orca') inside whenReady
|
||||
// (which would change the resolved path on case-sensitive filesystems).
|
||||
@@ -392,6 +404,108 @@ const syntheticTitleSpinnerByPaneKey = new Map<
|
||||
{ timer: ReturnType<typeof setInterval>; frame: number; profile: SyntheticTitleProfile }
|
||||
>()
|
||||
|
||||
type ServeOptions = {
|
||||
json: boolean
|
||||
wsPort?: number
|
||||
pairingAddress: string | null
|
||||
noPairing: boolean
|
||||
mobilePairing: boolean
|
||||
}
|
||||
|
||||
function getServeOptions(argv = process.argv): ServeOptions {
|
||||
const valueAfter = (flag: string): string | null => {
|
||||
const index = argv.indexOf(flag)
|
||||
if (index === -1) {
|
||||
return null
|
||||
}
|
||||
const value = argv[index + 1]
|
||||
return value && !value.startsWith('--') ? value : null
|
||||
}
|
||||
const rawPort = valueAfter('--serve-port')
|
||||
let wsPort: number | undefined
|
||||
if (rawPort) {
|
||||
const parsedPort = Number(rawPort)
|
||||
if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) {
|
||||
throw new Error(`Invalid --serve-port value: ${rawPort}`)
|
||||
}
|
||||
wsPort = parsedPort
|
||||
}
|
||||
return {
|
||||
json: argv.includes('--serve-json'),
|
||||
...(wsPort !== undefined ? { wsPort } : {}),
|
||||
pairingAddress: valueAfter('--serve-pairing-address'),
|
||||
noPairing: argv.includes('--serve-no-pairing'),
|
||||
mobilePairing: argv.includes('--serve-mobile-pairing')
|
||||
}
|
||||
}
|
||||
|
||||
async function renderTerminalPairingQr(pairingUrl: string): Promise<string | null> {
|
||||
try {
|
||||
return await QRCode.toString(pairingUrl, { type: 'terminal', small: true })
|
||||
} catch {
|
||||
try {
|
||||
return await QRCode.toString(pairingUrl, { type: 'utf8' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function printServeReady(options: ServeOptions): Promise<void> {
|
||||
if (!runtime || !runtimeRpc) {
|
||||
throw new Error('Runtime server must be initialized before printing serve readiness')
|
||||
}
|
||||
const endpoint = runtimeRpc.getWebSocketEndpoint()
|
||||
const pairing = options.noPairing
|
||||
? ({ available: false } as const)
|
||||
: runtimeRpc.createPairingOffer({
|
||||
address: options.pairingAddress,
|
||||
name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`,
|
||||
scope: options.mobilePairing ? 'mobile' : 'runtime'
|
||||
})
|
||||
const pairingQr =
|
||||
pairing.available && options.mobilePairing
|
||||
? await renderTerminalPairingQr(pairing.pairingUrl)
|
||||
: null
|
||||
if (options.json) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
type: 'orca_server_ready',
|
||||
runtimeId: runtime.getRuntimeId(),
|
||||
endpoint,
|
||||
pairing: pairing.available
|
||||
? {
|
||||
url: pairing.pairingUrl,
|
||||
endpoint: pairing.endpoint,
|
||||
deviceId: pairing.deviceId,
|
||||
scope: options.mobilePairing ? 'mobile' : 'runtime',
|
||||
qr: pairingQr
|
||||
}
|
||||
: null
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
console.log(`Orca server ready: ${endpoint ?? 'websocket unavailable'}`)
|
||||
if (pairing.available) {
|
||||
if (options.mobilePairing && pairingQr) {
|
||||
console.log(`Mobile pairing QR:\n${pairingQr}`)
|
||||
}
|
||||
console.log(`Pairing URL: ${pairing.pairingUrl}`)
|
||||
}
|
||||
}
|
||||
|
||||
function installServeSignalHandlers(): void {
|
||||
const quit = (): void => {
|
||||
// Why: foreground `orca serve` is controlled by the parent CLI/terminal,
|
||||
// so POSIX termination signals should follow Electron's normal quit path
|
||||
// and flush runtime metadata, daemon checkpoints, and telemetry.
|
||||
app.quit()
|
||||
}
|
||||
process.once('SIGINT', quit)
|
||||
process.once('SIGTERM', quit)
|
||||
}
|
||||
|
||||
// Why: on PTY teardown the paneKey→ptyId mapping is dropped, so the spinner
|
||||
// interval would keep firing but sendSyntheticTitle would no-op forever.
|
||||
// Stop the interval explicitly so the process doesn't carry a timer per dead
|
||||
@@ -622,38 +736,70 @@ app.whenReady().then(async () => {
|
||||
// ws://127.0.0.1:6769 is stable; a second dev instance still falls back via
|
||||
// ws-transport's EADDRINUSE handler.
|
||||
const devWsPort = is.dev && !isE2E ? 6769 : undefined
|
||||
let serveOptions: ServeOptions | null = null
|
||||
try {
|
||||
serveOptions = isServeMode ? getServeOptions() : null
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
app.exit(1)
|
||||
return
|
||||
}
|
||||
runtimeRpc = new OrcaRuntimeRpcServer({
|
||||
runtime,
|
||||
userDataPath: app.getPath('userData'),
|
||||
enableWebSocket: true,
|
||||
...(isE2E ? { wsPort: 0 } : {}),
|
||||
...(devWsPort !== undefined ? { wsPort: devWsPort } : {})
|
||||
...(devWsPort !== undefined ? { wsPort: devWsPort } : {}),
|
||||
...(serveOptions?.wsPort !== undefined ? { wsPort: serveOptions.wsPort } : {})
|
||||
})
|
||||
registerMobileHandlers(runtimeRpc)
|
||||
|
||||
await startFirstWindowStartupServices({
|
||||
// Why: the persistent-terminal daemon is always started. If it fails, the
|
||||
// LocalPtyProvider remains as the implicit fallback — terminals work, just
|
||||
// without cross-restart persistence.
|
||||
startDaemonPtyProvider: () => initDaemonPtyProvider(),
|
||||
// Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state,
|
||||
// so the hook server must start before restored terminals can mount.
|
||||
startAgentHookServer: () =>
|
||||
agentHookServer.start({
|
||||
env: app.isPackaged ? 'production' : 'development',
|
||||
// Why: hooks source this endpoint file at invocation time, so old PTY
|
||||
// env still reaches the current Orca process after an app restart.
|
||||
userDataPath: app.getPath('userData')
|
||||
}),
|
||||
onDaemonError: (error) => {
|
||||
console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error)
|
||||
},
|
||||
onAgentHookServerError: (error) => {
|
||||
// Why: Claude/Codex/Gemini/OpenCode/Cursor hook callbacks are sidebar
|
||||
// enrichment only. Orca must still boot if the loopback receiver fails.
|
||||
console.error('[agent-hooks] Failed to start local hook server:', error)
|
||||
}
|
||||
})
|
||||
if (!isServeMode) {
|
||||
await startFirstWindowStartupServices({
|
||||
// Why: the persistent-terminal daemon is desktop-only. Headless
|
||||
// `orca serve` registers its PTY runtime below and must not spawn the
|
||||
// desktop daemon or hook loopback listener.
|
||||
startDaemonPtyProvider: () => initDaemonPtyProvider(),
|
||||
// Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state,
|
||||
// so the hook server must start before restored terminals can mount.
|
||||
startAgentHookServer: () =>
|
||||
agentHookServer.start({
|
||||
env: app.isPackaged ? 'production' : 'development',
|
||||
// Why: hooks source this endpoint file at invocation time, so old PTY
|
||||
// env still reaches the current Orca process after an app restart.
|
||||
userDataPath: app.getPath('userData')
|
||||
}),
|
||||
onDaemonError: (error) => {
|
||||
console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error)
|
||||
},
|
||||
onAgentHookServerError: (error) => {
|
||||
// Why: Claude/Codex/Gemini/OpenCode/Cursor hook callbacks are sidebar
|
||||
// enrichment only. Orca must still boot if the loopback receiver fails.
|
||||
console.error('[agent-hooks] Failed to start local hook server:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (serveOptions) {
|
||||
registerHeadlessPtyRuntime(
|
||||
runtime,
|
||||
() => codexRuntimeHome!.prepareForCodexLaunch(),
|
||||
() => store!.getSettings(),
|
||||
() => claudeRuntimeAuth!.prepareForClaudeLaunch(),
|
||||
store
|
||||
)
|
||||
// Why: headless servers have no renderer graph publisher. Publish an
|
||||
// explicit empty graph so status clients see a ready server while
|
||||
// renderer-only operations still fail at their own window boundary.
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
await runtimeRpc.start().catch((error) => {
|
||||
console.error('[runtime] Failed to start headless RPC transport:', error)
|
||||
throw error
|
||||
})
|
||||
installServeSignalHandlers()
|
||||
await printServeReady(serveOptions)
|
||||
return
|
||||
}
|
||||
|
||||
// Why: once the hook server is ready (or has already failed open), window
|
||||
// creation and runtime RPC startup are independent.
|
||||
|
||||
@@ -179,7 +179,16 @@ describe('fs:importExternalPaths — SSH operations', () => {
|
||||
connectionId: connId
|
||||
})
|
||||
expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' })
|
||||
expect(mkdirSftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`)
|
||||
expect(mkdirSftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`, {
|
||||
allowExisting: false
|
||||
})
|
||||
expect(uploadDirMock).toHaveBeenCalledWith(
|
||||
mockSftp,
|
||||
path.resolve('/tmp/dropped/assets'),
|
||||
`${destDir}/assets`,
|
||||
path.resolve('/tmp/dropped/assets'),
|
||||
{ exclusive: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('reports per-item failure when deconfliction throws', async () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import path from 'path'
|
||||
import { constants } from 'fs'
|
||||
import { Readable, Writable } from 'stream'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
|
||||
@@ -8,7 +10,9 @@ const {
|
||||
mkdirMock,
|
||||
realpathMock,
|
||||
copyFileMock,
|
||||
openMock,
|
||||
readdirMock,
|
||||
unlinkMock,
|
||||
sftpExistsMock,
|
||||
uploadFileMock,
|
||||
uploadDirMock,
|
||||
@@ -20,7 +24,9 @@ const {
|
||||
mkdirMock: vi.fn(),
|
||||
realpathMock: vi.fn(),
|
||||
copyFileMock: vi.fn(),
|
||||
openMock: vi.fn(),
|
||||
readdirMock: vi.fn(),
|
||||
unlinkMock: vi.fn(),
|
||||
sftpExistsMock: vi.fn(),
|
||||
uploadFileMock: vi.fn(),
|
||||
uploadDirMock: vi.fn(),
|
||||
@@ -36,7 +42,10 @@ vi.mock('fs/promises', () => ({
|
||||
writeFile: vi.fn(),
|
||||
realpath: realpathMock,
|
||||
copyFile: copyFileMock,
|
||||
readdir: readdirMock
|
||||
open: openMock,
|
||||
readdir: readdirMock,
|
||||
unlink: unlinkMock,
|
||||
rm: vi.fn()
|
||||
}))
|
||||
vi.mock('../ssh/sftp-upload', () => ({
|
||||
sftpPathExists: sftpExistsMock,
|
||||
@@ -92,7 +101,9 @@ describe('fs:importExternalPaths — SSH routing & connection', () => {
|
||||
mkdirMock,
|
||||
realpathMock,
|
||||
copyFileMock,
|
||||
openMock,
|
||||
readdirMock,
|
||||
unlinkMock,
|
||||
sftpExistsMock,
|
||||
uploadFileMock,
|
||||
uploadDirMock,
|
||||
@@ -105,6 +116,30 @@ describe('fs:importExternalPaths — SSH routing & connection', () => {
|
||||
})
|
||||
realpathMock.mockImplementation(async (p: string) => p)
|
||||
lstatMock.mockRejectedValue(enoent())
|
||||
openMock.mockImplementation(async (_p: string, flags: unknown) => {
|
||||
if (flags === 'wx') {
|
||||
return {
|
||||
createWriteStream: () =>
|
||||
new Writable({
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback()
|
||||
}
|
||||
}),
|
||||
close: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
}
|
||||
return {
|
||||
stat: vi.fn().mockResolvedValue({
|
||||
size: 12,
|
||||
ino: 1,
|
||||
dev: 1,
|
||||
isFile: () => true
|
||||
}),
|
||||
createReadStream: () => Readable.from([Buffer.from('file-content')]),
|
||||
close: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
})
|
||||
unlinkMock.mockResolvedValue(undefined)
|
||||
sftpExistsMock.mockResolvedValue(false)
|
||||
uploadFileMock.mockResolvedValue(undefined)
|
||||
uploadDirMock.mockResolvedValue(undefined)
|
||||
@@ -121,7 +156,12 @@ describe('fs:importExternalPaths — SSH routing & connection', () => {
|
||||
connectionId: connId
|
||||
})
|
||||
expect(results[0]).toMatchObject({ status: 'imported', kind: 'file' })
|
||||
expect(uploadFileMock).toHaveBeenCalled()
|
||||
expect(uploadFileMock).toHaveBeenCalledWith(
|
||||
mockSftp,
|
||||
path.resolve('/tmp/dropped/file.txt'),
|
||||
`${destDir}/file.txt`,
|
||||
{ exclusive: true }
|
||||
)
|
||||
expect(copyFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -132,7 +172,10 @@ describe('fs:importExternalPaths — SSH routing & connection', () => {
|
||||
destDir: path.resolve('/workspace/repo/src')
|
||||
})
|
||||
expect(results[0]).toMatchObject({ status: 'imported' })
|
||||
expect(copyFileMock).toHaveBeenCalled()
|
||||
expect(openMock).toHaveBeenCalledWith(
|
||||
path.resolve('/tmp/dropped/file.txt'),
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
|
||||
)
|
||||
})
|
||||
|
||||
it('returns empty results without opening SFTP', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lstat, readdir } from 'fs/promises'
|
||||
import { lstat, readdir, realpath } from 'fs/promises'
|
||||
import { basename, join, posix, resolve } from 'path'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import { authorizeExternalPath, isENOENT } from './filesystem-auth'
|
||||
@@ -124,10 +124,12 @@ async function importOneSourceSsh(
|
||||
const renamed = finalName !== originalName
|
||||
|
||||
if (isDir) {
|
||||
await mkdirSftp(sftp, destPath)
|
||||
await uploadDirectory(sftp, resolvedSource, destPath)
|
||||
await mkdirSftp(sftp, destPath, { allowExisting: false })
|
||||
await uploadDirectory(sftp, resolvedSource, destPath, await realpath(resolvedSource), {
|
||||
exclusive: true
|
||||
})
|
||||
} else {
|
||||
await uploadFile(sftp, resolvedSource, destPath)
|
||||
await uploadFile(sftp, resolvedSource, destPath, { exclusive: true })
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
/* eslint-disable max-lines -- Why: import tests cover local copy, SSH routing,
|
||||
symlink safety, and runtime-upload staging against one shared IPC fixture. */
|
||||
import path from 'path'
|
||||
import { constants } from 'fs'
|
||||
import { Readable, Writable } from 'stream'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
|
||||
const { handleMock, lstatMock, mkdirMock, realpathMock, copyFileMock, readdirMock } = vi.hoisted(
|
||||
() => ({
|
||||
handleMock: vi.fn(),
|
||||
lstatMock: vi.fn(),
|
||||
mkdirMock: vi.fn(),
|
||||
realpathMock: vi.fn(),
|
||||
copyFileMock: vi.fn(),
|
||||
readdirMock: vi.fn()
|
||||
})
|
||||
)
|
||||
const {
|
||||
handleMock,
|
||||
lstatMock,
|
||||
mkdirMock,
|
||||
realpathMock,
|
||||
copyFileMock,
|
||||
openMock,
|
||||
readFileMock,
|
||||
readdirMock,
|
||||
rmMock,
|
||||
unlinkMock
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
lstatMock: vi.fn(),
|
||||
mkdirMock: vi.fn(),
|
||||
realpathMock: vi.fn(),
|
||||
copyFileMock: vi.fn(),
|
||||
openMock: vi.fn(),
|
||||
readFileMock: vi.fn(),
|
||||
readdirMock: vi.fn(),
|
||||
rmMock: vi.fn(),
|
||||
unlinkMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { handle: handleMock }
|
||||
@@ -20,11 +37,15 @@ vi.mock('electron', () => ({
|
||||
vi.mock('fs/promises', () => ({
|
||||
lstat: lstatMock,
|
||||
mkdir: mkdirMock,
|
||||
open: openMock,
|
||||
rename: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
realpath: realpathMock,
|
||||
copyFile: copyFileMock,
|
||||
readdir: readdirMock
|
||||
readFile: readFileMock,
|
||||
readdir: readdirMock,
|
||||
rm: rmMock,
|
||||
unlink: unlinkMock
|
||||
}))
|
||||
|
||||
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
|
||||
@@ -50,7 +71,14 @@ describe('fs:importExternalPaths', () => {
|
||||
const resolvedPath = path.resolve(filePath)
|
||||
lstatMock.mockImplementation(async (p: string) => {
|
||||
if (p === resolvedPath) {
|
||||
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
|
||||
return {
|
||||
size: 12,
|
||||
ino: 1,
|
||||
dev: 1,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
@@ -62,6 +90,17 @@ describe('fs:importExternalPaths', () => {
|
||||
if (p === resolvedDir) {
|
||||
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
|
||||
}
|
||||
const entry = entries.find((e) => path.join(resolvedDir, e.name) === p)
|
||||
if (entry) {
|
||||
return {
|
||||
size: entry.isDir ? 0 : 12,
|
||||
ino: entry.isDir ? 2 : 3,
|
||||
dev: 1,
|
||||
isFile: () => !entry.isDir,
|
||||
isDirectory: () => entry.isDir,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
readdirMock.mockImplementation(async () => {
|
||||
@@ -84,6 +123,35 @@ describe('fs:importExternalPaths', () => {
|
||||
})
|
||||
}
|
||||
|
||||
function mockLocalCopyOpenSuccess(content = Buffer.from('file-content')): void {
|
||||
openMock.mockImplementation(async (_p: string, flags: unknown) => {
|
||||
if (flags === 'wx') {
|
||||
const written: Buffer[] = []
|
||||
return {
|
||||
createWriteStream: () =>
|
||||
new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
written.push(Buffer.from(chunk))
|
||||
callback()
|
||||
}
|
||||
}),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
written
|
||||
}
|
||||
}
|
||||
return {
|
||||
stat: vi.fn().mockResolvedValue({
|
||||
size: content.byteLength,
|
||||
ino: 1,
|
||||
dev: 1,
|
||||
isFile: () => true
|
||||
}),
|
||||
createReadStream: () => Readable.from([content]),
|
||||
close: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
handleMock.mockReset()
|
||||
@@ -91,7 +159,11 @@ describe('fs:importExternalPaths', () => {
|
||||
mkdirMock.mockReset()
|
||||
realpathMock.mockReset()
|
||||
copyFileMock.mockReset()
|
||||
openMock.mockReset()
|
||||
readFileMock.mockReset()
|
||||
readdirMock.mockReset()
|
||||
rmMock.mockReset()
|
||||
unlinkMock.mockReset()
|
||||
|
||||
handleMock.mockImplementation((channel: string, handler: never) => {
|
||||
handlers.set(channel, handler)
|
||||
@@ -101,7 +173,11 @@ describe('fs:importExternalPaths', () => {
|
||||
lstatMock.mockRejectedValue(enoent())
|
||||
mkdirMock.mockResolvedValue(undefined)
|
||||
copyFileMock.mockResolvedValue(undefined)
|
||||
mockLocalCopyOpenSuccess()
|
||||
readFileMock.mockResolvedValue(Buffer.from('file-content'))
|
||||
readdirMock.mockResolvedValue([])
|
||||
rmMock.mockResolvedValue(undefined)
|
||||
unlinkMock.mockResolvedValue(undefined)
|
||||
|
||||
registerFilesystemMutationHandlers(store as never)
|
||||
})
|
||||
@@ -122,10 +198,44 @@ describe('fs:importExternalPaths', () => {
|
||||
renamed: false,
|
||||
destPath: path.join(destDir, 'logo.png')
|
||||
})
|
||||
expect(copyFileMock).toHaveBeenCalledWith(
|
||||
expect(openMock).toHaveBeenCalledWith(
|
||||
path.resolve(sourcePath),
|
||||
path.join(destDir, 'logo.png')
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
|
||||
)
|
||||
expect(openMock).toHaveBeenCalledWith(path.join(destDir, 'logo.png'), 'wx')
|
||||
expect(copyFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails local import instead of clobbering when the chosen destination appears late', async () => {
|
||||
const sourcePath = '/tmp/dropped/logo.png'
|
||||
mockSourceFile(sourcePath)
|
||||
openMock.mockImplementation(async (_p: string, flags: unknown) => {
|
||||
if (flags === 'wx') {
|
||||
throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' })
|
||||
}
|
||||
return {
|
||||
stat: vi.fn().mockResolvedValue({
|
||||
size: 12,
|
||||
ino: 1,
|
||||
dev: 1,
|
||||
isFile: () => true
|
||||
}),
|
||||
createReadStream: () => Readable.from([Buffer.from('file-content')]),
|
||||
close: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
const result = (await handlers.get('fs:importExternalPaths')!(null, {
|
||||
sourcePaths: [sourcePath],
|
||||
destDir
|
||||
})) as { results: { status: string; reason?: string }[] }
|
||||
|
||||
expect(result.results[0]).toMatchObject({ status: 'failed', reason: 'EEXIST' })
|
||||
expect(openMock).toHaveBeenCalledWith(
|
||||
path.resolve(sourcePath),
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
|
||||
)
|
||||
expect(openMock).toHaveBeenCalledWith(path.join(destDir, 'logo.png'), 'wx')
|
||||
})
|
||||
|
||||
it('imports multiple files in one batch', async () => {
|
||||
@@ -276,6 +386,44 @@ describe('fs:importExternalPaths', () => {
|
||||
expect(copyFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails and removes output if a local directory entry becomes a symlink after pre-scan', async () => {
|
||||
const sourcePath = '/tmp/dropped/mixeddir'
|
||||
const resolvedSource = path.resolve(sourcePath)
|
||||
const childPath = path.join(resolvedSource, 'normal.txt')
|
||||
lstatMock.mockImplementation(async (p: string) => {
|
||||
if (p === resolvedSource) {
|
||||
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
|
||||
}
|
||||
if (p === childPath) {
|
||||
return { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => true }
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
readdirMock.mockResolvedValue([
|
||||
{
|
||||
name: 'normal.txt',
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
isFile: () => true
|
||||
}
|
||||
])
|
||||
|
||||
const result = (await handlers.get('fs:importExternalPaths')!(null, {
|
||||
sourcePaths: [sourcePath],
|
||||
destDir
|
||||
})) as { results: { status: string; reason?: string }[] }
|
||||
|
||||
expect(result.results[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
reason: "Symlink not allowed in 'normal.txt'"
|
||||
})
|
||||
expect(openMock).not.toHaveBeenCalledWith(childPath, expect.anything())
|
||||
expect(rmMock).toHaveBeenCalledWith(path.join(destDir, 'mixeddir'), {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unauthorized destinations', async () => {
|
||||
const sourcePath = '/tmp/dropped/file.txt'
|
||||
mockSourceFile(sourcePath)
|
||||
@@ -328,4 +476,118 @@ describe('fs:importExternalPaths', () => {
|
||||
reason: 'missing'
|
||||
})
|
||||
})
|
||||
|
||||
it('stages external files for runtime upload without copying into the local worktree', async () => {
|
||||
const sourcePath = '/tmp/dropped/logo.png'
|
||||
const resolvedPath = path.resolve(sourcePath)
|
||||
lstatMock.mockImplementation(async (p: string) => {
|
||||
if (p === resolvedPath) {
|
||||
return {
|
||||
size: 4,
|
||||
ino: 1,
|
||||
dev: 1,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
const closeMock = vi.fn().mockResolvedValue(undefined)
|
||||
const readFileHandleMock = vi.fn().mockResolvedValue(Buffer.from('png'))
|
||||
openMock.mockResolvedValue({
|
||||
stat: vi.fn().mockResolvedValue({
|
||||
size: 4,
|
||||
ino: 1,
|
||||
dev: 1,
|
||||
isFile: () => true
|
||||
}),
|
||||
readFile: readFileHandleMock,
|
||||
close: closeMock
|
||||
})
|
||||
|
||||
const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, {
|
||||
sourcePaths: [sourcePath]
|
||||
})) as { sources: unknown[] }
|
||||
|
||||
expect(result.sources).toEqual([
|
||||
{
|
||||
sourcePath,
|
||||
status: 'staged',
|
||||
name: 'logo.png',
|
||||
kind: 'file',
|
||||
entries: [{ relativePath: '', kind: 'file', contentBase64: 'cG5n' }]
|
||||
}
|
||||
])
|
||||
expect(copyFileMock).not.toHaveBeenCalled()
|
||||
expect(readFileHandleMock).toHaveBeenCalled()
|
||||
expect(closeMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails runtime upload staging when a file changes between lstat and open', async () => {
|
||||
const sourcePath = '/tmp/dropped/logo.png'
|
||||
const resolvedPath = path.resolve(sourcePath)
|
||||
lstatMock.mockImplementation(async (p: string) => {
|
||||
if (p === resolvedPath) {
|
||||
return {
|
||||
size: 4,
|
||||
ino: 1,
|
||||
dev: 1,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
const readFileHandleMock = vi.fn().mockResolvedValue(Buffer.from('png'))
|
||||
openMock.mockResolvedValue({
|
||||
stat: vi.fn().mockResolvedValue({
|
||||
size: 4,
|
||||
ino: 2,
|
||||
dev: 1,
|
||||
isFile: () => true
|
||||
}),
|
||||
readFile: readFileHandleMock,
|
||||
close: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, {
|
||||
sourcePaths: [sourcePath]
|
||||
})) as { sources: { status: string; reason?: string }[] }
|
||||
|
||||
expect(result.sources[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
reason: "File changed during upload staging: ''"
|
||||
})
|
||||
expect(readFileHandleMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails runtime upload staging when a checked directory resolves outside the upload root', async () => {
|
||||
const sourcePath = '/tmp/dropped/assets'
|
||||
const resolvedPath = path.resolve(sourcePath)
|
||||
lstatMock.mockImplementation(async (p: string) => {
|
||||
if (p === resolvedPath) {
|
||||
return {
|
||||
isFile: () => false,
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
readdirMock.mockResolvedValue([])
|
||||
realpathMock
|
||||
.mockResolvedValueOnce(resolvedPath)
|
||||
.mockResolvedValueOnce(path.resolve('/private/assets'))
|
||||
|
||||
const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, {
|
||||
sourcePaths: [sourcePath]
|
||||
})) as { sources: { status: string; reason?: string }[] }
|
||||
|
||||
expect(result.sources[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
reason: "Path escaped upload root during staging: ''"
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,16 +2,16 @@ import path from 'path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
|
||||
const { handleMock, lstatMock, mkdirMock, renameMock, writeFileMock, realpathMock } = vi.hoisted(
|
||||
() => ({
|
||||
const { handleMock, copyFileMock, lstatMock, mkdirMock, renameMock, writeFileMock, realpathMock } =
|
||||
vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
copyFileMock: vi.fn(),
|
||||
lstatMock: vi.fn(),
|
||||
mkdirMock: vi.fn(),
|
||||
renameMock: vi.fn(),
|
||||
writeFileMock: vi.fn(),
|
||||
realpathMock: vi.fn()
|
||||
})
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { handle: handleMock }
|
||||
@@ -23,11 +23,15 @@ vi.mock('fs/promises', () => ({
|
||||
rename: renameMock,
|
||||
writeFile: writeFileMock,
|
||||
realpath: realpathMock,
|
||||
copyFile: vi.fn(),
|
||||
copyFile: copyFileMock,
|
||||
readdir: vi.fn()
|
||||
}))
|
||||
|
||||
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
|
||||
import {
|
||||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
|
||||
// Why: paths are resolved via path.resolve() in production code, so test
|
||||
// data must use resolved paths to avoid Unix-vs-Windows mismatches.
|
||||
@@ -58,6 +62,7 @@ describe('registerFilesystemMutationHandlers', () => {
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
handleMock.mockReset()
|
||||
copyFileMock.mockReset()
|
||||
lstatMock.mockReset()
|
||||
mkdirMock.mockReset()
|
||||
renameMock.mockReset()
|
||||
@@ -74,6 +79,7 @@ describe('registerFilesystemMutationHandlers', () => {
|
||||
mkdirMock.mockResolvedValue(undefined)
|
||||
writeFileMock.mockResolvedValue(undefined)
|
||||
renameMock.mockResolvedValue(undefined)
|
||||
copyFileMock.mockResolvedValue(undefined)
|
||||
|
||||
registerFilesystemMutationHandlers(store as never)
|
||||
})
|
||||
@@ -210,6 +216,36 @@ describe('registerFilesystemMutationHandlers', () => {
|
||||
expect(renameMock).toHaveBeenCalledWith(oldPath, newPath)
|
||||
})
|
||||
|
||||
// ── fs:copy ────────────────────────────────────────────────────
|
||||
|
||||
it('copies a file without overwriting an existing destination', async () => {
|
||||
const sourcePath = path.resolve('/workspace/repo/source.ts')
|
||||
const destinationPath = path.resolve('/workspace/repo/source copy.ts')
|
||||
|
||||
await handlers.get('fs:copy')!(null, { sourcePath, destinationPath })
|
||||
|
||||
expect(mkdirMock).toHaveBeenCalledWith(path.resolve('/workspace/repo'), { recursive: true })
|
||||
expect(copyFileMock).toHaveBeenCalledWith(sourcePath, destinationPath, expect.any(Number))
|
||||
})
|
||||
|
||||
it('routes copy through the SSH filesystem provider when a connection is present', async () => {
|
||||
const copy = vi.fn().mockResolvedValue(undefined)
|
||||
registerSshFilesystemProvider('ssh-1', { copy } as never)
|
||||
|
||||
try {
|
||||
await handlers.get('fs:copy')!(null, {
|
||||
sourcePath: '/home/me/repo/source.ts',
|
||||
destinationPath: '/home/me/repo/source copy.ts',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
}
|
||||
|
||||
expect(copy).toHaveBeenCalledWith('/home/me/repo/source.ts', '/home/me/repo/source copy.ts')
|
||||
expect(copyFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// ── Edge cases ─────────────────────────────────────────────────
|
||||
|
||||
it('propagates non-ENOENT lstat errors in assertNotExists', async () => {
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
/* eslint-disable max-lines -- Why: filesystem mutation IPC handlers stay centralized so
|
||||
authorization, SSH routing, and external import behavior remain audited together. */
|
||||
import { ipcMain } from 'electron'
|
||||
import { copyFile, lstat, mkdir, readdir, rename, writeFile } from 'fs/promises'
|
||||
import { basename, dirname, join, resolve } from 'path'
|
||||
import { constants } from 'fs'
|
||||
import {
|
||||
copyFile,
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
unlink,
|
||||
writeFile
|
||||
} from 'fs/promises'
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve } from 'path'
|
||||
import { pipeline } from 'stream/promises'
|
||||
import type { Store } from '../persistence'
|
||||
import { authorizeExternalPath, resolveAuthorizedPath, isENOENT } from './filesystem-auth'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
@@ -117,14 +132,42 @@ export function registerFilesystemMutationHandlers(store: Store): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'fs:copy',
|
||||
async (
|
||||
_event,
|
||||
args: { sourcePath: string; destinationPath: string; connectionId?: string }
|
||||
): Promise<void> => {
|
||||
if (args.connectionId) {
|
||||
const provider = getSshFilesystemProvider(args.connectionId)
|
||||
if (!provider) {
|
||||
throw new Error(`No filesystem provider for connection "${args.connectionId}"`)
|
||||
}
|
||||
return provider.copy(args.sourcePath, args.destinationPath)
|
||||
}
|
||||
const sourcePath = await resolveAuthorizedPath(args.sourcePath, store, {
|
||||
preserveSymlink: true
|
||||
})
|
||||
const destinationPath = await resolveAuthorizedPath(args.destinationPath, store, {
|
||||
preserveSymlink: true
|
||||
})
|
||||
await mkdir(dirname(destinationPath), { recursive: true })
|
||||
// Why: duplicate/copy callers deconflict before copying. COPYFILE_EXCL
|
||||
// keeps a late race from silently overwriting an existing file.
|
||||
await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'fs:importExternalPaths',
|
||||
async (
|
||||
_event,
|
||||
args: { sourcePaths: string[]; destDir: string; connectionId?: string }
|
||||
args: { sourcePaths: string[]; destDir: string; connectionId?: string; ensureDir?: boolean }
|
||||
): Promise<{ results: ImportItemResult[] }> => {
|
||||
if (args.connectionId) {
|
||||
return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId)
|
||||
return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId, {
|
||||
ensureDir: args.ensureDir
|
||||
})
|
||||
}
|
||||
|
||||
// Why: destDir must be authorized before any copy work begins. If the
|
||||
@@ -148,6 +191,20 @@ export function registerFilesystemMutationHandlers(store: Store): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'fs:stageExternalPathsForRuntimeUpload',
|
||||
async (
|
||||
_event,
|
||||
args: { sourcePaths: string[] }
|
||||
): Promise<{ sources: StagedExternalImportSource[] }> => {
|
||||
const sources: StagedExternalImportSource[] = []
|
||||
for (const sourcePath of args.sourcePaths) {
|
||||
sources.push(await stageOneSourceForRuntimeUpload(sourcePath))
|
||||
}
|
||||
return { sources }
|
||||
}
|
||||
)
|
||||
|
||||
// Why: terminal drag-and-drop resolver. Local worktrees pass paths through
|
||||
// unchanged (reference-in-place; preserves zero-latency drop). SSH worktrees
|
||||
// upload each path into `${worktreePath}/.orca/drops/` and return remote
|
||||
@@ -217,6 +274,32 @@ export type ImportItemResult =
|
||||
reason: string
|
||||
}
|
||||
|
||||
export type StagedExternalImportSource =
|
||||
| {
|
||||
sourcePath: string
|
||||
status: 'staged'
|
||||
name: string
|
||||
kind: 'file' | 'directory'
|
||||
entries: StagedExternalImportEntry[]
|
||||
}
|
||||
| {
|
||||
sourcePath: string
|
||||
status: 'skipped'
|
||||
reason: ImportSkipReason
|
||||
}
|
||||
| {
|
||||
sourcePath: string
|
||||
status: 'failed'
|
||||
reason: string
|
||||
}
|
||||
|
||||
export type StagedExternalImportEntry =
|
||||
| { relativePath: string; kind: 'directory' }
|
||||
| { relativePath: string; kind: 'file'; contentBase64: string }
|
||||
|
||||
const REMOTE_IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024
|
||||
const REMOTE_IMPORT_MAX_TOTAL_BYTES = 100 * 1024 * 1024
|
||||
|
||||
// ─── External Import Implementation ─────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -288,8 +371,13 @@ async function importOneSource(
|
||||
const renamed = finalName !== originalName
|
||||
|
||||
try {
|
||||
await (isDir ? recursiveCopyDir(resolvedSource, destPath) : copyFile(resolvedSource, destPath))
|
||||
await (isDir
|
||||
? recursiveCopyDir(resolvedSource, destPath)
|
||||
: copyLocalFileNoFollow(resolvedSource, destPath))
|
||||
} catch (error) {
|
||||
if (isDir) {
|
||||
await rm(destPath, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
return {
|
||||
sourcePath,
|
||||
status: 'failed',
|
||||
@@ -306,6 +394,194 @@ async function importOneSource(
|
||||
}
|
||||
}
|
||||
|
||||
async function stageOneSourceForRuntimeUpload(
|
||||
sourcePath: string
|
||||
): Promise<StagedExternalImportSource> {
|
||||
const resolvedSource = resolve(sourcePath)
|
||||
|
||||
// Why: runtime uploads read client-local paths in the client main process;
|
||||
// authorize before lstat/readFile just like local copy imports.
|
||||
authorizeExternalPath(resolvedSource)
|
||||
|
||||
let sourceStat: Awaited<ReturnType<typeof lstat>>
|
||||
try {
|
||||
sourceStat = await lstat(resolvedSource)
|
||||
} catch (error) {
|
||||
if (isENOENT(error)) {
|
||||
return { sourcePath, status: 'skipped', reason: 'missing' }
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
((error as NodeJS.ErrnoException).code === 'EACCES' ||
|
||||
(error as NodeJS.ErrnoException).code === 'EPERM')
|
||||
) {
|
||||
return { sourcePath, status: 'skipped', reason: 'permission-denied' }
|
||||
}
|
||||
return {
|
||||
sourcePath,
|
||||
status: 'failed',
|
||||
reason: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
return { sourcePath, status: 'skipped', reason: 'symlink' }
|
||||
}
|
||||
if (!sourceStat.isFile() && !sourceStat.isDirectory()) {
|
||||
return { sourcePath, status: 'skipped', reason: 'unsupported' }
|
||||
}
|
||||
if (sourceStat.isDirectory() && (await preScanForSymlinks(resolvedSource))) {
|
||||
return { sourcePath, status: 'skipped', reason: 'symlink' }
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = sourceStat.isDirectory()
|
||||
? await stageDirectoryEntries(resolvedSource)
|
||||
: [await stageFileEntry(resolvedSource, '')]
|
||||
return {
|
||||
sourcePath,
|
||||
status: 'staged',
|
||||
name: basename(resolvedSource),
|
||||
kind: sourceStat.isDirectory() ? 'directory' : 'file',
|
||||
entries
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
sourcePath,
|
||||
status: 'failed',
|
||||
reason: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stageDirectoryEntries(rootPath: string): Promise<StagedExternalImportEntry[]> {
|
||||
const entries: StagedExternalImportEntry[] = [{ relativePath: '', kind: 'directory' }]
|
||||
let totalBytes = 0
|
||||
const rootRealPath = await realpath(rootPath)
|
||||
|
||||
async function visit(dirPath: string): Promise<void> {
|
||||
const dirStat = await lstat(dirPath)
|
||||
if (dirStat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Symlink not allowed in '${normalizeRelativeUploadPath(relative(rootPath, dirPath))}'`
|
||||
)
|
||||
}
|
||||
if (!dirStat.isDirectory()) {
|
||||
throw new Error(
|
||||
`Unsupported file type in '${normalizeRelativeUploadPath(relative(rootPath, dirPath))}'`
|
||||
)
|
||||
}
|
||||
await assertRealPathInsideRoot(
|
||||
rootRealPath,
|
||||
dirPath,
|
||||
normalizeRelativeUploadPath(relative(rootPath, dirPath))
|
||||
)
|
||||
const dirEntries = await readdir(dirPath, { withFileTypes: true })
|
||||
for (const entry of dirEntries) {
|
||||
const childPath = join(dirPath, entry.name)
|
||||
const childRelativePath = normalizeRelativeUploadPath(relative(rootPath, childPath))
|
||||
if (entry.isDirectory()) {
|
||||
const childStat = await lstat(childPath)
|
||||
if (childStat.isSymbolicLink()) {
|
||||
throw new Error(`Symlink not allowed in '${childRelativePath}'`)
|
||||
}
|
||||
if (!childStat.isDirectory()) {
|
||||
throw new Error(`Unsupported file type in '${childRelativePath}'`)
|
||||
}
|
||||
entries.push({ relativePath: childRelativePath, kind: 'directory' })
|
||||
await visit(childPath)
|
||||
continue
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
throw new Error(`Unsupported file type in '${childRelativePath}'`)
|
||||
}
|
||||
const statResult = await lstat(childPath)
|
||||
totalBytes += statResult.size
|
||||
assertRemoteUploadBudget(childRelativePath, statResult.size, totalBytes)
|
||||
entries.push(await stageFileEntry(childPath, childRelativePath, rootRealPath))
|
||||
}
|
||||
}
|
||||
|
||||
await visit(rootPath)
|
||||
return entries
|
||||
}
|
||||
|
||||
async function stageFileEntry(
|
||||
filePath: string,
|
||||
relativePath: string,
|
||||
rootRealPath?: string
|
||||
): Promise<StagedExternalImportEntry> {
|
||||
const statResult = await lstat(filePath)
|
||||
const displayPath = normalizeRelativeUploadPath(relativePath)
|
||||
if (statResult.isSymbolicLink()) {
|
||||
throw new Error(`Symlink not allowed in '${displayPath}'`)
|
||||
}
|
||||
if (!statResult.isFile()) {
|
||||
throw new Error(`Unsupported file type in '${displayPath}'`)
|
||||
}
|
||||
if (rootRealPath) {
|
||||
await assertRealPathInsideRoot(rootRealPath, filePath, displayPath)
|
||||
}
|
||||
assertRemoteUploadBudget(relativePath, statResult.size, statResult.size)
|
||||
const fileHandle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
|
||||
try {
|
||||
const openedStat = await fileHandle.stat()
|
||||
if (!openedStat.isFile()) {
|
||||
throw new Error(`Unsupported file type in '${displayPath}'`)
|
||||
}
|
||||
if (
|
||||
openedStat.size !== statResult.size ||
|
||||
(statResult.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== statResult.ino) ||
|
||||
(statResult.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== statResult.dev)
|
||||
) {
|
||||
throw new Error(`File changed during upload staging: '${displayPath}'`)
|
||||
}
|
||||
assertRemoteUploadBudget(relativePath, openedStat.size, openedStat.size)
|
||||
const buffer = await fileHandle.readFile()
|
||||
const afterReadStat = await fileHandle.stat()
|
||||
if (afterReadStat.size !== openedStat.size) {
|
||||
throw new Error(`File changed during upload staging: '${displayPath}'`)
|
||||
}
|
||||
return {
|
||||
relativePath: displayPath,
|
||||
kind: 'file',
|
||||
contentBase64: buffer.toString('base64')
|
||||
}
|
||||
} finally {
|
||||
await fileHandle.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function assertRealPathInsideRoot(
|
||||
rootRealPath: string,
|
||||
candidatePath: string,
|
||||
displayPath: string
|
||||
): Promise<void> {
|
||||
const candidateRealPath = await realpath(candidatePath)
|
||||
const relativeToRoot = relative(rootRealPath, candidateRealPath)
|
||||
if (relativeToRoot !== '' && (relativeToRoot.startsWith('..') || isAbsolute(relativeToRoot))) {
|
||||
throw new Error(`Path escaped upload root during staging: '${displayPath}'`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRemoteUploadBudget(
|
||||
relativePath: string,
|
||||
fileBytes: number,
|
||||
totalBytes: number
|
||||
): void {
|
||||
if (fileBytes > REMOTE_IMPORT_MAX_FILE_BYTES) {
|
||||
throw new Error(`'${relativePath}' is too large for remote import`)
|
||||
}
|
||||
if (totalBytes > REMOTE_IMPORT_MAX_TOTAL_BYTES) {
|
||||
throw new Error('Remote import is too large')
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRelativeUploadPath(path: string): string {
|
||||
return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-scan a directory tree for symlinks. Returns true if any symlink
|
||||
* is found anywhere in the subtree.
|
||||
@@ -332,12 +608,72 @@ async function preScanForSymlinks(dirPath: string): Promise<boolean> {
|
||||
* buffering entire files into memory.
|
||||
*/
|
||||
async function recursiveCopyDir(srcDir: string, destDir: string): Promise<void> {
|
||||
await mkdir(destDir, { recursive: true })
|
||||
await mkdir(destDir, { recursive: false })
|
||||
const entries = await readdir(srcDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const srcPath = join(srcDir, entry.name)
|
||||
const dstPath = join(destDir, entry.name)
|
||||
await (entry.isDirectory() ? recursiveCopyDir(srcPath, dstPath) : copyFile(srcPath, dstPath))
|
||||
const statResult = await lstat(srcPath)
|
||||
if (statResult.isSymbolicLink()) {
|
||||
throw new Error(`Symlink not allowed in '${entry.name}'`)
|
||||
}
|
||||
if (statResult.isDirectory()) {
|
||||
await recursiveCopyDir(srcPath, dstPath)
|
||||
continue
|
||||
}
|
||||
if (!statResult.isFile()) {
|
||||
throw new Error(`Unsupported file type in '${entry.name}'`)
|
||||
}
|
||||
await copyLocalFileNoFollow(srcPath, dstPath, statResult)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLocalFileNoFollow(
|
||||
srcPath: string,
|
||||
dstPath: string,
|
||||
statResult?: Awaited<ReturnType<typeof lstat>>
|
||||
): Promise<void> {
|
||||
const beforeOpenStat = statResult ?? (await lstat(srcPath))
|
||||
if (beforeOpenStat.isSymbolicLink()) {
|
||||
throw new Error(`Symlink not allowed in '${basename(srcPath)}'`)
|
||||
}
|
||||
if (!beforeOpenStat.isFile()) {
|
||||
throw new Error(`Unsupported file type in '${basename(srcPath)}'`)
|
||||
}
|
||||
|
||||
let destinationCreated = false
|
||||
const sourceHandle = await open(srcPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
|
||||
let destinationHandle: Awaited<ReturnType<typeof open>> | null = null
|
||||
try {
|
||||
const openedStat = await sourceHandle.stat()
|
||||
if (
|
||||
!openedStat.isFile() ||
|
||||
(typeof beforeOpenStat.size === 'number' && openedStat.size !== beforeOpenStat.size) ||
|
||||
(typeof beforeOpenStat.ino === 'number' &&
|
||||
beforeOpenStat.ino !== 0 &&
|
||||
openedStat.ino !== 0 &&
|
||||
openedStat.ino !== beforeOpenStat.ino) ||
|
||||
(typeof beforeOpenStat.dev === 'number' &&
|
||||
beforeOpenStat.dev !== 0 &&
|
||||
openedStat.dev !== 0 &&
|
||||
openedStat.dev !== beforeOpenStat.dev)
|
||||
) {
|
||||
throw new Error(`File changed during import: '${basename(srcPath)}'`)
|
||||
}
|
||||
// Why: copyFile(path, path) would follow a source symlink if the source is
|
||||
// swapped after validation. Streaming from an O_NOFOLLOW handle keeps the
|
||||
// authorized file identity pinned for the copy.
|
||||
destinationHandle = await open(dstPath, 'wx')
|
||||
destinationCreated = true
|
||||
await pipeline(sourceHandle.createReadStream(), destinationHandle.createWriteStream())
|
||||
} catch (error) {
|
||||
if (destinationCreated) {
|
||||
await unlink(dstPath).catch(() => {})
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
await sourceHandle.close().catch(() => {})
|
||||
await destinationHandle?.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,16 +28,25 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
}))
|
||||
|
||||
import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher'
|
||||
import { stat } from 'fs/promises'
|
||||
import { subscribe as subscribeParcelWatcher } from '@parcel/watcher'
|
||||
|
||||
type HandlerMap = Record<string, (_event: unknown, args: unknown) => Promise<unknown> | unknown>
|
||||
|
||||
describe('registerFilesystemWatcherHandlers', () => {
|
||||
const handlers: HandlerMap = {}
|
||||
const originalPlatform = process.platform
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
handleMock.mockReset()
|
||||
getSshFilesystemProviderMock.mockReset()
|
||||
vi.mocked(stat).mockReset()
|
||||
vi.mocked(subscribeParcelWatcher).mockReset()
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
for (const key of Object.keys(handlers)) {
|
||||
delete handlers[key]
|
||||
}
|
||||
@@ -47,6 +56,28 @@ describe('registerFilesystemWatcherHandlers', () => {
|
||||
registerFilesystemWatcherHandlers()
|
||||
})
|
||||
|
||||
it('pins Parcel to the Windows backend for local Windows watches', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
|
||||
vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: vi.fn() } as never)
|
||||
|
||||
await handlers['fs:watchWorktree'](
|
||||
{ sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } },
|
||||
{ worktreePath: 'C:\\repo' }
|
||||
)
|
||||
|
||||
expect(subscribeParcelWatcher).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ backend: 'windows' })
|
||||
)
|
||||
|
||||
await closeAllWatchers()
|
||||
})
|
||||
|
||||
it('quietly skips SSH worktree watches while the filesystem provider is unavailable', async () => {
|
||||
vi.useFakeTimers()
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
@@ -239,6 +239,14 @@ async function createWatcher(rootKey: string, rootPath: string): Promise<Watched
|
||||
// therefore never unsubscribed), leaking a native file-watcher handle.
|
||||
let errorCleanedUp = false
|
||||
|
||||
const watcherOptions = {
|
||||
ignore: WATCHER_IGNORE_DIRS,
|
||||
// Why: Parcel checks Watchman before the native Windows backend by
|
||||
// default, and Windows prints a shell-level "watchman not recognized"
|
||||
// error for that probe. Pinning the backend keeps local watches quiet.
|
||||
...(process.platform === 'win32' ? { backend: 'windows' as const } : {})
|
||||
}
|
||||
|
||||
root.subscription = await watcher.subscribe(
|
||||
rootPath,
|
||||
(err, events) => {
|
||||
@@ -280,9 +288,7 @@ async function createWatcher(rootKey: string, rootPath: string): Promise<Watched
|
||||
root.batch.events.push(...events)
|
||||
scheduleBatchFlush(rootKey, root)
|
||||
},
|
||||
{
|
||||
ignore: WATCHER_IGNORE_DIRS
|
||||
}
|
||||
watcherOptions
|
||||
)
|
||||
|
||||
// Why: if the error callback already fired and cleaned up watchedRoots
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { handleMock } = vi.hoisted(() => ({
|
||||
handleMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { handle: handleMock }
|
||||
}))
|
||||
|
||||
vi.mock('qrcode', () => ({
|
||||
default: {
|
||||
toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,qr')
|
||||
}
|
||||
}))
|
||||
|
||||
import { registerMobileHandlers } from './mobile'
|
||||
|
||||
describe('registerMobileHandlers', () => {
|
||||
const handlers = new Map<string, (...args: unknown[]) => unknown>()
|
||||
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
handleMock.mockReset()
|
||||
handleMock.mockImplementation((channel: string, handler: (...args: unknown[]) => unknown) => {
|
||||
handlers.set(channel, handler)
|
||||
})
|
||||
})
|
||||
|
||||
it('lists only paired mobile-scoped devices', () => {
|
||||
const rpcServer = {
|
||||
getDeviceRegistry: () => ({
|
||||
listDevices: () => [
|
||||
{
|
||||
deviceId: 'mobile-1',
|
||||
name: 'Phone',
|
||||
scope: 'mobile',
|
||||
pairedAt: 1,
|
||||
lastSeenAt: 2
|
||||
},
|
||||
{
|
||||
deviceId: 'runtime-1',
|
||||
name: 'CLI',
|
||||
scope: 'runtime',
|
||||
pairedAt: 1,
|
||||
lastSeenAt: 2
|
||||
},
|
||||
{
|
||||
deviceId: 'pending-mobile',
|
||||
name: 'Pending',
|
||||
scope: 'mobile',
|
||||
pairedAt: 1,
|
||||
lastSeenAt: 0
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
registerMobileHandlers(rpcServer as never)
|
||||
|
||||
expect(handlers.get('mobile:listDevices')?.()).toEqual({
|
||||
devices: [
|
||||
{
|
||||
deviceId: 'mobile-1',
|
||||
name: 'Phone',
|
||||
pairedAt: 1,
|
||||
lastSeenAt: 2
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
+13
-28
@@ -2,7 +2,6 @@ import { ipcMain } from 'electron'
|
||||
import { networkInterfaces } from 'os'
|
||||
import QRCode from 'qrcode'
|
||||
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
|
||||
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
|
||||
|
||||
export type NetworkInterface = {
|
||||
name: string
|
||||
@@ -46,12 +45,6 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
|
||||
ipcMain.handle(
|
||||
'mobile:getPairingQR',
|
||||
async (_event, args?: { address?: string; rotate?: boolean }) => {
|
||||
const rawEndpoint = rpcServer.getWebSocketEndpoint()
|
||||
const registry = rpcServer.getDeviceRegistry()
|
||||
if (!rawEndpoint || !registry) {
|
||||
return { available: false as const }
|
||||
}
|
||||
|
||||
// Why: allow the caller to specify which network interface address to
|
||||
// embed in the QR code. This supports overlay networks (Tailscale,
|
||||
// ZeroTier) where the default LAN IP isn't reachable from the phone.
|
||||
@@ -59,7 +52,6 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
|
||||
if (!ip) {
|
||||
return { available: false as const }
|
||||
}
|
||||
const endpoint = rawEndpoint.replace('0.0.0.0', ip)
|
||||
|
||||
// Why: coalesce repeated QR regenerations onto a single never-scanned
|
||||
// pending token so the copy-button flow doesn't accumulate orphaned
|
||||
@@ -68,24 +60,17 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
|
||||
// `rotate: true` (explicit "Regenerate" intent because the prior token
|
||||
// may have been exposed), we discard any pending token and mint a fresh
|
||||
// one so the new QR carries a different credential.
|
||||
const name = `Mobile ${new Date().toLocaleDateString()}`
|
||||
const device = args?.rotate
|
||||
? registry.rotatePendingDevice(name)
|
||||
: registry.getOrCreatePendingDevice(name)
|
||||
|
||||
const publicKeyB64 = rpcServer.getE2EEPublicKey()
|
||||
if (!publicKeyB64) {
|
||||
const offer = rpcServer.createPairingOffer({
|
||||
address: ip,
|
||||
rotate: args?.rotate,
|
||||
name: `Mobile ${new Date().toLocaleDateString()}`,
|
||||
scope: 'mobile'
|
||||
})
|
||||
if (!offer.available) {
|
||||
return { available: false as const }
|
||||
}
|
||||
|
||||
const url = encodePairingOffer({
|
||||
v: PAIRING_OFFER_VERSION,
|
||||
endpoint,
|
||||
deviceToken: device.token,
|
||||
publicKeyB64
|
||||
})
|
||||
|
||||
const qrDataUrl = await QRCode.toDataURL(url, {
|
||||
const qrDataUrl = await QRCode.toDataURL(offer.pairingUrl, {
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 2,
|
||||
width: 256
|
||||
@@ -94,9 +79,9 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
|
||||
return {
|
||||
available: true as const,
|
||||
qrDataUrl,
|
||||
pairingUrl: url,
|
||||
endpoint,
|
||||
deviceId: device.deviceId
|
||||
pairingUrl: offer.pairingUrl,
|
||||
endpoint: offer.endpoint,
|
||||
deviceId: offer.deviceId
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -112,7 +97,7 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
|
||||
return {
|
||||
devices: registry
|
||||
.listDevices()
|
||||
.filter((d) => d.lastSeenAt > 0)
|
||||
.filter((d) => d.scope === 'mobile' && d.lastSeenAt > 0)
|
||||
.map((d) => ({
|
||||
deviceId: d.deviceId,
|
||||
name: d.name,
|
||||
@@ -127,7 +112,7 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
|
||||
if (!registry) {
|
||||
return { revoked: false }
|
||||
}
|
||||
return { revoked: registry.removeDevice(args.deviceId) }
|
||||
return { revoked: rpcServer.revokeMobileDevice(args.deviceId) }
|
||||
})
|
||||
|
||||
ipcMain.handle('mobile:isWebSocketReady', () => {
|
||||
|
||||
@@ -931,6 +931,13 @@ export function registerPtyHandlers(
|
||||
return null
|
||||
}
|
||||
},
|
||||
hasChildProcesses: async (ptyId) => {
|
||||
try {
|
||||
return await getProviderForPty(ptyId).hasChildProcesses(ptyId)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
clearBuffer: async (ptyId) => {
|
||||
// Why: desktop xterm owns local scrollback, while daemon/SSH providers
|
||||
// own their own retained buffers. Clear both surfaces so mobile
|
||||
@@ -1620,6 +1627,34 @@ export function registerPtyHandlers(
|
||||
)
|
||||
}
|
||||
|
||||
export function registerHeadlessPtyRuntime(
|
||||
runtime: OrcaRuntimeService,
|
||||
getSelectedCodexHomePath?: () => string | null,
|
||||
getSettings?: () => GlobalSettings,
|
||||
prepareClaudeAuth?: () => Promise<ClaudeRuntimeAuthPreparation>,
|
||||
store?: Store
|
||||
): void {
|
||||
// Why: headless `orca serve` has no renderer window, but the runtime still
|
||||
// needs the same PTY controller and provider listeners as desktop so remote
|
||||
// clients can create, stream, inspect, and stop terminals.
|
||||
const headlessWindow = {
|
||||
isDestroyed: () => true,
|
||||
webContents: {
|
||||
send: () => {},
|
||||
on: () => {},
|
||||
removeListener: () => {}
|
||||
}
|
||||
} as unknown as BrowserWindow
|
||||
registerPtyHandlers(
|
||||
headlessWindow,
|
||||
runtime,
|
||||
getSelectedCodexHomePath,
|
||||
getSettings,
|
||||
prepareClaudeAuth,
|
||||
store
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill all PTY processes. Call on app quit.
|
||||
*/
|
||||
|
||||
@@ -24,6 +24,7 @@ const {
|
||||
registerUIHandlersMock,
|
||||
registerFilesystemHandlersMock,
|
||||
registerRuntimeHandlersMock,
|
||||
registerRuntimeEnvironmentHandlersMock,
|
||||
registerCodexAccountHandlersMock,
|
||||
registerAgentHookHandlersMock,
|
||||
registerAgentTrustHandlersMock,
|
||||
@@ -65,6 +66,7 @@ const {
|
||||
registerUIHandlersMock: vi.fn(),
|
||||
registerFilesystemHandlersMock: vi.fn(),
|
||||
registerRuntimeHandlersMock: vi.fn(),
|
||||
registerRuntimeEnvironmentHandlersMock: vi.fn(),
|
||||
registerCodexAccountHandlersMock: vi.fn(),
|
||||
registerAgentHookHandlersMock: vi.fn(),
|
||||
registerAgentTrustHandlersMock: vi.fn(),
|
||||
@@ -194,6 +196,10 @@ vi.mock('./runtime', () => ({
|
||||
registerRuntimeHandlers: registerRuntimeHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-environments', () => ({
|
||||
registerRuntimeEnvironmentHandlers: registerRuntimeEnvironmentHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./codex-accounts', () => ({
|
||||
registerCodexAccountHandlers: registerCodexAccountHandlersMock
|
||||
}))
|
||||
@@ -262,6 +268,7 @@ describe('registerCoreHandlers', () => {
|
||||
registerUIHandlersMock.mockReset()
|
||||
registerFilesystemHandlersMock.mockReset()
|
||||
registerRuntimeHandlersMock.mockReset()
|
||||
registerRuntimeEnvironmentHandlersMock.mockReset()
|
||||
registerCodexAccountHandlersMock.mockReset()
|
||||
registerAgentHookHandlersMock.mockReset()
|
||||
registerAgentTrustHandlersMock.mockReset()
|
||||
@@ -328,6 +335,7 @@ describe('registerCoreHandlers', () => {
|
||||
expect(registerUIHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime)
|
||||
expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalled()
|
||||
expect(registerCliHandlersMock).toHaveBeenCalled()
|
||||
expect(registerPreflightHandlersMock).toHaveBeenCalled()
|
||||
expect(registerShellHandlersMock).toHaveBeenCalled()
|
||||
|
||||
@@ -18,6 +18,7 @@ import { registerStatsHandlers } from './stats'
|
||||
import { registerMemoryHandlers } from './memory'
|
||||
import { registerRateLimitHandlers } from './rate-limits'
|
||||
import { registerRuntimeHandlers } from './runtime'
|
||||
import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
|
||||
import { registerNotesHandlers } from './notes'
|
||||
import { registerNotificationHandlers } from './notifications'
|
||||
import { registerNotebookHandlers } from './notebook'
|
||||
@@ -120,6 +121,7 @@ export function registerCoreHandlers(
|
||||
registerFilesystemHandlers(store)
|
||||
registerFilesystemWatcherHandlers()
|
||||
registerRuntimeHandlers(runtime)
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerNotesHandlers(runtime)
|
||||
registerClipboardHandlers()
|
||||
registerUpdaterHandlers(store)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
const REMOTE_RUNTIME_CALL_CONCURRENCY = 8
|
||||
const REMOTE_RUNTIME_BACKGROUND_CALL_CONCURRENCY = 2
|
||||
|
||||
type QueuedRuntimeCall<T> = {
|
||||
background: boolean
|
||||
run: () => Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (error: unknown) => void
|
||||
}
|
||||
|
||||
type RuntimeCallQueue = {
|
||||
active: number
|
||||
backgroundActive: number
|
||||
foreground: QueuedRuntimeCall<unknown>[]
|
||||
background: QueuedRuntimeCall<unknown>[]
|
||||
}
|
||||
|
||||
const runtimeCallQueues = new Map<string, RuntimeCallQueue>()
|
||||
|
||||
function isBackgroundRuntimeMethod(method: string): boolean {
|
||||
return (
|
||||
method === 'hostedReview.forBranch' ||
|
||||
method === 'github.listWorkItems' ||
|
||||
method === 'github.countWorkItems' ||
|
||||
method === 'git.status' ||
|
||||
method === 'git.conflictOperation' ||
|
||||
method === 'git.branchCompare' ||
|
||||
method === 'git.upstreamStatus'
|
||||
)
|
||||
}
|
||||
|
||||
function getRuntimeCallQueue(selector: string): RuntimeCallQueue {
|
||||
let queue = runtimeCallQueues.get(selector)
|
||||
if (!queue) {
|
||||
queue = { active: 0, backgroundActive: 0, foreground: [], background: [] }
|
||||
runtimeCallQueues.set(selector, queue)
|
||||
}
|
||||
return queue
|
||||
}
|
||||
|
||||
export function enqueueRuntimeCall<T>(
|
||||
selector: string,
|
||||
method: string,
|
||||
run: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const queue = getRuntimeCallQueue(selector)
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const call: QueuedRuntimeCall<T> = {
|
||||
background: isBackgroundRuntimeMethod(method),
|
||||
run,
|
||||
resolve,
|
||||
reject
|
||||
}
|
||||
const targetQueue = call.background ? queue.background : queue.foreground
|
||||
targetQueue.push(call as QueuedRuntimeCall<unknown>)
|
||||
pumpRuntimeCallQueue(selector, queue)
|
||||
})
|
||||
}
|
||||
|
||||
function pumpRuntimeCallQueue(selector: string, queue: RuntimeCallQueue): void {
|
||||
while (queue.active < REMOTE_RUNTIME_CALL_CONCURRENCY) {
|
||||
let call = queue.foreground.shift()
|
||||
if (!call && queue.backgroundActive < REMOTE_RUNTIME_BACKGROUND_CALL_CONCURRENCY) {
|
||||
call = queue.background.shift()
|
||||
}
|
||||
if (!call) {
|
||||
break
|
||||
}
|
||||
|
||||
queue.active += 1
|
||||
if (call.background) {
|
||||
queue.backgroundActive += 1
|
||||
}
|
||||
// Why: remote WebSocket servers have a finite connection budget and each
|
||||
// one-shot RPC currently opens its own encrypted socket. Background PR/task
|
||||
// refreshes must not stampede the server and starve terminal/worktree calls.
|
||||
void call
|
||||
.run()
|
||||
.then(call.resolve, call.reject)
|
||||
.finally(() => {
|
||||
queue.active = Math.max(0, queue.active - 1)
|
||||
if (call.background) {
|
||||
queue.backgroundActive = Math.max(0, queue.backgroundActive - 1)
|
||||
}
|
||||
if (queue.active === 0 && queue.foreground.length === 0 && queue.background.length === 0) {
|
||||
runtimeCallQueues.delete(selector)
|
||||
return
|
||||
}
|
||||
pumpRuntimeCallQueue(selector, queue)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { PairingOffer } from '../../shared/pairing'
|
||||
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
import { RemoteRuntimeRequestConnection } from '../../shared/remote-runtime-request-connection'
|
||||
|
||||
type CachedRuntimeConnection = {
|
||||
pairingKey: string
|
||||
connection: RemoteRuntimeRequestConnection
|
||||
}
|
||||
|
||||
const requestConnections = new Map<string, CachedRuntimeConnection>()
|
||||
|
||||
export function sendRemoteRuntimeConnectionRequest<TResult>(
|
||||
environmentId: string,
|
||||
pairing: PairingOffer,
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number
|
||||
): Promise<RuntimeRpcResponse<TResult>> {
|
||||
const pairingKey = getPairingKey(pairing)
|
||||
let cached = requestConnections.get(environmentId)
|
||||
if (!cached || cached.pairingKey !== pairingKey) {
|
||||
cached?.connection.close()
|
||||
cached = {
|
||||
pairingKey,
|
||||
connection: new RemoteRuntimeRequestConnection(pairing)
|
||||
}
|
||||
requestConnections.set(environmentId, cached)
|
||||
}
|
||||
return cached.connection.request(method, params, timeoutMs)
|
||||
}
|
||||
|
||||
export function closeRemoteRuntimeRequestConnection(environmentId: string): void {
|
||||
const cached = requestConnections.get(environmentId)
|
||||
requestConnections.delete(environmentId)
|
||||
cached?.connection.close()
|
||||
}
|
||||
|
||||
export function closeAllRemoteRuntimeRequestConnections(): void {
|
||||
for (const environmentId of Array.from(requestConnections.keys())) {
|
||||
closeRemoteRuntimeRequestConnection(environmentId)
|
||||
}
|
||||
}
|
||||
|
||||
function getPairingKey(pairing: PairingOffer): string {
|
||||
return [pairing.endpoint, pairing.deviceToken, pairing.publicKeyB64].join('\0')
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
/* eslint-disable max-lines -- Why: this suite covers runtime environment
|
||||
management, secret redaction, one-shot RPC, and streaming cleanup contracts. */
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { encodePairingOffer } from '../../shared/pairing'
|
||||
import * as environmentStore from '../../shared/runtime-environment-store'
|
||||
|
||||
const {
|
||||
handleMock,
|
||||
onMock,
|
||||
getPathMock,
|
||||
sendRemoteRuntimeRequestMock,
|
||||
subscribeRemoteRuntimeRequestMock,
|
||||
sendRemoteRuntimeConnectionRequestMock,
|
||||
closeRemoteRuntimeRequestConnectionMock
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
onMock: vi.fn(),
|
||||
getPathMock: vi.fn(),
|
||||
sendRemoteRuntimeRequestMock: vi.fn(),
|
||||
subscribeRemoteRuntimeRequestMock: vi.fn(),
|
||||
sendRemoteRuntimeConnectionRequestMock: vi.fn(),
|
||||
closeRemoteRuntimeRequestConnectionMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: getPathMock },
|
||||
ipcMain: { handle: handleMock, on: onMock }
|
||||
}))
|
||||
|
||||
vi.mock('../../shared/remote-runtime-client', () => ({
|
||||
sendRemoteRuntimeRequest: sendRemoteRuntimeRequestMock,
|
||||
subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-environment-request-connections', () => ({
|
||||
sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock,
|
||||
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
|
||||
}))
|
||||
|
||||
import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
|
||||
|
||||
function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string {
|
||||
return encodePairingOffer({
|
||||
v: 2,
|
||||
endpoint,
|
||||
deviceToken: 'device-token',
|
||||
publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64')
|
||||
})
|
||||
}
|
||||
|
||||
function handler<TArgs, TResult>(
|
||||
channel: string
|
||||
): (_event: unknown, args: TArgs) => TResult | Promise<TResult> {
|
||||
const match = handleMock.mock.calls.find((call) => call[0] === channel)
|
||||
expect(match).toBeTruthy()
|
||||
return match![1] as (_event: unknown, args: TArgs) => TResult | Promise<TResult>
|
||||
}
|
||||
|
||||
describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
let userDataPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-ipc-'))
|
||||
getPathMock.mockReset()
|
||||
getPathMock.mockReturnValue(userDataPath)
|
||||
handleMock.mockReset()
|
||||
onMock.mockReset()
|
||||
sendRemoteRuntimeRequestMock.mockReset()
|
||||
subscribeRemoteRuntimeRequestMock.mockReset()
|
||||
sendRemoteRuntimeConnectionRequestMock.mockReset()
|
||||
closeRemoteRuntimeRequestConnectionMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('registers desktop runtime environment management handlers', () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
|
||||
expect(handleMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
'runtimeEnvironments:list',
|
||||
'runtimeEnvironments:addFromPairingCode',
|
||||
'runtimeEnvironments:resolve',
|
||||
'runtimeEnvironments:remove',
|
||||
'runtimeEnvironments:getStatus',
|
||||
'runtimeEnvironments:call',
|
||||
'runtimeEnvironments:subscribe',
|
||||
'runtimeEnvironments:unsubscribe'
|
||||
])
|
||||
expect(onMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
'runtimeEnvironments:subscriptionBinary'
|
||||
])
|
||||
})
|
||||
|
||||
it('stores, resolves, lists, and removes environments under Electron userData', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
|
||||
const add = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ environment: { id: string; name: string } }
|
||||
>('runtimeEnvironments:addFromPairingCode')
|
||||
const added = await add(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
expect(JSON.stringify(added)).not.toContain('device-token')
|
||||
expect(JSON.stringify(added)).not.toContain('publicKeyB64')
|
||||
|
||||
const list = handler<undefined, { id: string; name: string }[]>('runtimeEnvironments:list')
|
||||
expect(await list(null, undefined)).toMatchObject([{ id: added.environment.id, name: 'desk' }])
|
||||
expect(JSON.stringify(await list(null, undefined))).not.toContain('device-token')
|
||||
|
||||
const resolve = handler<{ selector: string }, { id: string; name: string }>(
|
||||
'runtimeEnvironments:resolve'
|
||||
)
|
||||
expect(await resolve(null, { selector: 'desk' })).toMatchObject({
|
||||
id: added.environment.id,
|
||||
name: 'desk'
|
||||
})
|
||||
expect(JSON.stringify(await resolve(null, { selector: 'desk' }))).not.toContain('device-token')
|
||||
|
||||
const remove = handler<{ selector: string }, { removed: { id: string; name: string } }>(
|
||||
'runtimeEnvironments:remove'
|
||||
)
|
||||
const removed = await remove(null, { selector: added.environment.id })
|
||||
expect(removed).toMatchObject({
|
||||
removed: { id: added.environment.id, name: 'desk' }
|
||||
})
|
||||
expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id)
|
||||
expect(JSON.stringify(removed)).not.toContain('device-token')
|
||||
expect(await list(null, undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('checks a saved remote runtime and records the runtime id on success', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: { runtimeId: 'runtime-remote', graphStatus: 'ready' },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
|
||||
const add = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ environment: { id: string; name: string } }
|
||||
>('runtimeEnvironments:addFromPairingCode')
|
||||
const added = await add(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
|
||||
const getStatus = handler<
|
||||
{ selector: string; timeoutMs?: number },
|
||||
{ ok: true; result: { runtimeId: string } }
|
||||
>('runtimeEnvironments:getStatus')
|
||||
expect(await getStatus(null, { selector: 'desk', timeoutMs: 50 })).toMatchObject({
|
||||
ok: true,
|
||||
result: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768', deviceToken: 'device-token' }),
|
||||
'status.get',
|
||||
undefined,
|
||||
50
|
||||
)
|
||||
|
||||
const resolve = handler<{ selector: string }, { id: string; runtimeId: string | null }>(
|
||||
'runtimeEnvironments:resolve'
|
||||
)
|
||||
expect(await resolve(null, { selector: added.environment.id })).toMatchObject({
|
||||
id: added.environment.id,
|
||||
runtimeId: 'runtime-remote'
|
||||
})
|
||||
})
|
||||
|
||||
it('proxies generic one-shot RPC calls to the saved remote runtime', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'rpc-2',
|
||||
ok: true,
|
||||
result: { repos: [{ id: 'repo-1' }] },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
|
||||
const add = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ environment: { id: string; name: string } }
|
||||
>('runtimeEnvironments:addFromPairingCode')
|
||||
await add(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
|
||||
const call = handler<
|
||||
{ selector: string; method: string; params?: unknown; timeoutMs?: number },
|
||||
{ ok: true; result: unknown }
|
||||
>('runtimeEnvironments:call')
|
||||
expect(
|
||||
await call(null, { selector: 'desk', method: 'repo.list', timeoutMs: 75 })
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
result: { repos: [{ id: 'repo-1' }] }
|
||||
})
|
||||
expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }),
|
||||
'repo.list',
|
||||
undefined,
|
||||
75
|
||||
)
|
||||
expect(sendRemoteRuntimeConnectionRequestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the cached request connection for terminal hot path RPCs', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
sendRemoteRuntimeConnectionRequestMock.mockResolvedValue({
|
||||
id: 'rpc-terminal',
|
||||
ok: true,
|
||||
result: { send: { accepted: true } },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
|
||||
const add = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ environment: { id: string; name: string } }
|
||||
>('runtimeEnvironments:addFromPairingCode')
|
||||
await add(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
|
||||
const call = handler<
|
||||
{ selector: string; method: string; params?: unknown; timeoutMs?: number },
|
||||
{ ok: true; result: unknown }
|
||||
>('runtimeEnvironments:call')
|
||||
expect(
|
||||
await call(null, {
|
||||
selector: 'desk',
|
||||
method: 'terminal.send',
|
||||
params: { terminal: 't1', text: 'a' },
|
||||
timeoutMs: 75
|
||||
})
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
result: { send: { accepted: true } }
|
||||
})
|
||||
expect(sendRemoteRuntimeConnectionRequestMock).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }),
|
||||
'terminal.send',
|
||||
{ terminal: 't1', text: 'a' },
|
||||
75
|
||||
)
|
||||
expect(sendRemoteRuntimeRequestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('limits background one-shot RPCs without blocking foreground runtime calls', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
const pendingBackground: ((value: unknown) => void)[] = []
|
||||
sendRemoteRuntimeRequestMock.mockImplementation(async () => {
|
||||
return await new Promise((resolve) => pendingBackground.push(resolve))
|
||||
})
|
||||
sendRemoteRuntimeConnectionRequestMock.mockResolvedValue({
|
||||
id: 'terminal-send',
|
||||
ok: true,
|
||||
result: { send: { accepted: true } },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
|
||||
const add = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ environment: { id: string; name: string } }
|
||||
>('runtimeEnvironments:addFromPairingCode')
|
||||
await add(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
|
||||
const call = handler<
|
||||
{ selector: string; method: string; params?: unknown; timeoutMs?: number },
|
||||
{ ok: true; result: unknown }
|
||||
>('runtimeEnvironments:call')
|
||||
const bg1 = call(null, { selector: 'desk', method: 'hostedReview.forBranch' })
|
||||
const bg2 = call(null, { selector: 'desk', method: 'github.listWorkItems' })
|
||||
await vi.waitFor(() => expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledTimes(2))
|
||||
|
||||
const bg3 = call(null, { selector: 'desk', method: 'git.status' })
|
||||
const foreground = call(null, {
|
||||
selector: 'desk',
|
||||
method: 'terminal.send',
|
||||
params: { terminal: 'term-1', text: 'a' }
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(sendRemoteRuntimeRequestMock.mock.calls.map((call) => call[1])).toEqual([
|
||||
'hostedReview.forBranch',
|
||||
'github.listWorkItems'
|
||||
])
|
||||
)
|
||||
expect(sendRemoteRuntimeConnectionRequestMock).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
'terminal.send',
|
||||
{ terminal: 'term-1', text: 'a' },
|
||||
15_000
|
||||
)
|
||||
|
||||
await expect(foreground).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: { send: { accepted: true } }
|
||||
})
|
||||
expect(pendingBackground).toHaveLength(2)
|
||||
|
||||
pendingBackground.shift()?.({
|
||||
id: 'background-1',
|
||||
ok: true,
|
||||
result: null,
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
await vi.waitFor(() => expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledTimes(3))
|
||||
expect(sendRemoteRuntimeRequestMock.mock.calls.map((call) => call[1])).toEqual([
|
||||
'hostedReview.forBranch',
|
||||
'github.listWorkItems',
|
||||
'git.status'
|
||||
])
|
||||
|
||||
pendingBackground.splice(0).forEach((resolve) =>
|
||||
resolve({
|
||||
id: 'background',
|
||||
ok: true,
|
||||
result: null,
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
)
|
||||
await expect(bg1).resolves.toMatchObject({ ok: true })
|
||||
await expect(bg2).resolves.toMatchObject({ ok: true })
|
||||
await expect(bg3).resolves.toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
it('starts and stops streaming subscriptions for a saved remote runtime', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
const close = vi.fn()
|
||||
const sendBinary = vi.fn()
|
||||
const markUsedSpy = vi.spyOn(environmentStore, 'markEnvironmentUsed')
|
||||
subscribeRemoteRuntimeRequestMock.mockImplementation(
|
||||
async (_pairing, _method, _params, _timeoutMs, callbacks) => {
|
||||
callbacks.onResponse({
|
||||
id: 'stream-1',
|
||||
ok: true,
|
||||
result: { type: 'subscribed' },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
callbacks.onResponse({
|
||||
id: 'stream-1',
|
||||
ok: true,
|
||||
result: { type: 'data', chunk: 'hello' },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
callbacks.onBinary(new Uint8Array([1, 2, 3]))
|
||||
return { requestId: 'stream-1', close, sendBinary }
|
||||
}
|
||||
)
|
||||
|
||||
const add = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ environment: { id: string; name: string } }
|
||||
>('runtimeEnvironments:addFromPairingCode')
|
||||
await add(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
|
||||
const sent: unknown[] = []
|
||||
const destroyedListenerRemoved = vi.fn()
|
||||
const subscribe = handler<
|
||||
{
|
||||
selector: string
|
||||
method: string
|
||||
params?: unknown
|
||||
timeoutMs?: number
|
||||
subscriptionId?: string
|
||||
},
|
||||
{ subscriptionId: string; requestId: string }
|
||||
>('runtimeEnvironments:subscribe')
|
||||
const result = await subscribe(
|
||||
{
|
||||
sender: {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
send: (_channel: string, payload: unknown) => sent.push(payload),
|
||||
once: vi.fn(),
|
||||
removeListener: destroyedListenerRemoved
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'desk',
|
||||
method: 'terminal.subscribe',
|
||||
params: { terminal: 't1' },
|
||||
timeoutMs: 25,
|
||||
subscriptionId: 'preload-sub-1'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.requestId).toBe('stream-1')
|
||||
expect(result.subscriptionId).toBe('preload-sub-1')
|
||||
expect(subscribeRemoteRuntimeRequestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }),
|
||||
'terminal.subscribe',
|
||||
{ terminal: 't1' },
|
||||
25,
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(sent).toEqual([
|
||||
expect.objectContaining({ subscriptionId: result.subscriptionId, type: 'response' }),
|
||||
expect.objectContaining({ subscriptionId: result.subscriptionId, type: 'response' }),
|
||||
expect.objectContaining({ subscriptionId: result.subscriptionId, type: 'binary' })
|
||||
])
|
||||
expect(markUsedSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
const binaryListener = onMock.mock.calls.find(
|
||||
(call) => call[0] === 'runtimeEnvironments:subscriptionBinary'
|
||||
)?.[1] as (_event: unknown, args: unknown) => void
|
||||
const bytes = new Uint8Array([9, 8, 7])
|
||||
binaryListener({ sender: { id: 1 } }, { subscriptionId: result.subscriptionId, bytes })
|
||||
expect(sendBinary).toHaveBeenCalledWith(bytes)
|
||||
|
||||
const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>(
|
||||
'runtimeEnvironments:unsubscribe'
|
||||
)
|
||||
expect(
|
||||
await unsubscribe({ sender: { id: 1 } }, { subscriptionId: result.subscriptionId })
|
||||
).toEqual({
|
||||
unsubscribed: true
|
||||
})
|
||||
expect(close).toHaveBeenCalled()
|
||||
expect(destroyedListenerRemoved).toHaveBeenCalledWith('destroyed', expect.any(Function))
|
||||
markUsedSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('rejects cross-window streaming subscription control', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
const close = vi.fn()
|
||||
const sendBinary = vi.fn()
|
||||
subscribeRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
requestId: 'stream-1',
|
||||
close,
|
||||
sendBinary
|
||||
})
|
||||
|
||||
const add = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ environment: { id: string; name: string } }
|
||||
>('runtimeEnvironments:addFromPairingCode')
|
||||
await add(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
|
||||
const subscribe = handler<
|
||||
{
|
||||
selector: string
|
||||
method: string
|
||||
params?: unknown
|
||||
subscriptionId?: string
|
||||
},
|
||||
{ subscriptionId: string; requestId: string }
|
||||
>('runtimeEnvironments:subscribe')
|
||||
const result = await subscribe(
|
||||
{
|
||||
sender: {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
send: vi.fn(),
|
||||
once: vi.fn(),
|
||||
removeListener: vi.fn()
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'desk',
|
||||
method: 'terminal.subscribe',
|
||||
params: { terminal: 't1' },
|
||||
subscriptionId: 'owned-sub'
|
||||
}
|
||||
)
|
||||
|
||||
const binaryListener = onMock.mock.calls.find(
|
||||
(call) => call[0] === 'runtimeEnvironments:subscriptionBinary'
|
||||
)?.[1] as (_event: unknown, args: unknown) => void
|
||||
binaryListener(
|
||||
{ sender: { id: 2 } },
|
||||
{ subscriptionId: result.subscriptionId, bytes: new Uint8Array([1]) }
|
||||
)
|
||||
expect(sendBinary).not.toHaveBeenCalled()
|
||||
|
||||
const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>(
|
||||
'runtimeEnvironments:unsubscribe'
|
||||
)
|
||||
expect(
|
||||
await unsubscribe({ sender: { id: 2 } }, { subscriptionId: result.subscriptionId })
|
||||
).toEqual({
|
||||
unsubscribed: false
|
||||
})
|
||||
expect(close).not.toHaveBeenCalled()
|
||||
|
||||
expect(
|
||||
await unsubscribe({ sender: { id: 1 } }, { subscriptionId: result.subscriptionId })
|
||||
).toEqual({
|
||||
unsubscribed: true
|
||||
})
|
||||
expect(close).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes a streaming subscription that resolves after the sender is destroyed', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
const close = vi.fn()
|
||||
let resolveSubscribe: (value: {
|
||||
requestId: string
|
||||
close: () => void
|
||||
sendBinary: (bytes: Uint8Array<ArrayBufferLike>) => boolean
|
||||
}) => void = () => {}
|
||||
subscribeRemoteRuntimeRequestMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSubscribe = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const add = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ environment: { id: string; name: string } }
|
||||
>('runtimeEnvironments:addFromPairingCode')
|
||||
await add(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
|
||||
let destroyed = false
|
||||
let destroyedHandler: unknown = null
|
||||
const destroyedListenerRemoved = vi.fn()
|
||||
const subscribe = handler<
|
||||
{
|
||||
selector: string
|
||||
method: string
|
||||
params?: unknown
|
||||
subscriptionId?: string
|
||||
},
|
||||
{ subscriptionId: string; requestId: string }
|
||||
>('runtimeEnvironments:subscribe')
|
||||
const resultPromise = subscribe(
|
||||
{
|
||||
sender: {
|
||||
id: 1,
|
||||
isDestroyed: () => destroyed,
|
||||
send: vi.fn(),
|
||||
once: vi.fn((_event: string, handler: () => void) => {
|
||||
destroyedHandler = () => {
|
||||
destroyed = true
|
||||
handler()
|
||||
}
|
||||
}),
|
||||
removeListener: destroyedListenerRemoved
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'desk',
|
||||
method: 'terminal.subscribe',
|
||||
params: { terminal: 't1' },
|
||||
subscriptionId: 'late-sub'
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(subscribeRemoteRuntimeRequestMock).toHaveBeenCalled()
|
||||
})
|
||||
expect(destroyedHandler).toBeTypeOf('function')
|
||||
;(destroyedHandler as () => void)()
|
||||
resolveSubscribe({ requestId: 'stream-late', close, sendBinary: vi.fn() })
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({
|
||||
subscriptionId: 'late-sub',
|
||||
requestId: 'stream-late'
|
||||
})
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
expect(destroyedListenerRemoved).toHaveBeenCalledWith('destroyed', expect.any(Function))
|
||||
|
||||
const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>(
|
||||
'runtimeEnvironments:unsubscribe'
|
||||
)
|
||||
expect(await unsubscribe({ sender: { id: 1 } }, { subscriptionId: 'late-sub' })).toEqual({
|
||||
unsubscribed: false
|
||||
})
|
||||
})
|
||||
|
||||
it('removes the destroyed listener when streaming subscription setup rejects', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
subscribeRemoteRuntimeRequestMock.mockRejectedValue(new Error('connect failed'))
|
||||
|
||||
const add = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ environment: { id: string; name: string } }
|
||||
>('runtimeEnvironments:addFromPairingCode')
|
||||
await add(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
|
||||
const destroyedListenerRemoved = vi.fn()
|
||||
const subscribe = handler<
|
||||
{
|
||||
selector: string
|
||||
method: string
|
||||
params?: unknown
|
||||
subscriptionId?: string
|
||||
},
|
||||
{ subscriptionId: string; requestId: string }
|
||||
>('runtimeEnvironments:subscribe')
|
||||
|
||||
await expect(
|
||||
subscribe(
|
||||
{
|
||||
sender: {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
send: vi.fn(),
|
||||
once: vi.fn(),
|
||||
removeListener: destroyedListenerRemoved
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'desk',
|
||||
method: 'terminal.subscribe',
|
||||
params: { terminal: 't1' },
|
||||
subscriptionId: 'failed-sub'
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('connect failed')
|
||||
|
||||
expect(destroyedListenerRemoved).toHaveBeenCalledWith('destroyed', expect.any(Function))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,313 @@
|
||||
/* eslint-disable max-lines -- Why: runtime environment IPC is the security boundary for saved server calls and subscriptions; keeping ownership checks, lifecycle cleanup, and binary forwarding together makes the bridge auditable. */
|
||||
import { app, ipcMain } from 'electron'
|
||||
import { randomUUID } from 'crypto'
|
||||
import {
|
||||
addEnvironmentFromPairingCode,
|
||||
listEnvironments,
|
||||
markEnvironmentUsed,
|
||||
removeEnvironment,
|
||||
resolveEnvironment,
|
||||
resolveEnvironmentPairingOffer
|
||||
} from '../../shared/runtime-environment-store'
|
||||
import {
|
||||
redactRuntimeEnvironment,
|
||||
getPreferredPairingOffer,
|
||||
type PublicKnownRuntimeEnvironment
|
||||
} from '../../shared/runtime-environments'
|
||||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
import {
|
||||
sendRemoteRuntimeRequest,
|
||||
subscribeRemoteRuntimeRequest,
|
||||
type RemoteRuntimeSubscription
|
||||
} from '../../shared/remote-runtime-client'
|
||||
import { enqueueRuntimeCall } from './runtime-environment-call-queue'
|
||||
import {
|
||||
closeRemoteRuntimeRequestConnection,
|
||||
sendRemoteRuntimeConnectionRequest
|
||||
} from './runtime-environment-request-connections'
|
||||
|
||||
const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000
|
||||
type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & {
|
||||
ownerWebContentsId: number
|
||||
removeDestroyedListener: () => void
|
||||
}
|
||||
const remoteRuntimeSubscriptions = new Map<string, RetainedRemoteRuntimeSubscription>()
|
||||
|
||||
function getUserDataPath(): string {
|
||||
return app.getPath('userData')
|
||||
}
|
||||
|
||||
function shouldUseCachedRequestConnection(method: string): boolean {
|
||||
return method === 'terminal.send' || method === 'terminal.updateViewport'
|
||||
}
|
||||
|
||||
export function registerRuntimeEnvironmentHandlers(): void {
|
||||
ipcMain.handle('runtimeEnvironments:list', (): PublicKnownRuntimeEnvironment[] =>
|
||||
listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment)
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:addFromPairingCode',
|
||||
(
|
||||
_event,
|
||||
args: { name: string; pairingCode: string }
|
||||
): { environment: PublicKnownRuntimeEnvironment } => ({
|
||||
environment: redactRuntimeEnvironment(addEnvironmentFromPairingCode(getUserDataPath(), args))
|
||||
})
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:resolve',
|
||||
(_event, args: { selector: string }): PublicKnownRuntimeEnvironment =>
|
||||
redactRuntimeEnvironment(resolveEnvironment(getUserDataPath(), args.selector))
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:remove',
|
||||
(_event, args: { selector: string }): { removed: PublicKnownRuntimeEnvironment } => {
|
||||
const removed = removeEnvironment(getUserDataPath(), args.selector)
|
||||
closeRemoteRuntimeRequestConnection(removed.id)
|
||||
if (args.selector !== removed.id) {
|
||||
closeRemoteRuntimeRequestConnection(args.selector)
|
||||
}
|
||||
return { removed: redactRuntimeEnvironment(removed) }
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:getStatus',
|
||||
async (
|
||||
_event,
|
||||
args: { selector: string; timeoutMs?: number }
|
||||
): Promise<RuntimeRpcResponse<RuntimeStatus>> => {
|
||||
const userDataPath = getUserDataPath()
|
||||
const response = await sendRemoteRuntimeRequest<RuntimeStatus>(
|
||||
resolveEnvironmentPairingOffer(userDataPath, args.selector),
|
||||
'status.get',
|
||||
undefined,
|
||||
args.timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS
|
||||
)
|
||||
if (response.ok === true) {
|
||||
markEnvironmentUsed(userDataPath, args.selector, { runtimeId: response._meta.runtimeId })
|
||||
}
|
||||
return response
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:call',
|
||||
async (
|
||||
_event,
|
||||
args: { selector: string; method: string; params?: unknown; timeoutMs?: number }
|
||||
): Promise<RuntimeRpcResponse<unknown>> => {
|
||||
return callRuntimeEnvironment(args.selector, args.method, args.params, args.timeoutMs)
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:subscribe',
|
||||
async (
|
||||
event,
|
||||
args: {
|
||||
selector: string
|
||||
method: string
|
||||
params?: unknown
|
||||
timeoutMs?: number
|
||||
subscriptionId?: string
|
||||
}
|
||||
): Promise<{ subscriptionId: string; requestId: string }> => {
|
||||
const subscriptionId =
|
||||
typeof args.subscriptionId === 'string' && args.subscriptionId.length > 0
|
||||
? args.subscriptionId
|
||||
: randomUUID()
|
||||
if (remoteRuntimeSubscriptions.has(subscriptionId)) {
|
||||
throw new Error('Runtime environment subscription id already exists')
|
||||
}
|
||||
const sender = event.sender
|
||||
const ownerWebContentsId = sender.id
|
||||
let senderDestroyed = sender.isDestroyed()
|
||||
let subscription: RemoteRuntimeSubscription | null = null
|
||||
let destroyedListenerAttached = false
|
||||
const removeDestroyedListener = (): void => {
|
||||
if (!destroyedListenerAttached) {
|
||||
return
|
||||
}
|
||||
destroyedListenerAttached = false
|
||||
sender.removeListener('destroyed', closeSubscription)
|
||||
}
|
||||
const closeSubscription = (): void => {
|
||||
senderDestroyed = true
|
||||
const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null
|
||||
remoteRuntimeSubscriptions.delete(subscriptionId)
|
||||
if (retained) {
|
||||
retained.close()
|
||||
return
|
||||
}
|
||||
removeDestroyedListener()
|
||||
subscription?.close()
|
||||
}
|
||||
sender.once('destroyed', closeSubscription)
|
||||
destroyedListenerAttached = true
|
||||
try {
|
||||
subscription = await subscribeRuntimeEnvironment(
|
||||
args.selector,
|
||||
args.method,
|
||||
args.params,
|
||||
args.timeoutMs,
|
||||
{
|
||||
onEvent: (payload) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send('runtimeEnvironments:subscriptionEvent', {
|
||||
subscriptionId,
|
||||
...payload
|
||||
})
|
||||
}
|
||||
},
|
||||
onClose: () => {
|
||||
const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null
|
||||
retained?.removeDestroyedListener()
|
||||
remoteRuntimeSubscriptions.delete(subscriptionId)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
removeDestroyedListener()
|
||||
throw error
|
||||
}
|
||||
if (senderDestroyed || sender.isDestroyed()) {
|
||||
removeDestroyedListener()
|
||||
subscription.close()
|
||||
return { subscriptionId, requestId: subscription.requestId }
|
||||
}
|
||||
remoteRuntimeSubscriptions.set(subscriptionId, {
|
||||
requestId: subscription.requestId,
|
||||
ownerWebContentsId,
|
||||
removeDestroyedListener,
|
||||
sendBinary: (bytes) => subscription?.sendBinary(bytes) ?? false,
|
||||
close: () => {
|
||||
removeDestroyedListener()
|
||||
subscription?.close()
|
||||
}
|
||||
})
|
||||
return { subscriptionId, requestId: subscription.requestId }
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:unsubscribe',
|
||||
(event, args: { subscriptionId: string }): { unsubscribed: boolean } => {
|
||||
const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId)
|
||||
if (!subscription || subscription.ownerWebContentsId !== event.sender.id) {
|
||||
return { unsubscribed: false }
|
||||
}
|
||||
remoteRuntimeSubscriptions.delete(args.subscriptionId)
|
||||
subscription.close()
|
||||
return { unsubscribed: true }
|
||||
}
|
||||
)
|
||||
ipcMain.on(
|
||||
'runtimeEnvironments:subscriptionBinary',
|
||||
(event, args: { subscriptionId?: unknown; bytes?: unknown }) => {
|
||||
if (typeof args.subscriptionId !== 'string') {
|
||||
return
|
||||
}
|
||||
const bytes = toBinaryPayload(args.bytes)
|
||||
if (!bytes) {
|
||||
return
|
||||
}
|
||||
const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId)
|
||||
if (subscription?.ownerWebContentsId === event.sender.id) {
|
||||
subscription.sendBinary(bytes)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function toBinaryPayload(value: unknown): Uint8Array<ArrayBufferLike> | null {
|
||||
if (value instanceof Uint8Array) {
|
||||
return value
|
||||
}
|
||||
if (value instanceof ArrayBuffer) {
|
||||
return new Uint8Array(value)
|
||||
}
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function callRuntimeEnvironment(
|
||||
selector: string,
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs?: number
|
||||
): Promise<RuntimeRpcResponse<unknown>> {
|
||||
const userDataPath = getUserDataPath()
|
||||
const environment = resolveEnvironment(userDataPath, selector)
|
||||
return enqueueRuntimeCall(environment.id, method, async () => {
|
||||
const currentEnvironment = resolveEnvironment(userDataPath, environment.id)
|
||||
const pairing = getPreferredPairingOffer(currentEnvironment)
|
||||
const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS
|
||||
// Why: the cached request socket is only needed for terminal hot paths.
|
||||
// Startup/control-plane RPCs use the proven one-shot path so repo hydration
|
||||
// cannot be coupled to a stale terminal-control connection.
|
||||
const response = shouldUseCachedRequestConnection(method)
|
||||
? await sendRemoteRuntimeConnectionRequest(
|
||||
currentEnvironment.id,
|
||||
pairing,
|
||||
method,
|
||||
params,
|
||||
effectiveTimeoutMs
|
||||
)
|
||||
: await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs)
|
||||
if (response.ok === true) {
|
||||
markEnvironmentUsed(userDataPath, currentEnvironment.id, {
|
||||
runtimeId: response._meta.runtimeId
|
||||
})
|
||||
}
|
||||
return response
|
||||
})
|
||||
}
|
||||
|
||||
async function subscribeRuntimeEnvironment(
|
||||
selector: string,
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number | undefined,
|
||||
callbacks: {
|
||||
onEvent: (
|
||||
payload:
|
||||
| { type: 'response'; response: RuntimeRpcResponse<unknown> }
|
||||
| { type: 'binary'; bytes: Uint8Array<ArrayBufferLike> }
|
||||
| { type: 'error'; code: string; message: string }
|
||||
| { type: 'close' }
|
||||
) => void
|
||||
onClose: () => void
|
||||
}
|
||||
): Promise<RemoteRuntimeSubscription> {
|
||||
const userDataPath = getUserDataPath()
|
||||
let markedUsed = false
|
||||
const markUsedOnce = (runtimeId: string): void => {
|
||||
if (markedUsed) {
|
||||
return
|
||||
}
|
||||
markedUsed = true
|
||||
markEnvironmentUsed(userDataPath, selector, { runtimeId })
|
||||
}
|
||||
const subscription = await subscribeRemoteRuntimeRequest(
|
||||
resolveEnvironmentPairingOffer(userDataPath, selector),
|
||||
method,
|
||||
params,
|
||||
timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS,
|
||||
{
|
||||
onResponse: (response) => {
|
||||
if (response.ok === true) {
|
||||
markUsedOnce(response._meta.runtimeId)
|
||||
}
|
||||
callbacks.onEvent({ type: 'response', response })
|
||||
},
|
||||
onBinary: (bytes) => callbacks.onEvent({ type: 'binary', bytes }),
|
||||
onError: (error) =>
|
||||
callbacks.onEvent({ type: 'error', code: error.code, message: error.message }),
|
||||
onClose: () => {
|
||||
callbacks.onEvent({ type: 'close' })
|
||||
callbacks.onClose()
|
||||
}
|
||||
}
|
||||
)
|
||||
return subscription
|
||||
}
|
||||
@@ -28,7 +28,8 @@ describe('registerRuntimeHandlers', () => {
|
||||
it('routes sync requests through the authoritative browser window id', () => {
|
||||
const runtime = {
|
||||
syncWindowGraph: vi.fn().mockReturnValue({ graphStatus: 'ready' }),
|
||||
getStatus: vi.fn().mockReturnValue({ graphStatus: 'unavailable' })
|
||||
getStatus: vi.fn().mockReturnValue({ graphStatus: 'unavailable' }),
|
||||
getRuntimeId: vi.fn().mockReturnValue('runtime-1')
|
||||
}
|
||||
|
||||
registerRuntimeHandlers(runtime as never)
|
||||
@@ -46,4 +47,33 @@ describe('registerRuntimeHandlers', () => {
|
||||
expect(runtime.syncWindowGraph).toHaveBeenCalledWith(17, { tabs: [], leaves: [] })
|
||||
expect(result).toEqual({ graphStatus: 'ready' })
|
||||
})
|
||||
|
||||
it('routes generic local runtime RPC calls through the dispatcher', async () => {
|
||||
const runtime = {
|
||||
syncWindowGraph: vi.fn(),
|
||||
getStatus: vi.fn().mockReturnValue({
|
||||
runtimeId: 'runtime-1',
|
||||
rendererGraphEpoch: 0,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: null,
|
||||
liveTabCount: 0,
|
||||
liveLeafCount: 0
|
||||
}),
|
||||
getRuntimeId: vi.fn().mockReturnValue('runtime-1')
|
||||
}
|
||||
|
||||
registerRuntimeHandlers(runtime as never)
|
||||
|
||||
const callRegistration = handleMock.mock.calls.find(([channel]) => channel === 'runtime:call')
|
||||
expect(callRegistration).toBeTruthy()
|
||||
|
||||
const handler = callRegistration![1]
|
||||
const result = await handler({ sender: {} }, { method: 'status.get' })
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
result: { runtimeId: 'runtime-1', graphStatus: 'ready' },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../shared/runtime-types'
|
||||
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
import { RpcDispatcher } from '../runtime/rpc/dispatcher'
|
||||
|
||||
export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
|
||||
ipcMain.removeHandler('runtime:syncWindowGraph')
|
||||
ipcMain.removeHandler('runtime:getStatus')
|
||||
ipcMain.removeHandler('runtime:call')
|
||||
|
||||
ipcMain.handle(
|
||||
'runtime:syncWindowGraph',
|
||||
@@ -21,6 +24,21 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
|
||||
return runtime.getStatus()
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'runtime:call',
|
||||
async (
|
||||
_event,
|
||||
args: { method: string; params?: unknown }
|
||||
): Promise<RuntimeRpcResponse<unknown>> => {
|
||||
return (await new RpcDispatcher({ runtime }).dispatch({
|
||||
id: 'desktop-ipc',
|
||||
authToken: 'desktop-ipc',
|
||||
method: args.method,
|
||||
params: args.params
|
||||
})) as RuntimeRpcResponse<unknown>
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.removeHandler('runtime:getTerminalFitOverrides')
|
||||
ipcMain.handle(
|
||||
'runtime:getTerminalFitOverrides',
|
||||
|
||||
@@ -108,7 +108,7 @@ async function ensureUniqueRemoteName(repoPath: string, preferred: string): Prom
|
||||
throw new Error(`Could not find an available remote name for ${preferred}.`)
|
||||
}
|
||||
|
||||
async function prepareWorktreePushTarget(
|
||||
export async function prepareWorktreePushTarget(
|
||||
repoPath: string,
|
||||
target: GitPushTarget
|
||||
): Promise<GitPushTarget> {
|
||||
@@ -138,7 +138,7 @@ async function prepareWorktreePushTarget(
|
||||
}
|
||||
}
|
||||
|
||||
async function configureCreatedWorktreePushTarget(
|
||||
export async function configureCreatedWorktreePushTarget(
|
||||
worktreePath: string,
|
||||
branchName: string,
|
||||
target: GitPushTarget
|
||||
|
||||
@@ -13,10 +13,13 @@ describe('PowerShell OSC 133 bootstrap', () => {
|
||||
expect(script).toContain('ORCA_PI_CODING_AGENT_DIR')
|
||||
expect(script).toContain('function Global:prompt')
|
||||
expect(script).toContain('function Global:PSConsoleHostReadLine')
|
||||
expect(script).toContain('`e]133;D;$fakeExitCode`a')
|
||||
expect(script).toContain('`e]133;A`a')
|
||||
expect(script).toContain('`e]133;B`a')
|
||||
expect(script).toContain('`e]133;C`a')
|
||||
expect(script).toContain('Esc = [char]27')
|
||||
expect(script).toContain('Bel = [char]7')
|
||||
expect(script).toContain(')]133;D;$fakeExitCode$(')
|
||||
expect(script).toContain(')]133;A$(')
|
||||
expect(script).toContain(')]133;B$(')
|
||||
expect(script).toContain(')]133;C$(')
|
||||
expect(script).not.toContain('`e]133')
|
||||
expect(script).not.toContain('$PROFILE')
|
||||
expect(script).not.toContain('ExecutionPolicy')
|
||||
expect(script).not.toContain('NoProfile')
|
||||
|
||||
@@ -28,6 +28,8 @@ $Global:__OrcaOsc133State = @{
|
||||
OriginalReadLine = $function:PSConsoleHostReadLine
|
||||
HasSeenPrompt = $false
|
||||
HasPSReadLine = $null -ne (Get-Module -Name PSReadLine)
|
||||
Esc = [char]27
|
||||
Bel = [char]7
|
||||
}
|
||||
|
||||
function Global:prompt {
|
||||
@@ -39,15 +41,15 @@ function Global:prompt {
|
||||
# Emit D from prompt, not readline state. Some profile setups bypass
|
||||
# PSConsoleHostReadLine; the consumer only needs completion.
|
||||
if ($Global:__OrcaOsc133State.HasSeenPrompt) {
|
||||
$result += "\`e]133;D;$fakeExitCode\`a"
|
||||
$result += "$($Global:__OrcaOsc133State.Esc)]133;D;$fakeExitCode$($Global:__OrcaOsc133State.Bel)"
|
||||
}
|
||||
$Global:__OrcaOsc133State.HasSeenPrompt = $true
|
||||
|
||||
$result += "\`e]133;A\`a"
|
||||
$result += "$($Global:__OrcaOsc133State.Esc)]133;A$($Global:__OrcaOsc133State.Bel)"
|
||||
# Preserve the previous success/failure value for prompts that inspect it.
|
||||
if ($fakeExitCode -ne 0) { Write-Error "failure" -ea ignore }
|
||||
$result += $Global:__OrcaOsc133State.OriginalPrompt.Invoke()
|
||||
$result += "\`e]133;B\`a"
|
||||
$result += "$($Global:__OrcaOsc133State.Esc)]133;B$($Global:__OrcaOsc133State.Bel)"
|
||||
$result
|
||||
}
|
||||
|
||||
@@ -55,7 +57,7 @@ if ($Global:__OrcaOsc133State.HasPSReadLine -and
|
||||
$null -ne $Global:__OrcaOsc133State.OriginalReadLine) {
|
||||
function Global:PSConsoleHostReadLine {
|
||||
$commandLine = $Global:__OrcaOsc133State.OriginalReadLine.Invoke()
|
||||
[Console]::Write("\`e]133;C\`a")
|
||||
[Console]::Write("$($Global:__OrcaOsc133State.Esc)]133;C$($Global:__OrcaOsc133State.Bel)")
|
||||
return $commandLine
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/* eslint-disable max-lines -- Why: SSH filesystem provider coverage keeps relay fallback,
|
||||
SFTP binary writes, watch fan-out, and provider lifecycle tests together so
|
||||
transport parity regressions are visible in one suite. */
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { SshFilesystemProvider } from './ssh-filesystem-provider'
|
||||
|
||||
@@ -90,6 +93,64 @@ describe('SshFilesystemProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeFileBase64', () => {
|
||||
it('writes decoded bytes through SFTP', async () => {
|
||||
const written: Buffer[] = []
|
||||
const writeStream = {
|
||||
on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream),
|
||||
end: vi.fn((buffer: Buffer) => {
|
||||
written.push(buffer)
|
||||
const closeHandler = writeStream.on.mock.calls.find(([event]) => event === 'close')?.[1]
|
||||
closeHandler?.()
|
||||
}),
|
||||
destroy: vi.fn()
|
||||
}
|
||||
const sftp = {
|
||||
createWriteStream: vi.fn(() => writeStream),
|
||||
end: vi.fn()
|
||||
}
|
||||
provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never)
|
||||
|
||||
await provider.writeFileBase64('/home/user/logo.png', 'cG5n')
|
||||
|
||||
expect(sftp.createWriteStream).toHaveBeenCalledWith('/home/user/logo.png', { flags: 'wx' })
|
||||
expect(written).toEqual([Buffer.from('png')])
|
||||
expect(sftp.end).toHaveBeenCalled()
|
||||
expect(mux.request).not.toHaveBeenCalledWith('fs.writeFile', expect.anything())
|
||||
})
|
||||
|
||||
it('can append decoded chunks through SFTP', async () => {
|
||||
const writeStream = {
|
||||
on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream),
|
||||
end: vi.fn((_buffer: Buffer) => {
|
||||
const closeHandler = writeStream.on.mock.calls.find(([event]) => event === 'close')?.[1]
|
||||
closeHandler?.()
|
||||
}),
|
||||
destroy: vi.fn()
|
||||
}
|
||||
const sftp = {
|
||||
createWriteStream: vi.fn(() => writeStream),
|
||||
end: vi.fn()
|
||||
}
|
||||
provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never)
|
||||
|
||||
await provider.writeFileBase64Chunk('/home/user/logo.png', 'cG5n', true)
|
||||
|
||||
expect(sftp.createWriteStream).toHaveBeenCalledWith('/home/user/logo.png', { flags: 'a' })
|
||||
expect(sftp.end).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createDirNoClobber', () => {
|
||||
it('sends fs.createDirNoClobber request', async () => {
|
||||
await provider.createDirNoClobber('/home/user/new-dir')
|
||||
|
||||
expect(mux.request).toHaveBeenCalledWith('fs.createDirNoClobber', {
|
||||
dirPath: '/home/user/new-dir'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('stat', () => {
|
||||
it('sends fs.stat request', async () => {
|
||||
const statResult = { size: 1024, type: 'file', mtime: 1234567890 }
|
||||
@@ -196,6 +257,112 @@ describe('SshFilesystemProvider', () => {
|
||||
expect(callback).toHaveBeenCalledWith(events)
|
||||
})
|
||||
|
||||
it('fans out same-root watch events and unwatches only after the last subscriber', async () => {
|
||||
const first = vi.fn()
|
||||
const second = vi.fn()
|
||||
const unsubFirst = await provider.watch('/home/user/project', first)
|
||||
const unsubSecond = await provider.watch('/home/user/project', second)
|
||||
|
||||
expect(mux.request).toHaveBeenCalledTimes(1)
|
||||
expect(mux.request).toHaveBeenCalledWith('fs.watch', { rootPath: '/home/user/project' })
|
||||
|
||||
const notifHandler = mux.onNotification.mock.calls[0][0]
|
||||
const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }]
|
||||
notifHandler('fs.changed', { events })
|
||||
expect(first).toHaveBeenCalledWith(events)
|
||||
expect(second).toHaveBeenCalledWith(events)
|
||||
|
||||
unsubFirst()
|
||||
expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' })
|
||||
notifHandler('fs.changed', { events })
|
||||
expect(second).toHaveBeenCalledTimes(2)
|
||||
|
||||
unsubSecond()
|
||||
expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' })
|
||||
})
|
||||
|
||||
it('shares an in-flight same-root watch setup across concurrent subscribers', async () => {
|
||||
let resolveWatch: () => void = () => {}
|
||||
mux.request.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveWatch = resolve
|
||||
})
|
||||
)
|
||||
const first = vi.fn()
|
||||
const second = vi.fn()
|
||||
|
||||
const firstWatch = provider.watch('/home/user/project', first)
|
||||
const secondWatch = provider.watch('/home/user/project', second)
|
||||
|
||||
expect(mux.request).toHaveBeenCalledTimes(1)
|
||||
resolveWatch()
|
||||
const [unsubFirst, unsubSecond] = await Promise.all([firstWatch, secondWatch])
|
||||
|
||||
const notifHandler = mux.onNotification.mock.calls[0][0]
|
||||
const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }]
|
||||
notifHandler('fs.changed', { events })
|
||||
expect(first).toHaveBeenCalledWith(events)
|
||||
expect(second).toHaveBeenCalledWith(events)
|
||||
|
||||
unsubFirst()
|
||||
expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' })
|
||||
unsubSecond()
|
||||
expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' })
|
||||
})
|
||||
|
||||
it('does not retain a watch listener when fs.watch setup fails', async () => {
|
||||
mux.request.mockRejectedValueOnce(new Error('watch unavailable'))
|
||||
const first = vi.fn()
|
||||
await expect(provider.watch('/home/user/project', first)).rejects.toThrow('watch unavailable')
|
||||
|
||||
const second = vi.fn()
|
||||
await provider.watch('/home/user/project', second)
|
||||
|
||||
const notifHandler = mux.onNotification.mock.calls[0][0]
|
||||
const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }]
|
||||
notifHandler('fs.changed', { events })
|
||||
expect(first).not.toHaveBeenCalled()
|
||||
expect(second).toHaveBeenCalledWith(events)
|
||||
})
|
||||
|
||||
it('does not forward sibling paths with matching prefixes', async () => {
|
||||
const callback = vi.fn()
|
||||
await provider.watch('/home/user/project', callback)
|
||||
|
||||
const notifHandler = mux.onNotification.mock.calls[0][0]
|
||||
notifHandler('fs.changed', {
|
||||
events: [
|
||||
{ kind: 'update', absolutePath: '/home/user/project-old/file.ts' },
|
||||
{ kind: 'update', absolutePath: '/home/user/project2/file.ts' }
|
||||
]
|
||||
})
|
||||
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('matches Windows and UNC watch roots case-insensitively', async () => {
|
||||
const driveCallback = vi.fn()
|
||||
const uncCallback = vi.fn()
|
||||
await provider.watch('C:\\Repo', driveCallback)
|
||||
await provider.watch('//Server/Share/Repo', uncCallback)
|
||||
|
||||
const notifHandler = mux.onNotification.mock.calls[0][0]
|
||||
notifHandler('fs.changed', {
|
||||
events: [
|
||||
{ kind: 'update', absolutePath: 'c:\\repo\\src\\file.ts' },
|
||||
{ kind: 'update', absolutePath: '//server/share/repo/docs/readme.md' }
|
||||
]
|
||||
})
|
||||
|
||||
expect(driveCallback).toHaveBeenCalledWith([
|
||||
{ kind: 'update', absolutePath: 'c:\\repo\\src\\file.ts' }
|
||||
])
|
||||
expect(uncCallback).toHaveBeenCalledWith([
|
||||
{ kind: 'update', absolutePath: '//server/share/repo/docs/readme.md' }
|
||||
])
|
||||
})
|
||||
|
||||
it('sends fs.unwatch when last listener unsubscribes', async () => {
|
||||
const callback = vi.fn()
|
||||
const unsub = await provider.watch('/home/user/project', callback)
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader'
|
||||
import { uploadBuffer } from '../ssh/sftp-upload'
|
||||
import type { IFilesystemProvider, FileStat, FileReadResult } from './types'
|
||||
import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
|
||||
type SftpFactory = () => Promise<SFTPWrapper>
|
||||
type WatchRegistration = {
|
||||
callbacks: Set<(events: FsChangeEvent[]) => void>
|
||||
setupPromise: Promise<void>
|
||||
}
|
||||
|
||||
export class SshFilesystemProvider implements IFilesystemProvider {
|
||||
private connectionId: string
|
||||
@@ -9,7 +18,7 @@ export class SshFilesystemProvider implements IFilesystemProvider {
|
||||
// Why: each watch() call registers for a specific rootPath, but the relay
|
||||
// sends all fs.changed events on one notification channel. Keying by rootPath
|
||||
// prevents cross-pollination between different worktree watchers.
|
||||
private watchListeners = new Map<string, (events: FsChangeEvent[]) => void>()
|
||||
private watchListeners = new Map<string, WatchRegistration>()
|
||||
// Why: store the unsubscribe handle so dispose() can detach from the
|
||||
// multiplexer. Without this, notification callbacks keep firing after
|
||||
// the provider is torn down on disconnect, routing events to stale state.
|
||||
@@ -19,17 +28,23 @@ export class SshFilesystemProvider implements IFilesystemProvider {
|
||||
// relays get diagnosed quickly without per-read log spam.
|
||||
private loggedStreamFallback = false
|
||||
|
||||
constructor(connectionId: string, mux: SshChannelMultiplexer) {
|
||||
constructor(
|
||||
connectionId: string,
|
||||
mux: SshChannelMultiplexer,
|
||||
private readonly createSftp?: SftpFactory
|
||||
) {
|
||||
this.connectionId = connectionId
|
||||
this.mux = mux
|
||||
|
||||
this.unsubscribeNotifications = mux.onNotification((method, params) => {
|
||||
if (method === 'fs.changed') {
|
||||
const events = params.events as FsChangeEvent[]
|
||||
for (const [rootPath, cb] of this.watchListeners) {
|
||||
const matching = events.filter((e) => e.absolutePath.startsWith(rootPath))
|
||||
for (const [rootPath, registration] of this.watchListeners) {
|
||||
const matching = events.filter((e) => isPathInsideOrEqual(rootPath, e.absolutePath))
|
||||
if (matching.length > 0) {
|
||||
cb(matching)
|
||||
for (const cb of registration.callbacks) {
|
||||
cb(matching)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +93,31 @@ export class SshFilesystemProvider implements IFilesystemProvider {
|
||||
await this.mux.request('fs.writeFile', { filePath, content })
|
||||
}
|
||||
|
||||
async writeFileBase64(filePath: string, contentBase64: string): Promise<void> {
|
||||
await this.writeFileBase64Chunk(filePath, contentBase64, false)
|
||||
}
|
||||
|
||||
async writeFileBase64Chunk(
|
||||
filePath: string,
|
||||
contentBase64: string,
|
||||
append: boolean
|
||||
): Promise<void> {
|
||||
if (!this.createSftp) {
|
||||
throw new Error('remote_binary_upload_unavailable')
|
||||
}
|
||||
const sftp = await this.createSftp()
|
||||
try {
|
||||
// Why: relay fs.writeFile is text-only. SFTP writes the decoded bytes
|
||||
// directly so runtime uploads do not corrupt images, PDFs, or archives.
|
||||
await uploadBuffer(sftp, Buffer.from(contentBase64, 'base64'), filePath, {
|
||||
append,
|
||||
exclusive: !append
|
||||
})
|
||||
} finally {
|
||||
sftp.end()
|
||||
}
|
||||
}
|
||||
|
||||
async stat(filePath: string): Promise<FileStat> {
|
||||
return (await this.mux.request('fs.stat', { filePath })) as FileStat
|
||||
}
|
||||
@@ -94,6 +134,10 @@ export class SshFilesystemProvider implements IFilesystemProvider {
|
||||
await this.mux.request('fs.createDir', { dirPath })
|
||||
}
|
||||
|
||||
async createDirNoClobber(dirPath: string): Promise<void> {
|
||||
await this.mux.request('fs.createDirNoClobber', { dirPath })
|
||||
}
|
||||
|
||||
async rename(oldPath: string, newPath: string): Promise<void> {
|
||||
await this.mux.request('fs.rename', { oldPath, newPath })
|
||||
}
|
||||
@@ -122,15 +166,41 @@ export class SshFilesystemProvider implements IFilesystemProvider {
|
||||
}
|
||||
|
||||
async watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> {
|
||||
this.watchListeners.set(rootPath, callback)
|
||||
await this.mux.request('fs.watch', { rootPath })
|
||||
let registration = this.watchListeners.get(rootPath)
|
||||
if (registration) {
|
||||
registration.callbacks.add(callback)
|
||||
await registration.setupPromise
|
||||
return this.createWatchUnsubscribe(rootPath, registration, callback)
|
||||
}
|
||||
|
||||
const callbacks = new Set<(events: FsChangeEvent[]) => void>([callback])
|
||||
const setupPromise = this.mux.request('fs.watch', { rootPath }).then(
|
||||
() => undefined,
|
||||
(error) => {
|
||||
if (this.watchListeners.get(rootPath) === registration) {
|
||||
this.watchListeners.delete(rootPath)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
)
|
||||
registration = { callbacks, setupPromise }
|
||||
this.watchListeners.set(rootPath, registration)
|
||||
await setupPromise
|
||||
|
||||
return this.createWatchUnsubscribe(rootPath, registration, callback)
|
||||
}
|
||||
|
||||
private createWatchUnsubscribe(
|
||||
rootPath: string,
|
||||
registration: WatchRegistration,
|
||||
callback: (events: FsChangeEvent[]) => void
|
||||
): () => void {
|
||||
return () => {
|
||||
this.watchListeners.delete(rootPath)
|
||||
// Why: each watch() starts a @parcel/watcher on the relay for this specific
|
||||
// rootPath. We must always notify the relay to stop it, not only when all
|
||||
// watchers are gone — otherwise the remote watcher leaks inotify descriptors.
|
||||
this.mux.notify('fs.unwatch', { rootPath })
|
||||
registration.callbacks.delete(callback)
|
||||
if (registration.callbacks.size === 0 && this.watchListeners.get(rootPath) === registration) {
|
||||
this.watchListeners.delete(rootPath)
|
||||
this.mux.notify('fs.unwatch', { rootPath })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,10 +118,13 @@ export type IFilesystemProvider = {
|
||||
readDir(dirPath: string): Promise<DirEntry[]>
|
||||
readFile(filePath: string): Promise<FileReadResult>
|
||||
writeFile(filePath: string, content: string): Promise<void>
|
||||
writeFileBase64(filePath: string, contentBase64: string): Promise<void>
|
||||
writeFileBase64Chunk(filePath: string, contentBase64: string, append: boolean): Promise<void>
|
||||
stat(filePath: string): Promise<FileStat>
|
||||
deletePath(targetPath: string, recursive?: boolean): Promise<void>
|
||||
createFile(filePath: string): Promise<void>
|
||||
createDir(dirPath: string): Promise<void>
|
||||
createDirNoClobber(dirPath: string): Promise<void>
|
||||
rename(oldPath: string, newPath: string): Promise<void>
|
||||
copy(source: string, destination: string): Promise<void>
|
||||
realpath(filePath: string): Promise<string>
|
||||
|
||||
@@ -41,8 +41,11 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
expect(opencodeRestoreIndex).toBeGreaterThan(outputEncodingIndex)
|
||||
expect(piRestoreIndex).toBeGreaterThan(outputEncodingIndex)
|
||||
expect(promptIndex).toBeGreaterThan(piRestoreIndex)
|
||||
expect(command).toContain('`e]133;D;$fakeExitCode`a')
|
||||
expect(command).toContain('`e]133;C`a')
|
||||
expect(command).toContain('Esc = [char]27')
|
||||
expect(command).toContain('Bel = [char]7')
|
||||
expect(command).toContain(')]133;D;$fakeExitCode$(')
|
||||
expect(command).toContain(')]133;C$(')
|
||||
expect(command).not.toContain('`e]133')
|
||||
})
|
||||
|
||||
it('handles pwsh.exe (PowerShell Core) the same as Windows PowerShell', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GitWorktreeInfo, Repo } from '../shared/types'
|
||||
import { listWorktrees } from './git/worktree'
|
||||
import { isFolderRepo } from '../shared/repo-kind'
|
||||
import { getSshGitProvider } from './providers/ssh-git-dispatch'
|
||||
|
||||
export function createFolderWorktree(repo: Repo): GitWorktreeInfo {
|
||||
return {
|
||||
@@ -19,5 +20,12 @@ export async function listRepoWorktrees(repo: Repo): Promise<GitWorktreeInfo[]>
|
||||
if (isFolderRepo(repo)) {
|
||||
return [createFolderWorktree(repo)]
|
||||
}
|
||||
if (repo.connectionId) {
|
||||
const provider = getSshGitProvider(repo.connectionId)
|
||||
// Why: runtime worktree resolution can run before SSH providers have
|
||||
// reattached during startup. Return empty instead of falling back to
|
||||
// local git against a server path.
|
||||
return provider ? await provider.listWorktrees(repo.path) : []
|
||||
}
|
||||
return await listWorktrees(repo.path)
|
||||
}
|
||||
|
||||
@@ -3,15 +3,19 @@
|
||||
// compromising one device doesn't expose others. The registry is a simple
|
||||
// JSON file with hardened permissions matching the runtime metadata pattern.
|
||||
import { randomBytes, randomUUID } from 'crypto'
|
||||
import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs'
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file'
|
||||
|
||||
const DEVICE_REGISTRY_FILENAME = 'orca-devices.json'
|
||||
|
||||
export type DeviceScope = 'mobile' | 'runtime'
|
||||
|
||||
export type DeviceEntry = {
|
||||
deviceId: string
|
||||
name: string
|
||||
token: string
|
||||
scope: DeviceScope
|
||||
pairedAt: number
|
||||
lastSeenAt: number
|
||||
}
|
||||
@@ -25,11 +29,12 @@ export class DeviceRegistry {
|
||||
this.load()
|
||||
}
|
||||
|
||||
addDevice(name: string): DeviceEntry {
|
||||
addDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry {
|
||||
const entry: DeviceEntry = {
|
||||
deviceId: randomUUID(),
|
||||
name,
|
||||
token: randomBytes(24).toString('hex'),
|
||||
scope,
|
||||
pairedAt: Date.now(),
|
||||
lastSeenAt: 0
|
||||
}
|
||||
@@ -44,12 +49,12 @@ export class DeviceRegistry {
|
||||
// copy-button flow that encourages regeneration) leaves an orphaned token
|
||||
// forever. Returns an existing never-scanned entry if present; otherwise
|
||||
// mints a new one and drops any stale pending entries.
|
||||
getOrCreatePendingDevice(name: string): DeviceEntry {
|
||||
const existing = this.devices.find((d) => d.lastSeenAt === 0)
|
||||
getOrCreatePendingDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry {
|
||||
const existing = this.devices.find((d) => d.lastSeenAt === 0 && d.scope === scope)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
return this.addDevice(name)
|
||||
return this.addDevice(name, scope)
|
||||
}
|
||||
|
||||
// Why: explicit rotation path for "Regenerate QR" — invalidates any
|
||||
@@ -58,9 +63,9 @@ export class DeviceRegistry {
|
||||
// this, getOrCreatePendingDevice keeps returning the same token forever
|
||||
// until a phone actually pairs, so users have no way to revoke a leaked
|
||||
// pre-pairing token.
|
||||
rotatePendingDevice(name: string): DeviceEntry {
|
||||
this.devices = this.devices.filter((d) => d.lastSeenAt !== 0)
|
||||
return this.addDevice(name)
|
||||
rotatePendingDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry {
|
||||
this.devices = this.devices.filter((d) => d.lastSeenAt !== 0 || d.scope !== scope)
|
||||
return this.addDevice(name, scope)
|
||||
}
|
||||
|
||||
removeDevice(deviceId: string): boolean {
|
||||
@@ -73,6 +78,10 @@ export class DeviceRegistry {
|
||||
return false
|
||||
}
|
||||
|
||||
getDevice(deviceId: string): DeviceEntry | null {
|
||||
return this.devices.find((d) => d.deviceId === deviceId) ?? null
|
||||
}
|
||||
|
||||
listDevices(): readonly DeviceEntry[] {
|
||||
return this.devices
|
||||
}
|
||||
@@ -95,14 +104,20 @@ export class DeviceRegistry {
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.devices = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[]
|
||||
hardenExistingSecureFile(this.registryPath)
|
||||
const parsed = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[]
|
||||
this.devices = parsed.map((device) => ({
|
||||
...device,
|
||||
// Why: older registries only existed for phone pairing. Treat missing
|
||||
// scope as mobile so legacy device tokens do not gain new CLI powers.
|
||||
scope: device.scope === 'runtime' ? 'runtime' : 'mobile'
|
||||
}))
|
||||
} catch {
|
||||
this.devices = []
|
||||
}
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
writeFileSync(this.registryPath, JSON.stringify(this.devices, null, 2), { mode: 0o600 })
|
||||
chmodSync(this.registryPath, 0o600)
|
||||
writeSecureJsonFile(this.registryPath, this.devices)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Why: the E2EE keypair enables application-layer encryption between mobile
|
||||
// and desktop over plain ws://. The public key is embedded in the QR pairing
|
||||
// offer so the mobile client can derive a shared secret via ECDH.
|
||||
import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs'
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import nacl from 'tweetnacl'
|
||||
import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file'
|
||||
|
||||
const KEYPAIR_FILENAME = 'orca-e2ee-keypair.json'
|
||||
const KEYPAIR_VERSION = 1
|
||||
@@ -25,6 +26,7 @@ export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair {
|
||||
|
||||
if (existsSync(filePath)) {
|
||||
try {
|
||||
hardenExistingSecureFile(filePath)
|
||||
const raw: KeypairFile = JSON.parse(readFileSync(filePath, 'utf-8'))
|
||||
if (raw.v === KEYPAIR_VERSION && raw.publicKeyB64 && raw.secretKeyB64) {
|
||||
const publicKey = Uint8Array.from(Buffer.from(raw.publicKeyB64, 'base64'))
|
||||
@@ -43,8 +45,7 @@ export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair {
|
||||
const secretKeyB64 = Buffer.from(keypair.secretKey).toString('base64')
|
||||
|
||||
const data: KeypairFile = { v: KEYPAIR_VERSION, publicKeyB64, secretKeyB64 }
|
||||
writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8')
|
||||
chmodSync(filePath, 0o600)
|
||||
writeSecureJsonFile(filePath, data)
|
||||
|
||||
return { publicKey: keypair.publicKey, secretKey: keypair.secretKey, publicKeyB64 }
|
||||
}
|
||||
|
||||
@@ -295,6 +295,27 @@ describe('mobile presence lock — multi-mobile semantics', () => {
|
||||
expect(driverEvents.slice(before).every((e) => e.driver.kind === 'mobile')).toBe(true)
|
||||
})
|
||||
|
||||
it('updateDesktopViewport resizes the source PTY and records desktop geometry', async () => {
|
||||
const { runtime, ptySizes, resizes } = createRuntime()
|
||||
|
||||
expect(await runtime.updateDesktopViewport('pty-1', { cols: 132, rows: 44 })).toBe(true)
|
||||
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 132, rows: 44 })
|
||||
expect(resizes.at(-1)).toEqual({ ptyId: 'pty-1', cols: 132, rows: 44 })
|
||||
expect(runtime.getLastRendererSize('pty-1')).toEqual({ cols: 132, rows: 44 })
|
||||
})
|
||||
|
||||
it('updateDesktopViewport does not resize while mobile is driving', async () => {
|
||||
const { runtime, ptySizes, resizes } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 20 })
|
||||
resizes.length = 0
|
||||
|
||||
expect(await runtime.updateDesktopViewport('pty-1', { cols: 132, rows: 44 })).toBe(false)
|
||||
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 20 })
|
||||
expect(resizes).toEqual([])
|
||||
})
|
||||
|
||||
it('updateMobileViewport then disconnect restores PTY to original baseline', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 })
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as Fs from 'fs'
|
||||
import type * as FsPromises from 'fs/promises'
|
||||
import type * as FilesystemAuth from '../ipc/filesystem-auth'
|
||||
|
||||
const { resolveAuthorizedPathMock, statMock, watchMock } = vi.hoisted(() => ({
|
||||
resolveAuthorizedPathMock: vi.fn(),
|
||||
statMock: vi.fn(),
|
||||
watchMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual<typeof Fs>('fs')
|
||||
return {
|
||||
...actual,
|
||||
watch: watchMock
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof FsPromises>('fs/promises')
|
||||
return {
|
||||
...actual,
|
||||
stat: statMock
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../ipc/filesystem-auth', async () => {
|
||||
const actual = await vi.importActual<typeof FilesystemAuth>('../ipc/filesystem-auth')
|
||||
return {
|
||||
...actual,
|
||||
resolveAuthorizedPath: resolveAuthorizedPathMock
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
getSshFilesystemProvider: vi.fn()
|
||||
}))
|
||||
|
||||
import { RuntimeFileCommands } from './orca-runtime-files'
|
||||
|
||||
describe('RuntimeFileCommands', () => {
|
||||
const originalPlatform = process.platform
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
resolveAuthorizedPathMock.mockReset()
|
||||
statMock.mockReset()
|
||||
watchMock.mockReset()
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('uses a conservative Node watcher for Windows runtime file watches', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
|
||||
const store = { getRepo: vi.fn(() => undefined) }
|
||||
const close = vi.fn()
|
||||
const on = vi.fn()
|
||||
let listener: (() => void) | null = null
|
||||
watchMock.mockImplementation((_rootPath, _options, callback) => {
|
||||
listener = callback
|
||||
return { close, on }
|
||||
})
|
||||
resolveAuthorizedPathMock.mockResolvedValue('C:\\repo')
|
||||
statMock.mockResolvedValue({ isDirectory: () => true })
|
||||
|
||||
const commands = new RuntimeFileCommands({
|
||||
getRuntimeId: () => 'runtime-1',
|
||||
requireStore: () => store,
|
||||
resolveWorktreeSelector: vi.fn(async () => ({
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: 'C:\\repo'
|
||||
})),
|
||||
resolveRuntimeGitTarget: vi.fn(),
|
||||
openFile: vi.fn()
|
||||
} as never)
|
||||
const onEvents = vi.fn()
|
||||
|
||||
const unsubscribe = await commands.watchFileExplorer('id:wt-1', onEvents)
|
||||
|
||||
expect(watchMock).toHaveBeenCalledWith('C:\\repo', { recursive: true }, expect.any(Function))
|
||||
const emit = listener as (() => void) | null
|
||||
expect(emit).not.toBeNull()
|
||||
|
||||
emit?.()
|
||||
emit?.()
|
||||
await vi.advanceTimersByTimeAsync(149)
|
||||
expect(onEvents).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(onEvents).toHaveBeenCalledTimes(1)
|
||||
expect(onEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: 'C:\\repo' }])
|
||||
|
||||
unsubscribe()
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,918 @@
|
||||
/* eslint-disable max-lines -- Why: filesystem, editor-file, and search commands share the same local/SSH path authorization rules. Keeping that IO adapter together prevents separate command paths from drifting on safety checks. */
|
||||
import type { ChildProcess } from 'child_process'
|
||||
import { watch as watchFs } from 'fs'
|
||||
import {
|
||||
constants,
|
||||
copyFile,
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
writeFile
|
||||
} from 'fs/promises'
|
||||
import { basename, dirname, extname, join } from 'path'
|
||||
import type {
|
||||
DirEntry,
|
||||
FsChangeEvent,
|
||||
GitWorktreeInfo,
|
||||
MarkdownDocument,
|
||||
SearchOptions,
|
||||
SearchResult,
|
||||
Worktree
|
||||
} from '../../shared/types'
|
||||
import type {
|
||||
RuntimeFileListResult,
|
||||
RuntimeFileOpenResult,
|
||||
RuntimeFilePreviewResult,
|
||||
RuntimeFileReadResult
|
||||
} from '../../shared/runtime-types'
|
||||
import { wslAwareSpawn } from '../git/runner'
|
||||
import { parseWslPath, toWindowsWslPath } from '../wsl'
|
||||
import { isENOENT, resolveAuthorizedPath } from '../ipc/filesystem-auth'
|
||||
import { listQuickOpenFiles } from '../ipc/filesystem-list-files'
|
||||
import { searchWithGitGrep } from '../ipc/filesystem-search-git'
|
||||
import { checkRgAvailable } from '../ipc/rg-availability'
|
||||
import {
|
||||
listMarkdownDocuments,
|
||||
markdownDocumentsFromRelativePaths
|
||||
} from '../ipc/markdown-documents'
|
||||
import {
|
||||
buildRgArgs,
|
||||
createAccumulator,
|
||||
DEFAULT_SEARCH_MAX_RESULTS,
|
||||
finalize,
|
||||
ingestRgJsonLine,
|
||||
SEARCH_TIMEOUT_MS
|
||||
} from '../../shared/text-search'
|
||||
import type { Store } from '../persistence'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths'
|
||||
|
||||
const MOBILE_FILE_LIST_LIMIT = 5000
|
||||
const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024
|
||||
const RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES = 10 * 1024 * 1024
|
||||
const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150
|
||||
const MOBILE_BINARY_EXTENSIONS = new Set([
|
||||
'.avif',
|
||||
'.bmp',
|
||||
'.gif',
|
||||
'.heic',
|
||||
'.ico',
|
||||
'.jpeg',
|
||||
'.jpg',
|
||||
'.mov',
|
||||
'.mp3',
|
||||
'.mp4',
|
||||
'.pdf',
|
||||
'.png',
|
||||
'.webp',
|
||||
'.zip'
|
||||
])
|
||||
const RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp',
|
||||
'.bmp': 'image/bmp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.pdf': 'application/pdf'
|
||||
}
|
||||
|
||||
export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo }
|
||||
|
||||
export type RuntimeFileCommandHost = {
|
||||
getRuntimeId(): string
|
||||
requireStore(): Store
|
||||
resolveWorktreeSelector(selector: string): Promise<ResolvedRuntimeFileWorktree>
|
||||
resolveRuntimeGitTarget(
|
||||
selector: string
|
||||
): Promise<{ worktree: ResolvedRuntimeFileWorktree; connectionId?: string }>
|
||||
openFile(worktreeId: string, filePath: string, relativePath: string): void
|
||||
}
|
||||
|
||||
export class RuntimeFileCommands {
|
||||
private activeRuntimeTextSearches = new Map<string, ChildProcess>()
|
||||
|
||||
constructor(private readonly host: RuntimeFileCommandHost) {}
|
||||
|
||||
async listMobileFiles(worktreeSelector: string): Promise<RuntimeFileListResult> {
|
||||
const store = this.host.requireStore()
|
||||
const worktree = await this.host.resolveWorktreeSelector(worktreeSelector)
|
||||
const repo = store.getRepo(worktree.repoId)
|
||||
const connectionId = repo?.connectionId ?? undefined
|
||||
const files = connectionId
|
||||
? await this.listRemoteMobileFiles(worktree.path, connectionId)
|
||||
: await listQuickOpenFiles(worktree.path, store)
|
||||
const entries = files
|
||||
.filter((relativePath) => isSafeMobileRelativePath(relativePath))
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.slice(0, MOBILE_FILE_LIST_LIMIT)
|
||||
.map((relativePath) => ({
|
||||
relativePath,
|
||||
basename: basenameFromRelativePath(relativePath),
|
||||
kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const)
|
||||
}))
|
||||
|
||||
return {
|
||||
worktree: worktree.id,
|
||||
rootPath: worktree.path,
|
||||
files: entries,
|
||||
totalCount: files.length,
|
||||
truncated: files.length > MOBILE_FILE_LIST_LIMIT
|
||||
}
|
||||
}
|
||||
|
||||
async openMobileFile(
|
||||
worktreeSelector: string,
|
||||
relativePath: string
|
||||
): Promise<RuntimeFileOpenResult> {
|
||||
const worktree = await this.host.resolveWorktreeSelector(worktreeSelector)
|
||||
if (!isSafeMobileRelativePath(relativePath)) {
|
||||
throw new Error('invalid_relative_path')
|
||||
}
|
||||
const kind = isMobileBinaryPath(relativePath)
|
||||
? 'binary'
|
||||
: isMobileMarkdownPath(relativePath)
|
||||
? 'markdown'
|
||||
: 'text'
|
||||
if (kind === 'binary') {
|
||||
return { worktree: worktree.id, relativePath, kind, opened: false }
|
||||
}
|
||||
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
|
||||
this.host.openFile(worktree.id, filePath, relativePath)
|
||||
return { worktree: worktree.id, relativePath, kind, opened: true }
|
||||
}
|
||||
|
||||
async readMobileFile(
|
||||
worktreeSelector: string,
|
||||
relativePath: string
|
||||
): Promise<RuntimeFileReadResult> {
|
||||
const store = this.host.requireStore()
|
||||
const worktree = await this.host.resolveWorktreeSelector(worktreeSelector)
|
||||
if (!isSafeMobileRelativePath(relativePath)) {
|
||||
throw new Error('invalid_relative_path')
|
||||
}
|
||||
if (isMobileBinaryPath(relativePath)) {
|
||||
throw new Error('binary_file')
|
||||
}
|
||||
|
||||
const repo = store.getRepo(worktree.repoId)
|
||||
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
|
||||
const content = repo?.connectionId
|
||||
? await this.readRemoteMobileFile(filePath, repo.connectionId)
|
||||
: await readLocalMobileFile(filePath, store)
|
||||
const truncated = truncateMobileFilePreview(content)
|
||||
|
||||
return {
|
||||
worktree: worktree.id,
|
||||
relativePath,
|
||||
content: truncated.content,
|
||||
truncated: truncated.truncated,
|
||||
byteLength: truncated.byteLength
|
||||
}
|
||||
}
|
||||
|
||||
async readFileExplorerDir(worktreeSelector: string, relativePath: string): Promise<DirEntry[]> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_filesystem_unavailable')
|
||||
}
|
||||
return provider.readDir(target.path)
|
||||
}
|
||||
|
||||
const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
||||
const entries = await readdir(dirPath, { withFileTypes: true })
|
||||
const mapped = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = join(dirPath, entry.name)
|
||||
return {
|
||||
name: entry.name,
|
||||
isDirectory: await isRuntimeDirectoryEntry(entryPath),
|
||||
isSymlink: entry.isSymbolicLink()
|
||||
}
|
||||
})
|
||||
)
|
||||
return mapped.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
async watchFileExplorer(
|
||||
worktreeSelector: string,
|
||||
callback: (events: FsChangeEvent[]) => void
|
||||
): Promise<() => void> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, '')
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_filesystem_unavailable')
|
||||
}
|
||||
return provider.watch(target.path, callback)
|
||||
}
|
||||
|
||||
const rootPath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
||||
const rootStats = await stat(rootPath)
|
||||
if (!rootStats.isDirectory()) {
|
||||
throw new Error('not_a_directory')
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return watchWindowsRuntimeFileExplorer(rootPath, callback)
|
||||
}
|
||||
const watcher = await import('@parcel/watcher')
|
||||
const subscription = await watcher.subscribe(
|
||||
rootPath,
|
||||
(err, events) => {
|
||||
if (err) {
|
||||
console.error('[runtime-files.watch] watcher error', { rootPath, err })
|
||||
callback([{ kind: 'overflow', absolutePath: rootPath }])
|
||||
return
|
||||
}
|
||||
void Promise.all(
|
||||
events.map(async (event): Promise<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'
|
||||
]
|
||||
}
|
||||
)
|
||||
return () => {
|
||||
void subscription.unsubscribe().catch((err: unknown) => {
|
||||
console.error('[runtime-files.watch] unsubscribe error', { rootPath, err })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async readFileExplorerPreview(
|
||||
worktreeSelector: string,
|
||||
relativePath: string
|
||||
): Promise<RuntimeFilePreviewResult> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_filesystem_unavailable')
|
||||
}
|
||||
const fileStats = await provider.stat(target.path)
|
||||
if (fileStats.size > RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES) {
|
||||
throw new Error('file_too_large')
|
||||
}
|
||||
const result = await provider.readFile(target.path)
|
||||
return result
|
||||
}
|
||||
|
||||
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
||||
const fileStats = await stat(filePath)
|
||||
const mimeType = RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()]
|
||||
if (mimeType) {
|
||||
if (fileStats.size > RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES) {
|
||||
throw new Error('file_too_large')
|
||||
}
|
||||
const buffer = await readFile(filePath)
|
||||
return {
|
||||
content: buffer.toString('base64'),
|
||||
isBinary: true,
|
||||
isImage: true,
|
||||
mimeType
|
||||
}
|
||||
}
|
||||
|
||||
if (fileStats.size > MOBILE_FILE_READ_MAX_BYTES) {
|
||||
throw new Error('file_too_large')
|
||||
}
|
||||
const buffer = await readFile(filePath)
|
||||
if (isBinaryBuffer(buffer)) {
|
||||
return { content: '', isBinary: true }
|
||||
}
|
||||
return { content: buffer.toString('utf-8'), isBinary: false }
|
||||
}
|
||||
|
||||
async writeFileExplorerFile(
|
||||
worktreeSelector: string,
|
||||
relativePath: string,
|
||||
content: string
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_filesystem_unavailable')
|
||||
}
|
||||
await provider.writeFile(target.path, content)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
||||
try {
|
||||
const fileStats = await lstat(filePath)
|
||||
if (fileStats.isDirectory()) {
|
||||
throw new Error('Cannot write to a directory')
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
await writeFile(filePath, content, 'utf-8')
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async writeFileExplorerFileBase64(
|
||||
worktreeSelector: string,
|
||||
relativePath: string,
|
||||
contentBase64: string
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
const content = Buffer.from(contentBase64, 'base64')
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_filesystem_unavailable')
|
||||
}
|
||||
await provider.writeFileBase64(target.path, contentBase64)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
||||
await mkdir(dirname(filePath), { recursive: true })
|
||||
await writeFile(filePath, content, { flag: 'wx' })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async writeFileExplorerFileBase64Chunk(
|
||||
worktreeSelector: string,
|
||||
relativePath: string,
|
||||
contentBase64: string,
|
||||
append: boolean
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
const content = Buffer.from(contentBase64, 'base64')
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_filesystem_unavailable')
|
||||
}
|
||||
await provider.writeFileBase64Chunk(target.path, contentBase64, append)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
||||
await mkdir(dirname(filePath), { recursive: true })
|
||||
await writeFile(filePath, content, { flag: append ? 'a' : 'wx' })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async createFileExplorerFile(
|
||||
worktreeSelector: string,
|
||||
relativePath: string
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_filesystem_unavailable')
|
||||
}
|
||||
await provider.createFile(target.path)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
||||
await mkdir(dirname(filePath), { recursive: true })
|
||||
try {
|
||||
await writeFile(filePath, '', { encoding: 'utf-8', flag: 'wx' })
|
||||
} catch (error) {
|
||||
rethrowRuntimeFileCreateError(error, filePath)
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async createFileExplorerDir(
|
||||
worktreeSelector: string,
|
||||
relativePath: string
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_filesystem_unavailable')
|
||||
}
|
||||
await provider.createDir(target.path)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
||||
await assertRuntimePathDoesNotExist(dirPath)
|
||||
await mkdir(dirPath, { recursive: false })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async createFileExplorerDirNoClobber(
|
||||
worktreeSelector: string,
|
||||
relativePath: string
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_filesystem_unavailable')
|
||||
}
|
||||
await provider.createDirNoClobber(target.path)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
||||
await mkdir(dirPath, { recursive: false })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async commitFileExplorerUpload(
|
||||
worktreeSelector: string,
|
||||
tempRelativePath: string,
|
||||
finalRelativePath: string
|
||||
): Promise<{ ok: true }> {
|
||||
const tempTarget = await this.resolveFileExplorerPath(worktreeSelector, tempRelativePath)
|
||||
const finalTarget = await this.resolveFileExplorerPath(worktreeSelector, finalRelativePath)
|
||||
const provider = tempTarget.connectionId
|
||||
? getSshFilesystemProvider(tempTarget.connectionId)
|
||||
: null
|
||||
if (tempTarget.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_filesystem_unavailable')
|
||||
}
|
||||
await provider.copy(tempTarget.path, finalTarget.path)
|
||||
await provider.deletePath(tempTarget.path, false).catch(() => {})
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const store = this.host.requireStore()
|
||||
const tempPath = await resolveAuthorizedPath(tempTarget.path, store)
|
||||
const finalPath = await resolveAuthorizedPath(finalTarget.path, store)
|
||||
await mkdir(dirname(finalPath), { recursive: true })
|
||||
await copyFile(tempPath, finalPath, constants.COPYFILE_EXCL)
|
||||
await rm(tempPath, { force: true })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async renameFileExplorerPath(
|
||||
worktreeSelector: string,
|
||||
oldRelativePath: string,
|
||||
newRelativePath: string
|
||||
): Promise<{ ok: true }> {
|
||||
const oldTarget = await this.resolveFileExplorerPath(worktreeSelector, oldRelativePath)
|
||||
const newTarget = await this.resolveFileExplorerPath(worktreeSelector, newRelativePath)
|
||||
const provider = oldTarget.connectionId
|
||||
? getSshFilesystemProvider(oldTarget.connectionId)
|
||||
: null
|
||||
if (oldTarget.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_filesystem_unavailable')
|
||||
}
|
||||
await provider.rename(oldTarget.path, newTarget.path)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const store = this.host.requireStore()
|
||||
const oldPath = await resolveAuthorizedPath(oldTarget.path, store, { preserveSymlink: true })
|
||||
const newPath = await resolveAuthorizedPath(newTarget.path, store, { preserveSymlink: true })
|
||||
await assertRuntimePathDoesNotExist(newPath)
|
||||
await rename(oldPath, newPath)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async copyFileExplorerPath(
|
||||
worktreeSelector: string,
|
||||
sourceRelativePath: string,
|
||||
destinationRelativePath: string
|
||||
): Promise<{ ok: true }> {
|
||||
const sourceTarget = await this.resolveFileExplorerPath(worktreeSelector, sourceRelativePath)
|
||||
const destinationTarget = await this.resolveFileExplorerPath(
|
||||
worktreeSelector,
|
||||
destinationRelativePath
|
||||
)
|
||||
const provider = sourceTarget.connectionId
|
||||
? getSshFilesystemProvider(sourceTarget.connectionId)
|
||||
: null
|
||||
if (sourceTarget.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_filesystem_unavailable')
|
||||
}
|
||||
await provider.copy(sourceTarget.path, destinationTarget.path)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const store = this.host.requireStore()
|
||||
const sourcePath = await resolveAuthorizedPath(sourceTarget.path, store, {
|
||||
preserveSymlink: true
|
||||
})
|
||||
const destinationPath = await resolveAuthorizedPath(destinationTarget.path, store, {
|
||||
preserveSymlink: true
|
||||
})
|
||||
await mkdir(dirname(destinationPath), { recursive: true })
|
||||
// Why: duplicate/copy operations are deconflicted by the caller. COPYFILE_EXCL
|
||||
// preserves the same no-clobber invariant as the local shell copy IPC.
|
||||
await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async deleteFileExplorerPath(
|
||||
worktreeSelector: string,
|
||||
relativePath: string,
|
||||
recursive?: boolean
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_filesystem_unavailable')
|
||||
}
|
||||
await provider.deletePath(target.path, recursive)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const targetPath = await resolveAuthorizedPath(target.path, this.host.requireStore(), {
|
||||
preserveSymlink: true
|
||||
})
|
||||
// Why: a non-local runtime has no client OS Trash/Recycling Bin; server-side
|
||||
// file mutations are permanent and the renderer confirms before calling this.
|
||||
await rm(targetPath, { recursive: recursive === true, force: true })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async searchRuntimeFiles(
|
||||
worktreeSelector: string,
|
||||
options: Omit<SearchOptions, 'rootPath'>
|
||||
): Promise<SearchResult> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
const rootPath = target.worktree.path
|
||||
const searchOptions = { ...options, rootPath }
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_filesystem_unavailable')
|
||||
}
|
||||
return provider.search(searchOptions)
|
||||
}
|
||||
return this.searchLocalRuntimeFiles(rootPath, searchOptions)
|
||||
}
|
||||
|
||||
async listRuntimeFiles(
|
||||
worktreeSelector: string,
|
||||
options: { excludePaths?: string[] } = {}
|
||||
): Promise<string[]> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
return []
|
||||
}
|
||||
return provider.listFiles(target.worktree.path, { excludePaths: options.excludePaths })
|
||||
}
|
||||
return listQuickOpenFiles(target.worktree.path, this.host.requireStore(), options.excludePaths)
|
||||
}
|
||||
|
||||
async listRuntimeMarkdownDocuments(worktreeSelector: string): Promise<MarkdownDocument[]> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_filesystem_unavailable')
|
||||
}
|
||||
const relativePaths = await provider.listFiles(target.worktree.path)
|
||||
return markdownDocumentsFromRelativePaths(target.worktree.path, relativePaths)
|
||||
}
|
||||
return listMarkdownDocuments(target.worktree.path)
|
||||
}
|
||||
|
||||
async statRuntimeFile(
|
||||
worktreeSelector: string,
|
||||
relativePath: string
|
||||
): Promise<{ size: number; isDirectory: boolean; mtime: number }> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_filesystem_unavailable')
|
||||
}
|
||||
const fileStat = await provider.stat(target.path)
|
||||
return {
|
||||
size: fileStat.size,
|
||||
isDirectory: fileStat.type === 'directory',
|
||||
mtime: fileStat.mtime
|
||||
}
|
||||
}
|
||||
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
||||
const stats = await stat(filePath)
|
||||
return { size: stats.size, isDirectory: stats.isDirectory(), mtime: stats.mtimeMs }
|
||||
}
|
||||
|
||||
private async searchLocalRuntimeFiles(
|
||||
rootPath: string,
|
||||
options: SearchOptions
|
||||
): Promise<SearchResult> {
|
||||
const authorizedRootPath = await resolveAuthorizedPath(rootPath, this.host.requireStore())
|
||||
const maxResults = Math.max(
|
||||
1,
|
||||
Math.min(options.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS)
|
||||
)
|
||||
const rgAvailable = await checkRgAvailable(authorizedRootPath)
|
||||
if (!rgAvailable) {
|
||||
return searchWithGitGrep(authorizedRootPath, options, maxResults)
|
||||
}
|
||||
|
||||
return new Promise((resolvePromise) => {
|
||||
const searchKey = `${this.host.getRuntimeId()}:${authorizedRootPath}`
|
||||
const rgArgs = buildRgArgs(options.query, authorizedRootPath, options)
|
||||
this.activeRuntimeTextSearches.get(searchKey)?.kill()
|
||||
|
||||
const acc = createAccumulator()
|
||||
let stdoutBuffer = ''
|
||||
let resolved = false
|
||||
let child: ChildProcess | null = null
|
||||
const wslInfo = parseWslPath(authorizedRootPath)
|
||||
const transformAbsPath = wslInfo
|
||||
? (p: string): string => toWindowsWslPath(p, wslInfo.distro)
|
||||
: undefined
|
||||
|
||||
const resolveOnce = (): void => {
|
||||
if (resolved) {
|
||||
return
|
||||
}
|
||||
resolved = true
|
||||
if (this.activeRuntimeTextSearches.get(searchKey) === child) {
|
||||
this.activeRuntimeTextSearches.delete(searchKey)
|
||||
}
|
||||
clearTimeout(killTimeout)
|
||||
resolvePromise(finalize(acc))
|
||||
}
|
||||
|
||||
const processLine = (line: string): void => {
|
||||
const verdict = ingestRgJsonLine(
|
||||
line,
|
||||
authorizedRootPath,
|
||||
acc,
|
||||
maxResults,
|
||||
transformAbsPath
|
||||
)
|
||||
if (verdict === 'stop') {
|
||||
child?.kill()
|
||||
}
|
||||
}
|
||||
|
||||
const nextChild = wslAwareSpawn('rg', rgArgs, {
|
||||
cwd: authorizedRootPath,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
child = nextChild
|
||||
this.activeRuntimeTextSearches.set(searchKey, nextChild)
|
||||
|
||||
nextChild.stdout!.setEncoding('utf-8')
|
||||
nextChild.stdout!.on('data', (chunk: string) => {
|
||||
stdoutBuffer += chunk
|
||||
const lines = stdoutBuffer.split('\n')
|
||||
stdoutBuffer = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
processLine(line)
|
||||
}
|
||||
})
|
||||
nextChild.stderr!.on('data', () => {
|
||||
// Drain stderr so rg cannot block on a full pipe.
|
||||
})
|
||||
nextChild.once('error', () => resolveOnce())
|
||||
nextChild.once('close', () => {
|
||||
if (stdoutBuffer) {
|
||||
processLine(stdoutBuffer)
|
||||
}
|
||||
resolveOnce()
|
||||
})
|
||||
|
||||
const killTimeout = setTimeout(() => {
|
||||
acc.truncated = true
|
||||
child?.kill()
|
||||
}, SEARCH_TIMEOUT_MS)
|
||||
})
|
||||
}
|
||||
|
||||
private async resolveFileExplorerPath(
|
||||
worktreeSelector: string,
|
||||
relativePath: string
|
||||
): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }> {
|
||||
const store = this.host.requireStore()
|
||||
const worktree = await this.host.resolveWorktreeSelector(worktreeSelector)
|
||||
const normalizedRelativePath = normalizeRuntimeRelativePath(relativePath)
|
||||
const repo = store.getRepo(worktree.repoId)
|
||||
return {
|
||||
worktree,
|
||||
path: joinWorktreeRelativePath(worktree.path, normalizedRelativePath),
|
||||
connectionId: repo?.connectionId ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async listRemoteMobileFiles(rootPath: string, connectionId: string): Promise<string[]> {
|
||||
const provider = getSshFilesystemProvider(connectionId)
|
||||
if (!provider) {
|
||||
return []
|
||||
}
|
||||
return provider.listFiles(rootPath)
|
||||
}
|
||||
|
||||
private async readRemoteMobileFile(filePath: string, connectionId: string): Promise<string> {
|
||||
const provider = getSshFilesystemProvider(connectionId)
|
||||
if (!provider) {
|
||||
throw new Error('remote_filesystem_unavailable')
|
||||
}
|
||||
const fileStat = await provider.stat(filePath)
|
||||
// Why: the SSH filesystem API does not expose ranged reads here, so reject
|
||||
// oversized remote previews instead of streaming a large file just to trim it.
|
||||
if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) {
|
||||
throw new Error('file_too_large')
|
||||
}
|
||||
const result = await provider.readFile(filePath)
|
||||
if (result.isBinary) {
|
||||
throw new Error('binary_file')
|
||||
}
|
||||
return result.content
|
||||
}
|
||||
}
|
||||
|
||||
function watchWindowsRuntimeFileExplorer(
|
||||
rootPath: string,
|
||||
callback: (events: FsChangeEvent[]) => void
|
||||
): () => void {
|
||||
let disposed = false
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const emitOverflow = (): void => {
|
||||
timer = null
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
callback([{ kind: 'overflow', absolutePath: rootPath }])
|
||||
}
|
||||
|
||||
const scheduleOverflow = (): void => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
timer = setTimeout(emitOverflow, WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
// Why: Parcel probes Watchman before the Windows backend and its native
|
||||
// watcher can abort the headless server process. For remote Windows runtimes,
|
||||
// a conservative overflow refresh is safer than a process-wide native crash.
|
||||
const watcher = watchFs(rootPath, { recursive: true }, scheduleOverflow)
|
||||
watcher.on('error', (err) => {
|
||||
console.error('[runtime-files.watch] Windows watcher error', { rootPath, err })
|
||||
scheduleOverflow()
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
try {
|
||||
watcher.close()
|
||||
} catch (err) {
|
||||
console.error('[runtime-files.watch] Windows watcher close error', { rootPath, err })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isSafeMobileRelativePath(relativePath: string): boolean {
|
||||
if (!relativePath || relativePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(relativePath)) {
|
||||
return false
|
||||
}
|
||||
const parts = relativePath.replace(/\\/g, '/').split('/')
|
||||
return parts.every((part) => part !== '' && part !== '.' && part !== '..')
|
||||
}
|
||||
|
||||
function isMobileMarkdownPath(relativePath: string): boolean {
|
||||
return /\.(md|mdx|markdown)$/i.test(relativePath)
|
||||
}
|
||||
|
||||
function isMobileBinaryPath(relativePath: string): boolean {
|
||||
const basename = basenameFromRelativePath(relativePath)
|
||||
const dotIndex = basename.lastIndexOf('.')
|
||||
if (dotIndex <= 0) {
|
||||
return false
|
||||
}
|
||||
return MOBILE_BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase())
|
||||
}
|
||||
|
||||
function basenameFromRelativePath(relativePath: string): string {
|
||||
const normalized = relativePath.replace(/\\/g, '/')
|
||||
return normalized.slice(normalized.lastIndexOf('/') + 1)
|
||||
}
|
||||
|
||||
async function isRuntimeDirectoryEntry(entryPath: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(entryPath)).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isBinaryBuffer(buffer: Buffer): boolean {
|
||||
const len = Math.min(buffer.length, 8192)
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
if (buffer[i] === 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function assertRuntimePathDoesNotExist(targetPath: string): Promise<void> {
|
||||
try {
|
||||
await lstat(targetPath)
|
||||
throw new Error(
|
||||
`A file or folder named '${basename(targetPath)}' already exists in this location`
|
||||
)
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rethrowRuntimeFileCreateError(error: unknown, targetPath: string): never {
|
||||
const name = basename(targetPath)
|
||||
if (error instanceof Error && 'code' in error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'EEXIST') {
|
||||
throw new Error(`A file or folder named '${name}' already exists in this location`)
|
||||
}
|
||||
if (code === 'EACCES' || code === 'EPERM') {
|
||||
throw new Error(`Permission denied: unable to create '${name}'`)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
async function readLocalMobileFile(filePath: string, store: Store): Promise<string> {
|
||||
const authorizedPath = await resolveAuthorizedPath(filePath, store)
|
||||
const fileStat = await stat(authorizedPath)
|
||||
// Why: mobile file previews are read-only convenience views; cap the read so
|
||||
// opening a generated log or bundle cannot block the WebSocket like oversized scrollback.
|
||||
const readLimit = Math.min(fileStat.size, MOBILE_FILE_READ_MAX_BYTES + 1)
|
||||
const handle = await open(authorizedPath, 'r')
|
||||
try {
|
||||
const buffer = Buffer.alloc(readLimit)
|
||||
const { bytesRead } = await handle.read(buffer, 0, readLimit, 0)
|
||||
return buffer.subarray(0, bytesRead).toString('utf8')
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
function truncateMobileFilePreview(content: string): {
|
||||
content: string
|
||||
truncated: boolean
|
||||
byteLength: number
|
||||
} {
|
||||
const buffer = Buffer.from(content, 'utf8')
|
||||
if (buffer.byteLength <= MOBILE_FILE_READ_MAX_BYTES) {
|
||||
return { content, truncated: false, byteLength: buffer.byteLength }
|
||||
}
|
||||
return {
|
||||
content: buffer.subarray(0, MOBILE_FILE_READ_MAX_BYTES).toString('utf8'),
|
||||
truncated: true,
|
||||
byteLength: buffer.byteLength
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import type {
|
||||
GitBranchCompareResult,
|
||||
GitConflictOperation,
|
||||
GitDiffResult,
|
||||
GitPushTarget,
|
||||
GitStatusResult,
|
||||
GitUpstreamStatus,
|
||||
GitWorktreeInfo,
|
||||
Worktree
|
||||
} from '../../shared/types'
|
||||
import { getRemoteFileUrl } from '../git/repo'
|
||||
import {
|
||||
bulkStageFiles,
|
||||
bulkUnstageFiles,
|
||||
commitChanges,
|
||||
detectConflictOperation,
|
||||
discardChanges,
|
||||
getBranchCompare,
|
||||
getBranchDiff,
|
||||
getDiff,
|
||||
getStatus as getGitStatus,
|
||||
stageFile,
|
||||
unstageFile
|
||||
} from '../git/status'
|
||||
import { getUpstreamStatus } from '../git/upstream'
|
||||
import { gitFetch, gitPull, gitPush } from '../git/remote'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { normalizeRuntimeRelativePath } from './runtime-relative-paths'
|
||||
|
||||
export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo }
|
||||
|
||||
export type RuntimeGitCommandHost = {
|
||||
resolveRuntimeGitTarget(
|
||||
selector: string
|
||||
): Promise<{ worktree: ResolvedRuntimeGitWorktree; connectionId?: string }>
|
||||
}
|
||||
|
||||
export class RuntimeGitCommands {
|
||||
constructor(private readonly host: RuntimeGitCommandHost) {}
|
||||
|
||||
async getRuntimeGitStatus(worktreeSelector: string): Promise<GitStatusResult> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_git_unavailable')
|
||||
}
|
||||
return provider.getStatus(target.worktree.path)
|
||||
}
|
||||
return getGitStatus(target.worktree.path)
|
||||
}
|
||||
|
||||
async getRuntimeGitConflictOperation(worktreeSelector: string): Promise<GitConflictOperation> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_git_unavailable')
|
||||
}
|
||||
return provider.detectConflictOperation(target.worktree.path)
|
||||
}
|
||||
return detectConflictOperation(target.worktree.path)
|
||||
}
|
||||
|
||||
async getRuntimeGitDiff(
|
||||
worktreeSelector: string,
|
||||
filePath: string,
|
||||
staged: boolean,
|
||||
compareAgainstHead?: boolean
|
||||
): Promise<GitDiffResult> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const relativePath = normalizeRuntimeRelativePath(filePath)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_git_unavailable')
|
||||
}
|
||||
return provider.getDiff(target.worktree.path, relativePath, staged, compareAgainstHead)
|
||||
}
|
||||
return getDiff(target.worktree.path, relativePath, staged, compareAgainstHead)
|
||||
}
|
||||
|
||||
async getRuntimeGitBranchCompare(
|
||||
worktreeSelector: string,
|
||||
baseRef: string
|
||||
): Promise<GitBranchCompareResult> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_git_unavailable')
|
||||
}
|
||||
return provider.getBranchCompare(target.worktree.path, baseRef)
|
||||
}
|
||||
return getBranchCompare(target.worktree.path, baseRef)
|
||||
}
|
||||
|
||||
async getRuntimeGitUpstreamStatus(worktreeSelector: string): Promise<GitUpstreamStatus> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_git_unavailable')
|
||||
}
|
||||
return provider.getUpstreamStatus(target.worktree.path)
|
||||
}
|
||||
return getUpstreamStatus(target.worktree.path)
|
||||
}
|
||||
|
||||
async fetchRuntimeGit(worktreeSelector: string): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_git_unavailable')
|
||||
}
|
||||
await provider.fetchRemote(target.worktree.path)
|
||||
return { ok: true }
|
||||
}
|
||||
await gitFetch(target.worktree.path)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async pullRuntimeGit(worktreeSelector: string): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_git_unavailable')
|
||||
}
|
||||
await provider.pullBranch(target.worktree.path)
|
||||
return { ok: true }
|
||||
}
|
||||
await gitPull(target.worktree.path)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async pushRuntimeGit(
|
||||
worktreeSelector: string,
|
||||
publish?: boolean,
|
||||
pushTarget?: GitPushTarget
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_git_unavailable')
|
||||
}
|
||||
await provider.pushBranch(target.worktree.path, publish === true, pushTarget)
|
||||
return { ok: true }
|
||||
}
|
||||
await gitPush(target.worktree.path, publish === true, pushTarget)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async getRuntimeGitBranchDiff(
|
||||
worktreeSelector: string,
|
||||
compare: { mergeBase: string; headOid: string },
|
||||
filePath: string,
|
||||
oldPath?: string
|
||||
): Promise<GitDiffResult> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const relativePath = normalizeRuntimeRelativePath(filePath)
|
||||
const oldRelativePath = oldPath ? normalizeRuntimeRelativePath(oldPath) : undefined
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_git_unavailable')
|
||||
}
|
||||
const results = await provider.getBranchDiff(target.worktree.path, compare.mergeBase, {
|
||||
includePatch: true,
|
||||
filePath: relativePath,
|
||||
oldPath: oldRelativePath
|
||||
})
|
||||
return (
|
||||
results[0] ?? {
|
||||
kind: 'text',
|
||||
originalContent: '',
|
||||
modifiedContent: '',
|
||||
originalIsBinary: false,
|
||||
modifiedIsBinary: false
|
||||
}
|
||||
)
|
||||
}
|
||||
return getBranchDiff(target.worktree.path, {
|
||||
mergeBase: compare.mergeBase,
|
||||
headOid: compare.headOid,
|
||||
filePath: relativePath,
|
||||
oldPath: oldRelativePath
|
||||
})
|
||||
}
|
||||
|
||||
async commitRuntimeGit(
|
||||
worktreeSelector: string,
|
||||
message: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (message.trim().length === 0) {
|
||||
throw new Error('Commit message is required')
|
||||
}
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_git_unavailable')
|
||||
}
|
||||
return provider.commit(target.worktree.path, message)
|
||||
}
|
||||
return commitChanges(target.worktree.path, message)
|
||||
}
|
||||
|
||||
async stageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const relativePath = normalizeRuntimeRelativePath(filePath)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_git_unavailable')
|
||||
}
|
||||
await provider.stageFile(target.worktree.path, relativePath)
|
||||
return { ok: true }
|
||||
}
|
||||
await stageFile(target.worktree.path, relativePath)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async unstageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const relativePath = normalizeRuntimeRelativePath(filePath)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_git_unavailable')
|
||||
}
|
||||
await provider.unstageFile(target.worktree.path, relativePath)
|
||||
return { ok: true }
|
||||
}
|
||||
await unstageFile(target.worktree.path, relativePath)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async bulkStageRuntimeGitPaths(
|
||||
worktreeSelector: string,
|
||||
filePaths: string[]
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const relativePaths = filePaths.map((path) => normalizeRuntimeRelativePath(path))
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_git_unavailable')
|
||||
}
|
||||
await provider.bulkStageFiles(target.worktree.path, relativePaths)
|
||||
return { ok: true }
|
||||
}
|
||||
await bulkStageFiles(target.worktree.path, relativePaths)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async bulkUnstageRuntimeGitPaths(
|
||||
worktreeSelector: string,
|
||||
filePaths: string[]
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const relativePaths = filePaths.map((path) => normalizeRuntimeRelativePath(path))
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_git_unavailable')
|
||||
}
|
||||
await provider.bulkUnstageFiles(target.worktree.path, relativePaths)
|
||||
return { ok: true }
|
||||
}
|
||||
await bulkUnstageFiles(target.worktree.path, relativePaths)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async discardRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const relativePath = normalizeRuntimeRelativePath(filePath)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_git_unavailable')
|
||||
}
|
||||
await provider.discardChanges(target.worktree.path, relativePath)
|
||||
return { ok: true }
|
||||
}
|
||||
await discardChanges(target.worktree.path, relativePath)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async getRuntimeGitRemoteFileUrl(
|
||||
worktreeSelector: string,
|
||||
relativePath: string,
|
||||
line: number
|
||||
): Promise<string | null> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const normalizedRelativePath = normalizeRuntimeRelativePath(relativePath)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_git_unavailable')
|
||||
}
|
||||
return provider.getRemoteFileUrl(target.worktree.path, normalizedRelativePath, line)
|
||||
}
|
||||
return getRemoteFileUrl(target.worktree.path, normalizedRelativePath, line)
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,19 @@ import { addWorktree, listWorktrees, removeWorktree } from '../git/worktree'
|
||||
import {
|
||||
createSetupRunnerScript,
|
||||
getEffectiveHooks,
|
||||
hasHooksFile,
|
||||
parseOrcaYaml,
|
||||
runHook,
|
||||
shouldRunSetupForCreate
|
||||
} from '../hooks'
|
||||
import { getDefaultBaseRef } from '../git/repo'
|
||||
import { OrchestrationDb } from './orchestration/db'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import {
|
||||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
@@ -62,7 +70,8 @@ vi.mock('../hooks', () => ({
|
||||
.fn()
|
||||
.mockImplementation((_repo: never, decision: string) => decision === 'run'),
|
||||
getEffectiveSetupRunPolicy: vi.fn().mockReturnValue('auto'),
|
||||
hasHooksFile: vi.fn().mockReturnValue(false)
|
||||
hasHooksFile: vi.fn().mockReturnValue(false),
|
||||
parseOrcaYaml: vi.fn().mockReturnValue(null)
|
||||
}))
|
||||
|
||||
vi.mock('../ipc/worktree-logic', async (importOriginal) => {
|
||||
@@ -99,10 +108,14 @@ afterEach(() => {
|
||||
vi.mocked(removeWorktree).mockReset()
|
||||
vi.mocked(createSetupRunnerScript).mockReset()
|
||||
vi.mocked(getEffectiveHooks).mockReset()
|
||||
vi.mocked(hasHooksFile).mockReset()
|
||||
vi.mocked(parseOrcaYaml).mockReset()
|
||||
vi.mocked(runHook).mockReset()
|
||||
vi.mocked(shouldRunSetupForCreate).mockReset()
|
||||
vi.mocked(shouldRunSetupForCreate).mockImplementation((_repo, decision) => decision === 'run')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue(null)
|
||||
vi.mocked(hasHooksFile).mockReturnValue(false)
|
||||
vi.mocked(parseOrcaYaml).mockReturnValue(null)
|
||||
computeWorktreePathMock.mockReset()
|
||||
ensurePathWithinWorkspaceMock.mockReset()
|
||||
invalidateAuthorizedRootsCacheMock.mockReset()
|
||||
@@ -143,6 +156,20 @@ function createRuntime(): OrcaRuntimeService {
|
||||
return new OrcaRuntimeService(store)
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (error: unknown) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
const store = {
|
||||
getRepo: (id: string) => store.getRepos().find((repo) => repo.id === id),
|
||||
getRepos: () => [
|
||||
@@ -224,10 +251,15 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(runtime.getRuntimeId()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reports protocol version and minimum compatible mobile version on status', () => {
|
||||
it('reports runtime protocol, capabilities, and mobile aliases on status', () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
const status = runtime.getStatus()
|
||||
expect(typeof status.runtimeProtocolVersion).toBe('number')
|
||||
expect(typeof status.minCompatibleRuntimeClientVersion).toBe('number')
|
||||
expect(status.runtimeProtocolVersion).toBe(status.protocolVersion)
|
||||
expect(status.minCompatibleRuntimeClientVersion).toBe(status.minCompatibleMobileVersion)
|
||||
expect(status.capabilities).toContain('terminal.binary-stream.v1')
|
||||
expect(typeof status.protocolVersion).toBe('number')
|
||||
expect(typeof status.minCompatibleMobileVersion).toBe('number')
|
||||
expect(status.protocolVersion).toBeGreaterThanOrEqual(1)
|
||||
@@ -354,12 +386,481 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('routes SSH-backed forward-slash UNC file and git paths without collapsing the root', async () => {
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
vi.mocked(listWorktrees).mockRejectedValue(new Error('local git should not run for SSH repos'))
|
||||
const remoteStore = {
|
||||
...store,
|
||||
getRepos: () => [
|
||||
{
|
||||
id: TEST_REPO_ID,
|
||||
path: '//Server/Share/Repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
],
|
||||
getRepo: () => ({
|
||||
id: TEST_REPO_ID,
|
||||
path: '//Server/Share/Repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
}
|
||||
const fsProvider = { readDir: vi.fn().mockResolvedValue([]) }
|
||||
const gitProvider = {
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '//Server/Share/Repo',
|
||||
head: 'abc',
|
||||
branch: 'feature/foo',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
]),
|
||||
getStatus: vi.fn().mockResolvedValue({
|
||||
branch: 'feature/foo',
|
||||
files: [],
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasConflicts: false
|
||||
})
|
||||
}
|
||||
registerSshFilesystemProvider('ssh-1', fsProvider as never)
|
||||
registerSshGitProvider('ssh-1', gitProvider as never)
|
||||
const runtime = new OrcaRuntimeService(remoteStore as never)
|
||||
|
||||
try {
|
||||
await runtime.readFileExplorerDir('path://server/share/repo', 'src')
|
||||
await runtime.getRuntimeGitStatus('path://server/share/repo')
|
||||
await expect(runtime.showRepo('path://server/share/repo')).resolves.toMatchObject({
|
||||
path: '//Server/Share/Repo'
|
||||
})
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
unregisterSshGitProvider('ssh-1')
|
||||
}
|
||||
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
expect(gitProvider.listWorktrees).toHaveBeenCalledWith('//Server/Share/Repo')
|
||||
expect(fsProvider.readDir).toHaveBeenCalledWith('\\\\Server\\Share\\Repo\\src')
|
||||
expect(gitProvider.getStatus).toHaveBeenCalledWith('//Server/Share/Repo')
|
||||
})
|
||||
|
||||
it('does not interpret active as a runtime-global worktree selector', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
await expect(runtime.showManagedWorktree('active')).rejects.toThrow('selector_not_found')
|
||||
})
|
||||
|
||||
it('does not reuse stale in-flight worktree scans after creating a worktree', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const staleScan = deferred<typeof MOCK_GIT_WORKTREES>()
|
||||
const createdWorktree = {
|
||||
path: '/tmp/workspaces/cache-race',
|
||||
head: 'def',
|
||||
branch: 'cache-race',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
computeWorktreePathMock.mockReturnValue(createdWorktree.path)
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path)
|
||||
vi.mocked(listWorktrees)
|
||||
.mockImplementationOnce(() => staleScan.promise)
|
||||
.mockResolvedValueOnce([createdWorktree])
|
||||
.mockResolvedValueOnce([...MOCK_GIT_WORKTREES, createdWorktree])
|
||||
|
||||
const staleLookup = runtime.showManagedWorktree(TEST_WORKTREE_ID)
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'cache-race'
|
||||
})
|
||||
const freshLookup = runtime.showManagedWorktree(result.worktree.id)
|
||||
|
||||
staleScan.resolve(MOCK_GIT_WORKTREES)
|
||||
|
||||
await expect(staleLookup).resolves.toMatchObject({ id: TEST_WORKTREE_ID })
|
||||
await expect(freshLookup).resolves.toMatchObject({
|
||||
id: result.worktree.id,
|
||||
path: createdWorktree.path
|
||||
})
|
||||
})
|
||||
|
||||
it('does not run local git when runtime worktree creation targets an SSH repo', async () => {
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
vi.mocked(addWorktree).mockClear()
|
||||
const remoteStore = {
|
||||
...store,
|
||||
getRepos: () => [
|
||||
{
|
||||
id: TEST_REPO_ID,
|
||||
path: '/remote/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
]
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(remoteStore as never)
|
||||
|
||||
await expect(
|
||||
runtime.createManagedWorktree({ repoSelector: TEST_REPO_ID, name: 'feature' })
|
||||
).rejects.toThrow('SSH-backed worktree creation is not supported through runtime RPC yet')
|
||||
|
||||
expect(addWorktree).not.toHaveBeenCalled()
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes SSH-backed runtime worktrees through the SSH git provider', async () => {
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
const remoteStore = {
|
||||
...store,
|
||||
getRepos: () => [
|
||||
{
|
||||
id: TEST_REPO_ID,
|
||||
path: '/remote/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
],
|
||||
getRepo: () => ({
|
||||
id: TEST_REPO_ID,
|
||||
path: '/remote/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
}
|
||||
const gitProvider = {
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/remote/repo',
|
||||
head: 'abc',
|
||||
branch: 'feature/foo',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
]),
|
||||
removeWorktree: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
registerSshGitProvider('ssh-1', gitProvider as never)
|
||||
const runtime = new OrcaRuntimeService(remoteStore as never)
|
||||
|
||||
try {
|
||||
await runtime.removeManagedWorktree('path:/remote/repo', true)
|
||||
} finally {
|
||||
unregisterSshGitProvider('ssh-1')
|
||||
}
|
||||
|
||||
expect(gitProvider.removeWorktree).toHaveBeenCalledWith('/remote/repo', true)
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads SSH repo hooks through the SSH filesystem provider', async () => {
|
||||
const remoteStore = {
|
||||
...store,
|
||||
getRepos: () => [
|
||||
{
|
||||
id: TEST_REPO_ID,
|
||||
path: 'C:/remote/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
]
|
||||
}
|
||||
const fsProvider = {
|
||||
readFile: vi.fn().mockResolvedValue({
|
||||
content: 'scripts:\n setup: pnpm install\n',
|
||||
isBinary: false
|
||||
})
|
||||
}
|
||||
vi.mocked(parseOrcaYaml).mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
registerSshFilesystemProvider('ssh-1', fsProvider as never)
|
||||
const runtime = new OrcaRuntimeService(remoteStore as never)
|
||||
|
||||
try {
|
||||
await expect(runtime.getRepoHooks('id:repo-1')).resolves.toMatchObject({
|
||||
hasHooksFile: true,
|
||||
hooks: { scripts: { setup: 'pnpm install' } },
|
||||
source: 'orca.yaml'
|
||||
})
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
}
|
||||
|
||||
expect(fsProvider.readFile).toHaveBeenCalledWith('C:\\remote\\repo\\.orca.yaml')
|
||||
expect(hasHooksFile).not.toHaveBeenCalled()
|
||||
expect(getEffectiveHooks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses remote path joins for SSH hook checks and issue-command files', async () => {
|
||||
const remoteStore = {
|
||||
...store,
|
||||
getRepos: () => [
|
||||
{
|
||||
id: TEST_REPO_ID,
|
||||
path: 'C:/remote/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
]
|
||||
}
|
||||
const fsProvider = {
|
||||
readFile: vi.fn(async (filePath: string) => ({
|
||||
content: filePath.includes('.orca.yaml')
|
||||
? 'scripts:\n setup: pnpm install\n'
|
||||
: filePath.endsWith('.gitignore')
|
||||
? 'node_modules\n'
|
||||
: 'Fix it',
|
||||
isBinary: false
|
||||
})),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
createDir: vi.fn().mockResolvedValue(undefined),
|
||||
deletePath: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
registerSshFilesystemProvider('ssh-1', fsProvider as never)
|
||||
const runtime = new OrcaRuntimeService(remoteStore as never)
|
||||
|
||||
try {
|
||||
await expect(runtime.checkRepoHooks('id:repo-1')).resolves.toMatchObject({
|
||||
hasHooks: true,
|
||||
mayNeedUpdate: false
|
||||
})
|
||||
await expect(runtime.readRepoIssueCommand('id:repo-1')).resolves.toMatchObject({
|
||||
localContent: 'Fix it',
|
||||
effectiveContent: 'Fix it',
|
||||
localFilePath: 'C:\\remote\\repo\\.orca\\issue-command'
|
||||
})
|
||||
await expect(runtime.writeRepoIssueCommand('id:repo-1', 'Ship it')).resolves.toEqual({
|
||||
ok: true
|
||||
})
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
}
|
||||
|
||||
expect(fsProvider.readFile).toHaveBeenCalledWith('C:\\remote\\repo\\.orca.yaml')
|
||||
expect(fsProvider.readFile).toHaveBeenCalledWith('C:\\remote\\repo\\.orca\\issue-command')
|
||||
expect(fsProvider.createDir).toHaveBeenCalledWith('C:\\remote\\repo\\.orca')
|
||||
expect(fsProvider.writeFile).toHaveBeenCalledWith(
|
||||
'C:\\remote\\repo\\.orca\\issue-command',
|
||||
'Ship it\n'
|
||||
)
|
||||
expect(fsProvider.writeFile).toHaveBeenCalledWith(
|
||||
'C:\\remote\\repo\\.gitignore',
|
||||
'node_modules\n.orca\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves SSH issue commands from shared orca.yaml and deletes empty overrides', async () => {
|
||||
const remoteStore = {
|
||||
...store,
|
||||
getRepos: () => [
|
||||
{
|
||||
id: TEST_REPO_ID,
|
||||
path: '/remote/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
]
|
||||
}
|
||||
vi.mocked(parseOrcaYaml).mockReturnValue({
|
||||
scripts: {},
|
||||
issueCommand: 'claude -p "Fix #{{issue}}"'
|
||||
})
|
||||
const fsProvider = {
|
||||
readFile: vi.fn(async (filePath: string) => {
|
||||
if (filePath.endsWith('.orca/issue-command')) {
|
||||
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
|
||||
}
|
||||
if (filePath.endsWith('orca.yaml')) {
|
||||
return { content: 'issueCommand: claude -p "Fix #{{issue}}"', isBinary: false }
|
||||
}
|
||||
return { content: '', isBinary: false }
|
||||
}),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
createDir: vi.fn().mockResolvedValue(undefined),
|
||||
deletePath: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
registerSshFilesystemProvider('ssh-1', fsProvider as never)
|
||||
const runtime = new OrcaRuntimeService(remoteStore as never)
|
||||
|
||||
try {
|
||||
await expect(runtime.readRepoIssueCommand('id:repo-1')).resolves.toMatchObject({
|
||||
localContent: null,
|
||||
sharedContent: 'claude -p "Fix #{{issue}}"',
|
||||
effectiveContent: 'claude -p "Fix #{{issue}}"',
|
||||
localFilePath: '/remote/repo/.orca/issue-command',
|
||||
source: 'shared'
|
||||
})
|
||||
await expect(runtime.writeRepoIssueCommand('id:repo-1', ' ')).resolves.toEqual({
|
||||
ok: true
|
||||
})
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
}
|
||||
|
||||
expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml')
|
||||
expect(fsProvider.deletePath).toHaveBeenCalledWith('/remote/repo/.orca/issue-command', false)
|
||||
expect(fsProvider.writeFile).not.toHaveBeenCalledWith(
|
||||
'/remote/repo/.orca/issue-command',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects host integration helpers for SSH repos instead of using remote paths locally', async () => {
|
||||
const remoteStore = {
|
||||
...store,
|
||||
getRepos: () => [
|
||||
{
|
||||
id: TEST_REPO_ID,
|
||||
path: '/remote/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
]
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(remoteStore as never)
|
||||
|
||||
await expect(runtime.getRepoSlug('id:repo-1')).rejects.toThrow(
|
||||
'repo_slug_unsupported_for_ssh_repo'
|
||||
)
|
||||
await expect(runtime.listRepoWorkItems('id:repo-1')).rejects.toThrow(
|
||||
'repo_work_items_unsupported_for_ssh_repo'
|
||||
)
|
||||
})
|
||||
|
||||
it('treats SSH worktree drift as unknown without local git probes', async () => {
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
vi.mocked(getDefaultBaseRef).mockClear()
|
||||
const remoteStore = {
|
||||
...store,
|
||||
getRepos: () => [
|
||||
{
|
||||
id: TEST_REPO_ID,
|
||||
path: '/remote/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
],
|
||||
getWorktreeMeta: () => null
|
||||
}
|
||||
const gitProvider = {
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/remote/repo',
|
||||
head: 'abc',
|
||||
branch: 'feature/foo',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
}
|
||||
registerSshGitProvider('ssh-1', gitProvider as never)
|
||||
const runtime = new OrcaRuntimeService(remoteStore as never)
|
||||
|
||||
try {
|
||||
await expect(runtime.probeWorktreeDrift('path:/remote/repo')).resolves.toBeNull()
|
||||
} finally {
|
||||
unregisterSshGitProvider('ssh-1')
|
||||
}
|
||||
|
||||
expect(gitProvider.listWorktrees).toHaveBeenCalledWith('/remote/repo')
|
||||
expect(getDefaultBaseRef).not.toHaveBeenCalled()
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deduplicates runtime repo paths with Windows/UNC comparison semantics', async () => {
|
||||
const added: Record<string, unknown>[] = []
|
||||
const uncStore = {
|
||||
...store,
|
||||
getRepos: () => [
|
||||
{
|
||||
id: 'repo-unc',
|
||||
path: '//Server/Share/Repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
kind: 'folder'
|
||||
},
|
||||
...added
|
||||
],
|
||||
addRepo: (repo: Record<string, unknown>) => {
|
||||
added.push(repo)
|
||||
},
|
||||
getRepo: (id: string) => [...uncStore.getRepos()].find((repo) => repo.id === id) as never
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(uncStore as never)
|
||||
|
||||
const repo = await runtime.addRepo('//server/share/repo', 'folder')
|
||||
|
||||
expect(repo).toMatchObject({ id: 'repo-unc', path: '//Server/Share/Repo' })
|
||||
expect(added).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('associates controller PTYs with mixed-case Windows and UNC cwd paths', async () => {
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
path: 'C:\\Repo',
|
||||
head: 'abc',
|
||||
branch: 'feature/windows',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '//Server/Share/Repo',
|
||||
head: 'def',
|
||||
branch: 'feature/unc',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
const runtime = createRuntime()
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [
|
||||
{ id: 'pty-windows', cwd: 'c:\\repo\\src', title: 'Windows shell' },
|
||||
{ id: 'pty-unc', cwd: '//server/share/repo/src', title: 'UNC shell' }
|
||||
]
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.markGraphReady(1)
|
||||
|
||||
const terminals = await runtime.listTerminals()
|
||||
|
||||
expect(terminals.terminals).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
worktreeId: `${TEST_REPO_ID}::C:\\Repo`,
|
||||
worktreePath: 'C:\\Repo'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
worktreeId: `${TEST_REPO_ID}:://Server/Share/Repo`,
|
||||
worktreePath: '//Server/Share/Repo'
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('reads bounded terminal output and writes through the PTY controller', async () => {
|
||||
const writes: string[] = []
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
@@ -1102,6 +1603,55 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('creates mobile session terminals in a headless runtime server', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-headless' })
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
|
||||
const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: TEST_WORKTREE_PATH,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
preAllocatedHandle: expect.stringMatching(/^term_/)
|
||||
})
|
||||
)
|
||||
expect(result.tab).toMatchObject({
|
||||
type: 'terminal',
|
||||
status: 'ready',
|
||||
terminal: expect.stringMatching(/^term_/),
|
||||
isActive: true
|
||||
})
|
||||
|
||||
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
expect(listed.tabs).toEqual([
|
||||
expect.objectContaining({
|
||||
id: result.tab.id,
|
||||
status: 'ready',
|
||||
terminal: result.tab.terminal
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('reports browser tab creation as unsupported for headless runtime servers', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
|
||||
await expect(
|
||||
runtime.browserTabCreate({ worktree: `id:${TEST_WORKTREE_ID}`, url: 'https://example.com' })
|
||||
).rejects.toMatchObject({
|
||||
code: 'browser_error',
|
||||
message: expect.stringContaining('headless orca serve')
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps already-idle status after tui-idle wait for immediate message delivery', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const db = new OrchestrationDb(':memory:')
|
||||
|
||||
+1933
-1653
File diff suppressed because it is too large
Load Diff
+7
-7
@@ -20,9 +20,9 @@ import { existsSync, mkdtempSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { OrcaRuntimeService } from '../main/runtime/orca-runtime'
|
||||
import { OrchestrationDb } from '../main/runtime/orchestration/db'
|
||||
import { OrcaRuntimeRpcServer } from '../main/runtime/runtime-rpc'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { OrchestrationDb } from './orchestration/db'
|
||||
import { OrcaRuntimeRpcServer } from './runtime-rpc'
|
||||
|
||||
// Why: Vitest runs tests with `process.cwd()` pinned to the repo root, so
|
||||
// join against it to locate the compiled CLI regardless of where this test
|
||||
@@ -108,12 +108,12 @@ describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => {
|
||||
expect(heartbeatLines[0]).toHaveProperty('elapsedMs')
|
||||
expect(heartbeatLines[0]).toHaveProperty('deadlineMs', waitTimeoutMs)
|
||||
|
||||
// Why: first heartbeat must arrive within one interval + scheduler
|
||||
// slack (300ms is generous); if the stream were fully buffered we'd
|
||||
// see everything only after exit.
|
||||
// Why: under full-suite load the child process startup may take longer
|
||||
// than one heartbeat interval. The invariant that matters is that at
|
||||
// least one heartbeat is observed before the terminal stdout payload.
|
||||
const firstHeartbeatChunk = stderrChunks.find((c) => c.data.includes('_heartbeat'))
|
||||
expect(firstHeartbeatChunk).toBeDefined()
|
||||
expect(firstHeartbeatChunk!.at).toBeLessThan(heartbeatMs + 300)
|
||||
expect(firstHeartbeatChunk!.at).toBeLessThan(stdoutChunks[0]?.at ?? Number.POSITIVE_INFINITY)
|
||||
|
||||
// Why: line-flushing proof — the *first* heartbeat chunk must arrive
|
||||
// strictly before the exit chunk; i.e. we got at least two separate
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultRepoHookSettings } from '../../shared/constants'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import { parsePairingCode } from '../../shared/pairing'
|
||||
import { RemoteRuntimeRequestConnection } from '../../shared/remote-runtime-request-connection'
|
||||
import type { OrcaRuntimeService } from './orca-runtime'
|
||||
import { OrcaRuntimeRpcServer } from './runtime-rpc'
|
||||
|
||||
describe('remote runtime request connection integration', () => {
|
||||
it('fetches repos through the real E2EE WebSocket runtime', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-request-'))
|
||||
const repoPath = join(userDataPath, 'repo')
|
||||
const repos: Repo[] = [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: repoPath,
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
hookSettings: getDefaultRepoHookSettings(),
|
||||
worktreeBaseRef: 'main',
|
||||
kind: 'git'
|
||||
}
|
||||
]
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'runtime-test',
|
||||
getStartedAt: () => 1,
|
||||
cleanupSubscriptionsForConnection: () => {},
|
||||
cancelMobileDictationForConnection: () => {},
|
||||
onClientDisconnected: () => {},
|
||||
listRepos: () => repos
|
||||
} as unknown as OrcaRuntimeService
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime,
|
||||
userDataPath,
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
|
||||
await server.start()
|
||||
try {
|
||||
const offer = server.createPairingOffer({ name: 'integration', scope: 'runtime' })
|
||||
if (!offer.available) {
|
||||
throw new Error('pairing unavailable')
|
||||
}
|
||||
const pairing = parsePairingCode(offer.pairingUrl)
|
||||
if (!pairing) {
|
||||
throw new Error('invalid pairing')
|
||||
}
|
||||
const connection = new RemoteRuntimeRequestConnection(pairing)
|
||||
try {
|
||||
await expect(connection.request('repo.list', undefined, 1000)).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: { repos }
|
||||
})
|
||||
} finally {
|
||||
connection.close()
|
||||
}
|
||||
} finally {
|
||||
await server.stop()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@
|
||||
// CLI-facing contract greppable and lets the dispatcher verify every payload
|
||||
// against the same shape the handler consumed during development.
|
||||
import { ZodError, type ZodType } from 'zod'
|
||||
import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
|
||||
export type RpcEnvelopeMeta = {
|
||||
@@ -59,6 +60,13 @@ export type RpcContext = {
|
||||
// responses after the binary terminal cutover. Undefined on Unix/socket
|
||||
// transports and non-E2EE WebSocket paths.
|
||||
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => void
|
||||
// Why: binary terminal input/resize frames arrive outside JSON-RPC after a
|
||||
// stream is established. The WebSocket transport owns the connection-scoped
|
||||
// stream table; handlers register only the stream IDs they created.
|
||||
registerBinaryStreamHandler?: (
|
||||
streamId: number,
|
||||
handler: (frame: TerminalStreamFrame) => void
|
||||
) => () => void
|
||||
}
|
||||
|
||||
export type RpcHandler<TParams> = (params: TParams, ctx: RpcContext) => Promise<unknown> | unknown
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type RpcRequest,
|
||||
type RpcResponse
|
||||
} from './core'
|
||||
import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol'
|
||||
import { errorResponse, mapBrowserError, mapRuntimeError, successResponse } from './errors'
|
||||
import { ALL_RPC_METHODS } from './methods'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
@@ -79,8 +80,13 @@ export class RpcDispatcher {
|
||||
reply: (response: string) => void,
|
||||
options?: {
|
||||
connectionId?: string
|
||||
signal?: AbortSignal
|
||||
clientId?: string
|
||||
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => void
|
||||
registerBinaryStreamHandler?: (
|
||||
streamId: number,
|
||||
handler: (frame: TerminalStreamFrame) => void
|
||||
) => () => void
|
||||
}
|
||||
): Promise<void> {
|
||||
const meta = this.meta()
|
||||
@@ -104,9 +110,11 @@ export class RpcDispatcher {
|
||||
try {
|
||||
const result = await method.handler(parsedParams.value, {
|
||||
runtime: this.runtime,
|
||||
signal: options?.signal,
|
||||
connectionId: options?.connectionId,
|
||||
clientId: options?.clientId,
|
||||
sendBinary: options?.sendBinary
|
||||
sendBinary: options?.sendBinary,
|
||||
registerBinaryStreamHandler: options?.registerBinaryStreamHandler
|
||||
})
|
||||
reply(JSON.stringify(successResponse(request.id, meta, result)))
|
||||
} catch (error) {
|
||||
@@ -126,9 +134,11 @@ export class RpcDispatcher {
|
||||
parsedParams.value,
|
||||
{
|
||||
runtime: this.runtime,
|
||||
signal: options?.signal,
|
||||
connectionId: options?.connectionId,
|
||||
clientId: options?.clientId,
|
||||
sendBinary: options?.sendBinary
|
||||
sendBinary: options?.sendBinary,
|
||||
registerBinaryStreamHandler: options?.registerBinaryStreamHandler
|
||||
},
|
||||
emit
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { WebSocket } from 'ws'
|
||||
import { E2EEChannel, type E2EEChannelOptions } from './e2ee-channel'
|
||||
import { generateKeyPair, deriveSharedKey, encrypt, decrypt } from './e2ee-crypto'
|
||||
import { generateKeyPair, deriveSharedKey, encrypt, decrypt, encryptBytes } from './e2ee-crypto'
|
||||
|
||||
function publicKeyToBase64(key: Uint8Array): string {
|
||||
return Buffer.from(key).toString('base64')
|
||||
@@ -182,6 +182,20 @@ describe('E2EEChannel', () => {
|
||||
expect(replyPlain).toBe('{"id":"rpc-1","ok":true}')
|
||||
})
|
||||
|
||||
it('decrypts and forwards binary messages after authentication', () => {
|
||||
const ctx = setup()
|
||||
const sharedKey = doHandshake(ctx)
|
||||
const received: Uint8Array<ArrayBufferLike>[] = []
|
||||
|
||||
ctx.channel.onBinaryMessage((bytes) => {
|
||||
received.push(bytes)
|
||||
})
|
||||
|
||||
ctx.channel.handleRawMessage(encryptBytes(new Uint8Array([1, 2, 3]), sharedKey))
|
||||
|
||||
expect([...received[0]!]).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('silently drops messages with wrong key', () => {
|
||||
const ctx = setup()
|
||||
doHandshake(ctx)
|
||||
|
||||
@@ -46,6 +46,7 @@ export class E2EEChannel {
|
||||
encryptedBinaryReply: (response: Uint8Array<ArrayBufferLike>) => void
|
||||
) => void)
|
||||
| null = null
|
||||
private binaryMessageHandler: ((plaintext: Uint8Array<ArrayBufferLike>) => void) | null = null
|
||||
|
||||
deviceToken: string | null = null
|
||||
|
||||
@@ -71,6 +72,10 @@ export class E2EEChannel {
|
||||
this.messageHandler = handler
|
||||
}
|
||||
|
||||
onBinaryMessage(handler: (plaintext: Uint8Array<ArrayBufferLike>) => void): void {
|
||||
this.binaryMessageHandler = handler
|
||||
}
|
||||
|
||||
handleRawMessage(raw: string | Uint8Array<ArrayBufferLike>): void {
|
||||
if (this.state === 'awaiting_hello') {
|
||||
if (typeof raw !== 'string') {
|
||||
@@ -89,7 +94,14 @@ export class E2EEChannel {
|
||||
const plaintextBytes = decryptBytes(raw, this.sharedKey)
|
||||
if (plaintextBytes === null) {
|
||||
this.trackDecryptFailure()
|
||||
return
|
||||
}
|
||||
this.consecutiveFailures = 0
|
||||
if (this.state !== 'ready') {
|
||||
this.onError(4001, 'Invalid binary message before authentication')
|
||||
return
|
||||
}
|
||||
this.binaryMessageHandler?.(plaintextBytes)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -211,5 +223,6 @@ export class E2EEChannel {
|
||||
}
|
||||
this.sharedKey = null
|
||||
this.messageHandler = null
|
||||
this.binaryMessageHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,8 @@
|
||||
// Why: shared E2EE primitives for the desktop side. Wraps tweetnacl to provide
|
||||
// encrypt/decrypt with the NaCl box format: [24-byte nonce][ciphertext]. JSON
|
||||
// RPC uses base64 text frames; terminal streams use the raw byte bundle.
|
||||
import nacl from 'tweetnacl'
|
||||
|
||||
export function generateKeyPair(): nacl.BoxKeyPair {
|
||||
return nacl.box.keyPair()
|
||||
}
|
||||
|
||||
export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array {
|
||||
return nacl.box.before(peerPublicKey, ourSecretKey)
|
||||
}
|
||||
|
||||
export function encrypt(plaintext: string, sharedKey: Uint8Array): string {
|
||||
const messageBytes = new TextEncoder().encode(plaintext)
|
||||
return Buffer.from(encryptBytes(messageBytes, sharedKey)).toString('base64')
|
||||
}
|
||||
|
||||
export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null {
|
||||
const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64'))
|
||||
const plaintext = decryptBytes(bundle, sharedKey)
|
||||
return plaintext ? new TextDecoder().decode(plaintext) : null
|
||||
}
|
||||
|
||||
export function encryptBytes(
|
||||
plaintext: Uint8Array<ArrayBufferLike>,
|
||||
sharedKey: Uint8Array
|
||||
): Uint8Array {
|
||||
const nonce = nacl.randomBytes(nacl.box.nonceLength)
|
||||
const ciphertext = nacl.box.after(plaintext, nonce, sharedKey)
|
||||
|
||||
const bundle = new Uint8Array(nonce.length + ciphertext.length)
|
||||
bundle.set(nonce)
|
||||
bundle.set(ciphertext, nonce.length)
|
||||
|
||||
return bundle
|
||||
}
|
||||
|
||||
export function decryptBytes(bundle: Uint8Array, sharedKey: Uint8Array): Uint8Array | null {
|
||||
if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nonce = bundle.slice(0, nacl.box.nonceLength)
|
||||
const ciphertext = bundle.slice(nacl.box.nonceLength)
|
||||
const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey)
|
||||
|
||||
if (!plaintext) {
|
||||
return null
|
||||
}
|
||||
|
||||
return plaintext
|
||||
}
|
||||
export {
|
||||
decrypt,
|
||||
decryptBytes,
|
||||
deriveSharedKey,
|
||||
encrypt,
|
||||
encryptBytes,
|
||||
generateKeyPair
|
||||
} from '../../../shared/e2ee-crypto'
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
LimitParam,
|
||||
ProfileCreate,
|
||||
ProfileDelete,
|
||||
ProfileImportFromBrowser,
|
||||
Screenshot,
|
||||
Scroll,
|
||||
Select,
|
||||
@@ -151,6 +152,21 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [
|
||||
params: ProfileDelete,
|
||||
handler: async (params, { runtime }) => runtime.browserProfileDelete(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.profileDetectBrowsers',
|
||||
params: null,
|
||||
handler: async (_params, { runtime }) => runtime.browserProfileDetectBrowsers()
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.profileImportFromBrowser',
|
||||
params: ProfileImportFromBrowser,
|
||||
handler: async (params, { runtime }) => runtime.browserProfileImportFromBrowser(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.profileClearDefaultCookies',
|
||||
params: null,
|
||||
handler: async (_params, { runtime }) => runtime.browserProfileClearDefaultCookies()
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.hover',
|
||||
params: Element,
|
||||
|
||||
@@ -143,6 +143,12 @@ export const ProfileDelete = z.object({
|
||||
profileId: requiredString('Missing required --profile')
|
||||
})
|
||||
|
||||
export const ProfileImportFromBrowser = z.object({
|
||||
profileId: requiredString('Missing required --profile'),
|
||||
browserFamily: requiredString('Missing required --browser-family'),
|
||||
browserProfile: OptionalString
|
||||
})
|
||||
|
||||
export const Drag = BrowserTarget.extend({
|
||||
from: requiredString('Missing required --from and --to element refs'),
|
||||
to: requiredString('Missing required --from and --to element refs')
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { BROWSER_CORE_METHODS } from './browser-core'
|
||||
import { BROWSER_EXTRA_METHODS } from './browser-extras'
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
|
||||
describe('browser RPC methods', () => {
|
||||
it('routes core browser automation commands to the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
browserSnapshot: vi.fn().mockResolvedValue({ elements: [] }),
|
||||
browserGoto: vi.fn().mockResolvedValue({ url: 'https://example.com' }),
|
||||
browserProfileDetectBrowsers: vi.fn().mockResolvedValue({ browsers: [] }),
|
||||
browserProfileImportFromBrowser: vi.fn().mockResolvedValue({ ok: false, reason: 'empty' }),
|
||||
browserTabCreate: vi.fn().mockResolvedValue({ browserPageId: 'page-1' }),
|
||||
browserTabSwitch: vi.fn().mockResolvedValue({ browserPageId: 'page-1' })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_CORE_METHODS })
|
||||
|
||||
await dispatcher.dispatch(makeRequest('browser.snapshot', { worktree: 'id:wt-1' }))
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('browser.goto', {
|
||||
worktree: 'id:wt-1',
|
||||
page: 'page-1',
|
||||
url: 'https://example.com'
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('browser.tabCreate', {
|
||||
worktree: 'id:wt-1',
|
||||
url: 'https://example.com',
|
||||
profileId: 'profile-1'
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('browser.tabSwitch', {
|
||||
worktree: 'id:wt-1',
|
||||
index: 0,
|
||||
focus: true
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(makeRequest('browser.profileDetectBrowsers'))
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('browser.profileImportFromBrowser', {
|
||||
profileId: 'profile-1',
|
||||
browserFamily: 'chrome',
|
||||
browserProfile: 'Default'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.browserSnapshot).toHaveBeenCalledWith({ worktree: 'id:wt-1' })
|
||||
expect(runtime.browserGoto).toHaveBeenCalledWith({
|
||||
worktree: 'id:wt-1',
|
||||
page: 'page-1',
|
||||
url: 'https://example.com'
|
||||
})
|
||||
expect(runtime.browserTabCreate).toHaveBeenCalledWith({
|
||||
worktree: 'id:wt-1',
|
||||
url: 'https://example.com',
|
||||
profileId: 'profile-1'
|
||||
})
|
||||
expect(runtime.browserTabSwitch).toHaveBeenCalledWith({
|
||||
worktree: 'id:wt-1',
|
||||
index: 0,
|
||||
focus: true
|
||||
})
|
||||
expect(runtime.browserProfileDetectBrowsers).toHaveBeenCalled()
|
||||
expect(runtime.browserProfileImportFromBrowser).toHaveBeenCalledWith({
|
||||
profileId: 'profile-1',
|
||||
browserFamily: 'chrome',
|
||||
browserProfile: 'Default'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes browser session and environment controls to the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
browserCookieGet: vi.fn().mockResolvedValue({ cookies: [] }),
|
||||
browserSetViewport: vi.fn().mockResolvedValue({ ok: true }),
|
||||
browserMouseWheel: vi.fn().mockResolvedValue({ ok: true }),
|
||||
browserStorageLocalSet: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_EXTRA_METHODS })
|
||||
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('browser.cookie.get', {
|
||||
worktree: 'id:wt-1',
|
||||
page: 'page-1',
|
||||
url: 'https://example.com'
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('browser.viewport', {
|
||||
worktree: 'id:wt-1',
|
||||
page: 'page-1',
|
||||
width: 1024,
|
||||
height: 768
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('browser.mouseWheel', {
|
||||
worktree: 'id:wt-1',
|
||||
page: 'page-1',
|
||||
dy: 240
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('browser.storage.local.set', {
|
||||
worktree: 'id:wt-1',
|
||||
page: 'page-1',
|
||||
key: 'orca',
|
||||
value: 'enabled'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.browserCookieGet).toHaveBeenCalledWith({
|
||||
worktree: 'id:wt-1',
|
||||
page: 'page-1',
|
||||
url: 'https://example.com'
|
||||
})
|
||||
expect(runtime.browserSetViewport).toHaveBeenCalledWith({
|
||||
worktree: 'id:wt-1',
|
||||
page: 'page-1',
|
||||
width: 1024,
|
||||
height: 768
|
||||
})
|
||||
expect(runtime.browserMouseWheel).toHaveBeenCalledWith({
|
||||
worktree: 'id:wt-1',
|
||||
page: 'page-1',
|
||||
dy: 240
|
||||
})
|
||||
expect(runtime.browserStorageLocalSet).toHaveBeenCalledWith({
|
||||
worktree: 'id:wt-1',
|
||||
page: 'page-1',
|
||||
key: 'orca',
|
||||
value: 'enabled'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { FsChangeEvent } from '../../../../shared/types'
|
||||
|
||||
const FILE_WATCH_FLUSH_MS = 150
|
||||
const FILE_WATCH_MAX_WAIT_MS = 500
|
||||
|
||||
export function createFileWatchEventBatcher(
|
||||
worktree: string,
|
||||
emit: (result: unknown) => void
|
||||
): {
|
||||
push: (events: FsChangeEvent[]) => void
|
||||
flush: () => void
|
||||
dispose: () => void
|
||||
} {
|
||||
let events: FsChangeEvent[] = []
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let firstEventAt = 0
|
||||
|
||||
const clearTimer = (): void => {
|
||||
if (!timer) {
|
||||
return
|
||||
}
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
const flush = (): void => {
|
||||
clearTimer()
|
||||
const nextEvents = events.splice(0)
|
||||
firstEventAt = 0
|
||||
if (nextEvents.length === 0) {
|
||||
return
|
||||
}
|
||||
emit({ type: 'changed', worktree, events: nextEvents })
|
||||
}
|
||||
|
||||
return {
|
||||
push(nextEvents: FsChangeEvent[]): void {
|
||||
if (nextEvents.length === 0) {
|
||||
return
|
||||
}
|
||||
events.push(...nextEvents)
|
||||
const now = Date.now()
|
||||
if (firstEventAt === 0) {
|
||||
firstEventAt = now
|
||||
}
|
||||
if (now - firstEventAt >= FILE_WATCH_MAX_WAIT_MS) {
|
||||
flush()
|
||||
return
|
||||
}
|
||||
clearTimer()
|
||||
// Why: remote file-watch events cross the runtime WebSocket before the
|
||||
// renderer refreshes the tree. Match local watcher batching here.
|
||||
timer = setTimeout(flush, FILE_WATCH_FLUSH_MS)
|
||||
if (typeof timer.unref === 'function') {
|
||||
timer.unref()
|
||||
}
|
||||
},
|
||||
flush,
|
||||
dispose(): void {
|
||||
clearTimer()
|
||||
events = []
|
||||
firstEventAt = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable max-lines -- Why: file RPC routing coverage stays together so
|
||||
the dispatcher contract for read, write, mutation, and watch methods is easy to audit. */
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
@@ -54,6 +56,117 @@ describe('file RPC methods', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('streams file watch changes until the subscription is cleaned up', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
type WatchCallback = (
|
||||
events: { kind: 'update'; absolutePath: string; isDirectory?: boolean }[]
|
||||
) => void
|
||||
const watchFileExplorer = vi.fn(async (_worktree: string, _callback: WatchCallback) => {
|
||||
return vi.fn()
|
||||
})
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
watchFileExplorer,
|
||||
registerSubscriptionCleanup: vi.fn().mockImplementation((id, cleanup) => {
|
||||
cleanups.set(id, cleanup)
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
const replies: unknown[] = []
|
||||
|
||||
const dispatch = dispatcher.dispatchStreaming(
|
||||
makeRequest('files.watch', { worktree: 'id:wt-1' }),
|
||||
(response) => replies.push(JSON.parse(response))
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(replies).toHaveLength(1)
|
||||
})
|
||||
expect(runtime.watchFileExplorer).toHaveBeenCalledWith('id:wt-1', expect.any(Function))
|
||||
expect(replies[0]).toMatchObject({
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result: { type: 'ready', subscriptionId: expect.stringContaining('files-watch-') }
|
||||
})
|
||||
|
||||
const emitWatchChange = watchFileExplorer.mock.calls[0]?.[1]
|
||||
expect(emitWatchChange).toBeDefined()
|
||||
emitWatchChange?.([{ kind: 'update', absolutePath: '/repo/readme.md', isDirectory: false }])
|
||||
emitWatchChange?.([
|
||||
{ kind: 'update', absolutePath: '/repo/package.json', isDirectory: false }
|
||||
])
|
||||
expect(replies).toHaveLength(1)
|
||||
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
|
||||
expect(replies[1]).toMatchObject({
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result: {
|
||||
type: 'changed',
|
||||
worktree: 'id:wt-1',
|
||||
events: [
|
||||
{ kind: 'update', absolutePath: '/repo/readme.md', isDirectory: false },
|
||||
{ kind: 'update', absolutePath: '/repo/package.json', isDirectory: false }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const ready = replies[0] as { result: { subscriptionId: string } }
|
||||
cleanups.get(ready.result.subscriptionId)?.()
|
||||
await dispatch
|
||||
|
||||
expect(replies[2]).toMatchObject({
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result: { type: 'end' }
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('tears down a file watch that resolves after the connection already closed', async () => {
|
||||
type WatchCallback = (
|
||||
events: { kind: 'update'; absolutePath: string; isDirectory?: boolean }[]
|
||||
) => void
|
||||
const unwatch = vi.fn()
|
||||
let resolveWatch: (value: () => void) => void = () => {}
|
||||
const watchFileExplorer = vi.fn((_worktree: string, _callback: WatchCallback) => {
|
||||
return new Promise<() => void>((resolve) => {
|
||||
resolveWatch = resolve
|
||||
})
|
||||
})
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
watchFileExplorer,
|
||||
registerSubscriptionCleanup: vi.fn()
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
const abortController = new AbortController()
|
||||
const replies: unknown[] = []
|
||||
|
||||
const dispatch = dispatcher.dispatchStreaming(
|
||||
makeRequest('files.watch', { worktree: 'id:wt-1' }),
|
||||
(response) => replies.push(JSON.parse(response)),
|
||||
{ connectionId: 'conn-1', signal: abortController.signal }
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
expect(watchFileExplorer).toHaveBeenCalled()
|
||||
})
|
||||
abortController.abort()
|
||||
await dispatch
|
||||
|
||||
resolveWatch(unwatch)
|
||||
await vi.waitFor(() => {
|
||||
expect(unwatch).toHaveBeenCalled()
|
||||
})
|
||||
expect(runtime.registerSubscriptionCleanup).not.toHaveBeenCalled()
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
|
||||
it('reads a relative file path for a selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
@@ -77,4 +190,285 @@ describe('file RPC methods', () => {
|
||||
result: { content: 'export {}\\n', truncated: false }
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a preview file for a selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
readFileExplorerPreview: vi.fn().mockResolvedValue({
|
||||
content: 'base64',
|
||||
isBinary: true,
|
||||
isImage: true,
|
||||
mimeType: 'image/png'
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.readPreview', { worktree: 'id:wt-1', relativePath: 'img/logo.png' })
|
||||
)
|
||||
|
||||
expect(runtime.readFileExplorerPreview).toHaveBeenCalledWith('id:wt-1', 'img/logo.png')
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { content: 'base64', isBinary: true, mimeType: 'image/png' }
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a file explorer directory for a selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
readFileExplorerDir: vi.fn().mockResolvedValue([{ name: 'src', isDirectory: true }])
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.readDir', { worktree: 'id:wt-1', relativePath: '' })
|
||||
)
|
||||
|
||||
expect(runtime.readFileExplorerDir).toHaveBeenCalledWith('id:wt-1', '')
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: [{ name: 'src', isDirectory: true }]
|
||||
})
|
||||
})
|
||||
|
||||
it('writes file explorer content for a selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
writeFileExplorerFile: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.write', {
|
||||
worktree: 'id:wt-1',
|
||||
relativePath: 'src/index.ts',
|
||||
content: 'export {}'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.writeFileExplorerFile).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
'src/index.ts',
|
||||
'export {}'
|
||||
)
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
it('writes base64 file explorer content for runtime uploads', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
writeFileExplorerFileBase64: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.writeBase64', {
|
||||
worktree: 'id:wt-1',
|
||||
relativePath: 'assets/logo.png',
|
||||
contentBase64: 'cG5n'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.writeFileExplorerFileBase64).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
'assets/logo.png',
|
||||
'cG5n'
|
||||
)
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
it('writes base64 file explorer content chunks for large runtime uploads', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
writeFileExplorerFileBase64Chunk: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.writeBase64Chunk', {
|
||||
worktree: 'id:wt-1',
|
||||
relativePath: 'assets/video.mov',
|
||||
contentBase64: 'AAAA',
|
||||
append: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.writeFileExplorerFileBase64Chunk).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
'assets/video.mov',
|
||||
'AAAA',
|
||||
true
|
||||
)
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
it('commits staged runtime uploads without clobbering the final destination', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
commitFileExplorerUpload: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.commitUpload', {
|
||||
worktree: 'id:wt-1',
|
||||
tempRelativePath: 'assets/.logo.png.orca-upload-a',
|
||||
finalRelativePath: 'assets/logo.png'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.commitFileExplorerUpload).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
'assets/.logo.png.orca-upload-a',
|
||||
'assets/logo.png'
|
||||
)
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
it('renames file explorer paths for a selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
renameFileExplorerPath: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.rename', {
|
||||
worktree: 'id:wt-1',
|
||||
oldRelativePath: 'old.ts',
|
||||
newRelativePath: 'new.ts'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.renameFileExplorerPath).toHaveBeenCalledWith('id:wt-1', 'old.ts', 'new.ts')
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
it('copies file explorer paths for a selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
copyFileExplorerPath: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.copy', {
|
||||
worktree: 'id:wt-1',
|
||||
sourceRelativePath: 'old.ts',
|
||||
destinationRelativePath: 'old copy.ts'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.copyFileExplorerPath).toHaveBeenCalledWith('id:wt-1', 'old.ts', 'old copy.ts')
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
it('deletes file explorer paths for a selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
deleteFileExplorerPath: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.delete', {
|
||||
worktree: 'id:wt-1',
|
||||
relativePath: 'src',
|
||||
recursive: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.deleteFileExplorerPath).toHaveBeenCalledWith('id:wt-1', 'src', true)
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
it('searches files for a selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
searchRuntimeFiles: vi.fn().mockResolvedValue({
|
||||
files: [],
|
||||
totalMatches: 0,
|
||||
truncated: false
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.search', {
|
||||
worktree: 'id:wt-1',
|
||||
query: 'needle',
|
||||
caseSensitive: true,
|
||||
maxResults: 50
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.searchRuntimeFiles).toHaveBeenCalledWith('id:wt-1', {
|
||||
query: 'needle',
|
||||
caseSensitive: true,
|
||||
wholeWord: undefined,
|
||||
useRegex: undefined,
|
||||
includePattern: undefined,
|
||||
excludePattern: undefined,
|
||||
maxResults: 50
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true, result: { files: [], totalMatches: 0 } })
|
||||
})
|
||||
|
||||
it('lists all quick-open files for a selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
listRuntimeFiles: vi.fn().mockResolvedValue(['src/index.ts'])
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.listAll', {
|
||||
worktree: 'id:wt-1',
|
||||
excludePaths: ['/repo/other-worktree']
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.listRuntimeFiles).toHaveBeenCalledWith('id:wt-1', {
|
||||
excludePaths: ['/repo/other-worktree']
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true, result: ['src/index.ts'] })
|
||||
})
|
||||
|
||||
it('lists markdown documents for a selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
listRuntimeMarkdownDocuments: vi.fn().mockResolvedValue([
|
||||
{
|
||||
filePath: '/repo/readme.md',
|
||||
relativePath: 'readme.md',
|
||||
basename: 'readme.md',
|
||||
name: 'readme'
|
||||
}
|
||||
])
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.listMarkdownDocuments', { worktree: 'id:wt-1' })
|
||||
)
|
||||
|
||||
expect(runtime.listRuntimeMarkdownDocuments).toHaveBeenCalledWith('id:wt-1')
|
||||
expect(response).toMatchObject({ ok: true, result: [{ relativePath: 'readme.md' }] })
|
||||
})
|
||||
|
||||
it('stats a relative path for a selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
statRuntimeFile: vi.fn().mockResolvedValue({ size: 12, isDirectory: false, mtime: 1 })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.stat', { worktree: 'id:wt-1', relativePath: 'readme.md' })
|
||||
)
|
||||
|
||||
expect(runtime.statRuntimeFile).toHaveBeenCalledWith('id:wt-1', 'readme.md')
|
||||
expect(response).toMatchObject({ ok: true, result: { isDirectory: false } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
|
||||
import { createFileWatchEventBatcher } from './file-watch-event-batcher'
|
||||
|
||||
let filesWatchSubscriptionSeq = 0
|
||||
|
||||
const WorktreeSelector = z.object({
|
||||
worktree: z
|
||||
@@ -15,7 +18,93 @@ const FileOpen = WorktreeSelector.extend({
|
||||
.pipe(z.string().min(1, 'Missing relative path'))
|
||||
})
|
||||
|
||||
export const FILE_METHODS: RpcMethod[] = [
|
||||
const FileTreePath = WorktreeSelector.extend({
|
||||
relativePath: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string())
|
||||
})
|
||||
|
||||
const FileWrite = FileOpen.extend({
|
||||
content: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string())
|
||||
})
|
||||
|
||||
const FileWriteBase64 = FileOpen.extend({
|
||||
contentBase64: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string())
|
||||
})
|
||||
|
||||
const FileWriteBase64Chunk = FileWriteBase64.extend({
|
||||
append: z.boolean().optional()
|
||||
})
|
||||
|
||||
const FileRename = WorktreeSelector.extend({
|
||||
oldRelativePath: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing source path')),
|
||||
newRelativePath: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing destination path'))
|
||||
})
|
||||
|
||||
const FileCopy = WorktreeSelector.extend({
|
||||
sourceRelativePath: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing source path')),
|
||||
destinationRelativePath: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing destination path'))
|
||||
})
|
||||
|
||||
const FileCommitUpload = WorktreeSelector.extend({
|
||||
tempRelativePath: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing temporary path')),
|
||||
finalRelativePath: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing final path'))
|
||||
})
|
||||
|
||||
const FileDelete = FileOpen.extend({
|
||||
recursive: z.boolean().optional()
|
||||
})
|
||||
|
||||
const FileSearch = WorktreeSelector.extend({
|
||||
query: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing search query')),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
wholeWord: z.boolean().optional(),
|
||||
useRegex: z.boolean().optional(),
|
||||
includePattern: z.string().optional(),
|
||||
excludePattern: z.string().optional(),
|
||||
maxResults: z.number().int().positive().optional()
|
||||
})
|
||||
|
||||
const FileListAll = WorktreeSelector.extend({
|
||||
excludePaths: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
const FileUnwatch = z.object({
|
||||
subscriptionId: z
|
||||
.unknown()
|
||||
.transform((value) => (typeof value === 'string' && value.length > 0 ? value : ''))
|
||||
.pipe(z.string().min(1, 'Missing subscriptionId'))
|
||||
})
|
||||
|
||||
export const FILE_METHODS: RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'files.list',
|
||||
params: WorktreeSelector,
|
||||
@@ -32,5 +121,198 @@ export const FILE_METHODS: RpcMethod[] = [
|
||||
params: FileOpen,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.readMobileFile(params.worktree, params.relativePath)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.readPreview',
|
||||
params: FileOpen,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.readFileExplorerPreview(params.worktree, params.relativePath)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.readDir',
|
||||
params: FileTreePath,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.readFileExplorerDir(params.worktree, params.relativePath)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.write',
|
||||
params: FileWrite,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.writeFileExplorerFile(params.worktree, params.relativePath, params.content)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.writeBase64',
|
||||
params: FileWriteBase64,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.writeFileExplorerFileBase64(
|
||||
params.worktree,
|
||||
params.relativePath,
|
||||
params.contentBase64
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.writeBase64Chunk',
|
||||
params: FileWriteBase64Chunk,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.writeFileExplorerFileBase64Chunk(
|
||||
params.worktree,
|
||||
params.relativePath,
|
||||
params.contentBase64,
|
||||
params.append === true
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.createFile',
|
||||
params: FileOpen,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.createFileExplorerFile(params.worktree, params.relativePath)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.createDir',
|
||||
params: FileOpen,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.createFileExplorerDir(params.worktree, params.relativePath)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.createDirNoClobber',
|
||||
params: FileOpen,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.createFileExplorerDirNoClobber(params.worktree, params.relativePath)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.commitUpload',
|
||||
params: FileCommitUpload,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.commitFileExplorerUpload(
|
||||
params.worktree,
|
||||
params.tempRelativePath,
|
||||
params.finalRelativePath
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.rename',
|
||||
params: FileRename,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.renameFileExplorerPath(
|
||||
params.worktree,
|
||||
params.oldRelativePath,
|
||||
params.newRelativePath
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.copy',
|
||||
params: FileCopy,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.copyFileExplorerPath(
|
||||
params.worktree,
|
||||
params.sourceRelativePath,
|
||||
params.destinationRelativePath
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.delete',
|
||||
params: FileDelete,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.deleteFileExplorerPath(params.worktree, params.relativePath, params.recursive)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.search',
|
||||
params: FileSearch,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.searchRuntimeFiles(params.worktree, {
|
||||
query: params.query,
|
||||
caseSensitive: params.caseSensitive,
|
||||
wholeWord: params.wholeWord,
|
||||
useRegex: params.useRegex,
|
||||
includePattern: params.includePattern,
|
||||
excludePattern: params.excludePattern,
|
||||
maxResults: params.maxResults
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.listAll',
|
||||
params: FileListAll,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.listRuntimeFiles(params.worktree, { excludePaths: params.excludePaths })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.listMarkdownDocuments',
|
||||
params: WorktreeSelector,
|
||||
handler: async (params, { runtime }) => runtime.listRuntimeMarkdownDocuments(params.worktree)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.stat',
|
||||
params: FileTreePath,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.statRuntimeFile(params.worktree, params.relativePath)
|
||||
}),
|
||||
defineStreamingMethod({
|
||||
name: 'files.watch',
|
||||
params: WorktreeSelector,
|
||||
handler: async (params, { runtime, connectionId, signal }, emit) => {
|
||||
const seq = ++filesWatchSubscriptionSeq
|
||||
const subscriptionId = `files-watch-${connectionId ?? 'inproc'}-${seq}`
|
||||
if (signal?.aborted) {
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false
|
||||
let unwatch: (() => void) | null = null
|
||||
const eventBatcher = createFileWatchEventBatcher(params.worktree, emit)
|
||||
const finish = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
signal?.removeEventListener('abort', handleAbort)
|
||||
resolve()
|
||||
}
|
||||
const cleanup = (): void => {
|
||||
eventBatcher.flush()
|
||||
eventBatcher.dispose()
|
||||
unwatch?.()
|
||||
emit({ type: 'end' })
|
||||
finish()
|
||||
}
|
||||
const handleAbort = (): void => {
|
||||
if (unwatch) {
|
||||
cleanup()
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
signal?.addEventListener('abort', handleAbort, { once: true })
|
||||
void runtime
|
||||
.watchFileExplorer(params.worktree, (events) => {
|
||||
eventBatcher.push(events)
|
||||
})
|
||||
.then((nextUnwatch) => {
|
||||
if (signal?.aborted || settled) {
|
||||
// Why: the connection can close while watch setup is still
|
||||
// resolving. Tear down the late watcher immediately instead of
|
||||
// registering cleanup on a connection that was already reaped.
|
||||
nextUnwatch()
|
||||
return
|
||||
}
|
||||
unwatch = nextUnwatch
|
||||
runtime.registerSubscriptionCleanup(subscriptionId, cleanup, connectionId)
|
||||
emit({ type: 'ready', subscriptionId })
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!settled) {
|
||||
signal?.removeEventListener('abort', handleAbort)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.unwatch',
|
||||
params: FileUnwatch,
|
||||
handler: async (params, { runtime }) => {
|
||||
runtime.cleanupSubscription(params.subscriptionId)
|
||||
return { unsubscribed: true }
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { GIT_METHODS } from './git'
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
|
||||
describe('git RPC methods', () => {
|
||||
it('returns status for a selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getRuntimeGitStatus: vi.fn().mockResolvedValue({
|
||||
entries: [],
|
||||
conflictOperation: 'unknown',
|
||||
branch: 'main',
|
||||
head: 'abc'
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('git.status', { worktree: 'id:wt-1' }))
|
||||
|
||||
expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1')
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { entries: [], branch: 'main' }
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a worktree file diff', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getRuntimeGitDiff: vi.fn().mockResolvedValue({
|
||||
kind: 'text',
|
||||
originalContent: '',
|
||||
modifiedContent: 'hello',
|
||||
originalIsBinary: false,
|
||||
modifiedIsBinary: false
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('git.diff', {
|
||||
worktree: 'id:wt-1',
|
||||
filePath: 'src/index.ts',
|
||||
staged: false,
|
||||
compareAgainstHead: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.getRuntimeGitDiff).toHaveBeenCalledWith('id:wt-1', 'src/index.ts', false, true)
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { kind: 'text', modifiedContent: 'hello' }
|
||||
})
|
||||
})
|
||||
|
||||
it('routes common mutations to the runtime', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
stageRuntimeGitPath: vi.fn().mockResolvedValue({ ok: true }),
|
||||
bulkUnstageRuntimeGitPaths: vi.fn().mockResolvedValue({ ok: true }),
|
||||
discardRuntimeGitPath: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
|
||||
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('git.stage', { worktree: 'id:wt-1', filePath: 'src/a.ts' })
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('git.bulkUnstage', { worktree: 'id:wt-1', filePaths: ['src/a.ts', 'b.ts'] })
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('git.discard', { worktree: 'id:wt-1', filePath: 'src/a.ts' })
|
||||
)
|
||||
|
||||
expect(runtime.stageRuntimeGitPath).toHaveBeenCalledWith('id:wt-1', 'src/a.ts')
|
||||
expect(runtime.bulkUnstageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['src/a.ts', 'b.ts'])
|
||||
expect(runtime.discardRuntimeGitPath).toHaveBeenCalledWith('id:wt-1', 'src/a.ts')
|
||||
})
|
||||
|
||||
it('routes remote operations to the runtime', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
commitRuntimeGit: vi.fn().mockResolvedValue({ success: true }),
|
||||
pushRuntimeGit: vi.fn().mockResolvedValue({ ok: true }),
|
||||
getRuntimeGitRemoteFileUrl: vi.fn().mockResolvedValue('https://example.com/file#L3')
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
|
||||
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('git.commit', { worktree: 'id:wt-1', message: 'feat: test' })
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('git.push', {
|
||||
worktree: 'id:wt-1',
|
||||
publish: true,
|
||||
pushTarget: { remote: 'origin' }
|
||||
})
|
||||
)
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('git.remoteFileUrl', {
|
||||
worktree: 'id:wt-1',
|
||||
relativePath: 'src/a.ts',
|
||||
line: 3
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.commitRuntimeGit).toHaveBeenCalledWith('id:wt-1', 'feat: test')
|
||||
expect(runtime.pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, { remote: 'origin' })
|
||||
expect(response).toMatchObject({ ok: true, result: 'https://example.com/file#L3' })
|
||||
})
|
||||
|
||||
it('rejects branch diff revisions that are not full object ids', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getRuntimeGitBranchDiff: vi.fn()
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('git.branchDiff', {
|
||||
worktree: 'id:wt-1',
|
||||
filePath: 'src/a.ts',
|
||||
compare: {
|
||||
headOid: '--output=/tmp/orca-test',
|
||||
mergeBase: 'a'.repeat(40)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(false)
|
||||
expect(runtime.getRuntimeGitBranchDiff).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects branch compare refs that look like git options', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getRuntimeGitBranchCompare: vi.fn()
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('git.branchCompare', {
|
||||
worktree: 'id:wt-1',
|
||||
baseRef: '--output=/tmp/orca-test'
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(false)
|
||||
expect(runtime.getRuntimeGitBranchCompare).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
|
||||
const WorktreeSelector = z.object({
|
||||
worktree: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing worktree selector'))
|
||||
})
|
||||
|
||||
const GitFilePath = WorktreeSelector.extend({
|
||||
filePath: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing file path'))
|
||||
})
|
||||
|
||||
const GitDiff = GitFilePath.extend({
|
||||
staged: z.boolean(),
|
||||
compareAgainstHead: z.boolean().optional()
|
||||
})
|
||||
|
||||
const GitBranchCompare = WorktreeSelector.extend({
|
||||
baseRef: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(
|
||||
z
|
||||
.string()
|
||||
.min(1, 'Missing base ref')
|
||||
.refine((value) => !value.startsWith('-'), 'Base ref must not start with -')
|
||||
)
|
||||
})
|
||||
|
||||
const FullGitObjectId = z
|
||||
.string()
|
||||
.regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id')
|
||||
|
||||
const GitBranchDiff = GitFilePath.extend({
|
||||
compare: z.object({
|
||||
baseRef: z.string().optional(),
|
||||
baseOid: FullGitObjectId.optional(),
|
||||
headOid: FullGitObjectId,
|
||||
mergeBase: FullGitObjectId
|
||||
}),
|
||||
oldPath: z.string().optional()
|
||||
})
|
||||
|
||||
const GitCommit = WorktreeSelector.extend({
|
||||
message: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing commit message'))
|
||||
})
|
||||
|
||||
const GitBulkPaths = WorktreeSelector.extend({
|
||||
filePaths: z.array(z.string())
|
||||
})
|
||||
|
||||
const GitPush = WorktreeSelector.extend({
|
||||
publish: z.boolean().optional(),
|
||||
pushTarget: z.unknown().optional()
|
||||
})
|
||||
|
||||
const GitRemoteFileUrl = WorktreeSelector.extend({
|
||||
relativePath: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing relative path')),
|
||||
line: z.number().int().min(1)
|
||||
})
|
||||
|
||||
export const GIT_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'git.status',
|
||||
params: WorktreeSelector,
|
||||
handler: async (params, { runtime }) => runtime.getRuntimeGitStatus(params.worktree)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.conflictOperation',
|
||||
params: WorktreeSelector,
|
||||
handler: async (params, { runtime }) => runtime.getRuntimeGitConflictOperation(params.worktree)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.diff',
|
||||
params: GitDiff,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.getRuntimeGitDiff(
|
||||
params.worktree,
|
||||
params.filePath,
|
||||
params.staged,
|
||||
params.compareAgainstHead
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.branchCompare',
|
||||
params: GitBranchCompare,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.getRuntimeGitBranchCompare(params.worktree, params.baseRef)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.upstreamStatus',
|
||||
params: WorktreeSelector,
|
||||
handler: async (params, { runtime }) => runtime.getRuntimeGitUpstreamStatus(params.worktree)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.fetch',
|
||||
params: WorktreeSelector,
|
||||
handler: async (params, { runtime }) => runtime.fetchRuntimeGit(params.worktree)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.pull',
|
||||
params: WorktreeSelector,
|
||||
handler: async (params, { runtime }) => runtime.pullRuntimeGit(params.worktree)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.push',
|
||||
params: GitPush,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.pushRuntimeGit(params.worktree, params.publish, params.pushTarget as never)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.branchDiff',
|
||||
params: GitBranchDiff,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.getRuntimeGitBranchDiff(
|
||||
params.worktree,
|
||||
params.compare,
|
||||
params.filePath,
|
||||
params.oldPath
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.commit',
|
||||
params: GitCommit,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.commitRuntimeGit(params.worktree, params.message)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.stage',
|
||||
params: GitFilePath,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.stageRuntimeGitPath(params.worktree, params.filePath)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.bulkStage',
|
||||
params: GitBulkPaths,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.bulkStageRuntimeGitPaths(params.worktree, params.filePaths)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.unstage',
|
||||
params: GitFilePath,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.unstageRuntimeGitPath(params.worktree, params.filePath)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.bulkUnstage',
|
||||
params: GitBulkPaths,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.bulkUnstageRuntimeGitPaths(params.worktree, params.filePaths)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.discard',
|
||||
params: GitFilePath,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.discardRuntimeGitPath(params.worktree, params.filePath)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.remoteFileUrl',
|
||||
params: GitRemoteFileUrl,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.getRuntimeGitRemoteFileUrl(params.worktree, params.relativePath, params.line)
|
||||
})
|
||||
]
|
||||
@@ -0,0 +1,519 @@
|
||||
/* eslint-disable max-lines -- Why: runtime GitHub RPC methods share one dispatcher suite so repo-scoped and Project-scoped contract coverage cannot drift. */
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { GITHUB_METHODS } from './github'
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
|
||||
describe('github RPC methods', () => {
|
||||
it('resolves the repo slug on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getRepoSlug: vi.fn().mockResolvedValue({ owner: 'acme', repo: 'orca' })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('github.repoSlug', { repo: 'repo-1' }))
|
||||
|
||||
expect(runtime.getRepoSlug).toHaveBeenCalledWith('repo-1')
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { owner: 'acme', repo: 'orca' }
|
||||
})
|
||||
})
|
||||
|
||||
it('fetches GitHub rate limits on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getGitHubRateLimit: vi.fn().mockResolvedValue({ ok: true, snapshot: { core: {} } })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('github.rateLimit', { force: true }))
|
||||
|
||||
expect(runtime.getGitHubRateLimit).toHaveBeenCalledWith({ force: true })
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
it('lists work items on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
listRepoWorkItems: vi.fn().mockResolvedValue({ items: [] })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.listWorkItems', { repo: 'repo-1', limit: 10, query: 'is:pr' })
|
||||
)
|
||||
|
||||
expect(runtime.listRepoWorkItems).toHaveBeenCalledWith('repo-1', 10, 'is:pr', undefined)
|
||||
expect(response).toMatchObject({ ok: true, result: { items: [] } })
|
||||
})
|
||||
|
||||
it('looks up a single work item on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getRepoWorkItem: vi.fn().mockResolvedValue({ number: 12, type: 'pr' })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.workItem', { repo: 'repo-1', number: 12, type: 'pr' })
|
||||
)
|
||||
|
||||
expect(runtime.getRepoWorkItem).toHaveBeenCalledWith('repo-1', 12, 'pr')
|
||||
expect(response).toMatchObject({ ok: true, result: { number: 12, type: 'pr' } })
|
||||
})
|
||||
|
||||
it('looks up a single work item by explicit owner/repo on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getRepoWorkItemByOwnerRepo: vi.fn().mockResolvedValue({ number: 12, type: 'pr' })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.workItemByOwnerRepo', {
|
||||
repo: 'repo-1',
|
||||
owner: 'acme',
|
||||
ownerRepo: 'orca',
|
||||
number: 12,
|
||||
type: 'pr'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.getRepoWorkItemByOwnerRepo).toHaveBeenCalledWith(
|
||||
'repo-1',
|
||||
{ owner: 'acme', repo: 'orca' },
|
||||
12,
|
||||
'pr'
|
||||
)
|
||||
expect(response).toMatchObject({ ok: true, result: { number: 12, type: 'pr' } })
|
||||
})
|
||||
|
||||
it('fetches work item details on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getRepoWorkItemDetails: vi.fn().mockResolvedValue({ body: 'Details' })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.workItemDetails', { repo: 'repo-1', number: 12, type: 'issue' })
|
||||
)
|
||||
|
||||
expect(runtime.getRepoWorkItemDetails).toHaveBeenCalledWith('repo-1', 12, 'issue')
|
||||
expect(response).toMatchObject({ ok: true, result: { body: 'Details' } })
|
||||
})
|
||||
|
||||
it('counts work items on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
countRepoWorkItems: vi.fn().mockResolvedValue(3)
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.countWorkItems', { repo: 'repo-1', query: 'is:open' })
|
||||
)
|
||||
|
||||
expect(runtime.countRepoWorkItems).toHaveBeenCalledWith('repo-1', 'is:open')
|
||||
expect(response).toMatchObject({ ok: true, result: 3 })
|
||||
})
|
||||
|
||||
it('lists repo issue metadata on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
listRepoLabels: vi.fn().mockResolvedValue(['bug']),
|
||||
listRepoAssignableUsers: vi.fn().mockResolvedValue([{ login: 'octo' }])
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const labels = await dispatcher.dispatch(makeRequest('github.listLabels', { repo: 'repo-1' }))
|
||||
const users = await dispatcher.dispatch(
|
||||
makeRequest('github.listAssignableUsers', { repo: 'repo-1' })
|
||||
)
|
||||
|
||||
expect(runtime.listRepoLabels).toHaveBeenCalledWith('repo-1')
|
||||
expect(runtime.listRepoAssignableUsers).toHaveBeenCalledWith('repo-1')
|
||||
expect(labels).toMatchObject({ ok: true, result: ['bug'] })
|
||||
expect(users).toMatchObject({ ok: true, result: [{ login: 'octo' }] })
|
||||
})
|
||||
|
||||
it('fetches PR checks on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getRepoPRChecks: vi.fn().mockResolvedValue([])
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.prChecks', {
|
||||
repo: 'repo-1',
|
||||
prNumber: 7,
|
||||
headSha: 'abc123',
|
||||
noCache: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.getRepoPRChecks).toHaveBeenCalledWith('repo-1', 7, 'abc123', {
|
||||
noCache: true
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true, result: [] })
|
||||
})
|
||||
|
||||
it('fetches PR file contents on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getRepoPRFileContents: vi.fn().mockResolvedValue({
|
||||
original: '',
|
||||
modified: 'new',
|
||||
originalIsBinary: false,
|
||||
modifiedIsBinary: false
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.prFileContents', {
|
||||
repo: 'repo-1',
|
||||
prNumber: 7,
|
||||
path: 'src/app.ts',
|
||||
status: 'modified',
|
||||
headSha: 'head',
|
||||
baseSha: 'base'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.getRepoPRFileContents).toHaveBeenCalledWith('repo-1', {
|
||||
prNumber: 7,
|
||||
path: 'src/app.ts',
|
||||
oldPath: undefined,
|
||||
status: 'modified',
|
||||
headSha: 'head',
|
||||
baseSha: 'base'
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true, result: { modified: 'new' } })
|
||||
})
|
||||
|
||||
it('resolves review threads on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
resolveRepoReviewThread: vi.fn().mockResolvedValue(true)
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.resolveReviewThread', {
|
||||
repo: 'repo-1',
|
||||
threadId: 'PRRT_1',
|
||||
resolve: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.resolveRepoReviewThread).toHaveBeenCalledWith('repo-1', 'PRRT_1', true)
|
||||
expect(response).toMatchObject({ ok: true, result: true })
|
||||
})
|
||||
|
||||
it('updates PR titles on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
updateRepoPRTitle: vi.fn().mockResolvedValue(true)
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.updatePRTitle', {
|
||||
repo: 'repo-1',
|
||||
prNumber: 7,
|
||||
title: 'New title'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.updateRepoPRTitle).toHaveBeenCalledWith('repo-1', 7, 'New title')
|
||||
expect(response).toMatchObject({ ok: true, result: true })
|
||||
})
|
||||
|
||||
it('merges PRs on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
mergeRepoPR: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.mergePR', {
|
||||
repo: 'repo-1',
|
||||
prNumber: 7,
|
||||
method: 'squash'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.mergeRepoPR).toHaveBeenCalledWith('repo-1', 7, 'squash')
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
it('creates issues on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
createRepoIssue: vi.fn().mockResolvedValue({ ok: true, number: 3, url: 'https://gh/3' })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.createIssue', {
|
||||
repo: 'repo-1',
|
||||
title: 'Bug',
|
||||
body: 'Body'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.createRepoIssue).toHaveBeenCalledWith('repo-1', 'Bug', 'Body')
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true, number: 3 } })
|
||||
})
|
||||
|
||||
it('updates issues on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
updateRepoIssue: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.updateIssue', {
|
||||
repo: 'repo-1',
|
||||
number: 3,
|
||||
updates: { state: 'closed', addLabels: ['bug'] }
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.updateRepoIssue).toHaveBeenCalledWith('repo-1', 3, {
|
||||
state: 'closed',
|
||||
addLabels: ['bug']
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
it('adds issue comments on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
addRepoIssueComment: vi.fn().mockResolvedValue({ ok: true, comment: { id: 1 } })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.addIssueComment', {
|
||||
repo: 'repo-1',
|
||||
number: 3,
|
||||
body: 'Looks good',
|
||||
type: 'pr'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.addRepoIssueComment).toHaveBeenCalledWith('repo-1', 3, 'Looks good')
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true, comment: { id: 1 } } })
|
||||
})
|
||||
|
||||
it('adds PR review comments on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
addRepoPRReviewComment: vi.fn().mockResolvedValue({ ok: true, comment: { id: 2 } })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.addPRReviewComment', {
|
||||
repo: 'repo-1',
|
||||
prNumber: 7,
|
||||
commitId: 'head',
|
||||
path: 'src/app.ts',
|
||||
line: 12,
|
||||
startLine: 10,
|
||||
body: 'Please tweak'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.addRepoPRReviewComment).toHaveBeenCalledWith('repo-1', {
|
||||
prNumber: 7,
|
||||
commitId: 'head',
|
||||
path: 'src/app.ts',
|
||||
line: 12,
|
||||
startLine: 10,
|
||||
body: 'Please tweak'
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true, comment: { id: 2 } } })
|
||||
})
|
||||
|
||||
it('adds PR review comment replies on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
addRepoPRReviewCommentReply: vi.fn().mockResolvedValue({ ok: true, comment: { id: 4 } })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.addPRReviewCommentReply', {
|
||||
repo: 'repo-1',
|
||||
prNumber: 7,
|
||||
commentId: 2,
|
||||
body: 'Done',
|
||||
threadId: 'PRRT_1',
|
||||
path: 'src/app.ts',
|
||||
line: 12
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.addRepoPRReviewCommentReply).toHaveBeenCalledWith('repo-1', {
|
||||
prNumber: 7,
|
||||
commentId: 2,
|
||||
body: 'Done',
|
||||
threadId: 'PRRT_1',
|
||||
path: 'src/app.ts',
|
||||
line: 12
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true, comment: { id: 4 } } })
|
||||
})
|
||||
|
||||
it('fetches GitHub project views on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
listGitHubProjectViews: vi.fn().mockResolvedValue({ ok: true, views: [] })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.project.listViews', {
|
||||
owner: 'acme',
|
||||
ownerType: 'organization',
|
||||
projectNumber: 1
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.listGitHubProjectViews).toHaveBeenCalledWith({
|
||||
owner: 'acme',
|
||||
ownerType: 'organization',
|
||||
projectNumber: 1
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true, views: [] } })
|
||||
})
|
||||
|
||||
it('lists slug-addressed issue metadata on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
listGitHubLabelsBySlug: vi.fn().mockResolvedValue({ ok: true, labels: ['bug'] }),
|
||||
listGitHubAssignableUsersBySlug: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, users: [{ login: 'octo' }] }),
|
||||
listGitHubIssueTypesBySlug: vi.fn().mockResolvedValue({ ok: true, types: [{ id: 'it-1' }] })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const labels = await dispatcher.dispatch(
|
||||
makeRequest('github.project.listLabelsBySlug', { owner: 'acme', repo: 'orca' })
|
||||
)
|
||||
const users = await dispatcher.dispatch(
|
||||
makeRequest('github.project.listAssignableUsersBySlug', {
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
seedLogins: ['octo']
|
||||
})
|
||||
)
|
||||
const issueTypes = await dispatcher.dispatch(
|
||||
makeRequest('github.project.listIssueTypesBySlug', { owner: 'acme', repo: 'orca' })
|
||||
)
|
||||
|
||||
expect(runtime.listGitHubLabelsBySlug).toHaveBeenCalledWith({ owner: 'acme', repo: 'orca' })
|
||||
expect(runtime.listGitHubAssignableUsersBySlug).toHaveBeenCalledWith({
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
seedLogins: ['octo']
|
||||
})
|
||||
expect(runtime.listGitHubIssueTypesBySlug).toHaveBeenCalledWith({ owner: 'acme', repo: 'orca' })
|
||||
expect(labels).toMatchObject({ ok: true, result: { ok: true, labels: ['bug'] } })
|
||||
expect(users).toMatchObject({ ok: true, result: { ok: true, users: [{ login: 'octo' }] } })
|
||||
expect(issueTypes).toMatchObject({ ok: true, result: { ok: true, types: [{ id: 'it-1' }] } })
|
||||
})
|
||||
|
||||
it('fetches GitHub project tables on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getGitHubProjectViewTable: vi.fn().mockResolvedValue({ ok: true, data: { rows: [] } })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.project.viewTable', {
|
||||
owner: 'acme',
|
||||
ownerType: 'organization',
|
||||
projectNumber: 1,
|
||||
viewId: 'view-1',
|
||||
queryOverride: 'is:open'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.getGitHubProjectViewTable).toHaveBeenCalledWith({
|
||||
owner: 'acme',
|
||||
ownerType: 'organization',
|
||||
projectNumber: 1,
|
||||
viewId: 'view-1',
|
||||
viewNumber: undefined,
|
||||
viewName: undefined,
|
||||
queryOverride: 'is:open'
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true, data: { rows: [] } } })
|
||||
})
|
||||
|
||||
it('updates GitHub project item fields on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
updateGitHubProjectItemField: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.project.updateItemField', {
|
||||
projectId: 'project-1',
|
||||
itemId: 'item-1',
|
||||
fieldId: 'field-1',
|
||||
value: { kind: 'text', text: 'Now' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.updateGitHubProjectItemField).toHaveBeenCalledWith({
|
||||
projectId: 'project-1',
|
||||
itemId: 'item-1',
|
||||
fieldId: 'field-1',
|
||||
value: { kind: 'text', text: 'Now' }
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
it('updates GitHub project issue types on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
updateGitHubIssueTypeBySlug: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.project.updateIssueTypeBySlug', {
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
number: 9,
|
||||
issueTypeId: null
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.updateGitHubIssueTypeBySlug).toHaveBeenCalledWith({
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
number: 9,
|
||||
issueTypeId: null
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,442 @@
|
||||
/* eslint-disable max-lines -- Why: GitHub runtime RPC keeps related repo, project, and mutation schemas beside their handlers so the method contract stays reviewable in one place. */
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
|
||||
|
||||
const RepoSelector = z.object({
|
||||
repo: requiredString('Missing repo selector')
|
||||
})
|
||||
|
||||
const WorkItemsList = RepoSelector.extend({
|
||||
limit: OptionalFiniteNumber,
|
||||
query: OptionalString,
|
||||
before: OptionalString
|
||||
})
|
||||
|
||||
const WorkItem = RepoSelector.extend({
|
||||
number: z.number().int().positive(),
|
||||
type: z.enum(['issue', 'pr']).optional()
|
||||
})
|
||||
|
||||
const WorkItemByOwnerRepo = RepoSelector.extend({
|
||||
owner: requiredString('Missing owner'),
|
||||
ownerRepo: requiredString('Missing repo'),
|
||||
number: z.number().int().positive(),
|
||||
type: z.enum(['issue', 'pr'])
|
||||
})
|
||||
|
||||
const WorkItemDetails = WorkItem
|
||||
|
||||
const WorkItemsCount = RepoSelector.extend({
|
||||
query: OptionalString
|
||||
})
|
||||
|
||||
const RateLimit = z.object({
|
||||
force: z.boolean().optional()
|
||||
})
|
||||
|
||||
const SlugRepo = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
repo: requiredString('Missing repo')
|
||||
})
|
||||
|
||||
const SlugAssignableUsers = SlugRepo.extend({
|
||||
seedLogins: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
const PrForBranch = RepoSelector.extend({
|
||||
branch: requiredString('Missing branch'),
|
||||
linkedPRNumber: z.number().int().positive().nullable().optional()
|
||||
})
|
||||
|
||||
const Issue = RepoSelector.extend({
|
||||
number: z.number().int().positive()
|
||||
})
|
||||
|
||||
const PullRequest = RepoSelector.extend({
|
||||
prNumber: z.number().int().positive(),
|
||||
noCache: z.boolean().optional()
|
||||
})
|
||||
|
||||
const PullRequestChecks = PullRequest.extend({
|
||||
headSha: OptionalString
|
||||
})
|
||||
|
||||
const PullRequestFileContents = RepoSelector.extend({
|
||||
prNumber: z.number().int().positive(),
|
||||
path: requiredString('Missing file path'),
|
||||
oldPath: OptionalString,
|
||||
status: z.enum(['added', 'removed', 'modified', 'renamed', 'copied', 'changed', 'unchanged']),
|
||||
headSha: requiredString('Missing head SHA'),
|
||||
baseSha: requiredString('Missing base SHA')
|
||||
})
|
||||
|
||||
const ReviewThread = RepoSelector.extend({
|
||||
threadId: requiredString('Missing thread ID'),
|
||||
resolve: z.boolean()
|
||||
})
|
||||
|
||||
const UpdatePrTitle = RepoSelector.extend({
|
||||
prNumber: z.number().int().positive(),
|
||||
title: requiredString('Missing title')
|
||||
})
|
||||
|
||||
const MergePr = RepoSelector.extend({
|
||||
prNumber: z.number().int().positive(),
|
||||
method: z.enum(['merge', 'squash', 'rebase']).optional()
|
||||
})
|
||||
|
||||
const CreateIssue = RepoSelector.extend({
|
||||
title: requiredString('Missing title'),
|
||||
body: z.string()
|
||||
})
|
||||
|
||||
const IssueUpdate = z.object({
|
||||
state: z.enum(['open', 'closed']).optional(),
|
||||
title: OptionalString,
|
||||
body: OptionalString,
|
||||
addLabels: z.array(z.string()).optional(),
|
||||
removeLabels: z.array(z.string()).optional(),
|
||||
addAssignees: z.array(z.string()).optional(),
|
||||
removeAssignees: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
const UpdateIssue = RepoSelector.extend({
|
||||
number: z.number().int().positive(),
|
||||
updates: IssueUpdate
|
||||
})
|
||||
|
||||
const IssueComment = RepoSelector.extend({
|
||||
number: z.number().int().positive(),
|
||||
body: requiredString('Comment body required'),
|
||||
type: z.enum(['issue', 'pr']).optional()
|
||||
})
|
||||
|
||||
const PRReviewComment = RepoSelector.extend({
|
||||
prNumber: z.number().int().positive(),
|
||||
commitId: requiredString('Missing PR head SHA'),
|
||||
path: requiredString('File path required'),
|
||||
line: z.number().int().positive(),
|
||||
startLine: z.number().int().positive().optional(),
|
||||
body: requiredString('Comment body required')
|
||||
})
|
||||
|
||||
const PRReviewCommentReply = RepoSelector.extend({
|
||||
prNumber: z.number().int().positive(),
|
||||
commentId: z.number().int().positive(),
|
||||
body: requiredString('Comment body required'),
|
||||
threadId: OptionalString,
|
||||
path: OptionalString,
|
||||
line: z.number().int().positive().optional()
|
||||
})
|
||||
|
||||
const ProjectOwnerType = z.enum(['organization', 'user'])
|
||||
|
||||
const ProjectViewTable = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
ownerType: ProjectOwnerType,
|
||||
projectNumber: z.number().int().positive(),
|
||||
viewId: OptionalString,
|
||||
viewNumber: z.number().int().positive().optional(),
|
||||
viewName: OptionalString,
|
||||
queryOverride: OptionalString
|
||||
})
|
||||
|
||||
const ProjectRef = z.object({
|
||||
input: requiredString('Missing project reference')
|
||||
})
|
||||
|
||||
const ProjectViews = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
ownerType: ProjectOwnerType,
|
||||
projectNumber: z.number().int().positive()
|
||||
})
|
||||
|
||||
const ProjectItemField = z.object({
|
||||
projectId: requiredString('Missing project ID'),
|
||||
itemId: requiredString('Missing item ID'),
|
||||
fieldId: requiredString('Missing field ID'),
|
||||
value: z.any()
|
||||
})
|
||||
|
||||
const ClearProjectItemField = z.object({
|
||||
projectId: requiredString('Missing project ID'),
|
||||
itemId: requiredString('Missing item ID'),
|
||||
fieldId: requiredString('Missing field ID')
|
||||
})
|
||||
|
||||
const SlugIssueUpdate = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
repo: requiredString('Missing repo'),
|
||||
number: z.number().int().positive(),
|
||||
updates: IssueUpdate
|
||||
})
|
||||
|
||||
const SlugPullRequestUpdate = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
repo: requiredString('Missing repo'),
|
||||
number: z.number().int().positive(),
|
||||
updates: z.object({
|
||||
title: OptionalString,
|
||||
body: OptionalString
|
||||
})
|
||||
})
|
||||
|
||||
const SlugIssueTypeUpdate = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
repo: requiredString('Missing repo'),
|
||||
number: z.number().int().positive(),
|
||||
issueTypeId: z.string().nullable()
|
||||
})
|
||||
|
||||
const SlugIssueComment = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
repo: requiredString('Missing repo'),
|
||||
number: z.number().int().positive(),
|
||||
body: requiredString('Comment body required')
|
||||
})
|
||||
|
||||
const SlugIssueCommentEdit = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
repo: requiredString('Missing repo'),
|
||||
commentId: z.number().int().positive(),
|
||||
body: requiredString('Comment body required')
|
||||
})
|
||||
|
||||
const SlugIssueCommentDelete = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
repo: requiredString('Missing repo'),
|
||||
commentId: z.number().int().positive()
|
||||
})
|
||||
|
||||
export const GITHUB_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'github.repoSlug',
|
||||
params: RepoSelector,
|
||||
handler: async (params, { runtime }) => runtime.getRepoSlug(params.repo)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.rateLimit',
|
||||
params: RateLimit,
|
||||
handler: async (params, { runtime }) => runtime.getGitHubRateLimit(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.listWorkItems',
|
||||
params: WorkItemsList,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.listRepoWorkItems(params.repo, params.limit, params.query, params.before)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.countWorkItems',
|
||||
params: WorkItemsCount,
|
||||
handler: async (params, { runtime }) => runtime.countRepoWorkItems(params.repo, params.query)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.listLabels',
|
||||
params: RepoSelector,
|
||||
handler: async (params, { runtime }) => runtime.listRepoLabels(params.repo)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.listAssignableUsers',
|
||||
params: RepoSelector,
|
||||
handler: async (params, { runtime }) => runtime.listRepoAssignableUsers(params.repo)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.workItem',
|
||||
params: WorkItem,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.getRepoWorkItem(params.repo, params.number, params.type)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.workItemByOwnerRepo',
|
||||
params: WorkItemByOwnerRepo,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.getRepoWorkItemByOwnerRepo(
|
||||
params.repo,
|
||||
{ owner: params.owner, repo: params.ownerRepo },
|
||||
params.number,
|
||||
params.type
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.workItemDetails',
|
||||
params: WorkItemDetails,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.getRepoWorkItemDetails(params.repo, params.number, params.type)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.prForBranch',
|
||||
params: PrForBranch,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.getRepoPRForBranch(params.repo, params.branch, params.linkedPRNumber)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.issue',
|
||||
params: Issue,
|
||||
handler: async (params, { runtime }) => runtime.getRepoIssue(params.repo, params.number)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.prChecks',
|
||||
params: PullRequestChecks,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.getRepoPRChecks(params.repo, params.prNumber, params.headSha, {
|
||||
noCache: params.noCache
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.prComments',
|
||||
params: PullRequest,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.getRepoPRComments(params.repo, params.prNumber, { noCache: params.noCache })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.prFileContents',
|
||||
params: PullRequestFileContents,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.getRepoPRFileContents(params.repo, {
|
||||
prNumber: params.prNumber,
|
||||
path: params.path,
|
||||
oldPath: params.oldPath,
|
||||
status: params.status,
|
||||
headSha: params.headSha,
|
||||
baseSha: params.baseSha
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.resolveReviewThread',
|
||||
params: ReviewThread,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.resolveRepoReviewThread(params.repo, params.threadId, params.resolve)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.updatePRTitle',
|
||||
params: UpdatePrTitle,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.updateRepoPRTitle(params.repo, params.prNumber, params.title)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.mergePR',
|
||||
params: MergePr,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.mergeRepoPR(params.repo, params.prNumber, params.method)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.createIssue',
|
||||
params: CreateIssue,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.createRepoIssue(params.repo, params.title, params.body)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.updateIssue',
|
||||
params: UpdateIssue,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.updateRepoIssue(params.repo, params.number, params.updates)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.addIssueComment',
|
||||
params: IssueComment,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.addRepoIssueComment(params.repo, params.number, params.body)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.addPRReviewComment',
|
||||
params: PRReviewComment,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.addRepoPRReviewComment(params.repo, {
|
||||
prNumber: params.prNumber,
|
||||
commitId: params.commitId,
|
||||
path: params.path,
|
||||
line: params.line,
|
||||
startLine: params.startLine,
|
||||
body: params.body
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.addPRReviewCommentReply',
|
||||
params: PRReviewCommentReply,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.addRepoPRReviewCommentReply(params.repo, {
|
||||
prNumber: params.prNumber,
|
||||
commentId: params.commentId,
|
||||
body: params.body,
|
||||
threadId: params.threadId,
|
||||
path: params.path,
|
||||
line: params.line
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.listAccessible',
|
||||
params: z.object({}),
|
||||
handler: async (_params, { runtime }) => runtime.listGitHubProjects()
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.listLabelsBySlug',
|
||||
params: SlugRepo,
|
||||
handler: async (params, { runtime }) => runtime.listGitHubLabelsBySlug(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.listAssignableUsersBySlug',
|
||||
params: SlugAssignableUsers,
|
||||
handler: async (params, { runtime }) => runtime.listGitHubAssignableUsersBySlug(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.listIssueTypesBySlug',
|
||||
params: SlugRepo,
|
||||
handler: async (params, { runtime }) => runtime.listGitHubIssueTypesBySlug(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.resolveRef',
|
||||
params: ProjectRef,
|
||||
handler: async (params, { runtime }) => runtime.resolveGitHubProjectRef(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.listViews',
|
||||
params: ProjectViews,
|
||||
handler: async (params, { runtime }) => runtime.listGitHubProjectViews(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.viewTable',
|
||||
params: ProjectViewTable,
|
||||
handler: async (params, { runtime }) => runtime.getGitHubProjectViewTable(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.updateItemField',
|
||||
params: ProjectItemField,
|
||||
handler: async (params, { runtime }) => runtime.updateGitHubProjectItemField(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.clearItemField',
|
||||
params: ClearProjectItemField,
|
||||
handler: async (params, { runtime }) => runtime.clearGitHubProjectItemField(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.updateIssueBySlug',
|
||||
params: SlugIssueUpdate,
|
||||
handler: async (params, { runtime }) => runtime.updateGitHubIssueBySlug(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.updatePullRequestBySlug',
|
||||
params: SlugPullRequestUpdate,
|
||||
handler: async (params, { runtime }) => runtime.updateGitHubPullRequestBySlug(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.updateIssueTypeBySlug',
|
||||
params: SlugIssueTypeUpdate,
|
||||
handler: async (params, { runtime }) => runtime.updateGitHubIssueTypeBySlug(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.addIssueCommentBySlug',
|
||||
params: SlugIssueComment,
|
||||
handler: async (params, { runtime }) => runtime.addGitHubIssueCommentBySlug(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.updateIssueCommentBySlug',
|
||||
params: SlugIssueCommentEdit,
|
||||
handler: async (params, { runtime }) => runtime.updateGitHubIssueCommentBySlug(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.deleteIssueCommentBySlug',
|
||||
params: SlugIssueCommentDelete,
|
||||
handler: async (params, { runtime }) => runtime.deleteGitHubIssueCommentBySlug(params)
|
||||
})
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { HOSTED_REVIEW_METHODS } from './hosted-review'
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
|
||||
describe('hosted review RPC methods', () => {
|
||||
it('fetches branch review status on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getHostedReviewForBranch: vi.fn().mockResolvedValue({
|
||||
provider: 'github',
|
||||
number: 12,
|
||||
title: 'Feature',
|
||||
state: 'open',
|
||||
url: 'https://github.com/acme/orca/pull/12',
|
||||
status: 'success',
|
||||
updatedAt: '2026-05-10T00:00:00.000Z',
|
||||
mergeable: 'MERGEABLE'
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: HOSTED_REVIEW_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('hostedReview.forBranch', {
|
||||
repo: 'C:\\repo',
|
||||
branch: 'feature/windows',
|
||||
linkedGitHubPR: 12
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.getHostedReviewForBranch).toHaveBeenCalledWith({
|
||||
repoSelector: 'C:\\repo',
|
||||
branch: 'feature/windows',
|
||||
linkedGitHubPR: 12,
|
||||
linkedGitLabMR: null,
|
||||
linkedBitbucketPR: null
|
||||
})
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { provider: 'github', number: 12 }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { requiredString } from '../schemas'
|
||||
|
||||
const HostedReviewForBranch = z.object({
|
||||
repo: requiredString('Missing repo selector'),
|
||||
branch: requiredString('Missing branch'),
|
||||
linkedGitHubPR: z.number().int().positive().nullable().optional(),
|
||||
linkedGitLabMR: z.number().int().positive().nullable().optional(),
|
||||
linkedBitbucketPR: z.number().int().positive().nullable().optional()
|
||||
})
|
||||
|
||||
export const HOSTED_REVIEW_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'hostedReview.forBranch',
|
||||
params: HostedReviewForBranch,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.getHostedReviewForBranch({
|
||||
repoSelector: params.repo,
|
||||
branch: params.branch,
|
||||
linkedGitHubPR: params.linkedGitHubPR ?? null,
|
||||
linkedGitLabMR: params.linkedGitLabMR ?? null,
|
||||
linkedBitbucketPR: params.linkedBitbucketPR ?? null
|
||||
})
|
||||
})
|
||||
]
|
||||
@@ -12,6 +12,10 @@ import { ACCOUNT_METHODS } from './accounts'
|
||||
import { COMPUTER_METHODS } from './computer'
|
||||
import { SESSION_TAB_METHODS } from './session-tabs'
|
||||
import { FILE_METHODS } from './files'
|
||||
import { GIT_METHODS } from './git'
|
||||
import { GITHUB_METHODS } from './github'
|
||||
import { HOSTED_REVIEW_METHODS } from './hosted-review'
|
||||
import { LINEAR_METHODS } from './linear'
|
||||
import { NOTE_METHODS } from './notes'
|
||||
import { SPEECH_METHODS } from './speech'
|
||||
|
||||
@@ -32,6 +36,10 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
|
||||
...COMPUTER_METHODS,
|
||||
...SESSION_TAB_METHODS,
|
||||
...FILE_METHODS,
|
||||
...GIT_METHODS,
|
||||
...GITHUB_METHODS,
|
||||
...HOSTED_REVIEW_METHODS,
|
||||
...LINEAR_METHODS,
|
||||
...NOTE_METHODS,
|
||||
...SPEECH_METHODS
|
||||
]
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { LINEAR_METHODS } from './linear'
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
|
||||
describe('linear RPC methods', () => {
|
||||
it('routes Linear account methods to the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
linearStatus: vi.fn().mockResolvedValue({ connected: true, viewer: null }),
|
||||
linearTestConnection: vi.fn().mockResolvedValue({ ok: true, viewer: { displayName: 'Ada' } }),
|
||||
linearConnect: vi.fn().mockResolvedValue({ ok: true, viewer: { displayName: 'Ada' } }),
|
||||
linearDisconnect: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_METHODS })
|
||||
|
||||
await dispatcher.dispatch(makeRequest('linear.status'))
|
||||
await dispatcher.dispatch(makeRequest('linear.testConnection'))
|
||||
await dispatcher.dispatch(makeRequest('linear.connect', { apiKey: 'lin_api_key' }))
|
||||
await dispatcher.dispatch(makeRequest('linear.disconnect'))
|
||||
|
||||
expect(runtime.linearStatus).toHaveBeenCalled()
|
||||
expect(runtime.linearTestConnection).toHaveBeenCalled()
|
||||
expect(runtime.linearConnect).toHaveBeenCalledWith('lin_api_key')
|
||||
expect(runtime.linearDisconnect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes Linear issue queries and mutations to the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
linearSearchIssues: vi.fn().mockResolvedValue([{ id: 'issue-1' }]),
|
||||
linearListIssues: vi.fn().mockResolvedValue([{ id: 'issue-2' }]),
|
||||
linearGetIssue: vi.fn().mockResolvedValue({ id: 'issue-3' }),
|
||||
linearCreateIssue: vi.fn().mockResolvedValue({ ok: true, id: 'issue-4' }),
|
||||
linearUpdateIssue: vi.fn().mockResolvedValue({ ok: true }),
|
||||
linearAddIssueComment: vi.fn().mockResolvedValue({ ok: true, id: 'comment-1' }),
|
||||
linearIssueComments: vi.fn().mockResolvedValue([{ id: 'comment-2' }])
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_METHODS })
|
||||
|
||||
await dispatcher.dispatch(makeRequest('linear.searchIssues', { query: 'bug', limit: 30 }))
|
||||
await dispatcher.dispatch(makeRequest('linear.listIssues', { filter: 'assigned', limit: 20 }))
|
||||
await dispatcher.dispatch(makeRequest('linear.getIssue', { id: 'issue-3' }))
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('linear.createIssue', {
|
||||
teamId: 'team-1',
|
||||
title: 'Fix bug',
|
||||
description: 'Details'
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('linear.updateIssue', {
|
||||
id: 'issue-3',
|
||||
updates: { stateId: 'state-1', assigneeId: null, priority: 2, labelIds: ['label-1'] }
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('linear.addIssueComment', { issueId: 'issue-3', body: 'Looks good' })
|
||||
)
|
||||
await dispatcher.dispatch(makeRequest('linear.issueComments', { issueId: 'issue-3' }))
|
||||
|
||||
expect(runtime.linearSearchIssues).toHaveBeenCalledWith('bug', 30)
|
||||
expect(runtime.linearListIssues).toHaveBeenCalledWith('assigned', 20)
|
||||
expect(runtime.linearGetIssue).toHaveBeenCalledWith('issue-3')
|
||||
expect(runtime.linearCreateIssue).toHaveBeenCalledWith('team-1', 'Fix bug', 'Details')
|
||||
expect(runtime.linearUpdateIssue).toHaveBeenCalledWith('issue-3', {
|
||||
stateId: 'state-1',
|
||||
assigneeId: null,
|
||||
priority: 2,
|
||||
labelIds: ['label-1']
|
||||
})
|
||||
expect(runtime.linearAddIssueComment).toHaveBeenCalledWith('issue-3', 'Looks good')
|
||||
expect(runtime.linearIssueComments).toHaveBeenCalledWith('issue-3')
|
||||
})
|
||||
|
||||
it('routes Linear metadata requests to the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
linearListTeams: vi.fn().mockResolvedValue([{ id: 'team-1' }]),
|
||||
linearTeamStates: vi.fn().mockResolvedValue([{ id: 'state-1' }]),
|
||||
linearTeamLabels: vi.fn().mockResolvedValue([{ id: 'label-1' }]),
|
||||
linearTeamMembers: vi.fn().mockResolvedValue([{ id: 'member-1' }])
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_METHODS })
|
||||
|
||||
await dispatcher.dispatch(makeRequest('linear.listTeams'))
|
||||
await dispatcher.dispatch(makeRequest('linear.teamStates', { teamId: 'team-1' }))
|
||||
await dispatcher.dispatch(makeRequest('linear.teamLabels', { teamId: 'team-1' }))
|
||||
await dispatcher.dispatch(makeRequest('linear.teamMembers', { teamId: 'team-1' }))
|
||||
|
||||
expect(runtime.linearListTeams).toHaveBeenCalled()
|
||||
expect(runtime.linearTeamStates).toHaveBeenCalledWith('team-1')
|
||||
expect(runtime.linearTeamLabels).toHaveBeenCalledWith('team-1')
|
||||
expect(runtime.linearTeamMembers).toHaveBeenCalledWith('team-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
|
||||
|
||||
const VALID_FILTERS = ['assigned', 'created', 'all', 'completed'] as const
|
||||
|
||||
const Connect = z.object({
|
||||
apiKey: requiredString('Invalid API key')
|
||||
})
|
||||
|
||||
const SearchIssues = z.object({
|
||||
query: requiredString('Missing query'),
|
||||
limit: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
const ListIssues = z
|
||||
.object({
|
||||
filter: z.enum(VALID_FILTERS).optional(),
|
||||
limit: OptionalFiniteNumber
|
||||
})
|
||||
.optional()
|
||||
|
||||
const CreateIssue = z.object({
|
||||
teamId: requiredString('Team ID is required'),
|
||||
title: requiredString('Title is required'),
|
||||
description: OptionalString
|
||||
})
|
||||
|
||||
const IssueId = z.object({
|
||||
id: requiredString('Issue ID is required')
|
||||
})
|
||||
|
||||
const IssueComment = z.object({
|
||||
issueId: requiredString('Issue ID is required'),
|
||||
body: requiredString('Comment body is required')
|
||||
})
|
||||
|
||||
const TeamId = z.object({
|
||||
teamId: requiredString('Team ID is required')
|
||||
})
|
||||
|
||||
const IssueUpdate = z.object({
|
||||
id: requiredString('Issue ID is required'),
|
||||
updates: z.object({
|
||||
stateId: OptionalString,
|
||||
title: OptionalString,
|
||||
assigneeId: z.union([z.string(), z.null()]).optional(),
|
||||
priority: z.number().int().min(0).max(4).optional(),
|
||||
labelIds: z.array(z.string()).optional()
|
||||
})
|
||||
})
|
||||
|
||||
export const LINEAR_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'linear.connect',
|
||||
params: Connect,
|
||||
handler: async (params, { runtime }) => runtime.linearConnect(params.apiKey.trim())
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.disconnect',
|
||||
params: null,
|
||||
handler: async (_params, { runtime }) => runtime.linearDisconnect()
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.status',
|
||||
params: null,
|
||||
handler: async (_params, { runtime }) => runtime.linearStatus()
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.testConnection',
|
||||
params: null,
|
||||
handler: async (_params, { runtime }) => runtime.linearTestConnection()
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.searchIssues',
|
||||
params: SearchIssues,
|
||||
handler: async (params, { runtime }) => runtime.linearSearchIssues(params.query, params.limit)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.listIssues',
|
||||
params: ListIssues,
|
||||
handler: async (params, { runtime }) => runtime.linearListIssues(params?.filter, params?.limit)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.createIssue',
|
||||
params: CreateIssue,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearCreateIssue(
|
||||
params.teamId.trim(),
|
||||
params.title.trim(),
|
||||
params.description?.trim() || undefined
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.getIssue',
|
||||
params: IssueId,
|
||||
handler: async (params, { runtime }) => runtime.linearGetIssue(params.id.trim())
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.updateIssue',
|
||||
params: IssueUpdate,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearUpdateIssue(params.id.trim(), params.updates)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.addIssueComment',
|
||||
params: IssueComment,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearAddIssueComment(params.issueId.trim(), params.body.trim())
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.issueComments',
|
||||
params: z.object({ issueId: requiredString('Issue ID is required') }),
|
||||
handler: async (params, { runtime }) => runtime.linearIssueComments(params.issueId.trim())
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.listTeams',
|
||||
params: null,
|
||||
handler: async (_params, { runtime }) => runtime.linearListTeams()
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.teamStates',
|
||||
params: TeamId,
|
||||
handler: async (params, { runtime }) => runtime.linearTeamStates(params.teamId.trim())
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.teamLabels',
|
||||
params: TeamId,
|
||||
handler: async (params, { runtime }) => runtime.linearTeamLabels(params.teamId.trim())
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.teamMembers',
|
||||
params: TeamId,
|
||||
handler: async (params, { runtime }) => runtime.linearTeamMembers(params.teamId.trim())
|
||||
})
|
||||
]
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import type { RpcRequest } from '../core'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import { NOTE_METHODS } from './notes'
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
|
||||
describe('notes RPC methods', () => {
|
||||
it('routes note reads through the selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
listNotes: vi.fn().mockResolvedValue({ notes: [], totalCount: 0, truncated: false }),
|
||||
showNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: null })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: NOTE_METHODS })
|
||||
|
||||
await dispatcher.dispatch(makeRequest('note.list', { worktree: 'id:wt-1', limit: 50 }))
|
||||
await dispatcher.dispatch(makeRequest('note.show', { worktree: 'id:wt-1', note: 'note-1' }))
|
||||
|
||||
expect(runtime.listNotes).toHaveBeenCalledWith({ worktreeSelector: 'id:wt-1', limit: 50 })
|
||||
expect(runtime.showNote).toHaveBeenCalledWith({
|
||||
worktreeSelector: 'id:wt-1',
|
||||
note: 'note-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes note mutations through the selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
createNote: vi.fn().mockResolvedValue({ note: { id: 'created' }, linkKind: 'active' }),
|
||||
saveNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: 'active' }),
|
||||
renameNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: null }),
|
||||
deleteNote: vi.fn().mockResolvedValue({ noteId: 'note-1', projectId: 'repo-1' }),
|
||||
appendNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: null }),
|
||||
linkNote: vi.fn().mockResolvedValue({
|
||||
noteId: 'note-1',
|
||||
projectId: 'repo-1',
|
||||
worktreeId: 'wt-1',
|
||||
kind: 'active',
|
||||
createdAt: 'now'
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: NOTE_METHODS })
|
||||
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('note.create', {
|
||||
worktree: 'id:wt-1',
|
||||
title: 'Plan',
|
||||
bodyMarkdown: 'body',
|
||||
makeActive: true
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('note.save', {
|
||||
worktree: 'id:wt-1',
|
||||
note: 'note-1',
|
||||
title: 'Plan v2',
|
||||
bodyMarkdown: 'updated',
|
||||
revision: 2,
|
||||
makeActive: true
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('note.rename', { worktree: 'id:wt-1', note: 'note-1', title: 'Renamed' })
|
||||
)
|
||||
await dispatcher.dispatch(makeRequest('note.delete', { worktree: 'id:wt-1', note: 'note-1' }))
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('note.append', {
|
||||
worktree: 'id:wt-1',
|
||||
note: 'note-1',
|
||||
bodyMarkdown: 'more',
|
||||
makeActive: true
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('note.link', { worktree: 'id:wt-1', note: 'note-1', kind: 'active' })
|
||||
)
|
||||
|
||||
expect(runtime.createNote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeSelector: 'id:wt-1',
|
||||
title: 'Plan',
|
||||
bodyMarkdown: 'body',
|
||||
makeActive: true
|
||||
})
|
||||
)
|
||||
expect(runtime.saveNote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeSelector: 'id:wt-1',
|
||||
note: 'note-1',
|
||||
title: 'Plan v2',
|
||||
bodyMarkdown: 'updated',
|
||||
revision: 2,
|
||||
makeActive: true
|
||||
})
|
||||
)
|
||||
expect(runtime.renameNote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeSelector: 'id:wt-1',
|
||||
note: 'note-1',
|
||||
title: 'Renamed'
|
||||
})
|
||||
)
|
||||
expect(runtime.deleteNote).toHaveBeenCalledWith({
|
||||
worktreeSelector: 'id:wt-1',
|
||||
note: 'note-1'
|
||||
})
|
||||
expect(runtime.appendNote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeSelector: 'id:wt-1',
|
||||
note: 'note-1',
|
||||
bodyMarkdown: 'more',
|
||||
makeActive: true
|
||||
})
|
||||
)
|
||||
expect(runtime.linkNote).toHaveBeenCalledWith({
|
||||
worktreeSelector: 'id:wt-1',
|
||||
note: 'note-1',
|
||||
kind: 'active'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes panel state and search through the selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
searchNotes: vi.fn().mockResolvedValue({ notes: [], totalCount: 0, truncated: false }),
|
||||
resolveNotesPanelOpenStateForWorktree: vi.fn().mockResolvedValue({ state: 'emptyDraft' })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: NOTE_METHODS })
|
||||
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('note.search', { worktree: 'id:wt-1', query: 'todo', limit: 20 })
|
||||
)
|
||||
await dispatcher.dispatch(makeRequest('note.panelState', { worktree: 'id:wt-1' }))
|
||||
|
||||
expect(runtime.searchNotes).toHaveBeenCalledWith({
|
||||
worktreeSelector: 'id:wt-1',
|
||||
query: 'todo',
|
||||
limit: 20
|
||||
})
|
||||
expect(runtime.resolveNotesPanelOpenStateForWorktree).toHaveBeenCalledWith({
|
||||
worktreeSelector: 'id:wt-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -17,13 +17,34 @@ const NoteShowParams = NoteScopedParams.extend({
|
||||
const NoteCreateParams = NoteScopedParams.extend({
|
||||
title: requiredString('Missing note title'),
|
||||
bodyMarkdown: OptionalString,
|
||||
makeActive: z.boolean().optional()
|
||||
makeActive: z.boolean().optional(),
|
||||
createdBySessionId: z.string().nullable().optional()
|
||||
})
|
||||
|
||||
const NoteSaveParams = NoteScopedParams.extend({
|
||||
note: requiredString('Missing note selector'),
|
||||
title: OptionalString,
|
||||
bodyMarkdown: requiredString('Missing note body'),
|
||||
revision: OptionalFiniteNumber,
|
||||
makeActive: z.boolean().optional(),
|
||||
updatedBySessionId: z.string().nullable().optional()
|
||||
})
|
||||
|
||||
const NoteRenameParams = NoteScopedParams.extend({
|
||||
note: requiredString('Missing note selector'),
|
||||
title: requiredString('Missing note title'),
|
||||
updatedBySessionId: z.string().nullable().optional()
|
||||
})
|
||||
|
||||
const NoteDeleteParams = NoteScopedParams.extend({
|
||||
note: requiredString('Missing note selector')
|
||||
})
|
||||
|
||||
const NoteAppendParams = NoteScopedParams.extend({
|
||||
note: requiredString('Missing note selector'),
|
||||
bodyMarkdown: requiredString('Missing note body'),
|
||||
makeActive: z.boolean().optional()
|
||||
makeActive: z.boolean().optional(),
|
||||
updatedBySessionId: z.string().nullable().optional()
|
||||
})
|
||||
|
||||
const NoteSearchParams = NoteScopedParams.extend({
|
||||
@@ -31,6 +52,11 @@ const NoteSearchParams = NoteScopedParams.extend({
|
||||
limit: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
const NoteLinkParams = NoteScopedParams.extend({
|
||||
note: requiredString('Missing note selector'),
|
||||
kind: z.enum(['active', 'referenced'])
|
||||
})
|
||||
|
||||
export const NOTE_METHODS: readonly RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'note.list',
|
||||
@@ -58,7 +84,42 @@ export const NOTE_METHODS: readonly RpcAnyMethod[] = [
|
||||
worktreeSelector: params.worktree,
|
||||
title: params.title,
|
||||
bodyMarkdown: params.bodyMarkdown,
|
||||
makeActive: params.makeActive
|
||||
makeActive: params.makeActive,
|
||||
createdBySessionId: params.createdBySessionId
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'note.save',
|
||||
params: NoteSaveParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
await runtime.saveNote({
|
||||
worktreeSelector: params.worktree,
|
||||
note: params.note,
|
||||
title: params.title,
|
||||
bodyMarkdown: params.bodyMarkdown,
|
||||
revision: params.revision,
|
||||
makeActive: params.makeActive,
|
||||
updatedBySessionId: params.updatedBySessionId
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'note.rename',
|
||||
params: NoteRenameParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
await runtime.renameNote({
|
||||
worktreeSelector: params.worktree,
|
||||
note: params.note,
|
||||
title: params.title,
|
||||
updatedBySessionId: params.updatedBySessionId
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'note.delete',
|
||||
params: NoteDeleteParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
await runtime.deleteNote({
|
||||
worktreeSelector: params.worktree,
|
||||
note: params.note
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
@@ -69,7 +130,8 @@ export const NOTE_METHODS: readonly RpcAnyMethod[] = [
|
||||
worktreeSelector: params.worktree,
|
||||
note: params.note,
|
||||
bodyMarkdown: params.bodyMarkdown,
|
||||
makeActive: params.makeActive
|
||||
makeActive: params.makeActive,
|
||||
updatedBySessionId: params.updatedBySessionId
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
@@ -81,5 +143,23 @@ export const NOTE_METHODS: readonly RpcAnyMethod[] = [
|
||||
query: params.query,
|
||||
limit: params.limit
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'note.link',
|
||||
params: NoteLinkParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
await runtime.linkNote({
|
||||
worktreeSelector: params.worktree,
|
||||
note: params.note,
|
||||
kind: params.kind
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'note.panelState',
|
||||
params: NoteScopedParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
await runtime.resolveNotesPanelOpenStateForWorktree({
|
||||
worktreeSelector: params.worktree
|
||||
})
|
||||
})
|
||||
]
|
||||
|
||||
@@ -349,6 +349,42 @@ describe('orchestration RPC methods', () => {
|
||||
// Must not have marked read
|
||||
expect(db.getUnreadMessages('b')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not mark messages read when a waiting check is aborted', async () => {
|
||||
setup()
|
||||
const abortController = new AbortController()
|
||||
ctx = { runtime, signal: abortController.signal }
|
||||
vi.spyOn(runtime, 'waitForMessage').mockImplementation(async () => {
|
||||
db.insertMessage({ from: 'a', to: 'b', subject: 'arrived during close' })
|
||||
abortController.abort()
|
||||
})
|
||||
|
||||
const result = (await call('orchestration.check', {
|
||||
terminal: 'b',
|
||||
wait: true,
|
||||
timeoutMs: 100
|
||||
})) as { messages: unknown[]; count: number }
|
||||
|
||||
expect(result).toEqual({ messages: [], count: 0 })
|
||||
expect(db.getUnreadMessages('b')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not mark existing messages read when the check starts aborted', async () => {
|
||||
setup()
|
||||
const abortController = new AbortController()
|
||||
abortController.abort()
|
||||
ctx = { runtime, signal: abortController.signal }
|
||||
db.insertMessage({ from: 'a', to: 'b', subject: 'already unread' })
|
||||
|
||||
const result = (await call('orchestration.check', {
|
||||
terminal: 'b',
|
||||
wait: true,
|
||||
timeoutMs: 100
|
||||
})) as { messages: unknown[]; count: number }
|
||||
|
||||
expect(result).toEqual({ messages: [], count: 0 })
|
||||
expect(db.getUnreadMessages('b')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('orchestration.reply', () => {
|
||||
|
||||
@@ -241,6 +241,9 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
return { messages, count: messages.length }
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
return { messages: [], count: 0 }
|
||||
}
|
||||
const result = readAndReturn()
|
||||
if (result.count > 0 || !params.wait) {
|
||||
return result
|
||||
@@ -257,6 +260,9 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
timeoutMs: params.timeoutMs ?? undefined,
|
||||
signal
|
||||
})
|
||||
if (signal?.aborted) {
|
||||
return { messages: [], count: 0 }
|
||||
}
|
||||
return readAndReturn()
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { REPO_METHODS } from './repo'
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
|
||||
describe('repo RPC methods', () => {
|
||||
it('creates a repo on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
createRepo: vi.fn().mockResolvedValue({
|
||||
repo: { id: 'repo-1', path: '/srv/projects/new-app', kind: 'git' }
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('repo.create', {
|
||||
parentPath: '/srv/projects',
|
||||
name: 'new-app',
|
||||
kind: 'git'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.createRepo).toHaveBeenCalledWith('/srv/projects', 'new-app', 'git')
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { repo: { id: 'repo-1', path: '/srv/projects/new-app' } }
|
||||
})
|
||||
})
|
||||
|
||||
it('clones a repo on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
cloneRepo: vi.fn().mockResolvedValue({
|
||||
id: 'repo-1',
|
||||
path: '/srv/projects/orca',
|
||||
kind: 'git'
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('repo.clone', {
|
||||
url: 'https://github.com/example/orca.git',
|
||||
destination: '/srv/projects'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.cloneRepo).toHaveBeenCalledWith(
|
||||
'https://github.com/example/orca.git',
|
||||
'/srv/projects'
|
||||
)
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { repo: { id: 'repo-1', path: '/srv/projects/orca' } }
|
||||
})
|
||||
})
|
||||
|
||||
it('routes repository hook operations to the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
checkRepoHooks: vi.fn().mockResolvedValue({
|
||||
hasHooks: true,
|
||||
hooks: { scripts: { setup: 'pnpm install' } },
|
||||
mayNeedUpdate: false
|
||||
}),
|
||||
readRepoIssueCommand: vi.fn().mockResolvedValue({
|
||||
localContent: null,
|
||||
sharedContent: 'Fix {{artifact_url}}',
|
||||
effectiveContent: 'Fix {{artifact_url}}',
|
||||
localFilePath: '/srv/repo/.orca/issue-command',
|
||||
source: 'shared'
|
||||
}),
|
||||
writeRepoIssueCommand: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
|
||||
|
||||
await dispatcher.dispatch(makeRequest('repo.hooksCheck', { repo: 'repo-1' }))
|
||||
await dispatcher.dispatch(makeRequest('repo.issueCommandRead', { repo: 'repo-1' }))
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('repo.issueCommandWrite', {
|
||||
repo: 'repo-1',
|
||||
content: 'Fix it'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.checkRepoHooks).toHaveBeenCalledWith('repo-1')
|
||||
expect(runtime.readRepoIssueCommand).toHaveBeenCalledWith('repo-1')
|
||||
expect(runtime.writeRepoIssueCommand).toHaveBeenCalledWith('repo-1', 'Fix it')
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user