Files
orca/mobile/src/tasks/worktree-create-capability.ts
T
Jinwoo-H 05de821cb7 refactor(mobile): run workspace creation reads over the generic host lane
The page now drives the same desktop requests the native app makes, through
an RpcClient-shaped sender over workspace.hostRequest, so the shell's read and
source translators and their contracts are gone. Creation itself stays in the
shell: a workspace no catalog page has listed has no page handle, and only the
authority can mint one. Repo ids cross as host ids, so the re-lookups that
existed to re-resolve opaque repo handles are deleted with them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-07 16:06:57 -04:00

126 lines
4.9 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
import type { RpcRequestSender } from '../transport/rpc-client'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcSuccess } from '../transport/types'
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability'
import type { HostWorkspaceCreationOperations } from '../worktree/host-workspace-creation-operations'
import {
WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS,
resolveWorktreeCreateIdempotencySupport,
type WorktreeCreateIdempotencySupport
} from './worktree-create-idempotency-policy'
// Why: older hosts strip worktree.create's clientMutationId, so mobile must not
// replay an ambiguous create unless the host advertises idempotency support.
// Mirrors WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY in the shared protocol.
export const MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY = 'worktree.create-idempotency.v1'
const STATUS_CUTOVER_MAX_RETRIES = 5
export type NewWorktreeRuntimeCapabilities = {
tasksSupported: boolean
worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false
hostPlatform: NodeJS.Platform | null
}
const UNSUPPORTED_CAPABILITIES: NewWorktreeRuntimeCapabilities = {
tasksSupported: false,
worktreeCreateIdempotency: false,
hostPlatform: null
}
// Why: status.get is safe to replay and must settle before create, independently
// of slower provider probes, so ambiguous cutover retries are gated correctly.
export async function readNewWorktreeRuntimeCapabilities(
client: RpcRequestSender
): Promise<NewWorktreeRuntimeCapabilities> {
for (let migrationRetry = 0; ; migrationRetry += 1) {
try {
const response = await client.sendRequest('status.get')
if (!response.ok) {
return UNSUPPORTED_CAPABILITIES
}
const result = (response as RpcSuccess).result as {
capabilities?: string[]
worktreeCreateIdempotency?: unknown
}
const capabilities = result.capabilities ?? []
const supportsIdempotency = capabilities.includes(
MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY
)
const advertisedIdempotency = result.worktreeCreateIdempotency
return {
tasksSupported: capabilities.includes(MOBILE_TASKS_CAPABILITY),
worktreeCreateIdempotency: supportsIdempotency
? advertisedIdempotency === undefined
? { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS }
: advertisedIdempotency !== null &&
typeof advertisedIdempotency === 'object' &&
!Array.isArray(advertisedIdempotency)
? resolveWorktreeCreateIdempotencySupport(
(advertisedIdempotency as { dedupeTtlMs?: unknown }).dedupeTtlMs
)
: { dedupeTtlMs: 0 }
: false,
hostPlatform: readMobileRuntimeHostPlatform(result)
}
} catch (error) {
if (!isLogicalClientCutoverError(error) || migrationRetry >= STATUS_CUTOVER_MAX_RETRIES) {
return UNSUPPORTED_CAPABILITIES
}
}
}
}
export function useNewWorktreeRuntimeCapabilities(
operations: HostWorkspaceCreationOperations | null,
enabled: boolean
): {
tasksSupported: boolean
hostPlatform: NodeJS.Platform | null
getWorktreeCreateCutoverSupport: () => Promise<WorktreeCreateIdempotencySupport | false>
} {
const [tasksSupported, setTasksSupported] = useState(false)
const [hostPlatform, setHostPlatform] = useState<NodeJS.Platform | null>(null)
const capabilityProbeRef = useRef<{
operations: HostWorkspaceCreationOperations | null
promise: Promise<NewWorktreeRuntimeCapabilities>
} | null>(null)
const getCapabilities = useCallback((): Promise<NewWorktreeRuntimeCapabilities> => {
if (!capabilityProbeRef.current || capabilityProbeRef.current.operations !== operations) {
// Why: a queued tap can reach Create before passive effects run; lazily
// starting one shared probe keeps that path from failing open.
capabilityProbeRef.current = {
operations,
promise: operations
? operations.readRuntimeCapabilities()
: Promise.resolve(UNSUPPORTED_CAPABILITIES)
}
}
return capabilityProbeRef.current.promise
}, [operations])
useEffect(() => {
if (!enabled || !operations) {
return
}
let stale = false
void getCapabilities().then((capabilities) => {
if (!stale) {
setTasksSupported(capabilities.tasksSupported)
setHostPlatform(capabilities.hostPlatform)
}
})
return () => {
stale = true
}
}, [operations, enabled, getCapabilities, setTasksSupported])
const getWorktreeCreateCutoverSupport = useCallback(
() => getCapabilities().then((capabilities) => capabilities.worktreeCreateIdempotency),
[getCapabilities]
)
return { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport }
}