mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
fix(mobile): bind markdown creation to file owner
This commit is contained in:
@@ -182,6 +182,7 @@ import { useTerminalLiveInputModePreference } from '../../../../src/session/use-
|
||||
import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus'
|
||||
import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions'
|
||||
import { resolveMobileFileTabDoc } from '../../../../src/files/mobile-file-tab-doc'
|
||||
import { captureMobileFileMutationOwnership } from '../../../../src/files/mobile-file-mutation-ownership'
|
||||
import { openMobileTerminalFileTap } from '../../../../src/session/mobile-terminal-file-tap-open'
|
||||
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
|
||||
import {
|
||||
@@ -3901,11 +3902,12 @@ export default function SessionScreen() {
|
||||
|
||||
try {
|
||||
const worktree = `id:${worktreeId}`
|
||||
const mutationOwnership = await captureMobileFileMutationOwnership(client, worktree)
|
||||
for (let attempt = 1; attempt <= 100; attempt += 1) {
|
||||
const relativePath = attempt === 1 ? 'untitled.md' : `untitled-${attempt}.md`
|
||||
const createResponse = await client.sendRequest(
|
||||
'files.createFile',
|
||||
{ worktree, relativePath },
|
||||
{ worktree, relativePath, ...mutationOwnership },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
if (!createResponse.ok) {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SshConnectionState } from '../../../src/shared/ssh-types'
|
||||
import {
|
||||
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
|
||||
FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE
|
||||
} from '../../../src/shared/protocol-version'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import {
|
||||
buildMobileFileMutationOwnership,
|
||||
captureMobileFileMutationOwnership
|
||||
} from './mobile-file-mutation-ownership'
|
||||
|
||||
function success(result: unknown): RpcResponse {
|
||||
return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
|
||||
function clientWithResponses(responses: RpcResponse[]): {
|
||||
client: Pick<RpcClient, 'sendRequest'>
|
||||
sendRequest: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const sendRequest = vi.fn(async () => {
|
||||
const response = responses.shift()
|
||||
if (!response) {
|
||||
throw new Error('Unexpected RPC request')
|
||||
}
|
||||
return response
|
||||
})
|
||||
return { client: { sendRequest }, sendRequest }
|
||||
}
|
||||
|
||||
function sshState(targetId: string, connectionGeneration: number | undefined): SshConnectionState {
|
||||
return {
|
||||
targetId,
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile file mutation ownership', () => {
|
||||
it.each([undefined, 'local', 'runtime:environment-1'])(
|
||||
'binds %s worktrees to the runtime-local file host',
|
||||
(hostId) => {
|
||||
expect(buildMobileFileMutationOwnership(hostId)).toEqual({
|
||||
expectedExecutionHostId: 'local'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('binds SSH worktrees to the target and live connection generation', () => {
|
||||
expect(
|
||||
buildMobileFileMutationOwnership('ssh:target%20one', sshState('target one', 17))
|
||||
).toEqual({
|
||||
expectedExecutionHostId: 'ssh:target%20one',
|
||||
expectedSshTargetId: 'target one',
|
||||
expectedSshConnectionGeneration: 17
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a malformed owner', 'not-an-execution-host', null],
|
||||
['a missing SSH state', 'ssh:target-1', null],
|
||||
['a mismatched SSH target', 'ssh:target-1', sshState('target-2', 4)],
|
||||
['a missing SSH generation', 'ssh:target-1', sshState('target-1', undefined)]
|
||||
])('rejects %s', (_name, hostId, state) => {
|
||||
expect(() => buildMobileFileMutationOwnership(hostId, state)).toThrow(
|
||||
"Couldn't verify the SSH connection"
|
||||
)
|
||||
})
|
||||
|
||||
it('captures local ownership only after verifying the runtime capability', async () => {
|
||||
const { client, sendRequest } = clientWithResponses([
|
||||
success({ capabilities: [FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY] }),
|
||||
success({ worktree: { hostId: 'local' } })
|
||||
])
|
||||
|
||||
await expect(captureMobileFileMutationOwnership(client, 'id:worktree-1')).resolves.toEqual({
|
||||
expectedExecutionHostId: 'local'
|
||||
})
|
||||
expect(sendRequest.mock.calls).toEqual([
|
||||
['status.get', undefined, { timeoutMs: 15_000 }],
|
||||
['worktree.show', { worktree: 'id:worktree-1' }, { timeoutMs: 15_000 }]
|
||||
])
|
||||
})
|
||||
|
||||
it('captures SSH generation from the HUB before building mutation params', async () => {
|
||||
const state = sshState('target-1', 9)
|
||||
const { client, sendRequest } = clientWithResponses([
|
||||
success({ capabilities: [FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY] }),
|
||||
success({ worktree: { hostId: 'ssh:target-1' } }),
|
||||
success({ state })
|
||||
])
|
||||
|
||||
await expect(captureMobileFileMutationOwnership(client, 'id:worktree-1')).resolves.toEqual({
|
||||
expectedExecutionHostId: 'ssh:target-1',
|
||||
expectedSshTargetId: 'target-1',
|
||||
expectedSshConnectionGeneration: 9
|
||||
})
|
||||
expect(sendRequest.mock.calls[2]).toEqual([
|
||||
'ssh.getState',
|
||||
{ targetId: 'target-1' },
|
||||
{ timeoutMs: 15_000 }
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses older runtimes before reading or mutating workspace files', async () => {
|
||||
const { client, sendRequest } = clientWithResponses([success({ capabilities: [] })])
|
||||
|
||||
await expect(captureMobileFileMutationOwnership(client, 'id:worktree-1')).rejects.toThrow(
|
||||
FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE
|
||||
)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { parseExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { assertFileMutationOwnershipCapability } from '../../../src/shared/file-mutation-ownership'
|
||||
import type { RuntimeStatus } from '../../../src/shared/runtime-types'
|
||||
import type { SshConnectionState, SshMutationExpectation } from '../../../src/shared/ssh-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcFailure, RpcSuccess } from '../transport/types'
|
||||
|
||||
const FILE_MUTATION_TIMEOUT_MS = 15_000
|
||||
const SSH_OWNER_CHANGED_MESSAGE =
|
||||
"Couldn't verify the SSH connection. Reconnect the host and try again."
|
||||
|
||||
export type MobileFileMutationOwnership = SshMutationExpectation & {
|
||||
expectedExecutionHostId: 'local' | `ssh:${string}`
|
||||
}
|
||||
|
||||
export function buildMobileFileMutationOwnership(
|
||||
worktreeHostId: string | null | undefined,
|
||||
sshState: SshConnectionState | null = null
|
||||
): MobileFileMutationOwnership {
|
||||
const host = parseExecutionHostId(worktreeHostId)
|
||||
if (worktreeHostId !== undefined && !host) {
|
||||
throw new Error(SSH_OWNER_CHANGED_MESSAGE)
|
||||
}
|
||||
if (!host || host.kind === 'local' || host.kind === 'runtime') {
|
||||
return { expectedExecutionHostId: 'local' }
|
||||
}
|
||||
if (sshState?.targetId !== host.targetId || sshState.connectionGeneration === undefined) {
|
||||
throw new Error(SSH_OWNER_CHANGED_MESSAGE)
|
||||
}
|
||||
return {
|
||||
expectedExecutionHostId: host.id,
|
||||
expectedSshTargetId: host.targetId,
|
||||
expectedSshConnectionGeneration: sshState.connectionGeneration
|
||||
}
|
||||
}
|
||||
|
||||
export async function captureMobileFileMutationOwnership(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
worktree: string
|
||||
): Promise<MobileFileMutationOwnership> {
|
||||
const status = await requestResult<Pick<RuntimeStatus, 'capabilities'>>(
|
||||
client,
|
||||
'status.get',
|
||||
undefined
|
||||
)
|
||||
assertFileMutationOwnershipCapability(status)
|
||||
|
||||
const result = await requestResult<{ worktree?: { hostId?: string | null } }>(
|
||||
client,
|
||||
'worktree.show',
|
||||
{ worktree }
|
||||
)
|
||||
if (!result.worktree) {
|
||||
throw new Error(SSH_OWNER_CHANGED_MESSAGE)
|
||||
}
|
||||
|
||||
const host = parseExecutionHostId(result.worktree.hostId)
|
||||
const sshState =
|
||||
host?.kind === 'ssh'
|
||||
? (
|
||||
await requestResult<{ state: SshConnectionState | null }>(client, 'ssh.getState', {
|
||||
targetId: host.targetId
|
||||
})
|
||||
).state
|
||||
: null
|
||||
return buildMobileFileMutationOwnership(result.worktree.hostId, sshState)
|
||||
}
|
||||
|
||||
async function requestResult<TResult>(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
method: string,
|
||||
params: unknown
|
||||
): Promise<TResult> {
|
||||
const response = await client.sendRequest(method, params, {
|
||||
timeoutMs: FILE_MUTATION_TIMEOUT_MS
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error((response as RpcFailure).error.message)
|
||||
}
|
||||
return (response as RpcSuccess).result as TResult
|
||||
}
|
||||
Reference in New Issue
Block a user