fix(mobile): bind markdown creation to file owner

This commit is contained in:
Jinjing
2026-07-22 20:12:03 -07:00
parent ffe54a59a3
commit c799b3169e
3 changed files with 200 additions and 1 deletions
@@ -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
}