diff --git a/mobile/src/components/codex-reset-credit-capability-operation.ts b/mobile/src/components/codex-reset-credit-capability-operation.ts new file mode 100644 index 00000000000..e7436b13e46 --- /dev/null +++ b/mobile/src/components/codex-reset-credit-capability-operation.ts @@ -0,0 +1,33 @@ +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import type { RpcCompatibleReader } from '../transport/rpc-operation-contract' +import { rpcReadUnchecked } from '../transport/rpc-reader-payload' + +// Reads the capability list off a status the object policy already admitted, so a non-object +// result reads as no capabilities rather than throwing — which is what the probe's `catch` did. +const capabilityListReader: RpcCompatibleReader< + Record, + 'capabilities', + unknown +> = (raw) => rpcReadUnchecked('capabilities', raw.capabilities) + +/** + * status.get read for the Codex reset-credit probe, the fourth policy on this method. + * + * `object-result-or-null` is the only one that matches: the probe treats a refusal, a null result + * and a non-object result identically as "unsupported", where the tasks and files families throw + * or skip. It is the policy `rpcObjectResultOrNull` already spelled at this call site. + */ +export const codexResetCreditCapabilityRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'status.codex-reset-credit-capability', + method: 'status.get', + acceptance: 'object-result-or-null', + barrier: 'after-caller-barrier', + read: capabilityListReader + }) +) + +/** What the probe sends with, named from an operation so no module names the raw port. */ +export type MobileCodexResetCapabilityRpcSender = Parameters< + typeof codexResetCreditCapabilityRead.request +>[0] diff --git a/mobile/src/components/codex-reset-credit-capability.ts b/mobile/src/components/codex-reset-credit-capability.ts index 8e88f74f2f9..c1f7f9790e3 100644 --- a/mobile/src/components/codex-reset-credit-capability.ts +++ b/mobile/src/components/codex-reset-credit-capability.ts @@ -2,18 +2,22 @@ import { useEffect, useState } from 'react' import { CODEX_RESET_CREDIT_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import type { RpcClient } from '../transport/rpc-client' import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe' -import { rpcObjectResultOrNull } from '../transport/rpc-acceptance-policies' +import { + codexResetCreditCapabilityRead, + type MobileCodexResetCapabilityRpcSender +} from './codex-reset-credit-capability-operation' // Why: source the capability string from the shared contract so a host bump can never // silently drift from the mobile probe. export const MOBILE_CODEX_RESET_CREDIT_CAPABILITY = CODEX_RESET_CREDIT_RUNTIME_CAPABILITY export async function readCodexResetCreditCapability( - client: Pick + client: MobileCodexResetCapabilityRpcSender ): Promise { try { - const response = await client.sendRequest('status.get') - const capabilities = rpcObjectResultOrNull(response)?.capabilities + const capabilities = codexResetCreditCapabilityRead.interpret( + await codexResetCreditCapabilityRead.request(client) + ) return ( Array.isArray(capabilities) && capabilities.includes(MOBILE_CODEX_RESET_CREDIT_CAPABILITY) ) diff --git a/mobile/src/components/new-workspace-operations.ts b/mobile/src/components/new-workspace-operations.ts new file mode 100644 index 00000000000..2840fe8bcba --- /dev/null +++ b/mobile/src/components/new-workspace-operations.ts @@ -0,0 +1,43 @@ +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import type { RpcCompatibleReader } from '../transport/rpc-operation-contract' +import { rpcReadUnchecked, rpcUncheckedPayloadReader } from '../transport/rpc-reader-payload' + +// The New Workspace drawer's own reads. Its SSH connect, SSH state and agent detection are the +// workspace-create operations in ../tasks/mobile-workspace-source-operations.ts, asked with the +// same acceptance by the same flow, so the drawer sends those rather than restating them. + +/** + * repo.hooks read for the drawer, the second of two policies on this method. + * + * The tasks create path (`repo.setup-hooks`) throws the host's message because it cannot decide + * whether to run setup without an answer. The drawer only decorates a form: a refusal leaves the + * advanced section on its defaults and the message is never shown, so refusal is a skip here. + */ +export const newWorkspaceSetupHooksRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'repo.drawer-setup-hooks-or-skip', + method: 'repo.hooks', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('repo-hooks') + }) +) + +// Reads `ui` the way the drawer always has: through optional chaining, so a null or absent result +// is untrusted-but-not-fatal rather than the property-read throw the Tasks screen's reader keeps. +const optionalUiMemberReader: RpcCompatibleReader = (raw) => + rpcReadUnchecked('optional-ui-member', raw == null ? undefined : Object(raw).ui) + +/** Persisted UI state, read for the trusted-hooks record only. A refused read trusts nothing. */ +export const newWorkspaceUiStateRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'ui.new-workspace-trust-or-skip', + method: 'ui.get', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: optionalUiMemberReader + }) +) + +/** What a drawer read sends with, named from an operation so no module names the raw port. */ +export type MobileNewWorkspaceRpcSender = Parameters[0] diff --git a/mobile/src/components/use-new-workspace-execution-target.ts b/mobile/src/components/use-new-workspace-execution-target.ts index 27302d9ecb2..1a3c7798931 100644 --- a/mobile/src/components/use-new-workspace-execution-target.ts +++ b/mobile/src/components/use-new-workspace-execution-target.ts @@ -1,7 +1,12 @@ import { useEffect, useState } from 'react' import type { SshConnectionState } from '../../../src/shared/ssh-types' import type { RpcClient } from '../transport/rpc-client' -import type { RpcSuccess } from '../transport/types' +import { + localAgentDetectionRead, + remoteAgentDetectionRead, + sshRepoConnectRun, + sshRepoStateRead +} from '../tasks/mobile-workspace-source-operations' import { deriveWorkspaceSshGate, type WorkspaceSshGate } from '../tasks/workspace-ssh-gate' type DetectedAgentIdsState = { @@ -48,17 +53,15 @@ export function useNewWorkspaceExecutionTarget(args: { return } let stale = false - void client - .sendRequest('ssh.getState', { targetId: connectionId }) - .then((response) => { + void sshRepoStateRead + .request(client, { targetId: connectionId }) + .then((reply) => { if (stale) { return } - if (!response.ok) { - throw new Error(response.error.message) - } - const state = (response as RpcSuccess).result as { state?: SshConnectionState | null } - setSshState(state.state ?? fallbackSshState(connectionId, 'disconnected', null)) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const state = sshRepoStateRead.interpret(reply) as SshConnectionState | null | undefined + setSshState(state ?? fallbackSshState(connectionId, 'disconnected', null)) }) .catch((error) => { if (!stale) { @@ -83,13 +86,16 @@ export function useNewWorkspaceExecutionTarget(args: { let stale = false void (async () => { try { - const response = connectionId - ? await client.sendRequest('preflight.detectRemoteAgents', { connectionId }) - : await client.sendRequest('preflight.detectAgents') + const detected = connectionId + ? remoteAgentDetectionRead.interpret( + await remoteAgentDetectionRead.request(client, { connectionId }) + ) + : localAgentDetectionRead.interpret(await localAgentDetectionRead.request(client)) if (!stale) { setDetectedAgentIdsState({ connectionId, - ids: response.ok ? new Set((response as RpcSuccess).result as string[]) : new Set() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + ids: detected.accepted ? new Set(detected.value as string[]) : new Set() }) } } catch { @@ -110,16 +116,14 @@ export function useNewWorkspaceExecutionTarget(args: { setConnectingTargetId(connectionId) setSshState(fallbackSshState(connectionId, 'connecting', null)) try { - const response = await client.sendRequest( - 'ssh.connect', + const reply = await sshRepoConnectRun.request( + client, { targetId: connectionId }, { timeoutMs: 120_000 } ) - if (!response.ok) { - throw new Error(response.error.message) - } - const result = (response as RpcSuccess).result as { state?: SshConnectionState | null } - setSshState(result.state ?? fallbackSshState(connectionId, 'connected', null)) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const state = sshRepoConnectRun.interpret(reply) as SshConnectionState | null | undefined + setSshState(state ?? fallbackSshState(connectionId, 'connected', null)) } catch (error) { setSshState( fallbackSshState( diff --git a/mobile/src/components/use-new-workspace-runtime-context.ts b/mobile/src/components/use-new-workspace-runtime-context.ts index b7d3b142675..15173052bef 100644 --- a/mobile/src/components/use-new-workspace-runtime-context.ts +++ b/mobile/src/components/use-new-workspace-runtime-context.ts @@ -2,18 +2,26 @@ import { optionalSettingsRead } from '../transport/settings-read-operations' import { useEffect, useState } from 'react' import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types' import type { RpcClient } from '../transport/rpc-client' -import type { RpcResponse, RpcSuccess } from '../transport/types' +import type { RpcResponse } from '../transport/types' +import { taskLinearStatusRead, taskPreflightRead } from '../tasks/mobile-task-runtime-operations' import { filterAvailableTaskProviders, normalizeVisibleTaskProviders, type TaskProvider } from '../tasks/mobile-task-providers' import type { NewWorktreeRuntimeSettings } from './new-worktree-agent-selection' +import { newWorkspaceUiStateRead } from './new-workspace-operations' -type UiGetResult = { ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks } } | null | undefined - -function settledSuccess(entry: PromiseSettledResult): RpcSuccess | null { - return entry.status === 'fulfilled' && entry.value.ok ? (entry.value as RpcSuccess) : null +/** A settled probe's accepted payload, or undefined when it never landed or was refused. */ +function settledValue( + entry: PromiseSettledResult, + interpret: (reply: RpcResponse) => { accepted: false } | { accepted: true; value: unknown } +): unknown { + if (entry.status !== 'fulfilled') { + return undefined + } + const verdict = interpret(entry.value) + return verdict.accepted ? verdict.value : undefined } export function useNewWorkspaceRuntimeContext( @@ -38,12 +46,12 @@ export function useNewWorkspaceRuntimeContext( let stale = false void (async () => { const probes = Promise.allSettled([ - client.sendRequest('preflight.check'), - client.sendRequest('linear.status') + taskPreflightRead.request(client), + taskLinearStatusRead.request(client) ]) const [settingsRes, uiRes] = await Promise.allSettled([ optionalSettingsRead.request(client), - client.sendRequest('ui.get') + newWorkspaceUiStateRead.request(client) ]) if (stale) { return @@ -60,11 +68,13 @@ export function useNewWorkspaceRuntimeContext( if (settingsValue) { setRuntimeSettings(settingsValue) } - const uiResult = settledSuccess(uiRes) - if (uiResult) { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary; a missing result reads as untrusted. - const ui = (uiResult.result as UiGetResult)?.ui - setTrustedOrcaHooks(ui?.trustedOrcaHooks ?? {}) + if (uiRes.status === 'fulfilled') { + const ui = newWorkspaceUiStateRead.interpret(uiRes.value) + if (ui.accepted) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary; a missing result reads as untrusted. + const trust = ui.value as { trustedOrcaHooks?: PersistedTrustedOrcaHooks } | undefined + setTrustedOrcaHooks(trust?.trustedOrcaHooks ?? {}) + } } const [preflightRes, linearRes] = await probes @@ -72,11 +82,19 @@ export function useNewWorkspaceRuntimeContext( return } const glabInstalled = - (settledSuccess(preflightRes)?.result as { glab?: { installed?: boolean } } | undefined) - ?.glab?.installed === true + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + ( + settledValue(preflightRes, taskPreflightRead.interpret) as + | { glab?: { installed?: boolean } } + | undefined + )?.glab?.installed === true const linearConnected = - (settledSuccess(linearRes)?.result as { connected?: boolean } | undefined)?.connected === - true + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + ( + settledValue(linearRes, taskLinearStatusRead.interpret) as + | { connected?: boolean } + | undefined + )?.connected === true const visibleProviders = normalizeVisibleTaskProviders(settingsValue?.visibleTaskProviders) setAvailableProviders( filterAvailableTaskProviders(visibleProviders, { diff --git a/mobile/src/components/use-new-workspace-setup-script.ts b/mobile/src/components/use-new-workspace-setup-script.ts index e564b8365e3..2df9067c0e8 100644 --- a/mobile/src/components/use-new-workspace-setup-script.ts +++ b/mobile/src/components/use-new-workspace-setup-script.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import type { RpcClient } from '../transport/rpc-client' -import type { RpcSuccess } from '../transport/types' import { normalizeSetupHookTrust } from '../tasks/setup-hook-trust' +import { newWorkspaceSetupHooksRead } from './new-workspace-operations' import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params' import type { MobileWorkspaceRepo, @@ -39,13 +39,15 @@ export function useNewWorkspaceSetupScript(args: { return } let stale = false - void client - .sendRequest('repo.hooks', { repo: `id:${selectedRepo.id}` }) - .then((response) => { - if (stale || !response.ok) { + void newWorkspaceSetupHooksRead + .request(client, { repo: `id:${selectedRepo.id}` }) + .then((reply) => { + const hooks = newWorkspaceSetupHooksRead.interpret(reply) + if (stale || !hooks.accepted) { return } - const result = (response as RpcSuccess).result as RepoHooksResponse + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = hooks.value as RepoHooksResponse const command = result.hooks?.scripts?.setup?.trim() || null const runPolicy = result.setupRunPolicy ?? 'run-by-default' setDetails({ diff --git a/mobile/src/files/mobile-file-mutation-ownership.ts b/mobile/src/files/mobile-file-mutation-ownership.ts index e5a1cbbeb65..978cb0796d8 100644 --- a/mobile/src/files/mobile-file-mutation-ownership.ts +++ b/mobile/src/files/mobile-file-mutation-ownership.ts @@ -2,8 +2,12 @@ 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' +import { + fileOwnershipRuntimeStatusRead, + fileOwnershipSshStateRead, + fileOwnershipWorktreeRead, + type MobileFileOwnershipRpcSender +} from './mobile-file-ownership-operations' const FILE_MUTATION_TIMEOUT_MS = 15_000 const SSH_OWNER_CHANGED_MESSAGE = @@ -35,47 +39,42 @@ export function buildMobileFileMutationOwnership( } export async function captureMobileFileMutationOwnership( - client: Pick, + client: MobileFileOwnershipRpcSender, worktree: string ): Promise { - const status = await requestResult>( - client, - 'status.get', - undefined - ) + const statusReply = await fileOwnershipRuntimeStatusRead.request(client, undefined, { + timeoutMs: FILE_MUTATION_TIMEOUT_MS + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const status = fileOwnershipRuntimeStatusRead.interpret(statusReply) as Pick< + RuntimeStatus, + 'capabilities' + > assertFileMutationOwnershipCapability(status) - const result = await requestResult<{ worktree?: { hostId?: string | null } }>( + const worktreeReply = await fileOwnershipWorktreeRead.request( client, - 'worktree.show', - { worktree } + { worktree }, + { timeoutMs: FILE_MUTATION_TIMEOUT_MS } ) - if (!result.worktree) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const summary = fileOwnershipWorktreeRead.interpret(worktreeReply) as + | { hostId?: string | null } + | undefined + if (!summary) { 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( - client: Pick, - method: string, - params: unknown -): Promise { - const response = await client.sendRequest(method, params, { - timeoutMs: FILE_MUTATION_TIMEOUT_MS - }) - if (!response.ok) { - throw new Error((response as RpcFailure).error.message) + const host = parseExecutionHostId(summary.hostId) + let sshState: SshConnectionState | null = null + if (host?.kind === 'ssh') { + const stateReply = await fileOwnershipSshStateRead.request( + client, + { targetId: host.targetId }, + { timeoutMs: FILE_MUTATION_TIMEOUT_MS } + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + sshState = fileOwnershipSshStateRead.interpret(stateReply) as SshConnectionState | null } - return (response as RpcSuccess).result as TResult + return buildMobileFileMutationOwnership(summary.hostId, sshState) } diff --git a/mobile/src/files/mobile-file-ownership-operations.ts b/mobile/src/files/mobile-file-ownership-operations.ts new file mode 100644 index 00000000000..ef7246bee83 --- /dev/null +++ b/mobile/src/files/mobile-file-ownership-operations.ts @@ -0,0 +1,52 @@ +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { + rpcUncheckedMemberReader, + rpcUncheckedPayloadReader +} from '../transport/rpc-reader-payload' + +// The three reads that pin which execution host owns a workspace before a file mutation is sent. +// All three share one acceptance because the capture is all-or-nothing: any refusal aborts the +// mutation with the host's own message rather than letting a write land on the wrong host. + +/** + * status.get read for the file-mutation capability gate, a third policy on this method alongside + * `status.task-runtime` and `status.create-capabilities-or-skip` in the tasks domain. It matches + * the first exactly; it stays a family of its own because a refused status here blocks a write, + * and merging the two would tie a files-domain failure to a Tasks-screen decision. + */ +export const fileOwnershipRuntimeStatusRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'status.file-mutation-ownership', + method: 'status.get', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('runtime-status') + }) +) + +/** The workspace row the mutation targets. A null result throws where `result.worktree` did. */ +export const fileOwnershipWorktreeRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'worktree.file-mutation-owner', + method: 'worktree.show', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedMemberReader('worktree-summary', 'worktree') + }) +) + +/** The SSH connection generation the mutation is expected to still be running on. */ +export const fileOwnershipSshStateRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'ssh.file-mutation-owner-state', + method: 'ssh.getState', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedMemberReader('ssh-connection-state', 'state') + }) +) + +/** What an ownership capture sends with, named from an operation so no module names the raw port. */ +export type MobileFileOwnershipRpcSender = Parameters< + typeof fileOwnershipRuntimeStatusRead.request +>[0] diff --git a/mobile/src/files/mobile-file-preview-operations.ts b/mobile/src/files/mobile-file-preview-operations.ts new file mode 100644 index 00000000000..ac679e61bce --- /dev/null +++ b/mobile/src/files/mobile-file-preview-operations.ts @@ -0,0 +1,83 @@ +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { rpcUncheckedPayloadReader } from '../transport/rpc-reader-payload' + +/** + * The preview screen's reads and writes. + * + * Every one of them is a skip rather than a throw, because a refused preview is not an error the + * screen raises: it is a result the screen renders. The refusal itself stays at the call site, + * which maps the host's code and message into display copy (`previewError`) and decides whether + * the failure is a stale terminal-artifact grant worth refreshing. No acceptance policy exposes a + * refusal code, and only these two consumers want one. + * + * The payloads are unchecked here because the shape depends on the path, not on the method: + * `normalizeMobileFilePreviewResult` picks the image or text projection from the file name, which + * a module-level reader cannot see. + */ + +/** files.read for a preview. The tab doc asks the same method under a throwing policy. */ +export const filePreviewTextRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'files.preview-text-or-skip', + method: 'files.read', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('file-preview') + }) +) + +/** files.readPreview for a preview; the tab doc's image read is the other policy on it. */ +export const filePreviewImageRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'files.preview-image-or-skip', + method: 'files.readPreview', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('file-preview') + }) +) + +export const terminalArtifactTextRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'files.terminal-artifact-text-or-skip', + method: 'files.readTerminalArtifact', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('file-preview') + }) +) + +export const terminalArtifactImageRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'files.terminal-artifact-image-or-skip', + method: 'files.readTerminalArtifactPreview', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('file-preview') + }) +) + +/** The save. Its reply body is never read: a success is the whole answer. */ +export const terminalArtifactWrite = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'files.write-terminal-artifact-or-skip', + method: 'files.writeTerminalArtifact', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('artifact-written') + }) +) + +/** Re-resolves a terminal path to mint a fresh grant. A refusal leaves the stale grant in place. */ +export const terminalArtifactPathResolve = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'files.resolve-terminal-path-or-skip', + method: 'files.resolveTerminalPath', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('terminal-path-resolution') + }) +) + +/** What a preview send takes, named from an operation so no module names the raw port. */ +export type MobileFilePreviewRpcSender = Parameters[0] diff --git a/mobile/src/files/mobile-file-preview-request.ts b/mobile/src/files/mobile-file-preview-request.ts index 63b61326654..933bdaa228a 100644 --- a/mobile/src/files/mobile-file-preview-request.ts +++ b/mobile/src/files/mobile-file-preview-request.ts @@ -1,9 +1,17 @@ import { classifyMobileArtifact } from '../session/mobile-artifact-kind' import type { RpcFailure, RpcResponse } from '../transport/types' -import type { RpcClient } from '../transport/rpc-client' import { - normalizeMobileFilePreviewResponse, + filePreviewImageRead, + filePreviewTextRead, + terminalArtifactImageRead, + terminalArtifactTextRead, + terminalArtifactWrite, + type MobileFilePreviewRpcSender +} from './mobile-file-preview-operations' +import { + normalizeMobileFilePreviewResult, previewError, + previewErrorFromRefusal, type MobileFilePreviewResult } from './mobile-file-preview-response' import { @@ -17,6 +25,7 @@ export { normalizeMobileFilePreviewResponse, previewError } from './mobile-file-preview-response' + export type { MobileFilePreviewResult, MobileFilePreviewTextKind @@ -35,17 +44,26 @@ export type MobileFilePreviewSource = } | MobileTerminalArtifactPreviewSource -export type MobileFilePreviewRequest = { - method: MobileFilePreviewReadMethod | MobileTerminalArtifactPreviewReadMethod - params: { - worktree: string - relativePath?: string - absolutePath?: string - grantId?: string - } -} +/** Which read the path selects, and the params that read takes. */ +export type MobileFilePreviewRequest = + | { + method: MobileFilePreviewReadMethod + params: { worktree: string; relativePath: string } + } + | { + method: MobileTerminalArtifactPreviewReadMethod + params: { worktree: string; absolutePath: string; grantId: string } + } + +/** + * A settled preview send. The refusal is carried rather than interpreted because the preview + * screen's fallback copy is the host's `code`, which no acceptance policy exposes, and the grant + * refresh reads the same code to decide whether a stale grant is worth re-minting. + */ +type MobileFilePreviewOutcome = + | { accepted: true; payload: unknown } + | { accepted: false; refusal: RpcFailure['error'] } -type MobileFilePreviewClient = Pick type TerminalArtifactSource = MobileTerminalArtifactPreviewSource type TerminalArtifactSaveOptions = TerminalArtifactRetryOptions & { baseContent?: string @@ -83,35 +101,80 @@ export function createMobileFilePreviewRequest( } } +async function sendMobileFilePreviewRead( + client: MobileFilePreviewRpcSender, + request: MobileFilePreviewRequest +): Promise { + switch (request.method) { + case 'files.read': + return settlePreviewSend( + await filePreviewTextRead.request(client, request.params), + filePreviewTextRead.interpret + ) + case 'files.readPreview': + return settlePreviewSend( + await filePreviewImageRead.request(client, request.params), + filePreviewImageRead.interpret + ) + case 'files.readTerminalArtifact': + return settlePreviewSend( + await terminalArtifactTextRead.request(client, request.params), + terminalArtifactTextRead.interpret + ) + case 'files.readTerminalArtifactPreview': + return settlePreviewSend( + await terminalArtifactImageRead.request(client, request.params), + terminalArtifactImageRead.interpret + ) + } +} + +function settlePreviewSend( + reply: RpcResponse, + interpret: (reply: RpcResponse) => { accepted: false } | { accepted: true; value: unknown } +): MobileFilePreviewOutcome { + const verdict = interpret(reply) + return verdict.accepted + ? { accepted: true, payload: verdict.value } + : // The policy skipped it, so the envelope is the refusal it skipped. + { accepted: false, refusal: (reply as RpcFailure).error } +} + export async function loadMobileFilePreview( - client: MobileFilePreviewClient, + client: MobileFilePreviewRpcSender, worktreeIdOrSource: string | MobileFilePreviewSource, relativePath?: string, options: TerminalArtifactRetryOptions = {} ): Promise { let source = worktreeIdOrSource - let request = createMobileFilePreviewRequest(source, relativePath) - let response = await client.sendRequest(request.method, request.params) - if (!response.ok && typeof source !== 'string' && source.source === 'terminalArtifact') { + let read = await sendMobileFilePreviewRead( + client, + createMobileFilePreviewRequest(source, relativePath) + ) + if (!read.accepted && typeof source !== 'string' && source.source === 'terminalArtifact') { const refreshed = await refreshTerminalArtifactSourceAfterGrantFailure( client, source, - response, + read.refusal, options ) if (refreshed) { source = refreshed options.onTerminalArtifactSourceRefreshed?.(refreshed) - request = createMobileFilePreviewRequest(source, relativePath) - response = await client.sendRequest(request.method, request.params) + read = await sendMobileFilePreviewRead( + client, + createMobileFilePreviewRequest(source, relativePath) + ) } } const previewPath = typeof source === 'string' ? relativePath! : previewPathForSource(source) - return normalizeMobileFilePreviewResponse(previewPath, response) + return read.accepted + ? normalizeMobileFilePreviewResult(previewPath, read.payload) + : previewErrorFromRefusal(read.refusal) } export async function saveMobileTerminalArtifactPreview( - client: MobileFilePreviewClient, + client: MobileFilePreviewRpcSender, source: TerminalArtifactSource, content: string, options: TerminalArtifactSaveOptions = {} @@ -135,26 +198,22 @@ export async function saveMobileTerminalArtifactPreview( options.onTerminalArtifactSourceRefreshed?.(verified.source) } } - let response = await writeTerminalArtifactPreview(client, writeSource, content) - if (response.ok) { + let write = await writeTerminalArtifactPreview(client, writeSource, content) + if (write.accepted) { return { status: 'saved' } } if (typeof options.baseContent !== 'string') { - return previewError( - (response as RpcFailure).error.message || (response as RpcFailure).error.code - ) + return previewErrorFromRefusal(write.refusal) } const refreshed = await refreshTerminalArtifactSourceAfterGrantFailure( client, writeSource, - response, + write.refusal, options ) if (!refreshed) { - return previewError( - (response as RpcFailure).error.message || (response as RpcFailure).error.code - ) + return previewErrorFromRefusal(write.refusal) } const verified = await verifyTerminalArtifactBaseContent(client, refreshed, options.baseContent, { refreshGrant: false @@ -164,17 +223,15 @@ export async function saveMobileTerminalArtifactPreview( } options.onTerminalArtifactSourceRefreshed?.(refreshed) writeSource = verified.source - response = await writeTerminalArtifactPreview(client, writeSource, content) - if (!response.ok) { - return previewError( - (response as RpcFailure).error.message || (response as RpcFailure).error.code - ) + write = await writeTerminalArtifactPreview(client, writeSource, content) + if (!write.accepted) { + return previewErrorFromRefusal(write.refusal) } return { status: 'saved' } } async function verifyTerminalArtifactBaseContent( - client: MobileFilePreviewClient, + client: MobileFilePreviewRpcSender, source: TerminalArtifactSource, baseContent: string, options: TerminalArtifactRetryOptions @@ -183,38 +240,26 @@ async function verifyTerminalArtifactBaseContent( | { status: 'error'; error: MobileFilePreviewResult } > { let readSource = source - let request = createMobileFilePreviewRequest(readSource) - let response = await client.sendRequest(request.method, request.params) + let read = await sendMobileFilePreviewRead(client, createMobileFilePreviewRequest(readSource)) let refreshed = false - if (!response.ok) { + if (!read.accepted) { const nextSource = await refreshTerminalArtifactSourceAfterGrantFailure( client, readSource, - response, + read.refusal, options ) if (!nextSource) { - return { - status: 'error', - error: previewError( - (response as RpcFailure).error.message || (response as RpcFailure).error.code - ) - } + return { status: 'error', error: previewErrorFromRefusal(read.refusal) } } readSource = nextSource refreshed = true - request = createMobileFilePreviewRequest(readSource) - response = await client.sendRequest(request.method, request.params) + read = await sendMobileFilePreviewRead(client, createMobileFilePreviewRequest(readSource)) } - if (!response.ok) { - return { - status: 'error', - error: previewError( - (response as RpcFailure).error.message || (response as RpcFailure).error.code - ) - } + if (!read.accepted) { + return { status: 'error', error: previewErrorFromRefusal(read.refusal) } } - const latest = normalizeMobileFilePreviewResponse(readSource.absolutePath, response) + const latest = normalizeMobileFilePreviewResult(readSource.absolutePath, read.payload) if (latest.status === 'error' || latest.status === 'waiting') { return { status: 'error', error: latest } } @@ -231,17 +276,20 @@ async function verifyTerminalArtifactBaseContent( return { status: 'ok', source: readSource, refreshed } } -function writeTerminalArtifactPreview( - client: MobileFilePreviewClient, +async function writeTerminalArtifactPreview( + client: MobileFilePreviewRpcSender, source: TerminalArtifactSource, content: string -): Promise { - return client.sendRequest('files.writeTerminalArtifact', { - worktree: `id:${source.worktreeId}`, - absolutePath: source.absolutePath, - grantId: source.grantId, - content - }) +): Promise { + return settlePreviewSend( + await terminalArtifactWrite.request(client, { + worktree: `id:${source.worktreeId}`, + absolutePath: source.absolutePath, + grantId: source.grantId, + content + }), + terminalArtifactWrite.interpret + ) } function terminalArtifactPreviewMatchesBase( diff --git a/mobile/src/files/mobile-file-preview-response.ts b/mobile/src/files/mobile-file-preview-response.ts index 0b7b1b23be8..1f187884e9c 100644 --- a/mobile/src/files/mobile-file-preview-response.ts +++ b/mobile/src/files/mobile-file-preview-response.ts @@ -42,18 +42,27 @@ export function normalizeMobileFilePreviewResponse( response: RpcResponse ): MobileFilePreviewResult { if (!response.ok) { - return previewError( - (response as RpcFailure).error.message || (response as RpcFailure).error.code - ) + return previewErrorFromRefusal((response as RpcFailure).error) } + return normalizeMobileFilePreviewResult(relativePath, (response as RpcSuccess).result) +} - const result = (response as RpcSuccess).result +/** The accepted arm, for a call site whose acceptance policy already admitted the payload. */ +export function normalizeMobileFilePreviewResult( + relativePath: string, + result: unknown +): MobileFilePreviewResult { if (classifyMobileArtifact(relativePath) === 'image') { return normalizeImagePreviewResult(result) } return normalizeTextPreviewResult(relativePath, result) } +/** The refused arm. The code is the fallback copy, which is why the refusal itself is needed. */ +export function previewErrorFromRefusal(error: RpcFailure['error']): MobileFilePreviewResult { + return previewError(error.message || error.code) +} + export function previewError(message: string): MobileFilePreviewResult { const normalized = message.toLowerCase() if (normalized === 'binary_file' || normalized.includes('binary_file')) { diff --git a/mobile/src/files/mobile-file-tab-doc-operations.ts b/mobile/src/files/mobile-file-tab-doc-operations.ts new file mode 100644 index 00000000000..244d44e5e39 --- /dev/null +++ b/mobile/src/files/mobile-file-tab-doc-operations.ts @@ -0,0 +1,47 @@ +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { rpcUncheckedPayloadReader } from '../transport/rpc-reader-payload' + +/** + * What a session file tab reads to render one document. + * + * All three throw the host's message on refusal, which is the opposite of the preview screen's + * policy on the same two file methods: a tab maps the throw to an error doc and keeps the tab, + * while the preview screen renders the refusal as body copy. Two policies, two families, named + * here and in mobile-file-preview-operations.ts so neither can drift onto the other. + * + * The payloads stay unchecked: the tab picks its projection from the path, and moving a shape + * check into a reader would reject replies the tab renders today. + */ + +export const fileTabDiffRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'git.file-tab-diff', + method: 'git.diff', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('file-tab-diff') + }) +) + +export const fileTabTextRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'files.file-tab-text', + method: 'files.read', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('file-tab-text') + }) +) + +export const fileTabImageRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'files.file-tab-image', + method: 'files.readPreview', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('file-tab-image') + }) +) + +/** What a file tab reads with, named from an operation so no module names the raw port. */ +export type MobileFileTabDocRpcSender = Parameters[0] diff --git a/mobile/src/files/mobile-file-tab-doc.ts b/mobile/src/files/mobile-file-tab-doc.ts index 78977b5fd30..1e9d43abbba 100644 --- a/mobile/src/files/mobile-file-tab-doc.ts +++ b/mobile/src/files/mobile-file-tab-doc.ts @@ -1,11 +1,13 @@ import { buildImageDataUri } from '../../../src/shared/image-data-uri' import { classifyMobileArtifact } from '../session/mobile-artifact-kind' import { buildMobileDiffLines, type MobileDiffLine } from '../session/mobile-diff-lines' -import type { RpcClient } from '../transport/rpc-client' -import type { RpcFailure, RpcSuccess } from '../transport/types' import { mobileDiffImageDataUri, type MobileBinaryDiffResult } from './mobile-diff-image-preview' - -type FileTabDocClient = Pick +import { + fileTabDiffRead, + fileTabImageRead, + fileTabTextRead, + type MobileFileTabDocRpcSender +} from './mobile-file-tab-doc-operations' // The ready doc a session file tab renders. Mirrors the ready arm of the route's // FileDocState; kept in src so the loader stays testable without the route. @@ -24,21 +26,19 @@ export type MobileFileTabDocRequest = { // Throws 'binary_file'/'file_too_large'/the RPC error message; callers map those // to error docs. export async function resolveMobileFileTabDoc( - client: FileTabDocClient, + client: MobileFileTabDocRpcSender, request: MobileFileTabDocRequest ): Promise { const worktree = `id:${request.worktreeId}` const { relativePath } = request if (request.diffSource === 'staged' || request.diffSource === 'unstaged') { - const response = await client.sendRequest('git.diff', { + const reply = await fileTabDiffRead.request(client, { worktree, filePath: relativePath, staged: request.diffSource === 'staged' }) - if (!response.ok) { - throw new Error((response as RpcFailure).error.message) - } - const result = (response as RpcSuccess).result as + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = fileTabDiffRead.interpret(reply) as | { kind: 'text'; originalContent: string; modifiedContent: string } | MobileBinaryDiffResult if (result.kind !== 'text') { @@ -56,11 +56,9 @@ export async function resolveMobileFileTabDoc( const artifactKind = classifyMobileArtifact(relativePath) if (artifactKind === 'image') { - const preview = await client.sendRequest('files.readPreview', { worktree, relativePath }) - if (!preview.ok) { - throw new Error((preview as RpcFailure).error.message) - } - const result = (preview as RpcSuccess).result as { + const preview = await fileTabImageRead.request(client, { worktree, relativePath }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = fileTabImageRead.interpret(preview) as { content: string isImage?: boolean mimeType?: string @@ -72,11 +70,9 @@ export async function resolveMobileFileTabDoc( return { status: 'ready', kind: 'image', dataUri } } - const response = await client.sendRequest('files.read', { worktree, relativePath }) - if (!response.ok) { - throw new Error((response as RpcFailure).error.message) - } - const result = (response as RpcSuccess).result as { + const reply = await fileTabTextRead.request(client, { worktree, relativePath }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = fileTabTextRead.interpret(reply) as { content: string truncated: boolean byteLength: number diff --git a/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts b/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts index cbe5ffdebd8..0e79764437b 100644 --- a/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts +++ b/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts @@ -1,10 +1,11 @@ import type { RuntimeNativeChatFileContext } from '../../../src/shared/runtime-types' -import type { RpcClient } from '../transport/rpc-client' -import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types' +import type { RpcFailure } from '../transport/types' +import { + terminalArtifactPathResolve, + type MobileFilePreviewRpcSender +} from './mobile-file-preview-operations' import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error' -type MobileFilePreviewClient = Pick - export type MobileTerminalArtifactPreviewSource = { source: 'terminalArtifact' worktreeId: string @@ -22,26 +23,28 @@ export type TerminalArtifactRetryOptions = { refreshGrant?: boolean } +/** Takes the refusal rather than the envelope: every caller already routed on its own acceptance. */ export async function refreshTerminalArtifactSourceAfterGrantFailure( - client: MobileFilePreviewClient, + client: MobileFilePreviewRpcSender, source: MobileTerminalArtifactPreviewSource, - response: RpcResponse, + refusal: RpcFailure['error'], options: TerminalArtifactRetryOptions = {} ): Promise { - if (response.ok || !isTerminalArtifactGrantFailure(response, options)) { + if (!isTerminalArtifactGrantFailure(refusal, options)) { return null } - const refreshed = await client.sendRequest('files.resolveTerminalPath', { + const reply = await terminalArtifactPathResolve.request(client, { worktree: `id:${source.worktreeId}`, pathText: source.pathText ?? source.absolutePath, ...(source.cwd ? { cwd: source.cwd } : {}), ...(source.terminalHandle ? { terminal: source.terminalHandle } : {}), ...(source.nativeChatContext ? { nativeChatContext: source.nativeChatContext } : {}) }) - if (!refreshed.ok) { + const resolved = terminalArtifactPathResolve.interpret(reply) + if (!resolved.accepted) { return null } - const result = (refreshed as RpcSuccess).result + const result = resolved.value if (!isTerminalArtifactResolution(result)) { return null } @@ -62,13 +65,13 @@ export async function refreshTerminalArtifactSourceAfterGrantFailure( } function isTerminalArtifactGrantFailure( - response: RpcFailure, + refusal: RpcFailure['error'], options: TerminalArtifactRetryOptions ): boolean { if (options.refreshGrant === false) { return false } - return isTerminalArtifactGrantError(`${response.error.code} ${response.error.message}`) + return isTerminalArtifactGrantError(`${refusal.code} ${refusal.message}`) } function isTerminalArtifactResolution(result: unknown): result is { diff --git a/mobile/src/home/mobile-home-host-operations.ts b/mobile/src/home/mobile-home-host-operations.ts new file mode 100644 index 00000000000..9bd77db3710 --- /dev/null +++ b/mobile/src/home/mobile-home-host-operations.ts @@ -0,0 +1,17 @@ +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { rpcUncheckedPayloadReader } from '../transport/rpc-reader-payload' + +/** + * The Home card's per-host counts. Decorative: a refused summary leaves the card on whatever it + * already showed, so refusal is a skip. Its glab and Linear probes are the task-tooling reads in + * ../tasks/mobile-task-runtime-operations.ts — the same question, asked by a second screen. + */ +export const homeHostStatsRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'stats.home-summary-or-skip', + method: 'stats.summary', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('home-stats-summary') + }) +) diff --git a/mobile/src/home/mobile-home-host-requests.ts b/mobile/src/home/mobile-home-host-requests.ts index cf4c45cf4a8..bf3918815c7 100644 --- a/mobile/src/home/mobile-home-host-requests.ts +++ b/mobile/src/home/mobile-home-host-requests.ts @@ -1,6 +1,7 @@ import { settingsRead } from '../transport/settings-read-operations' import { decodeAccountsSnapshot, type AccountsSnapshot } from '../components/AccountUsage' import type { HomeStatsSummary } from '../stats/home-stats-total' +import { taskLinearStatusRead, taskPreflightRead } from '../tasks/mobile-task-runtime-operations' import { filterAvailableTaskProviders, normalizeVisibleTaskProviders, @@ -8,6 +9,7 @@ import { } from '../tasks/mobile-task-providers' import type { RpcClient } from '../transport/rpc-client' import { sendSingleFlightRequest } from '../transport/request-single-flight' +import { homeHostStatsRead } from './mobile-home-host-operations' type HomeTaskSettings = { visibleTaskProviders?: unknown @@ -39,12 +41,15 @@ export function fetchMobileHomeStats( setStats: HomeStatsSetter, disposed: () => boolean ): void { - sendSingleFlightRequest(client, hostId, 'stats.summary') - .then((response) => { - if (!disposed() && response.ok) { + homeHostStatsRead + .requestSingleFlight(client, hostId) + .then((reply) => { + const summary = homeHostStatsRead.interpret(reply) + if (!disposed() && summary.accepted) { setStats((previous) => ({ ...previous, - [hostId]: response.result as HomeStatsSummary + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + [hostId]: summary.value as HomeStatsSummary })) } }) @@ -75,8 +80,8 @@ export function fetchMobileHomeTaskProviders( ): void { Promise.all([ settingsRead.requestSingleFlight(client, hostId), - sendSingleFlightRequest(client, hostId, 'preflight.check'), - sendSingleFlightRequest(client, hostId, 'linear.status') + taskPreflightRead.requestSingleFlight(client, hostId), + taskLinearStatusRead.requestSingleFlight(client, hostId) ]) .then(([settingsResponse, preflightResponse, linearResponse]) => { if (disposed()) { @@ -87,10 +92,14 @@ export function fetchMobileHomeTaskProviders( ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. ((settingsResult.value ?? {}) as HomeTaskSettings) : {} - const preflight = preflightResponse.ok - ? (preflightResponse.result as HomePreflightStatus) + const preflightResult = taskPreflightRead.interpret(preflightResponse) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const preflight = preflightResult.accepted + ? (preflightResult.value as HomePreflightStatus) : null - const linear = linearResponse.ok ? (linearResponse.result as HomeLinearStatus) : null + const linearResult = taskLinearStatusRead.interpret(linearResponse) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const linear = linearResult.accepted ? (linearResult.value as HomeLinearStatus) : null const providers = filterAvailableTaskProviders( normalizeVisibleTaskProviders(settings.visibleTaskProviders), { diff --git a/mobile/src/host-screen/host-screen-operations.ts b/mobile/src/host-screen/host-screen-operations.ts new file mode 100644 index 00000000000..abe972b03ee --- /dev/null +++ b/mobile/src/host-screen/host-screen-operations.ts @@ -0,0 +1,70 @@ +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { + rpcUncheckedMemberReader, + rpcUncheckedPayloadReader +} from '../transport/rpc-reader-payload' + +// What the host screen reads to label its rows and to mirror the desktop's workspace view store. +// Every read here is decorative: a refusal leaves the screen on what it already has and the next +// refresh retries, so all of them skip rather than throw. + +export const hostRepoCatalogRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'repo.host-catalog-or-skip', + method: 'repo.list', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('repo-catalog') + }) +) + +/** Row labels for a catalog that spans hosts. Absent on a host that predates the method. */ +export const hostSshTargetSummariesRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'ssh.host-target-summaries-or-skip', + method: 'ssh.listTargetSummaries', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('ssh-target-summaries') + }) +) + +export const hostPlatformRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'host.platform-or-skip', + method: 'host.platform', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('host-platform') + }) +) + +/** + * The desktop's shared workspace view settings, a third family on ui.get. + * + * It keeps the Tasks screen's property-read throw on a null result — the screen's own try/catch is + * what that throw has always landed in — where the New Workspace drawer's reader degrades instead. + */ +export const hostViewSettingsRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'ui.host-view-settings-or-skip', + method: 'ui.get', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedMemberReader('ui-view-settings', 'ui') + }) +) + +/** Patching the same store. Best-effort: the local state already moved, and no reply is read. */ +export const hostViewSettingsWrite = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'ui.set-host-view-settings-or-skip', + method: 'ui.set', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('ui-view-settings-written') + }) +) + +/** What a host-screen read sends with, named from an operation so no module names the raw port. */ +export type MobileHostScreenRpcSender = Parameters[0] diff --git a/mobile/src/host-screen/use-host-repo-metadata.ts b/mobile/src/host-screen/use-host-repo-metadata.ts index 64ece7b700e..c62a16fd702 100644 --- a/mobile/src/host-screen/use-host-repo-metadata.ts +++ b/mobile/src/host-screen/use-host-repo-metadata.ts @@ -3,31 +3,45 @@ import { useCallback } from 'react' import { getRepoExecutionHostId } from '../../../src/shared/execution-host' import { setCachedRepos } from '../cache/repo-cache' import type { RpcClient } from '../transport/rpc-client' -import type { ConnectionState, RpcResponse, RpcSuccess } from '../transport/types' +import type { ConnectionState, RpcResponse } from '../transport/types' import type { RepoSummary } from '../worktree/host-worktree-rpc-types' import { repoColor } from '../worktree/repo-color' import { buildHostLabelById, buildRepoHostIdByRepoId } from '../worktree/worktree-host-context-labels' +import { + hostPlatformRead, + hostRepoCatalogRead, + hostSshTargetSummariesRead +} from './host-screen-operations' import type { HostScreenState } from './use-host-screen-state' const REPO_METADATA_REFRESH_MS = 60_000 type SshTargetSummaryRow = { id: string; label: string } -async function requestMetadataResponse( - client: RpcClient, - method: 'repo.list' | 'ssh.listTargetSummaries' | 'host.platform' -): Promise { +async function settledMetadataReply(send: () => Promise): Promise { try { - return await client.sendRequest(method) + return await send() } catch { // Best-effort: hosts that predate a method still list repos; labels degrade to host ids. return null } } +/** An accepted metadata payload, or null for a refusal or a send that never landed. */ +function acceptedMetadata( + reply: RpcResponse | null, + interpret: (reply: RpcResponse) => { accepted: false } | { accepted: true; value: unknown } +): unknown { + if (!reply) { + return null + } + const verdict = interpret(reply) + return verdict.accepted ? verdict.value : null +} + function readSshTargets(result: unknown): SshTargetSummaryRow[] { const targets = (result as { targets?: unknown } | null)?.targets if (!Array.isArray(targets)) { @@ -93,15 +107,18 @@ export function useHostRepoMetadata(args: { try { do { fetchRepoMetadataPendingRef.current.delete(requestClient) - const repoResponse = await requestMetadataResponse(requestClient, 'repo.list') - if ( - clientRef.current !== requestClient || - hostId !== requestHostId || - !repoResponse?.ok - ) { + const repoReply = await settledMetadataReply(() => + hostRepoCatalogRead.request(requestClient) + ) + if (clientRef.current !== requestClient || hostId !== requestHostId) { return } - const repoResult = (repoResponse as RpcSuccess).result as { repos: RepoSummary[] } + const repos = repoReply && hostRepoCatalogRead.interpret(repoReply) + if (!repos || !repos.accepted) { + return + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const repoResult = repos.value as { repos: RepoSummary[] } repoMetadataFetchedAtRef.current = Date.now() setCachedRepos(requestHostId, repoResult.repos) setRepoColorsByName( @@ -127,9 +144,9 @@ export function useHostRepoMetadata(args: { const hostIds = new Set(repoResult.repos.map((repo) => getRepoExecutionHostId(repo))) if (hostIds.size > 1) { const [sshTargets, hostSettings, hostPlatform] = await Promise.all([ - requestMetadataResponse(requestClient, 'ssh.listTargetSummaries'), + settledMetadataReply(() => hostSshTargetSummariesRead.request(requestClient)), optionalSettingsRead.request(requestClient).catch(() => null), - requestMetadataResponse(requestClient, 'host.platform') + settledMetadataReply(() => hostPlatformRead.request(requestClient)) ]) if (clientRef.current !== requestClient || hostId !== requestHostId) { return @@ -139,13 +156,17 @@ export function useHostRepoMetadata(args: { : null setHostLabelById( buildHostLabelById({ - sshTargets: readSshTargets(sshTargets?.ok ? sshTargets.result : null), + sshTargets: readSshTargets( + acceptedMetadata(sshTargets, hostSshTargetSummariesRead.interpret) + ), hostSettingOverrides: readHostSettingOverrides( hostSettingsResult?.accepted ? hostSettingsResult.value : undefined ) }) ) - setHostPlatform(readHostPlatform(hostPlatform?.ok ? hostPlatform.result : null)) + setHostPlatform( + readHostPlatform(acceptedMetadata(hostPlatform, hostPlatformRead.interpret)) + ) } } while (fetchRepoMetadataPendingRef.current.has(requestClient)) } catch { diff --git a/mobile/src/host-screen/use-host-view-settings.ts b/mobile/src/host-screen/use-host-view-settings.ts index 877aa5f3c9f..9fb192b76ab 100644 --- a/mobile/src/host-screen/use-host-view-settings.ts +++ b/mobile/src/host-screen/use-host-view-settings.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo } from 'react' import type { RpcClient } from '../transport/rpc-client' -import type { ConnectionState, RpcSuccess } from '../transport/types' +import type { ConnectionState } from '../transport/types' import { getMobileWorkspaceLineageGroupKey } from '../worktree/mobile-workspace-lineage' import { WORKSPACE_SORT_OPTIONS as SORT_OPTIONS } from '../worktree/workspace-list-picker-options' import { @@ -12,6 +12,7 @@ import { type WorkspaceViewSettings } from '../worktree/workspace-view-settings' import type { Worktree } from '../worktree/workspace-list-sections' +import { hostViewSettingsRead, hostViewSettingsWrite } from './host-screen-operations' import type { HostScreenState } from './use-host-screen-state' export function useHostViewSettings(args: { @@ -79,7 +80,7 @@ export function useHostViewSettings(args: { if (Object.keys(payload).length === 0) { return } - void client.sendRequest('ui.set', payload).catch(() => { + void hostViewSettingsWrite.request(client, payload).catch(() => { // Best-effort: view settings are a convenience preference. }) }, @@ -94,11 +95,16 @@ export function useHostViewSettings(args: { const requestClient = client const requestHostId = hostId try { - const response = await requestClient.sendRequest('ui.get') - if (clientRef.current !== requestClient || hostId !== requestHostId || !response.ok) { + const reply = await hostViewSettingsRead.request(requestClient) + if (clientRef.current !== requestClient || hostId !== requestHostId) { return } - const ui = ((response as RpcSuccess).result as { ui?: WorkspaceViewSettings }).ui + const settings = hostViewSettingsRead.interpret(reply) + if (!settings.accepted) { + return + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const ui = settings.value as WorkspaceViewSettings | undefined if (!ui) { return } diff --git a/mobile/src/transport/rpc-operation.ts b/mobile/src/transport/rpc-operation.ts index 97f27143e93..8bdce8ebd88 100644 --- a/mobile/src/transport/rpc-operation.ts +++ b/mobile/src/transport/rpc-operation.ts @@ -248,18 +248,25 @@ export async function interpretAtRpcBarrier< } /** - * Preserves omitted sender arguments as well as explicit undefined. + * Whether a sender may omit the params argument entirely. * - * A params type with no required field may be omitted too, because the raw port always allowed it - * and several hosts' schemas are entirely optional (`preflight.check`). Forcing `{}` there would - * put a new object on the wire where main sent no params at all. + * A params type with no required field may be omitted as well as `void`, because the raw port + * always allowed it and several hosts' schemas are entirely optional (`preflight.check`). Forcing + * `{}` there would put a new object on the wire where main sent no params at all. Shared by both + * send helpers, so single-flight and direct sends cannot disagree about which methods that covers. */ -type RpcSendArguments = +type RpcParamsOmittable = void extends RpcSendParams - ? [params?: RpcSendParams, options?: SendRequestOptions] + ? true : Record extends RpcSendParams - ? [params?: RpcSendParams, options?: SendRequestOptions] - : [params: RpcSendParams, options?: SendRequestOptions] + ? true + : false + +/** Preserves omitted sender arguments as well as explicit undefined. */ +type RpcSendArguments = + RpcParamsOmittable extends true + ? [params?: RpcSendParams, options?: SendRequestOptions] + : [params: RpcSendParams, options?: SendRequestOptions] /** Binds sending and interpretation while preserving the transport promise identity. */ export function bindDeferredRpcOperation< @@ -277,7 +284,7 @@ export function bindDeferredRpcOperation< requestSingleFlight( client: RpcClient, hostId: string, - ...args: void extends RpcSendParams + ...args: RpcParamsOmittable extends true ? [params?: RpcSendParams] : [params: RpcSendParams] ) { diff --git a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts index 722c4e58310..33502020298 100644 --- a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts +++ b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts @@ -60,36 +60,41 @@ export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcReques { file: 'src/browser/use-mobile-browser-commands.ts', references: 5 }, { file: 'src/browser/use-mobile-browser-request.ts', references: 1 }, - // src/components/ — shared widgets that fetch their own data - { file: 'src/components/codex-reset-credit-capability.ts', references: 2 }, + // src/components/ — shared widgets that fetch their own data. The New Workspace drawer's + // execution target, setup hook, runtime context and Codex capability probe migrated in step 4: + // see new-workspace-operations.ts, codex-reset-credit-capability-operation.ts, and the SSH and + // agent-detection operations in tasks/mobile-workspace-source-operations.ts. Two remain, both + // because they reach native storage before or after the send and the recorder refuses to mount + // a device store: the reset-credit journal, and the drawer's last-visited repo read. { file: 'src/components/codex-reset-credit.ts', references: 3 }, - { file: 'src/components/use-new-workspace-execution-target.ts', references: 4 }, { file: 'src/components/use-new-workspace-repositories.ts', references: 1 }, - { file: 'src/components/use-new-workspace-runtime-context.ts', references: 3 }, - { file: 'src/components/use-new-workspace-setup-script.ts', references: 1 }, // src/dictation/ — dictation session control { file: 'src/dictation/mobile-dictation-setup.ts', references: 10 }, - // src/files/ — file read, write and preview - { file: 'src/files/mobile-file-mutation-ownership.ts', references: 3 }, - { file: 'src/files/mobile-file-preview-request.ts', references: 6 }, - { file: 'src/files/mobile-file-tab-doc.ts', references: 4 }, - { file: 'src/files/mobile-terminal-artifact-grant-refresh.ts', references: 2 }, + // src/files/ — file read, write and preview. The preview loader, the terminal-artifact grant + // refresh and save, the session file tab and the mutation-ownership capture migrated in step 4: + // see mobile-file-preview-operations.ts, mobile-file-tab-doc-operations.ts and + // mobile-file-ownership-operations.ts. The explorer panel's two sends sit inline in a React + // Native screen the recorder cannot mount. { file: 'src/files/MobileFileExplorerPanel.tsx', references: 2 }, - // src/home/ — home screen host reads - { file: 'src/home/mobile-home-host-requests.ts', references: 5 }, + // src/home/ — home screen host reads. The stats card and both task-provider probes migrated in + // step 4 (mobile-home-host-operations.ts, plus the shared task-tooling reads in + // tasks/mobile-task-runtime-operations.ts). The accounts read stays: its decoder is re-exported + // through a React Native screen module, which no recording can load. + { file: 'src/home/mobile-home-host-requests.ts', references: 2 }, // src/hooks/ — cross-screen data hooks { file: 'src/hooks/mobile-dictation-audio-chunk.ts', references: 1 }, { file: 'src/hooks/mobile-dictation-desktop-start.ts', references: 4 }, { file: 'src/hooks/use-mobile-dictation.ts', references: 4 }, - // src/host-screen/ — host screen catalog and actions + // src/host-screen/ — host screen catalog and actions. The repo and label metadata reads and the + // desktop view-settings mirror migrated in step 4; see host-screen-operations.ts. The two left + // send worktree mutations from a React Native screen and from a hook that also writes native + // storage, so neither reaches a recorded wire. { file: 'src/host-screen/host-screen-overlays.tsx', references: 1 }, - { file: 'src/host-screen/use-host-repo-metadata.ts', references: 1 }, - { file: 'src/host-screen/use-host-view-settings.ts', references: 2 }, { file: 'src/host-screen/use-host-worktree-actions.ts', references: 3 }, // src/notifications/ — push registration and delivery @@ -146,7 +151,8 @@ export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcReques { file: 'src/session/use-mobile-terminal-paste.ts', references: 1 }, { file: 'src/session/use-quick-commands.ts', references: 2 }, - // src/settings/ — settings screen actions + // src/settings/ — settings screen actions. Its one reference is the client parameter it forwards + // to dictation/mobile-dictation-setup.ts, so it can only drop when that file migrates. { file: 'src/settings/native-voice-settings-operations.ts', references: 1 }, // src/settings/ — notification display probe @@ -210,10 +216,5 @@ export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcReques { file: 'src/transport/pairing-candidate-race.ts', references: 1 }, { file: 'src/transport/pairing-relay-candidate.ts', references: 4 }, { file: 'src/transport/pre-profile-pairing-coordinator.ts', references: 2 }, - { file: 'src/transport/runtime-capability-probe.ts', references: 2 }, - - // src/worktree/ — worktree activation and resume - { file: 'src/worktree/home-host-worktree-fetch.ts', references: 2 }, - { file: 'src/worktree/use-retired-worktree-names.ts', references: 1 }, - { file: 'src/worktree/worktree-catalog-snapshot-client.ts', references: 1 } + { file: 'src/transport/runtime-capability-probe.ts', references: 2 } ] diff --git a/mobile/src/worktree/home-host-worktree-fetch.ts b/mobile/src/worktree/home-host-worktree-fetch.ts index 72b9e572ba1..5e119518a7d 100644 --- a/mobile/src/worktree/home-host-worktree-fetch.ts +++ b/mobile/src/worktree/home-host-worktree-fetch.ts @@ -1,5 +1,4 @@ import { setCachedWorktrees } from '../cache/worktree-cache' -import { sendSingleFlightRequest } from '../transport/request-single-flight' import type { RpcClient } from '../transport/rpc-client' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { @@ -8,6 +7,7 @@ import { type HostWorktreeInfo } from './home-worktree-info' import { pickResumeWorktree } from './resume-worktree' +import { worktreeCatalogRead } from './worktree-catalog-operations' import { WORKTREE_PS_FULL_LIMIT } from './worktree-catalog-snapshot-client' const ACTIVE_STATUSES = new Set(['working', 'active', 'permission']) @@ -36,16 +36,19 @@ export function fetchHomeHostWorktreeInfo( } const attempt = (cutoverRetriesLeft: number): Promise => - sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: WORKTREE_PS_FULL_LIMIT }) - .then((response) => { + worktreeCatalogRead + .requestSingleFlight(client, hostId, { limit: WORKTREE_PS_FULL_LIMIT }) + .then((reply) => { if (disposed()) { return } - if (!response.ok) { + const catalog = worktreeCatalogRead.interpret(reply) + if (!catalog.accepted) { markUnavailable() return } - const result = response.result as { worktrees?: HomeWorktreeSummary[] } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = catalog.value as { worktrees?: HomeWorktreeSummary[] } const worktrees = result.worktrees ?? [] setCachedWorktrees(hostId, worktrees, { proven: true }) const active = worktrees.filter((w) => w.status && ACTIVE_STATUSES.has(w.status)) diff --git a/mobile/src/worktree/use-retired-worktree-names.test.tsx b/mobile/src/worktree/use-retired-worktree-names.test.tsx index 660b2f018d9..848353e8592 100644 --- a/mobile/src/worktree/use-retired-worktree-names.test.tsx +++ b/mobile/src/worktree/use-retired-worktree-names.test.tsx @@ -55,7 +55,12 @@ function mountNames() { retiredNameTiersByRepo: Record = {} ) { await act(async () => { - pending[index]!.resolve({ result: { retiredNamesByRepo, retiredNameTiersByRepo } }) + // `ok` is what the host always sends and what the read's acceptance policy routes on; + // a reply without it read as a refusal, which is not a shape any host produces. + pending[index]!.resolve({ + ok: true, + result: { retiredNamesByRepo, retiredNameTiersByRepo } + }) await Promise.resolve() }) }, diff --git a/mobile/src/worktree/use-retired-worktree-names.ts b/mobile/src/worktree/use-retired-worktree-names.ts index 3158110f82c..5da29f4cb26 100644 --- a/mobile/src/worktree/use-retired-worktree-names.ts +++ b/mobile/src/worktree/use-retired-worktree-names.ts @@ -7,6 +7,7 @@ import { } from '../../../src/shared/worktree/retired-name-cache' import type { RetiredNameRegistry } from '../../../src/shared/worktree/retired-name-registry' import type { RpcClient } from '../transport/rpc-client' +import { retiredWorktreeNamesRead } from './worktree-catalog-operations' export function buildRetiredWorktreeNamesRefreshKey( existingWorktreePaths: readonly string[] | undefined @@ -42,13 +43,16 @@ export function useRetiredWorktreeNames( setLoaded((previous) => retiredNamesAfterRefresh(previous, activeRepoId, registry)) } } - void client - .sendRequest('worktree.listRetiredNames', { repo: `id:${activeRepoId}` }) - .then((response) => + void retiredWorktreeNamesRead + .request(client, { repo: `id:${activeRepoId}` }) + .then((reply) => { + const names = retiredWorktreeNamesRead.interpret(reply) + // A refusal is not a failure here: it settles as an empty registry, which un-retires the + // repo's names until the next refresh. Preserved from main, not repaired. settle( - readRetiredNameRegistryForRepo((response as { result?: unknown }).result, activeRepoId) + readRetiredNameRegistryForRepo(names.accepted ? names.value : undefined, activeRepoId) ) - ) + }) .catch(() => settle(null)) return () => { cancelled = true diff --git a/mobile/src/worktree/worktree-catalog-operations.ts b/mobile/src/worktree/worktree-catalog-operations.ts new file mode 100644 index 00000000000..24519f84dc5 --- /dev/null +++ b/mobile/src/worktree/worktree-catalog-operations.ts @@ -0,0 +1,38 @@ +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { rpcUncheckedPayloadReader } from '../transport/rpc-reader-payload' + +// The two workspace-catalog reads, both best-effort: a refused catalog leaves the last proven +// counts and the last confirmed rows in place rather than rendering a host as empty (STA-3123). + +/** + * worktree.ps. One family for both readers — the Home card's summary and the host screen's + * snapshot poll — because they ask the same question with the same acceptance. The payload stays + * unchecked: the snapshot client admits an `unchanged` envelope the card never sees. + */ +export const worktreeCatalogRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'worktree.catalog-or-skip', + method: 'worktree.ps', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('worktree-catalog') + }) +) + +/** + * Names already spent in one repo. The payload is unchecked because the call site projects it + * through `readRetiredNameRegistryForRepo`, which reads a refusal as an empty registry — the + * behaviour a skip preserves, and not the same thing as the failure a rejection means here. + */ +export const retiredWorktreeNamesRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'worktree.retired-names-or-skip', + method: 'worktree.listRetiredNames', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('retired-names') + }) +) + +/** What a catalog read sends with, named from an operation so no module names the raw port. */ +export type MobileWorktreeCatalogRpcSender = Parameters[0] diff --git a/mobile/src/worktree/worktree-catalog-snapshot-client.ts b/mobile/src/worktree/worktree-catalog-snapshot-client.ts index 9e804c39b34..82c9bd0493b 100644 --- a/mobile/src/worktree/worktree-catalog-snapshot-client.ts +++ b/mobile/src/worktree/worktree-catalog-snapshot-client.ts @@ -1,6 +1,7 @@ import type { RpcClient } from '../transport/rpc-client' -import type { RpcFailure, RpcSuccess } from '../transport/types' +import type { RpcFailure } from '../transport/types' import type { Worktree } from './workspace-list-sections' +import { worktreeCatalogRead } from './worktree-catalog-operations' // Why: worktree.ps silently truncates at 200; use a high cap so large hosts don't drop workspaces. export const WORKTREE_PS_FULL_LIMIT = 10_000 @@ -71,12 +72,15 @@ export class WorktreeCatalogSnapshotClient { this.confirmedWorktrees = null } const requestedSnapshotId = this.snapshotId - const response = await client.sendRequest('worktree.ps', { + const reply = await worktreeCatalogRead.request(client, { limit: WORKTREE_PS_FULL_LIMIT, afterSnapshotId: requestedSnapshotId }) - if (!response.ok) { - const code = (response as RpcFailure).error?.code + const catalog = worktreeCatalogRead.interpret(reply) + if (!catalog.accepted) { + // The policy skipped it; the refusal code the caller reports lives on the envelope, which + // no acceptance policy carries. + const code = (reply as RpcFailure).error?.code return { kind: 'request_failed', code: typeof code === 'string' && code.length > 0 ? code : 'request_failed' @@ -85,10 +89,7 @@ export class WorktreeCatalogSnapshotClient { return { kind: 'response', pending: { - admission: admitWorktreeCatalogResponse( - (response as RpcSuccess).result, - requestedSnapshotId - ), + admission: admitWorktreeCatalogResponse(catalog.value, requestedSnapshotId), client, hostId }