diff --git a/mobile/rpc-foundation/STEP0_5-TODO.md b/mobile/rpc-foundation/STEP0_5-TODO.md new file mode 100644 index 00000000000..cab28ca316f --- /dev/null +++ b/mobile/rpc-foundation/STEP0_5-TODO.md @@ -0,0 +1,415 @@ +# Step 0.5 acceptance consolidation remains separate + +Keep all current behavior, including malformed-result exceptions; classify the following predicates into named policy families before replacing any caller. The census includes if/conditional/return expressions using `.ok`, `.success`, or `isSuccess`, with exact source locations; it deliberately retains transport and non-RPC matches for review. + +Three existing definitions to remove only after partition fixtures exist: + +- mobile/src/transport/mobile-relay-direct-upgrade.ts:166 requireSuccess +- mobile/src/transport/mobile-relay-pairing-recovery.ts:297 requireSuccess +- mobile/src/transport/pre-profile-pairing-coordinator.ts:286 requireSuccess + +Inline predicate census: + +- mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx:85 — `worktreeResponse.ok` +- mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx:394 — `!repoResponse.ok` +- mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx:399 — `folderWorkspaceResponse?.ok === true` +- mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx:405 — `projectGroupResponse?.ok === true` +- mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx:409 — `settingsResponse?.ok === true` +- mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx:413 — `worktreeResponse?.ok === true` +- mobile/src/agent-history/use-mobile-agent-history-state.ts:95 — `!statusResponse.ok` +- mobile/src/agent-history/use-mobile-agent-history-state.ts:125 — `!response.ok` +- mobile/src/browser/mobile-browser-frame-state.ts:111 — `!response.ok` +- mobile/src/browser/use-mobile-browser-request.ts:46 — `!response.ok` +- mobile/src/components/accounts-snapshot.ts:232 — `!result.success` +- mobile/src/components/codex-reset-credit-capability.ts:15 — `!response.ok || !response.result || typeof response.result !== 'object'` +- mobile/src/components/codex-reset-credit.ts:139 — `parsed.success ? parsed.data : null` +- mobile/src/components/codex-reset-credit.ts:139 — `parsed.success` +- mobile/src/components/codex-reset-credit.ts:164 — `!scope.success || !scopesEqual(scope.data, expectedScope)` +- mobile/src/components/codex-reset-credit.ts:236 — `!response.ok` +- mobile/src/components/pr-sidebar/MobileLinkPrForm.tsx:34 — `outcome.ok` +- mobile/src/components/pr-sidebar/PRActionsSection.tsx:62 — `outcome.ok` +- mobile/src/components/pr-sidebar/PRChecksSection.tsx:65 — `outcome.ok` +- mobile/src/components/pr-sidebar/PrSidebarCreateEmptyState.tsx:115 — `!outcome.ok` +- mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx:57 — `outcome.ok` +- mobile/src/components/use-new-workspace-create-submit.ts:92 — `settingsResponse.ok` +- mobile/src/components/use-new-workspace-execution-target.ts:57 — `!response.ok` +- mobile/src/components/use-new-workspace-execution-target.ts:92 — `response.ok` +- mobile/src/components/use-new-workspace-execution-target.ts:118 — `!response.ok` +- mobile/src/components/use-new-workspace-repositories.ts:56 — `stale || !response.ok` +- mobile/src/components/use-new-workspace-runtime-context.ts:13 — `entry.status === 'fulfilled' && entry.value.ok ? (entry.value as RpcSuccess) : null` +- mobile/src/components/use-new-workspace-runtime-context.ts:13 — `entry.status === 'fulfilled' && entry.value.ok` +- mobile/src/components/use-new-workspace-setup-script.ts:45 — `stale || !response.ok` +- mobile/src/diagnostics/connection-diagnostics-submission.ts:37 — `response.ok ? { ok: true } : { ok: false, error: \`status ${response.status}\` }` +- mobile/src/diagnostics/connection-diagnostics-submission.ts:37 — `response.ok` +- mobile/src/dictation/mobile-dictation-setup.ts:36 — `!response.ok` +- mobile/src/dictation/mobile-dictation-setup.ts:63 — `!response.ok` +- mobile/src/dictation/mobile-dictation-setup.ts:73 — `!response.ok` +- mobile/src/dictation/mobile-dictation-setup.ts:84 — `!response.ok` +- mobile/src/files/MobileFileExplorerPanel.tsx:114 — `!response.ok` +- mobile/src/files/MobileFileExplorerPanel.tsx:125 — `legacy.ok` +- mobile/src/files/MobileFilePreviewScreen.tsx:36 — `route.ok` +- mobile/src/files/MobileFilePreviewScreen.tsx:40 — `route.ok` +- mobile/src/files/MobileFilePreviewScreen.tsx:91 — `route.ok` +- mobile/src/files/mobile-file-mutation-ownership.ts:77 — `!response.ok` +- mobile/src/files/mobile-file-preview-request.ts:95 — `!response.ok && typeof source !== 'string' && source.source === 'terminalArtifact'` +- mobile/src/files/mobile-file-preview-request.ts:139 — `response.ok` +- mobile/src/files/mobile-file-preview-request.ts:168 — `!response.ok` +- mobile/src/files/mobile-file-preview-request.ts:189 — `!response.ok` +- mobile/src/files/mobile-file-preview-request.ts:209 — `!response.ok` +- mobile/src/files/mobile-file-preview-response.ts:44 — `!response.ok` +- mobile/src/files/mobile-file-tab-doc.ts:38 — `!response.ok` +- mobile/src/files/mobile-file-tab-doc.ts:60 — `!preview.ok` +- mobile/src/files/mobile-file-tab-doc.ts:76 — `!response.ok` +- mobile/src/files/mobile-terminal-artifact-grant-refresh.ts:31 — `response.ok || !isTerminalArtifactGrantFailure(response, options)` +- mobile/src/files/mobile-terminal-artifact-grant-refresh.ts:41 — `!refreshed.ok` +- mobile/src/home/mobile-home-host-requests.ts:43 — `!disposed() && response.ok` +- mobile/src/home/mobile-home-host-requests.ts:61 — `!disposed() && response.ok` +- mobile/src/home/mobile-home-host-requests.ts:84 — `settingsResponse.ok` +- mobile/src/home/mobile-home-host-requests.ts:88 — `preflightResponse.ok` +- mobile/src/home/mobile-home-host-requests.ts:91 — `linearResponse.ok` +- mobile/src/hooks/mobile-dictation-audio-chunk.ts:40 — `!response.ok` +- mobile/src/hooks/mobile-dictation-desktop-start.ts:69 — `!response.ok` +- mobile/src/hooks/use-mobile-dictation.ts:218 — `!response.ok` +- mobile/src/host-screen/use-host-repo-metadata.ts:21 — `response.ok ? (response as RpcSuccess).result : null` +- mobile/src/host-screen/use-host-repo-metadata.ts:21 — `response.ok` +- mobile/src/host-screen/use-host-repo-metadata.ts:94 — `clientRef.current !== requestClient || hostId !== requestHostId || !repoResponse.ok` +- mobile/src/host-screen/use-host-view-settings.ts:98 — `clientRef.current !== requestClient || hostId !== requestHostId || !response.ok` +- mobile/src/host-screen/use-host-worktree-actions.ts:130 — `!response.ok` +- mobile/src/notifications/mobile-notifications.ts:158 — `!response.ok` +- mobile/src/session/ai-vault-resume-launch.ts:173 — `!created.ok` +- mobile/src/session/ai-vault-resume-launch.ts:189 — `!sent.ok` +- mobile/src/session/ai-vault-resume-preparation.ts:39 — `!response.ok` +- mobile/src/session/github-pr-mutations.ts:24 — `!response.ok` +- mobile/src/session/github-pr-mutations.ts:59 — `!response.ok` +- mobile/src/session/github-pr-mutations.ts:65 — `r.ok === true` +- mobile/src/session/github-pr-mutations.ts:109 — `!response.ok` +- mobile/src/session/github-pr-mutations.ts:267 — `!response.ok` +- mobile/src/session/github-pr-rpc.ts:96 — `!response.ok` +- mobile/src/session/mobile-clipboard-image.ts:98 — `!response.ok` +- mobile/src/session/mobile-clipboard-image.ts:136 — `!startResponse.ok` +- mobile/src/session/mobile-diff-review-loaders.ts:52 — `!response.ok` +- mobile/src/session/mobile-diff-review-loaders.ts:72 — `!statusResponse.ok` +- mobile/src/session/mobile-diff-review-loaders.ts:87 — `!worktreeResponse.ok` +- mobile/src/session/mobile-diff-review-loaders.ts:132 — `!response.ok` +- mobile/src/session/mobile-file-tap-open.ts:92 — `!response.ok` +- mobile/src/session/mobile-file-tap-open.ts:180 — `!openResponse.ok` +- mobile/src/session/mobile-new-tab-agent-loader.ts:29 — `!settingsResponse.ok` +- mobile/src/session/mobile-new-tab-agent-loader.ts:32 — `!detectedResponse.ok` +- mobile/src/session/mobile-new-tab-agent-loader.ts:48 — `!repoResponse.ok` +- mobile/src/session/mobile-pr-sidebar-state.ts:91 — `hintOutcome.ok && hintOutcome.result?.provider === 'github'` +- mobile/src/session/mobile-pr-sidebar-state.ts:98 — `!prOutcome.ok` +- mobile/src/session/mobile-pr-sidebar-state.ts:115 — `!checksOutcome.ok` +- mobile/src/session/mobile-pr-sidebar-state.ts:137 — `outcome.ok ? outcome.result : null` +- mobile/src/session/mobile-pr-sidebar-state.ts:137 — `outcome.ok` +- mobile/src/session/mobile-session-tab-activation.ts:36 — `response.ok` +- mobile/src/session/mobile-session-tab-activation.ts:60 — `response.ok` +- mobile/src/session/mobile-session-tabs-stream-health.ts:264 — `!response.ok` +- mobile/src/session/mobile-structured-agent-session-launch.ts:108 — `!supportResponse || typeof supportResponse !== 'object' || typeof supportResponse.ok !== 'boolean' || !supportResponse.ok` +- mobile/src/session/mobile-structured-agent-session-launch.ts:140 — `!response || typeof response !== 'object' || typeof response.ok !== 'boolean'` +- mobile/src/session/mobile-structured-agent-session-launch.ts:143 — `!response.ok` +- mobile/src/session/mobile-structured-agent-session-launch.ts:154 — `!result || typeof result !== 'object' || typeof result.ok !== 'boolean'` +- mobile/src/session/mobile-structured-agent-session-launch.ts:157 — `!result.ok` +- mobile/src/session/mobile-structured-agent-session-rpc.ts:46 — `!response.ok` +- mobile/src/session/mobile-structured-agent-session-rpc.ts:141 — `!result.ok && method === 'agentSession.conversationCommand' && result.refusal.code === 'agent_session_operation_unknown'` +- mobile/src/session/mobile-structured-agent-session-rpc.ts:147 — `result.ok ? { status: 'accepted', value: result.value } : { status: 'refused', message: result.refusal.message }` +- mobile/src/session/mobile-structured-agent-session-rpc.ts:147 — `result.ok` +- mobile/src/session/pr-actions-engine.ts:162 — `outcome.ok` +- mobile/src/session/pr-ai-triage-launch.ts:24 — `!created.ok` +- mobile/src/session/pr-ai-triage-launch.ts:36 — `!sent.ok` +- mobile/src/session/use-live-worktree-name.ts:113 — `!response.ok` +- mobile/src/session/use-mobile-diff-review-comment-actions.ts:74 — `!response.ok` +- mobile/src/session/use-mobile-diff-review-git-actions.ts:36 — `!response.ok` +- mobile/src/session/use-mobile-diff-review-git-actions.ts:71 — `response.ok` +- mobile/src/session/use-mobile-diff-review-interactions.ts:135 — `{ clearSentNotes, closeComposer, copyNotes, createTerminalAndSend, deleteComment, jumpHunk: (direction: 'next' | 'previous') => { if (diffState.kind !== 'ready') { return } const currentLineIndex = activeHunkIndex === null ? -1 : (diffState.hunks[activeHunkIndex]?.startIndex ?? -1) const nextIndex = direction === 'next' ? findNextMobileDiffHunkIndex(diffState.hunks, currentLineIndex) : findPreviousMobileDiffHunkIndex(diffState.hunks, currentLineIndex) const target = nextIndex === null ? null : diffState.hunks[nextIndex] if (!target || nextIndex === null) { return } setActiveHunkIndex(nextIndex) listRef.current?.scrollToIndex({ index: target.startIndex, animated: true, viewPosition: 0.16 }) triggerSelection() }, markReviewed, markUnreviewed, moveFile: (direction: 'next' | 'previous') => { if (filteredQueue.length === 0) { return } setCurrentIndex((index) => direction === 'next' ? index + 1 >= filteredQueue.length ? 0 : index + 1 : index - 1 < 0 ? filteredQueue.length - 1 : index - 1 ) }, openComposer, openEditComposer, openInSession: async () => { if (!client || !currentItem || currentItem.scope === 'branch') { return } const response = await client.sendRequest('files.openDiff', { worktree: \`id:${worktreeId}\`, relativePath: currentItem.filePath, staged: currentItem.scope === 'staged' }) if (!response.ok) { setActionError(response.error?.message || 'Unable to open in session') return } onOpenSession() }, openSendSheet, retryAction: () => { if (connState !== 'connected' && hostId) { void onReconnect(hostId) return } void loadReviewData() }, runGitMutation, saveComposer, selectFilter: (nextFilter: MobileDiffReviewQueueFilter) => { setFilter(nextFilter) setCurrentIndex(0) }, sendPromptToTerminal, stageReviewedFiles }` +- mobile/src/session/use-mobile-diff-review-interactions.ts:190 — `!response.ok` +- mobile/src/session/use-mobile-diff-review-send-actions.ts:88 — `!response.ok` +- mobile/src/session/use-mobile-diff-review-send-actions.ts:113 — `!response.ok` +- mobile/src/session/use-mobile-diff-review-send-actions.ts:135 — `!response.ok` +- mobile/src/session/use-mobile-native-chat-file-search.ts:94 — `!response.ok || generationRef.current !== generation` +- mobile/src/session/use-mobile-native-chat-file-search.ts:130 — `response.ok` +- mobile/src/session/use-mobile-native-chat-readability.ts:36 — `response.ok` +- mobile/src/session/use-mobile-native-chat-session.ts:249 — `!response.ok` +- mobile/src/session/use-mobile-pr-branch-context.ts:156 — `{ isGithubRepo: slugOutcome.ok && slugOutcome.result !== null }` +- mobile/src/session/use-mobile-pr-branch-context.ts:177 — `response.ok ? readMobileGitStatusResult(response.result) : null` +- mobile/src/session/use-mobile-pr-branch-context.ts:177 — `response.ok` +- mobile/src/session/use-mobile-pr-branch-context.ts:194 — `response.ok ? readMobileBranchCompareResult(response.result) : null` +- mobile/src/session/use-mobile-pr-branch-context.ts:194 — `response.ok` +- mobile/src/session/use-mobile-pr-comment-actions.ts:139 — `outcome.ok` +- mobile/src/session/use-mobile-pr-title-action.ts:75 — `outcome.ok` +- mobile/src/session/use-mobile-session-accessory-selection.ts:195 — `!repoResponse.ok` +- mobile/src/session/use-mobile-session-bulk-close.ts:76 — `!response.ok` +- mobile/src/session/use-mobile-session-close-actions.ts:46 — `response.ok` +- mobile/src/session/use-mobile-session-close-actions.ts:72 — `response.ok` +- mobile/src/session/use-mobile-session-close-actions.ts:107 — `response.ok` +- mobile/src/session/use-mobile-session-content-create-actions.ts:45 — `!createResponse.ok` +- mobile/src/session/use-mobile-session-content-create-actions.ts:58 — `!openResponse.ok` +- mobile/src/session/use-mobile-session-content-create-actions.ts:104 — `!response.ok` +- mobile/src/session/use-mobile-session-content-create-actions.ts:145 — `!response.ok` +- mobile/src/session/use-mobile-session-diff-comments.ts:36 — `!response.ok` +- mobile/src/session/use-mobile-session-diff-comments.ts:54 — `!response.ok` +- mobile/src/session/use-mobile-session-document-readers.ts:24 — `response.ok` +- mobile/src/session/use-mobile-session-document-readers.ts:54 — `!fallback.ok` +- mobile/src/session/use-mobile-session-markdown-actions.ts:147 — `!response.ok` +- mobile/src/session/use-mobile-session-startup.ts:132 — `response.ok` +- mobile/src/session/use-mobile-session-startup.ts:161 — `activationResponse?.ok` +- mobile/src/session/use-mobile-session-terminal-create-actions.ts:110 — `response.ok` +- mobile/src/session/use-mobile-session-terminal-create-actions.ts:167 — `!sendResponse.ok` +- mobile/src/session/use-mobile-session-terminal-list.ts:53 — `terminalInventoryRequest.run( allowEmptyLoaded, async (allowsEmpty, isCurrent) => { try { const response = await client.sendRequest('terminal.list', { worktree: \`id:${worktreeId}\`, includeVisualLayouts: false }) if (!isCurrent() || !response.ok) { return false } const result = (response as RpcSuccess).result as { terminals: Terminal[] } if (result.terminals.length === 0 && !allowsEmpty()) { return true } // Why: require two consecutive empties before trusting 0, so transient empty responses don't flash the UI empty. if (result.terminals.length === 0 && lastKnownTerminalCountRef.current > 0) { lastKnownTerminalCountRef.current = 0 return true } const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle)) const pruneContext = { liveHandles, showNativeChat: showNativeChatRef.current, activeHandle: activeHandleRef.current } // Why: terminal.list is the lifetime signal; lagging tab snapshots must not erase a user's buffered-mode opt-out. // Sweep against the retained set, not the raw list: a chat-covered handle // keeps its subscription across a graph reload, so erasing its live-input // preference on the same refresh is the erasure this guard exists to stop. const retainedHandles = resolveRetainedTerminalHandles(pruneContext) pruneTerminalHandlesFromLiveInput(retainedHandles) bufferedTerminalDraftState.pruneDrafts(retainedHandles) defaultTerminalHandlesToLiveInput([...liveHandles]) const shouldPrune = createTerminalPrunePredicate(pruneContext) for (const handle of Array.from(terminalUnsubsRef.current.keys())) { if (!shouldPrune(handle)) { continue } unsubscribeTerminal(handle) terminalRefs.current.delete(handle) initializedHandlesRef.current.delete(handle) viewportResubscribeBudgetRef.current.forget(handle) clearTerminalLiveInputDefault(handle) } setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune)) // Why: a chat-covered handle the host reports again refills its rearm budget, // so an exhausted rearm can't lock the composer until leave-chat. nativeChatStream.notifyListedHandles(liveHandles) // Why: same absence-gated refill for the viewport-fit budget — a handle that // left the list and returned may converge now, so it earns fresh attempts. viewportResubscribeBudgetRef.current.notifyListedHandles(liveHandles) lastKnownTerminalCountRef.current = result.terminals.length // Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order. const seen = new Set() const deduped = result.terminals.filter((t) => { if (seen.has(t.handle)) { return false } seen.add(t.handle) return true }) const mergedTerminals = mergeTerminalListWithKnownRecords( deduped, terminalsRef.current, sessionTabsRef.current ) setTerminals((prev) => terminalRecordsEqual(prev, mergedTerminals) ? prev : mergedTerminals ) terminalsRef.current = mergedTerminals // Session tabs are the UI authority; terminal.list only refreshes per-handle metadata for existing terminal surfaces. return true } catch { // Failed to list terminals return false } }, opts.onPhysicalRequestStarted )` +- mobile/src/session/use-mobile-session-terminal-list.ts:61 — `!isCurrent() || !response.ok` +- mobile/src/session/use-mobile-structured-agent-state.ts:172 — `result.ok && sessionKeyRef.current === requestSessionKey && streamGenerationRef.current === requestGeneration` +- mobile/src/session/use-pr-bot-author-overrides.ts:38 — `stale || !response.ok` +- mobile/src/session/use-quick-commands.ts:132 — `!response.ok` +- mobile/src/session/use-quick-commands.ts:195 — `!response.ok` +- mobile/src/source-control/MobileGitHistoryList.tsx:111 — `response.ok` +- mobile/src/source-control/mobile-branch-base-ref.ts:69 — `worktreeResponse?.ok` +- mobile/src/source-control/mobile-branch-base-ref.ts:76 — `repoResponse?.ok` +- mobile/src/source-control/mobile-branch-base-ref.ts:85 — `!defaultResponse.ok` +- mobile/src/source-control/mobile-commit-message-ai.ts:20 — `!response.ok` +- mobile/src/source-control/mobile-commit-message-ai.ts:27 — `result.success === true && typeof result.message === 'string' && result.message.length > 0` +- mobile/src/source-control/mobile-commit-message-ai.ts:33 — `result.success === false && typeof result.error === 'string' && result.error.length > 0` +- mobile/src/source-control/mobile-commit-message-ai.ts:36 — `{ success: false, error: hostError, ...(result.success === false && result.canceled ? { canceled: true } : {}) }` +- mobile/src/source-control/mobile-commit-message-ai.ts:39 — `result.success === false && result.canceled` +- mobile/src/source-control/mobile-git-history.ts:68 — `!response.ok` +- mobile/src/source-control/mobile-hosted-review-create-intent-runner.ts:46 — `!outcome.ok && progress === 'committing' && outcome.committed === false` +- mobile/src/source-control/mobile-hosted-review-create-intent-runner.ts:55 — `!prepared.ok` +- mobile/src/source-control/mobile-hosted-review-create-intent-runner.ts:79 — `!created.ok` +- mobile/src/source-control/mobile-hosted-review-create-intent.ts:117 — `!staged.ok` +- mobile/src/source-control/mobile-hosted-review-create-intent.ts:121 — `!stagedStatus.ok` +- mobile/src/source-control/mobile-hosted-review-create-intent.ts:154 — `!generated.success` +- mobile/src/source-control/mobile-hosted-review-create-intent.ts:167 — `!committed.ok` +- mobile/src/source-control/mobile-hosted-review-create-intent.ts:171 — `!committedStatus.ok` +- mobile/src/source-control/mobile-hosted-review-create-intent.ts:197 — `initialStatus.ok` +- mobile/src/source-control/mobile-hosted-review-create-intent.ts:199 — `{ ok: false, error: initialStatus.ok ? 'Branch changed while preparing the pull request.' : initialStatus.error, status: currentStatus }` +- mobile/src/source-control/mobile-hosted-review-create-intent.ts:201 — `initialStatus.ok` +- mobile/src/source-control/mobile-hosted-review-create-intent.ts:209 — `!committed.ok` +- mobile/src/source-control/mobile-hosted-review-create-intent.ts:226 — `!remote.ok` +- mobile/src/source-control/mobile-hosted-review-create-intent.ts:233 — `!refreshedStatus.ok` +- mobile/src/source-control/mobile-hosted-review-git-preparation.ts:15 — `!response.ok` +- mobile/src/source-control/mobile-hosted-review-git-preparation.ts:37 — `!response.ok` +- mobile/src/source-control/mobile-hosted-review-git-preparation.ts:56 — `!response.ok` +- mobile/src/source-control/mobile-hosted-review-git-preparation.ts:60 — `result?.success !== true` +- mobile/src/source-control/mobile-hosted-review-remote-prerequisite.ts:27 — `result.ok ? { ok: true, ran: true } : result` +- mobile/src/source-control/mobile-hosted-review-remote-prerequisite.ts:27 — `result.ok` +- mobile/src/source-control/mobile-hosted-review-remote-prerequisite.ts:37 — `result.ok ? { ok: true, ran: true } : result` +- mobile/src/source-control/mobile-hosted-review-remote-prerequisite.ts:37 — `result.ok` +- mobile/src/source-control/mobile-hosted-review-remote-prerequisite.ts:50 — `result.ok ? { ok: true, ran: true } : result` +- mobile/src/source-control/mobile-hosted-review-remote-prerequisite.ts:50 — `result.ok` +- mobile/src/source-control/mobile-hosted-review-service.ts:52 — `!response.ok` +- mobile/src/source-control/mobile-hosted-review-service.ts:181 — `!response.ok` +- mobile/src/source-control/mobile-hosted-review-service.ts:195 — `result.ok` +- mobile/src/source-control/mobile-hosted-review-service.ts:218 — `{ ok: true, url: result.url, number: result.number, ...(existing ? { existing: true } : {}), ...(linked.ok ? {} : { linkError: linked.error }) }` +- mobile/src/source-control/mobile-hosted-review-service.ts:223 — `linked.ok` +- mobile/src/source-control/mobile-hosted-review-service.ts:236 — `!push.ok` +- mobile/src/source-control/mobile-hosted-review-service.ts:245 — `!response.ok` +- mobile/src/source-control/mobile-hosted-review-service.ts:249 — `result.ok` +- mobile/src/source-control/mobile-pr-link.ts:57 — `!response.ok` +- mobile/src/source-control/mobile-pr-link.ts:92 — `!response.ok` +- mobile/src/source-control/mobile-pr-link.ts:122 — `!response.ok` +- mobile/src/source-control/reveal-mobile-source-control-session-diff.ts:67 — `!response.ok` +- mobile/src/source-control/reveal-mobile-source-control-session-diff.ts:109 — `response.ok && readActiveTabId(response.result) === tabId` +- mobile/src/source-control/use-mobile-commit-message-generation.ts:44 — `result.success` +- mobile/src/source-control/use-mobile-create-pr-runner.ts:59 — `useCallback( async (pushFirst: boolean) => { setShowActionSheet(false) const branch = status?.branch if (!client || !branch) { triggerError() setActionError('Check out a branch before creating a pull request.') return } const created: { current: MobileHostedReviewCreateIntentRunOutcome | null } = { current: null } let progress: MobileHostedReviewCreateIntentProgress | null = null const ran = await runGitWorkflow(pushFirst ? 'push-create-pr' : 'create-pr', async () => { created.current = await runMobileHostedReviewCreateIntent(client, worktreeId, { branch, title: branchLabel, status, commitMessage, onProgress: (nextProgress: MobileHostedReviewCreateIntentProgress) => { progress = nextProgress setActionError(mobileHostedReviewCreateIntentProgressMessage(nextProgress)) } }) if (!created.current.ok) { throw new Error(created.current.error) } }) const outcome = created.current if (outcome?.committed && mountedRef.current) { setCommitMessage('') } if (!ran && outcome?.status !== undefined && mountedRef.current) { await loadStatus({ preserveReadyOnFailure: true, clearActionErrorOnSuccess: false, force: true }) } if (!ran || !mountedRef.current || !outcome || !outcome.ok) { if (!ran && outcome && isMobileHostedReviewCommitFailure(outcome, progress)) { const outcomeStagedEntries = getMobileCommitFailureStagedEntries(outcome.status?.entries) recordCommitFailure({ error: outcome.error, commitMessage: outcome.commitMessage ?? commitMessage.trim(), stagedEntries: outcomeStagedEntries.length > 0 ? outcomeStagedEntries : stagedEntries }) } return } setActionError(null) setCreatedPrUrl(outcome.url) setCreatedPrWarning(outcome.warning ?? null) }, [ branchLabel, client, commitMessage, loadStatus, mountedRef, recordCommitFailure, runGitWorkflow, setActionError, setCommitMessage, setCreatedPrUrl, setCreatedPrWarning, setShowActionSheet, stagedEntries, status, worktreeId ] )` +- mobile/src/source-control/use-mobile-create-pr-runner.ts:83 — `!created.current.ok` +- mobile/src/source-control/use-mobile-create-pr-runner.ts:98 — `!ran || !mountedRef.current || !outcome || !outcome.ok` +- mobile/src/source-control/use-mobile-git-requests.ts:33 — `!response.ok` +- mobile/src/source-control/use-mobile-git-requests.ts:48 — `!result || result.success !== true` +- mobile/src/source-control/use-mobile-source-control-loaders.ts:127 — `!response.ok` +- mobile/src/source-control/use-mobile-source-control-loaders.ts:204 — `response.ok` +- mobile/src/source-control/use-mobile-source-control-openers.ts:120 — `!response.ok && isMobileGitUnavailable(response.error?.code, response.error?.message)` +- mobile/src/source-control/use-mobile-source-control-openers.ts:127 — `!response.ok` +- mobile/src/source-control/use-mobile-source-control-openers.ts:245 — `!response.ok` +- mobile/src/storage/codex-reset-attempt-journal.ts:105 — `!result.success || result.data.hostId !== identity.hostId || !stableAccountScopesEqual(result.data.expectedScope, identity.expectedScope)` +- mobile/src/storage/codex-reset-attempt-journal.ts:138 — `withScopeMutation(identity, async () => { const key = storageKey(identity) const raw = await AsyncStorage.getItem(key) if (raw !== null) { return parseAttempt(raw, identity) } const idempotencyKey = identity.createIdempotencyKey() if (!IdempotencyKeySchema.safeParse(idempotencyKey).success) { throw new Error('Codex reset attempt idempotency key is invalid') } const attempt = CodexResetAttemptSchema.parse({ v: 1, hostId: identity.hostId, expectedScope: identity.expectedScope, idempotencyKey }) // Why: the key must survive a committed provider mutation whose response is // lost; no reset RPC may start until this write has completed successfully. await AsyncStorage.setItem(key, JSON.stringify(attempt)) return attempt })` +- mobile/src/storage/codex-reset-attempt-journal.ts:146 — `!IdempotencyKeySchema.safeParse(idempotencyKey).success` +- mobile/src/tasks/composer-source-base-resolve.ts:37 — `!response.ok` +- mobile/src/tasks/composer-source-base-resolve.ts:68 — `!response.ok` +- mobile/src/tasks/mobile-tasks-item-mapping.ts:23 — `Boolean(response && typeof response === 'object' && (response as RpcSuccess).ok)` +- mobile/src/tasks/setup-hook-trust.ts:49 — `!response.ok` +- mobile/src/tasks/smart-source-paste-intent.ts:112 — `!response.ok && response.error.code === 'method_not_found'` +- mobile/src/tasks/smart-source-paste-intent.ts:118 — `response.ok` +- mobile/src/tasks/smart-source-paste-intent.ts:137 — `!response.ok` +- mobile/src/tasks/smart-source-paste-intent.ts:159 — `!response.ok` +- mobile/src/tasks/smart-source-paste-intent.ts:178 — `!response.ok` +- mobile/src/tasks/smart-source-search-requests.ts:34 — `!response.ok` +- mobile/src/tasks/smart-source-search-requests.ts:56 — `!response.ok` +- mobile/src/tasks/smart-source-search-requests.ts:88 — `!response.ok` +- mobile/src/tasks/smart-source-search-requests.ts:106 — `!response.ok` +- mobile/src/tasks/use-mobile-tasks-client-settings-actions.tsx:208 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-github-check-file-actions.tsx:47 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-github-check-file-actions.tsx:51 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-github-check-file-actions.tsx:90 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-github-check-file-actions.tsx:138 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-github-check-file-actions.tsx:204 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-github-check-file-actions.tsx:253 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-github-check-file-actions.tsx:261 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-github-reply-merge-actions.tsx:73 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-github-reply-merge-actions.tsx:81 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-github-reply-merge-actions.tsx:154 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-github-reply-merge-actions.tsx:158 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-github-reply-merge-actions.tsx:189 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-gitlab-github-status-actions.tsx:44 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-gitlab-github-status-actions.tsx:48 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-gitlab-github-status-actions.tsx:89 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-gitlab-github-status-actions.tsx:93 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-hosted-comment-review-actions.tsx:77 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-hosted-comment-review-actions.tsx:85 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-hosted-comment-review-actions.tsx:152 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-hosted-comment-review-actions.tsx:156 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-hosted-comment-review-actions.tsx:234 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-hosted-metadata-actions.tsx:48 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-hosted-metadata-actions.tsx:52 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-hosted-metadata-actions.tsx:131 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-hosted-metadata-actions.tsx:135 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx:55 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx:116 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx:207 — `!isSuccess(issueResponse)` +- mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx:211 — `isSuccess(commentsResponse)` +- mobile/src/tasks/use-mobile-tasks-item-detail-metadata-effects.tsx:55 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-item-detail-metadata-effects.tsx:89 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-linear-item-actions.tsx:46 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-linear-item-actions.tsx:50 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-linear-item-actions.tsx:87 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-linear-item-actions.tsx:127 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-linear-item-actions.tsx:138 — `result.ok === false || !result.id || !result.identifier` +- mobile/src/tasks/use-mobile-tasks-list-and-detail-effects.tsx:200 — `isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-list-and-detail-effects.tsx:241 — `isSuccess(statesResponse)` +- mobile/src/tasks/use-mobile-tasks-project-detail-loading.tsx:105 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-detail-loading.tsx:137 — `!result.ok` +- mobile/src/tasks/use-mobile-tasks-project-file-merge-actions.tsx:79 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-file-merge-actions.tsx:141 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-file-merge-actions.tsx:149 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-project-file-merge-actions.tsx:216 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-file-merge-actions.tsx:220 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-project-file-merge-actions.tsx:274 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-file-merge-actions.tsx:278 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-project-loading-actions.tsx:54 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-loading-actions.tsx:64 — `!result.ok` +- mobile/src/tasks/use-mobile-tasks-project-loading-actions.tsx:82 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-loading-actions.tsx:88 — `!result.ok` +- mobile/src/tasks/use-mobile-tasks-project-loading-actions.tsx:124 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-loading-actions.tsx:130 — `!result.ok` +- mobile/src/tasks/use-mobile-tasks-project-loading-actions.tsx:269 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-loading-actions.tsx:283 — `!result.ok` +- mobile/src/tasks/use-mobile-tasks-project-metadata-actions.tsx:57 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-metadata-actions.tsx:61 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-project-metadata-actions.tsx:168 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-metadata-actions.tsx:172 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-project-metadata-actions.tsx:234 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-metadata-actions.tsx:238 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-project-metadata-loading.tsx:51 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-metadata-loading.tsx:57 — `!result.ok` +- mobile/src/tasks/use-mobile-tasks-project-metadata-loading.tsx:106 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-metadata-loading.tsx:112 — `!result.ok` +- mobile/src/tasks/use-mobile-tasks-project-metadata-loading.tsx:164 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-metadata-loading.tsx:170 — `!result.ok` +- mobile/src/tasks/use-mobile-tasks-project-repository-resolution.tsx:67 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-review-check-actions.tsx:50 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-review-check-actions.tsx:54 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-project-review-check-actions.tsx:129 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-review-check-actions.tsx:174 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-review-check-actions.tsx:178 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-project-review-check-actions.tsx:216 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-thread-reply-actions.tsx:54 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-thread-reply-actions.tsx:61 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-project-thread-reply-actions.tsx:115 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-thread-reply-actions.tsx:189 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-thread-reply-actions.tsx:197 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx:117 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx:121 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx:199 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx:205 — `!result.ok` +- mobile/src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx:251 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx:258 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-provider-load-actions.tsx:49 — `!isSuccess(statusResponse)` +- mobile/src/tasks/use-mobile-tasks-provider-load-actions.tsx:70 — `!isSuccess(teamsResponse)` +- mobile/src/tasks/use-mobile-tasks-provider-load-actions.tsx:117 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-provider-load-actions.tsx:194 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-route-and-item-state.tsx:64 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx:198 — `!isSuccess(statusResponse)` +- mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx:262 — `isSuccess(settingsResponse)` +- mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx:267 — `isSuccess(uiResponse)` +- mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx:282 — `isSuccess(preflightResponse)` +- mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx:285 — `isSuccess(linearStatusResponse)` +- mobile/src/tasks/use-mobile-tasks-task-create-actions.tsx:61 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-task-create-actions.tsx:70 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-task-create-actions.tsx:118 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-task-create-actions.tsx:129 — `result.ok === false || !result.id || !result.identifier` +- mobile/src/tasks/use-mobile-tasks-task-create-actions.tsx:188 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-task-list-loading.tsx:146 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-task-list-loading.tsx:171 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-task-list-loading.tsx:223 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-task-pagination-actions.tsx:57 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-task-pagination-actions.tsx:61 — `result.ok === false` +- mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx:76 — `isSuccess(settingsResponse)` +- mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx:178 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx:223 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx:264 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-workspace-source-effects.tsx:54 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-workspace-source-effects.tsx:125 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx:91 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx:139 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx:55 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx:91 — `!isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx:129 — `isSuccess(response)` +- mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx:194 — `!isSuccess(response)` +- mobile/src/tasks/worktree-create-capability.ts:40 — `!response.ok` +- mobile/src/tasks/worktree-create-retry.ts:84 — `response.ok` +- mobile/src/terminal/terminal-send-rpc-response.ts:8 — `!response.ok` +- mobile/src/terminal/terminal-viewport-refit-state.ts:17 — `!response.ok || typeof response.result !== 'object' || response.result == null` +- mobile/src/terminal/terminal-viewport-refit-state.ts:24 — `!response.ok || typeof response.result !== 'object' || response.result == null` +- mobile/src/terminal/terminal-viewport-refit-state.ts:33 — `response.ok` +- mobile/src/terminal/worker-terminal-takeover-report.ts:45 — `!response.ok` +- mobile/src/transport/direct-rpc-client.ts:252 — `!response.ok && response.error.code === 'unauthorized'` +- mobile/src/transport/host-credential-cleanup.ts:87 — `!result.ok` +- mobile/src/transport/host-credential-cleanup.ts:99 — `!current.ok` +- mobile/src/transport/host-endpoint-edit.ts:27 — `!normalizedInput.ok` +- mobile/src/transport/host-endpoint-edit.ts:33 — `sameEndpointAuthority(normalizedInput.endpoint, storedEndpoint) || (normalizedDisplay.ok && sameEndpointAuthority(normalizedInput.endpoint, normalizedDisplay.endpoint))` +- mobile/src/transport/host-metadata-store.ts:40 — `parsed.flatMap((item) => { // Why: pre-v0.0.3 records embedded secrets; users re-pair instead of migrating them. if (item && typeof item === 'object' && 'deviceToken' in item) { return [] } const result = StoredHostProfileSchema.safeParse(item) return result.success ? [result.data] : [] })` +- mobile/src/transport/host-metadata-store.ts:46 — `result.success ? [result.data] : []` +- mobile/src/transport/host-metadata-store.ts:46 — `result.success` +- mobile/src/transport/host-status-gates.ts:54 — `!response.ok` +- mobile/src/transport/mobile-endpoint-supervisor-support.ts:55 — `first.ok || first.error instanceof RelayDialAbortedError || !isDirectorResolutionFailure(first.error) || !relay` +- mobile/src/transport/mobile-relay-credential-bundle.ts:84 — `result.success && result.data.hostId === hostId ? result.data : null` +- mobile/src/transport/mobile-relay-credential-bundle.ts:84 — `result.success && result.data.hostId === hostId` +- mobile/src/transport/mobile-relay-credential-rotation.ts:56 — `!response.ok` +- mobile/src/transport/mobile-relay-credential-rotation.ts:167 — `!response.ok` +- mobile/src/transport/mobile-relay-direct-upgrade-journal.ts:53 — `parsed.success && parsed.data.hostId === hostId ? parsed.data : null` +- mobile/src/transport/mobile-relay-direct-upgrade-journal.ts:53 — `parsed.success && parsed.data.hostId === hostId` +- mobile/src/transport/mobile-relay-direct-upgrade.ts:167 — `!response.ok` +- mobile/src/transport/mobile-relay-direct-upgrade.ts:174 — `!response.ok && response.error.code === 'method_not_found'` +- mobile/src/transport/mobile-relay-e2ee-link.ts:162 — `!parsed.success` +- mobile/src/transport/mobile-relay-e2ee-link.ts:165 — `!parsed.data.ok` +- mobile/src/transport/mobile-relay-host-overlay-store.ts:19 — `value.flatMap((item) => { const result = MobileRelayHostOverlaySchema.safeParse(item) return result.success ? [result.data] : [] })` +- mobile/src/transport/mobile-relay-host-overlay-store.ts:21 — `result.success ? [result.data] : []` +- mobile/src/transport/mobile-relay-host-overlay-store.ts:21 — `result.success` +- mobile/src/transport/mobile-relay-invite-director.ts:33 — `new Promise((resolve, reject) => { let settled = false const timeout = setTimeout( () => finish(new Error('relay director resolution timed out')), args.timeoutMs ?? 5_000 ) socket.onopen = () => { socket.send( JSON.stringify({ type: 'relay-auth', v: 1, mode: 'connect', credential: args.relay.inviteToken }) ) } socket.onmessage = (event) => { if (typeof event.data !== 'string') { finish(new Error('invalid relay director move')) return } let value: unknown try { value = JSON.parse(event.data) } catch { finish(new Error('invalid relay director move')) return } const moved = RelayMovedSchema.safeParse(value) if (!moved.success) { finish(new Error('invalid relay director move')) return } if (moved.data.assignmentEpoch <= args.relay.assignmentEpoch) { finish( new RelayDirectorMoveNotNewerError({ cellUrl: moved.data.cellUrl, assignmentEpoch: moved.data.assignmentEpoch, currentCellUrl: args.relay.cellUrl, currentAssignmentEpoch: args.relay.assignmentEpoch }) ) return } settled = true clearTimeout(timeout) socket.close() resolve({ ...args.relay, cellUrl: moved.data.cellUrl, assignmentEpoch: moved.data.assignmentEpoch }) } socket.onerror = () => finish(new Error('relay director transport error')) socket.onclose = (event) => { if (!settled) { finish(new Error(\`relay director closed before move: ${event.code || 1006}\`)) } } function finish(error: Error): void { if (settled) { return } settled = true clearTimeout(timeout) socket.close() reject(error) } })` +- mobile/src/transport/mobile-relay-invite-director.ts:62 — `!moved.success` +- mobile/src/transport/mobile-relay-pairing-journal-store.ts:128 — `result.success ? result.data : null` +- mobile/src/transport/mobile-relay-pairing-journal-store.ts:128 — `result.success` +- mobile/src/transport/mobile-relay-pairing-journal-store.ts:137 — `result.success ? result.data : null` +- mobile/src/transport/mobile-relay-pairing-journal-store.ts:137 — `result.success` +- mobile/src/transport/mobile-relay-pairing-recovery.ts:298 — `!response.ok` +- mobile/src/transport/mobile-relay-physical-client.ts:144 — `!parsed.success` +- mobile/src/transport/mobile-relay-physical-client.ts:147 — `!parsed.data.ok` +- mobile/src/transport/mobile-relay-resume-director.ts:55 — `!response.ok` +- mobile/src/transport/mobile-relay-rpc-session.ts:182 — `!response.ok` +- mobile/src/transport/mobile-relay-rpc-streams.ts:105 — `!response.ok` +- mobile/src/transport/mobile-relay-rpc-streams.ts:122 — `response.ok && response.streaming !== true` +- mobile/src/transport/mobile-relay-rpc-streams.ts:131 — `!response.ok` +- mobile/src/transport/mobile-relay-session-establisher.ts:61 — `result.ok` +- mobile/src/transport/mobile-runtime-capability-negotiation.ts:32 — `!response.ok` +- mobile/src/transport/pairing-candidate-race.ts:13 — `new Promise((resolve, reject) => { const successes: PairingCandidate[] = [] let failures = 0 let settled = false let selectionQueued = false for (const candidate of candidates) { void candidate.client.sendRequest('status.get').then( (response) => { if (!response.ok) { failures++ rejectIfFinished() return } successes.push(candidate) if (selectionQueued) { return } selectionQueued = true // Why: defer one microtask so simultaneous successes are visible and // direct deterministically wins the exact tie regardless of callback order. queueMicrotask(() => { if (settled) { return } settled = true const winner = successes.find(({ path }) => path === 'direct') ?? successes[0]! for (const loser of candidates) { if (loser !== winner) { loser.client.close() } } resolve(winner) }) }, () => { failures++ rejectIfFinished() } ) } function rejectIfFinished(): void { if (!settled && failures === candidates.length && successes.length === 0) { settled = true reject(new Error('direct and relay pairing paths both failed')) } } })` +- mobile/src/transport/pairing-candidate-race.ts:21 — `!response.ok` +- mobile/src/transport/pre-profile-pairing-coordinator.ts:287 — `!response.ok` +- mobile/src/transport/pre-profile-pairing-coordinator.ts:294 — `!response.ok && response.error.code === 'method_not_found'` +- mobile/src/transport/rpc-client-socket-session.ts:235 — `message.type === 'e2ee_error' || (message.ok === false && message.error?.code === 'unauthorized')` +- mobile/src/transport/rpc-client-stream-registry.ts:115 — `response.ok && response.streaming === true` +- mobile/src/transport/rpc-client-stream-registry.ts:120 — `response.ok` +- mobile/src/transport/rpc-client-stream-registry.ts:139 — `response.ok` +- mobile/src/transport/rpc-client-stream-registry.ts:140 — `response.ok` +- mobile/src/transport/rpc-response-shape.ts:15 — `response.ok === true` +- mobile/src/transport/rpc-response-shape.ts:18 — `( response.ok === false && !!response.error && typeof response.error.code === 'string' && typeof response.error.message === 'string' )` +- mobile/src/transport/runtime-capability-probe.ts:26 — `!response.ok` +- mobile/src/worktree/home-host-worktree-fetch.ts:44 — `!response.ok` +- mobile/src/worktree/worktree-catalog-snapshot-client.ts:78 — `!response.ok` +- mobile/src/worktree/worktree-show-resolution.ts:26 — `response.ok` diff --git a/mobile/rpc-foundation/access-inventory.json b/mobile/rpc-foundation/access-inventory.json index 5e0c59af88c..993af7a4101 100644 --- a/mobile/rpc-foundation/access-inventory.json +++ b/mobile/rpc-foundation/access-inventory.json @@ -1,12 +1,7 @@ { "schemaVersion": 1, - "scope": [ - "mobile/src", - "mobile/app" - ], - "excludes": [ - "*.generated.*" - ], + "scope": ["mobile/src", "mobile/app"], + "excludes": [], "entries": [ { "file": "mobile/app/connection-log.tsx", @@ -316,12 +311,7 @@ "role": "call", "descriptor": { "kind": "caller-resolved", - "methods": [ - "browser.back", - "browser.forward", - "browser.goto", - "browser.reload" - ] + "methods": ["browser.back", "browser.forward", "browser.goto", "browser.reload"] } }, { @@ -1813,11 +1803,7 @@ "role": "call", "descriptor": { "kind": "caller-resolved", - "methods": [ - "ssh.getState", - "status.get", - "worktree.show" - ] + "methods": ["ssh.getState", "status.get", "worktree.show"] } }, { @@ -2941,11 +2927,7 @@ "role": "call", "descriptor": { "kind": "caller-resolved", - "methods": [ - "host.platform", - "settings.get", - "ssh.listTargetSummaries" - ] + "methods": ["host.platform", "settings.get", "ssh.listTargetSummaries"] } }, { @@ -4283,10 +4265,7 @@ "role": "call", "descriptor": { "kind": "caller-resolved", - "methods": [ - "github.resolveReviewThread", - "github.updatePRTitle" - ] + "methods": ["github.resolveReviewThread", "github.updatePRTitle"] } }, { @@ -7406,7 +7385,10 @@ "agentSession.hold", "agentSession.options", "agentSession.release", - "agentSession.send" + "agentSession.respondToApproval", + "agentSession.respondToQuestion", + "agentSession.send", + "agentSession.setOption" ] } }, @@ -8415,11 +8397,7 @@ "role": "call", "descriptor": { "kind": "caller-resolved", - "methods": [ - "git.discard", - "git.stage", - "git.unstage" - ] + "methods": ["git.discard", "git.stage", "git.unstage"] } }, { @@ -10684,6 +10662,62 @@ "methods": [] } }, + { + "file": "mobile/src/session/use-mobile-native-chat-file-search.test.ts", + "line": 149, + "column": 63, + "symbol": "'sendRequest'", + "method": "dynamic", + "kind": "request", + "policy": "unclassified", + "role": "reference", + "descriptor": { + "kind": "access-reference", + "methods": [] + } + }, + { + "file": "mobile/src/session/use-mobile-native-chat-file-search.test.ts", + "line": 150, + "column": 11, + "symbol": "sendRequest", + "method": "dynamic", + "kind": "request", + "policy": "unclassified", + "role": "reference", + "descriptor": { + "kind": "access-reference", + "methods": [] + } + }, + { + "file": "mobile/src/session/use-mobile-native-chat-file-search.test.ts", + "line": 158, + "column": 52, + "symbol": "'sendRequest'", + "method": "dynamic", + "kind": "request", + "policy": "unclassified", + "role": "reference", + "descriptor": { + "kind": "access-reference", + "methods": [] + } + }, + { + "file": "mobile/src/session/use-mobile-native-chat-file-search.test.ts", + "line": 162, + "column": 22, + "symbol": "sendRequest", + "method": "dynamic", + "kind": "request", + "policy": "unclassified", + "role": "reference", + "descriptor": { + "kind": "access-reference", + "methods": [] + } + }, { "file": "mobile/src/session/use-mobile-native-chat-file-search.ts", "line": 91, @@ -13028,11 +13062,7 @@ "role": "call", "descriptor": { "kind": "caller-resolved", - "methods": [ - "browser.back", - "browser.forward", - "browser.reload" - ] + "methods": ["browser.back", "browser.forward", "browser.reload"] } }, { @@ -14628,10 +14658,7 @@ "role": "call", "descriptor": { "kind": "caller-resolved", - "methods": [ - "git.bulkStage", - "git.push" - ] + "methods": ["git.bulkStage", "git.push"] } }, { @@ -16385,10 +16412,7 @@ "role": "call", "descriptor": { "kind": "caller-resolved", - "methods": [ - "gitlab.addIssueComment", - "gitlab.addMRComment" - ] + "methods": ["gitlab.addIssueComment", "gitlab.addMRComment"] } }, { @@ -16432,10 +16456,49 @@ "role": "call", "descriptor": { "kind": "caller-resolved", - "methods": [ - "gitlab.updateIssue", - "gitlab.updateMR" - ] + "methods": ["gitlab.updateIssue", "gitlab.updateMR"] + } + }, + { + "file": "mobile/src/tasks/use-mobile-tasks-item-detail-loading.test.ts", + "line": 19, + "column": 11, + "symbol": "sendRequest", + "method": "dynamic", + "kind": "request", + "policy": "unclassified", + "role": "reference", + "descriptor": { + "kind": "access-reference", + "methods": [] + } + }, + { + "file": "mobile/src/tasks/use-mobile-tasks-item-detail-loading.test.ts", + "line": 24, + "column": 17, + "symbol": "sendRequest", + "method": "dynamic", + "kind": "request", + "policy": "unclassified", + "role": "reference", + "descriptor": { + "kind": "access-reference", + "methods": [] + } + }, + { + "file": "mobile/src/tasks/use-mobile-tasks-item-detail-loading.test.ts", + "line": 46, + "column": 12, + "symbol": "sendRequest", + "method": "dynamic", + "kind": "request", + "policy": "unclassified", + "role": "reference", + "descriptor": { + "kind": "access-reference", + "methods": [] } }, { @@ -16558,6 +16621,48 @@ "policy": "unclassified", "role": "call" }, + { + "file": "mobile/src/tasks/use-mobile-tasks-project-file-merge-actions.test.ts", + "line": 12, + "column": 11, + "symbol": "sendRequest", + "method": "dynamic", + "kind": "request", + "policy": "unclassified", + "role": "reference", + "descriptor": { + "kind": "access-reference", + "methods": [] + } + }, + { + "file": "mobile/src/tasks/use-mobile-tasks-project-file-merge-actions.test.ts", + "line": 18, + "column": 17, + "symbol": "sendRequest", + "method": "dynamic", + "kind": "request", + "policy": "unclassified", + "role": "reference", + "descriptor": { + "kind": "access-reference", + "methods": [] + } + }, + { + "file": "mobile/src/tasks/use-mobile-tasks-project-file-merge-actions.test.ts", + "line": 33, + "column": 12, + "symbol": "sendRequest", + "method": "dynamic", + "kind": "request", + "policy": "unclassified", + "role": "reference", + "descriptor": { + "kind": "access-reference", + "methods": [] + } + }, { "file": "mobile/src/tasks/use-mobile-tasks-project-file-merge-actions.tsx", "line": 65, @@ -16599,10 +16704,7 @@ "role": "call", "descriptor": { "kind": "caller-resolved", - "methods": [ - "github.updateIssue", - "github.updatePRState" - ] + "methods": ["github.updateIssue", "github.updatePRState"] } }, { @@ -16666,10 +16768,7 @@ "role": "call", "descriptor": { "kind": "caller-resolved", - "methods": [ - "github.project.clearItemField", - "github.project.updateItemField" - ] + "methods": ["github.project.clearItemField", "github.project.updateItemField"] } }, { @@ -16813,10 +16912,7 @@ "role": "call", "descriptor": { "kind": "caller-resolved", - "methods": [ - "github.project.updateIssueBySlug", - "github.project.updatePullRequestBySlug" - ] + "methods": ["github.project.updateIssueBySlug", "github.project.updatePullRequestBySlug"] } }, { @@ -16960,10 +17056,7 @@ "role": "call", "descriptor": { "kind": "caller-resolved", - "methods": [ - "github.createIssue", - "gitlab.createIssue" - ] + "methods": ["github.createIssue", "gitlab.createIssue"] } }, { @@ -17887,7 +17980,10 @@ "agentSession.hold", "agentSession.options", "agentSession.release", + "agentSession.respondToApproval", + "agentSession.respondToQuestion", "agentSession.send", + "agentSession.setOption", "aiVault.listSessions", "aiVault.prepareSessionResume", "browser.back", @@ -18068,11 +18164,7 @@ ], "owner": "Jinwoo", "policyId": "transport-forwarding", - "scenarioIds": [ - "transport:fulfilled", - "transport:refused", - "transport:rejection" - ], + "scenarioIds": ["transport:fulfilled", "transport:refused", "transport:rejection"], "reason": "transport port, below the operation boundary" } }, @@ -18116,11 +18208,7 @@ ], "owner": "Jinwoo", "policyId": "transport-forwarding", - "scenarioIds": [ - "transport:fulfilled", - "transport:refused", - "transport:rejection" - ], + "scenarioIds": ["transport:fulfilled", "transport:refused", "transport:rejection"], "reason": "transport port, below the operation boundary" } }, @@ -18944,11 +19032,7 @@ ], "owner": "Jinwoo", "policyId": "transport-forwarding", - "scenarioIds": [ - "transport:fulfilled", - "transport:refused", - "transport:rejection" - ], + "scenarioIds": ["transport:fulfilled", "transport:refused", "transport:rejection"], "reason": "transport port, below the operation boundary" } }, @@ -18963,10 +19047,7 @@ "role": "call", "descriptor": { "kind": "caller-resolved", - "methods": [ - "browser.screencast", - "runtime.clientEvents.subscribe" - ] + "methods": ["browser.screencast", "runtime.clientEvents.subscribe"] } }, { @@ -18980,10 +19061,7 @@ "role": "call", "descriptor": { "kind": "caller-resolved", - "methods": [ - "browser.screencast", - "runtime.clientEvents.subscribe" - ] + "methods": ["browser.screencast", "runtime.clientEvents.subscribe"] } }, { @@ -19485,7 +19563,10 @@ "agentSession.hold", "agentSession.options", "agentSession.release", + "agentSession.respondToApproval", + "agentSession.respondToQuestion", "agentSession.send", + "agentSession.setOption", "aiVault.listSessions", "aiVault.prepareSessionResume", "browser.back", @@ -19666,11 +19747,7 @@ ], "owner": "Jinwoo", "policyId": "transport-forwarding", - "scenarioIds": [ - "transport:fulfilled", - "transport:refused", - "transport:rejection" - ], + "scenarioIds": ["transport:fulfilled", "transport:refused", "transport:rejection"], "reason": "transport port, below the operation boundary" } }, @@ -21243,7 +21320,10 @@ "agentSession.hold", "agentSession.options", "agentSession.release", + "agentSession.respondToApproval", + "agentSession.respondToQuestion", "agentSession.send", + "agentSession.setOption", "aiVault.listSessions", "aiVault.prepareSessionResume", "browser.back", @@ -21424,11 +21504,7 @@ ], "owner": "Jinwoo", "policyId": "transport-forwarding", - "scenarioIds": [ - "transport:fulfilled", - "transport:refused", - "transport:rejection" - ], + "scenarioIds": ["transport:fulfilled", "transport:refused", "transport:rejection"], "reason": "transport port, below the operation boundary" } }, @@ -21486,11 +21562,7 @@ ], "owner": "Jinwoo", "policyId": "transport-forwarding", - "scenarioIds": [ - "transport:fulfilled", - "transport:refused", - "transport:rejection" - ], + "scenarioIds": ["transport:fulfilled", "transport:refused", "transport:rejection"], "reason": "transport port, below the operation boundary" } }, diff --git a/mobile/rpc-foundation/named-deltas.json b/mobile/rpc-foundation/named-deltas.json index 55e72ecf685..01ddb8be3af 100644 --- a/mobile/rpc-foundation/named-deltas.json +++ b/mobile/rpc-foundation/named-deltas.json @@ -106,6 +106,16 @@ "file": "mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx", "line": 189 } + }, + { + "id": "new-workspace-runtime-context-null-settings-typeerror", + "status": "ticket", + "description": "useNewWorkspaceRuntimeContext throws an unhandled TypeError when settings.get fulfills with a null/absent result: .settings is read off a cast with no guard and the void async effect has no catch", + "main": { + "sha": "aac38d698f", + "file": "mobile/src/components/use-new-workspace-runtime-context.ts", + "line": 53 + } } ] } diff --git a/mobile/rpc-foundation/reference-regressions.json b/mobile/rpc-foundation/reference-regressions.json new file mode 100644 index 00000000000..63f632575c9 --- /dev/null +++ b/mobile/rpc-foundation/reference-regressions.json @@ -0,0 +1,40 @@ +{ + "revision": "bcba08b3e4675fe0c687fe6346655f8ce66cd138", + "sources": [ + { + "file": "mobile/src/session/native-host-session-native-chat-operations.ts", + "sha256": "2b8bf516424f730617826e3e6e180a5c3e7b3b626d0aba759c695677968d3000" + }, + { + "file": "mobile/src/session/github-pr-mutations.ts", + "sha256": "9d07404d2936a7eca4a0b97aad495ae0adab01b18a15d37d9796d762edc0739a" + }, + { + "file": "mobile/src/tasks/native-host-task-project-mutation-operations.ts", + "sha256": "360e0609c30ce099c9bba014b3bb8b7458f0af453351e0614cc1a12b5608a594" + }, + { + "file": "mobile/src/tasks/native-host-task-detail-operations.ts", + "sha256": "92df0e2f312caeb7d6f8af20132ce6553d3343904ca2422c4a89c45344cec66b" + } + ], + "supportingFunctions": ["rankSuggestions", "buildGithubPrParams", "githubPrRepoSlugParam"], + "supportingSource": "current main; ranking and sender parameter construction only, never reply acceptance or lifecycle", + "results": [ + { + "id": "b1", + "killed": true, + "observed": "[\"stale/app.ts\"]" + }, + { + "id": "b2", + "killed": true, + "observed": "null result accepted" + }, + { + "id": "b3", + "killed": true, + "observed": "issue refused" + } + ] +} diff --git a/mobile/scripts/rpc-acceptance-census.mts b/mobile/scripts/rpc-acceptance-census.mts new file mode 100644 index 00000000000..5ecada118f7 --- /dev/null +++ b/mobile/scripts/rpc-acceptance-census.mts @@ -0,0 +1,56 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript' +import { files, root } from './rpc-artifact-io.mts' + +const predicates: { file: string; line: number; expression: string }[] = [] +for (const file of files(join(root, 'mobile/src')).filter( + (file) => /\.tsx?$/.test(file) && !/\.test\.|test-support|\.generated\./.test(file) +)) { + const sf = ts.createSourceFile( + file, + readFileSync(join(root, file), 'utf8'), + ts.ScriptTarget.Latest, + true + ) + const visit = (node: ts.Node): void => { + const expression = ts.isIfStatement(node) + ? node.expression + : ts.isConditionalExpression(node) + ? node.condition + : ts.isReturnStatement(node) + ? node.expression + : undefined + if (expression && /\.ok\b|\.success\b|\bisSuccess\(/.test(expression.getText(sf))) { + predicates.push({ + file, + line: sf.getLineAndCharacterOfPosition(expression.getStart(sf)).line + 1, + expression: expression.getText(sf).replace(/\s+/g, ' ') + }) + } + ts.forEachChild(node, visit) + } + visit(sf) +} +const helpers = [ + 'mobile/src/transport/mobile-relay-direct-upgrade.ts:166 requireSuccess', + 'mobile/src/transport/mobile-relay-pairing-recovery.ts:297 requireSuccess', + 'mobile/src/transport/pre-profile-pairing-coordinator.ts:286 requireSuccess' +] +const text = + '# Step 0.5 acceptance consolidation remains separate\n\nKeep all current behavior, including malformed-result exceptions; classify the following predicates into named policy families before replacing any caller. The census includes if/conditional/return expressions using `.ok`, `.success`, or `isSuccess`, with exact source locations; it deliberately retains transport and non-RPC matches for review.\n\nThree existing definitions to remove only after partition fixtures exist:\n\n' + + helpers.map((value) => `- ${value}`).join('\n') + + '\n\nInline predicate census:\n\n' + + predicates + .map((row) => `- ${row.file}:${row.line} — \`${row.expression.replace(/`/g, '\\`')}\``) + .join('\n') + + '\n' +const path = join(root, 'mobile/rpc-foundation/STEP0_5-TODO.md') +if (process.argv.includes('--check')) { + if (readFileSync(path, 'utf8') !== text) { + throw new Error('Stale acceptance census') + } +} else { + writeFileSync(path, text) +} +console.log(`acceptance census: 3 definitions, ${predicates.length} predicate expressions`) diff --git a/mobile/scripts/rpc-access-inventory.mts b/mobile/scripts/rpc-access-inventory.mts index cfc8e61b8d9..d57f32bcd5c 100644 --- a/mobile/scripts/rpc-access-inventory.mts +++ b/mobile/scripts/rpc-access-inventory.mts @@ -1,5 +1,6 @@ import { join } from 'node:path' import ts from 'typescript' +import { createRpcAccessResolver } from './rpc-access-resolution.mts' import { emit, files, root } from './rpc-artifact-io.mts' export type AccessEntry = { @@ -21,7 +22,7 @@ export type AccessEntry = { } } const paths = [...files(join(root, 'mobile/src')), ...files(join(root, 'mobile/app'))].filter( - (file) => /\.[jt]sx?$/.test(file) && !/\.generated\./.test(file) + (file) => /\.[jt]sx?$/.test(file) ) const config = ts.readConfigFile(join(root, 'mobile/tsconfig.json'), ts.sys.readFile) const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, join(root, 'mobile')) @@ -31,123 +32,18 @@ const program = ts.createProgram( ) const checker = program.getTypeChecker() const entries: AccessEntry[] = [] -function entryKind(name: string | undefined): AccessEntry['kind'] | undefined { - return name === 'sendRequest' ? 'request' : name === 'subscribe' ? 'subscribe' : undefined -} -function resolveKind(node: ts.Node, seen = new Set()): AccessEntry['kind'] | undefined { - if (seen.has(node)) return - seen.add(node) - if (ts.isPropertyAccessExpression(node)) return entryKind(node.name.text) - if ( - ts.isElementAccessExpression(node) && - node.argumentExpression && - ts.isStringLiteral(node.argumentExpression) - ) - return entryKind(node.argumentExpression.text) - if ( - ts.isCallExpression(node) && - ts.isPropertyAccessExpression(node.expression) && - node.expression.name.text === 'bind' - ) - return resolveKind(node.expression.expression, seen) - if (!ts.isIdentifier(node)) return - const direct = entryKind(node.text) - if (direct) return direct - let symbol = checker.getSymbolAtLocation(node) - if (symbol && symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol) - for (const declaration of symbol?.declarations ?? []) { - if (ts.isVariableDeclaration(declaration) && declaration.initializer) { - const kind = resolveKind(declaration.initializer, seen) - if (kind) return kind - } - if (ts.isBindingElement(declaration)) - return entryKind( - (declaration.propertyName ?? declaration.name).getText().replace(/['"]/g, '') - ) - } -} -const calls: ts.CallExpression[] = [] -for (const file of paths) { - const walk = (node: ts.Node): void => { - if (ts.isCallExpression(node)) calls.push(node) - ts.forEachChild(node, walk) - } - walk(program.getSourceFile(join(root, file))!) -} -function methods(node: ts.Expression | undefined, seen = new Set()): string[] { - if (!node) return [] - if (ts.isStringLiteralLike(node)) return [node.text] - if (seen.has(node)) return [] - seen = new Set(seen).add(node) - const type = checker.getTypeAtLocation(node) - const types = type.isUnion() ? type.types : [type] - if (types.every((item) => item.isStringLiteral())) - return types.map((item) => (item as ts.StringLiteralType).value).sort() - const symbol = checker.getSymbolAtLocation(node) - for (const declaration of symbol?.declarations ?? []) { - if (ts.isVariableDeclaration(declaration) && declaration.initializer) - return methods(declaration.initializer, seen) - if (ts.isBindingElement(declaration) && ts.isObjectBindingPattern(declaration.parent)) { - const binding = declaration.parent.parent - if (ts.isVariableDeclaration(binding) && binding.initializer) { - const key = (declaration.propertyName ?? declaration.name).getText() - const target = checker.getSymbolAtLocation(binding.initializer) - for (const param of target?.declarations ?? []) { - if (!ts.isParameter(param) || !ts.isFunctionLike(param.parent)) continue - const index = param.parent.parameters.indexOf(param) - const values = calls - .filter((call) => checker.getResolvedSignature(call)?.declaration === param.parent) - .map((call) => call.arguments[index]) - .flatMap((arg) => { - if (!arg || !ts.isObjectLiteralExpression(arg)) return [] - return arg.properties - .filter((property) => property.name?.getText() === key) - .flatMap((property) => - ts.isPropertyAssignment(property) - ? methods(property.initializer, seen) - : ts.isShorthandPropertyAssignment(property) - ? methods(property.name, seen) - : [] - ) - }) - if (values.length) return [...new Set(values)].sort() - } - } - } - if (ts.isShorthandPropertyAssignment(declaration)) { - const value = checker.getShorthandAssignmentValueSymbol(declaration) - for (const item of value?.declarations ?? []) - if (ts.isParameter(item)) { - const owner = item.parent - if (ts.isFunctionLike(owner)) { - const index = owner.parameters.indexOf(item) - const values = calls - .filter((call) => checker.getResolvedSignature(call)?.declaration === owner) - .flatMap((call) => methods(call.arguments[index], seen)) - if (values.length) return [...new Set(values)].sort() - } - } - } - if (ts.isParameter(declaration)) { - const owner = declaration.parent - if (!ts.isFunctionLike(owner)) continue - const index = owner.parameters.indexOf(declaration) - const callerArgs = calls - .filter((call) => checker.getResolvedSignature(call)?.declaration === owner) - .map((call) => call.arguments[index]) - const resolved = callerArgs.map((arg) => methods(arg, seen)) - if (resolved.length && resolved.every((values) => values.length)) - return [...new Set(resolved.flat())].sort() - } - } - return [] -} +const { entryKind, resolveKind, methods, calls } = createRpcAccessResolver( + program, + paths.map((file) => join(root, file)) +) for (const file of paths) { const sf = program.getSourceFile(join(root, file))! const positions = new Set() function add(node: ts.Node, kind: AccessEntry['kind'], call?: ts.CallExpression): void { const start = node.getStart(sf) - if (positions.has(start)) return + if (positions.has(start)) { + return + } positions.add(start) const location = sf.getLineAndCharacterOfPosition(start) const literals = methods(call?.arguments[0]) @@ -161,8 +57,11 @@ for (const file of paths) { policy: 'unclassified', role: call ? 'call' : 'reference' } - if (!call) base.descriptor = { kind: 'access-reference', methods: [] } - else if (literals.length > 1) base.descriptor = { kind: 'caller-resolved', methods: literals } + if (!call) { + base.descriptor = { kind: 'access-reference', methods: [] } + } else if (literals.length > 1) { + base.descriptor = { kind: 'caller-resolved', methods: literals } + } if (call && base.method === 'dynamic' && !base.descriptor) { const parameter = checker.getResolvedSignature(call)?.parameters[0] const parameterType = parameter && checker.getTypeOfSymbolAtLocation(parameter, call) @@ -172,23 +71,24 @@ for (const file of paths) { 'mobile/app/connection-log.tsx', 'mobile/src/session/use-mobile-native-chat-terminal-stream.ts' ].includes(file) - ) + ) { base.descriptor = { kind: 'non-rpc-listener', methods: [], reason: 'store listener or terminal handle callback; raw RPC is inventoried at its sender' } - else if ( + } else if ( kind === 'subscribe' && parameterType && !( parameterType.flags & (ts.TypeFlags.String | ts.TypeFlags.StringLiteral | ts.TypeFlags.Any | ts.TypeFlags.Union) ) - ) + ) { base.descriptor = { kind: 'non-rpc-listener', methods: [] } - else if (/\.test\./.test(file)) base.descriptor = { kind: 'test-reference', methods: [] } - else if (file.startsWith('mobile/src/transport/')) + } else if (/\.test\./.test(file)) { + base.descriptor = { kind: 'test-reference', methods: [] } + } else if (file.startsWith('mobile/src/transport/')) { base.descriptor = { kind: 'transport-exception', methods: [ @@ -203,7 +103,7 @@ for (const file of paths) { scenarioIds: ['transport:fulfilled', 'transport:refused', 'transport:rejection'], reason: 'transport port, below the operation boundary' } - else if (file.endsWith('/use-mobile-git-requests.ts')) { + } else if (file.endsWith('/use-mobile-git-requests.ts')) { const descriptorSource = program.getSourceFile( join(root, 'mobile/src/source-control/mobile-git-operation-descriptors.ts') )! @@ -213,8 +113,9 @@ for (const file of paths) { ts.isPropertyAssignment(part) && part.name.getText() === 'method' && ts.isStringLiteral(part.initializer) - ) + ) { literalMethods.push(part.initializer.text) + } ts.forEachChild(part, collect) } collect(descriptorSource) @@ -226,16 +127,22 @@ for (const file of paths) { function visit(node: ts.Node): void { if (ts.isCallExpression(node)) { const kind = resolveKind(node.expression) - if (kind) add(node.expression, kind, node) + if (kind) { + add(node.expression, kind, node) + } } if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { const kind = resolveKind(node) - if (kind) add(node, kind) + if (kind) { + add(node, kind) + } } else if (ts.isIdentifier(node) || ts.isStringLiteral(node)) { const parent = node.parent if (!(ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent))) { const kind = ts.isIdentifier(node) ? resolveKind(node) : entryKind(node.text) - if (kind) add(node, kind) + if (kind) { + add(node, kind) + } } } ts.forEachChild(node, visit) @@ -245,15 +152,16 @@ for (const file of paths) { entries.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column) if (process.argv.includes('--require-no-dynamic-calls')) { const unresolved = entries.filter((entry) => entry.method === 'dynamic' && !entry.descriptor) - if (unresolved.length) + if (unresolved.length) { throw new Error( `Dynamic calls lack descriptors:\n${unresolved.map((entry) => `${entry.file}:${entry.line} ${entry.symbol}`).join('\n')}` ) + } } emit('access-inventory', { schemaVersion: 1, scope: ['mobile/src', 'mobile/app'], - excludes: ['*.generated.*'], + excludes: [], entries }) console.log( diff --git a/mobile/scripts/rpc-access-resolution.mts b/mobile/scripts/rpc-access-resolution.mts new file mode 100644 index 00000000000..38b4dfdae8e --- /dev/null +++ b/mobile/scripts/rpc-access-resolution.mts @@ -0,0 +1,171 @@ +import ts from 'typescript' +export type RpcAccessKind = 'request' | 'subscribe' +export function createRpcAccessResolver(program: ts.Program, paths: string[]) { + const checker = program.getTypeChecker() + function entryKind(name: string | undefined): RpcAccessKind | undefined { + return name === 'sendRequest' ? 'request' : name === 'subscribe' ? 'subscribe' : undefined + } + function resolveKind(node: ts.Node, seen = new Set()): RpcAccessKind | undefined { + if (seen.has(node)) { + return + } + seen.add(node) + if (ts.isPropertyAccessExpression(node)) { + return entryKind(node.name.text) + } + if (ts.isElementAccessExpression(node) && node.argumentExpression) { + const keyType = checker.getTypeAtLocation(node.argumentExpression) + if (keyType.isStringLiteral()) { + return entryKind(keyType.value) + } + if (checker.getTypeAtLocation(node.expression).getProperty('sendRequest')) { + return 'request' + } + } + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === 'bind' + ) { + return resolveKind(node.expression.expression, seen) + } + if (!ts.isIdentifier(node)) { + return + } + const direct = entryKind(node.text) + if (direct) { + return direct + } + let symbol = checker.getSymbolAtLocation(node) + if (symbol && symbol.flags & ts.SymbolFlags.Alias) { + symbol = checker.getAliasedSymbol(symbol) + } + for (const declaration of symbol?.declarations ?? []) { + if (ts.isVariableDeclaration(declaration) && declaration.initializer) { + const kind = resolveKind(declaration.initializer, seen) + if (kind) { + return kind + } + } + if (ts.isBindingElement(declaration)) { + return entryKind( + (declaration.propertyName ?? declaration.name).getText().replace(/['"]/g, '') + ) + } + } + } + const calls: ts.CallExpression[] = [] + for (const file of paths) { + const walk = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + calls.push(node) + } + ts.forEachChild(node, walk) + } + walk(program.getSourceFile(file)!) + } + function callers(owner: ts.SignatureDeclaration): ts.CallExpression[] { + const direct = calls.filter((call) => checker.getResolvedSignature(call)?.declaration === owner) + if (direct.length) { + return direct + } + if ( + owner.getSourceFile().fileName.endsWith('use-mobile-structured-agent-session.ts') && + owner.parameters[0]?.name.getText() === 'method' + ) { + return calls.filter((call) => { + const declaration = checker.getResolvedSignature(call)?.declaration + return ( + declaration?.parent && + ts.isTypeAliasDeclaration(declaration.parent) && + declaration.parent.name.text === 'StructuredAgentSessionMutate' + ) + }) + } + return [] + } + function methods(node: ts.Expression | undefined, seen = new Set()): string[] { + if (!node) { + return [] + } + if (ts.isStringLiteralLike(node)) { + return [node.text] + } + if (seen.has(node)) { + return [] + } + seen = new Set(seen).add(node) + const type = checker.getTypeAtLocation(node) + const types = type.isUnion() ? type.types : [type] + if (types.every((item) => item.isStringLiteral())) { + return types.map((item) => (item as ts.StringLiteralType).value).sort() + } + const symbol = checker.getSymbolAtLocation(node) + for (const declaration of symbol?.declarations ?? []) { + if (ts.isVariableDeclaration(declaration) && declaration.initializer) { + return methods(declaration.initializer, seen) + } + if (ts.isBindingElement(declaration) && ts.isObjectBindingPattern(declaration.parent)) { + const binding = declaration.parent.parent + if (ts.isVariableDeclaration(binding) && binding.initializer) { + const key = (declaration.propertyName ?? declaration.name).getText() + const target = checker.getSymbolAtLocation(binding.initializer) + for (const param of target?.declarations ?? []) { + if (!ts.isParameter(param) || !ts.isFunctionLike(param.parent)) { + continue + } + const index = param.parent.parameters.indexOf(param) + const values = callers(param.parent) + .map((call) => call.arguments[index]) + .map((arg) => { + if (!arg || !ts.isObjectLiteralExpression(arg)) { + return [] + } + return arg.properties + .filter((property) => property.name?.getText() === key) + .flatMap((property) => + ts.isPropertyAssignment(property) + ? methods(property.initializer, seen) + : ts.isShorthandPropertyAssignment(property) + ? methods(property.name, seen) + : [] + ) + }) + if (values.length && values.every((value) => value.length)) { + return [...new Set(values.flat())].sort() + } + } + } + } + if (ts.isShorthandPropertyAssignment(declaration)) { + const value = checker.getShorthandAssignmentValueSymbol(declaration) + for (const item of value?.declarations ?? []) { + if (ts.isParameter(item)) { + const owner = item.parent + if (ts.isFunctionLike(owner)) { + const index = owner.parameters.indexOf(item) + const values = callers(owner).map((call) => methods(call.arguments[index], seen)) + if (values.length && values.every((value) => value.length)) { + return [...new Set(values.flat())].sort() + } + } + } + } + } + if (ts.isParameter(declaration)) { + const owner = declaration.parent + if (!ts.isFunctionLike(owner)) { + continue + } + const index = owner.parameters.indexOf(declaration) + const callerArgs = callers(owner).map((call) => call.arguments[index]) + const resolved = callerArgs.map((arg) => methods(arg, seen)) + if (resolved.length && resolved.every((values) => values.length)) { + return [...new Set(resolved.flat())].sort() + } + } + } + return [] + } + return { entryKind, resolveKind, methods, calls } +} diff --git a/mobile/scripts/rpc-artifact-io.mts b/mobile/scripts/rpc-artifact-io.mts index b22125486ef..8562a09f17a 100644 --- a/mobile/scripts/rpc-artifact-io.mts +++ b/mobile/scripts/rpc-artifact-io.mts @@ -1,9 +1,8 @@ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' import { isDeepStrictEqual } from 'node:util' import { runProcess } from '../../src/shared/child-process/run-process.ts' -export const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +export const root = resolve(import.meta.dirname, '../..') export function files(directory: string): string[] { return readdirSync(directory, { withFileTypes: true }) .flatMap((entry) => { @@ -14,21 +13,25 @@ export function files(directory: string): string[] { } export function option(name: string, fallback?: string): string { const index = process.argv.indexOf(`--${name}`) - const value = index < 0 ? fallback : process.argv[index + 1] - if (!value || value.startsWith('--')) throw new Error(`Missing --${name}`) + const value = index === -1 ? fallback : process.argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error(`Missing --${name}`) + } return value } export async function git(...args: string[]): Promise { const result = await runProcess({ program: 'git', args, cwd: root, maxOutputBytes: 8_000_000 }) - if (result.code !== 0 || result.outputTruncated) + if (result.code !== 0 || result.outputTruncated) { throw new Error(`git ${args.join(' ')}: ${result.stderr}`) + } return result.stdout.trimEnd() } export function emit(name: string, value: unknown): void { const path = resolve(root, option('output', `mobile/rpc-foundation/${name}.json`)) if (process.argv.includes('--check')) { - if (!isDeepStrictEqual(JSON.parse(readFileSync(path, 'utf8')), value)) + if (!isDeepStrictEqual(JSON.parse(readFileSync(path, 'utf8')), value)) { throw new Error(`Stale artifact: ${path}`) + } console.log(`${name}: current`) } else { mkdirSync(dirname(path), { recursive: true }) diff --git a/mobile/scripts/rpc-foundation-check.mts b/mobile/scripts/rpc-foundation-check.mts index 32e01e60c7b..76ff7bd523f 100644 --- a/mobile/scripts/rpc-foundation-check.mts +++ b/mobile/scripts/rpc-foundation-check.mts @@ -1,13 +1,12 @@ /** Ledger schema: { baseline, regression, entries: [{ id, description, status: * 'reference-only' | 'ticket', branch: { sha, file, line }, main: { sha, file, line } }] }. - * Reference-only entries document unmerged behavior; tickets never authorize changes. + * Tickets require only main provenance; reference-only entries require both sides. */ import { readFileSync, existsSync } from 'node:fs' -import { dirname, isAbsolute, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' +import { isAbsolute, resolve } from 'node:path' import { runProcess } from '../../src/shared/child-process/run-process.ts' -const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const root = resolve(import.meta.dirname, '../..') const artifacts = resolve(root, 'mobile/rpc-foundation') const contracts: Record = { step0: [ @@ -55,7 +54,9 @@ const [step, ...args] = process.argv.slice(2) const flags = new Map() function required(name: string): string { const value = flags.get(name) - if (!value) throw new Error(`Missing --${name}`) + if (!value) { + throw new Error(`Missing --${name}`) + } return value } function artifact(name: string): string { @@ -73,12 +74,13 @@ async function run(args: string[]): Promise { }) process.stdout.write(result.stdout) process.stderr.write(result.stderr) - if (result.code !== 0 || result.timedOut) throw new Error(`Check failed: ${args.join(' ')}`) + if (result.code !== 0 || result.timedOut) { + throw new Error(`Check failed: ${args.join(' ')}`) + } } async function generator(name: string, path: string, extra: string[] = []): Promise { await run([ - '--import', - 'tsx', + 'mobile/node_modules/tsx/dist/cli.mjs', `mobile/scripts/${name}.mts`, '--check', '--output', @@ -88,17 +90,26 @@ async function generator(name: string, path: string, extra: string[] = []): Prom } async function checkStep0(): Promise { const ledger = JSON.parse(readFileSync(artifact('deltas'), 'utf8')) - if (ledger.baseline !== required('baseline') || ledger.regression !== required('regression')) + if (ledger.baseline !== required('baseline') || ledger.regression !== required('regression')) { throw new Error('Ledger provenance differs') - if (!Array.isArray(ledger.entries) || ledger.entries.length < 7) + } + if (!Array.isArray(ledger.entries) || ledger.entries.length < 7) { throw new Error('Incomplete named deltas') + } for (const entry of ledger.entries) { - if (!entry.id || !entry.description || !['reference-only', 'ticket'].includes(entry.status)) + if (!entry.id || !entry.description || !['reference-only', 'ticket'].includes(entry.status)) { throw new Error('Invalid delta') - for (const side of ['main', 'branch']) { + } + for (const side of entry.status === 'ticket' ? ['main'] : ['main', 'branch']) { const location = entry[side] - if (!location?.sha || !location.file || !Number.isInteger(location.line) || location.line < 1) + if ( + !location?.sha || + !location.file || + !Number.isInteger(location.line) || + location.line < 1 + ) { throw new Error(`Invalid ${side} provenance: ${entry.id}`) + } } } if (flags.has('require-all-current-calls') || flags.has('require-no-dynamic-calls')) { @@ -111,26 +122,38 @@ async function checkStep0(): Promise { if ( flags.has('require-floor-derived-from-protocol-gate') || flags.has('require-retirement-decision') - ) + ) { await generator('rpc-support-floor', artifact('support-floor')) - if (flags.has('slice')) + } + if (flags.has('slice')) { await generator('rpc-slice-manifest', artifact('slice'), ['--pr-head', required('pr-head')]) - if (flags.has('require-native-rpc-inventory')) + } + if (flags.has('require-native-rpc-inventory')) { await generator('rpc-native-inventory', resolve(artifacts, 'native-rpc-inventory.json')) + } for (const flag of ['require-gate-fail-closed', 'require-below-floor-block-e2e']) { - if (!flags.has(flag)) continue - if (!flags.has('allow-lane-b-deferred')) + if (!flags.has(flag)) { + continue + } + if (!flags.has('allow-lane-b-deferred')) { throw new Error(`--${flag}: not implemented; lane B required`) + } console.log(`--${flag}: deferred to lane B`) } if (flags.has('require-regressions')) { const seeds = JSON.parse(readFileSync(resolve(artifacts, 'regression-seeds.json'), 'utf8')) - if (seeds.baseline !== required('baseline') || seeds.regression !== required('regression')) + if (seeds.baseline !== required('baseline') || seeds.regression !== required('regression')) { throw new Error('Regression provenance differs') + } + await generator('rpc-reference-regressions', resolve(artifacts, 'reference-regressions.json'), [ + '--regression', + required('regression') + ]) for (const id of required('require-regressions').split(',')) { const seed = seeds.tests.find((test: { id: string }) => test.id === id) - if (!seed || !readFileSync(resolve(root, seed.file), 'utf8').includes(seed.name)) + if (!seed || !readFileSync(resolve(root, seed.file), 'utf8').includes(seed.name)) { throw new Error(`Missing regression: ${id}`) + } await run([ 'mobile/node_modules/vitest/vitest.mjs', 'run', @@ -143,7 +166,9 @@ async function checkStep0(): Promise { } } const ratchet = 'mobile/src/transport/screen-rpc-ratchet.test.ts' - if (!existsSync(resolve(root, ratchet))) throw new Error('Missing main ratchet') + if (!existsSync(resolve(root, ratchet))) { + throw new Error('Missing main ratchet') + } await run([ 'mobile/node_modules/vitest/vitest.mjs', 'run', @@ -153,14 +178,19 @@ async function checkStep0(): Promise { ]) } try { - if (!contracts[step]) throw new Error(`Expected subcommand: ${Object.keys(contracts).join(', ')}`) + if (!contracts[step]) { + throw new Error(`Expected subcommand: ${Object.keys(contracts).join(', ')}`) + } for (let i = 0; i < args.length; i++) { const name = args[i].replace(/^--/, '') const contract = contracts[step].find((value) => value.replace(/=$/, '') === name) - if (!args[i].startsWith('--') || !contract || flags.has(name)) + if (!args[i].startsWith('--') || !contract || flags.has(name)) { throw new Error(`Unknown or duplicate flag: ${args[i]}`) + } const value = contract.endsWith('=') ? args[++i] : 'true' - if (!value || value.startsWith('--')) throw new Error(`Missing value for --${name}`) + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for --${name}`) + } flags.set(name, value) } if (step !== 'step0') { diff --git a/mobile/scripts/rpc-reference-regressions.mts b/mobile/scripts/rpc-reference-regressions.mts new file mode 100644 index 00000000000..60721668af0 --- /dev/null +++ b/mobile/scripts/rpc-reference-regressions.mts @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import ts from 'typescript' +import { emit, git, option } from './rpc-artifact-io.mts' +import { rankSuggestions } from '../src/session/mobile-native-chat-autocomplete.ts' +import { buildGithubPrParams, githubPrRepoSlugParam } from '../src/session/github-pr-rpc.ts' + +const revision = await git('rev-parse', `${option('regression', 'bcba08b3e4')}^{commit}`) +const sources: { file: string; sha256: string }[] = [] +async function reference( + file: string, + dependencies: Record = {} +): Promise< + Record Record Promise>> +> { + const source = await git('show', `${revision}:${file}`) + sources.push({ file, sha256: createHash('sha256').update(source).digest('hex') }) + const code = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } + }).outputText + const exports = {} + const require = (name: string): unknown => + dependencies[name] ?? + new Proxy( + {}, + { + get: (_target, key) => { + throw new Error(`Unprovided reference dependency ${name}:${String(key)}`) + } + } + ) + new Function('exports', 'require', code)(exports, require) + return exports +} +const native = await reference('mobile/src/session/native-host-session-native-chat-operations.ts', { + './mobile-native-chat-autocomplete': { rankSuggestions } +}) +const mutations = await reference('mobile/src/session/github-pr-mutations.ts', { + './github-pr-rpc': { buildGithubPrParams, githubPrRepoSlugParam } +}) +const project = await reference( + 'mobile/src/tasks/native-host-task-project-mutation-operations.ts', + { '../session/github-pr-mutations': mutations } +) +const detail = await reference('mobile/src/tasks/native-host-task-detail-operations.ts') +const flush = async (): Promise => { + for (let index = 0; index < 8; index++) { + await Promise.resolve() + } +} +const results: { id: string; killed: boolean; observed: string }[] = [] +const pending: Array<(response: unknown) => void> = [] +const operations = native.nativeHostSessionNativeChatOperations({ + sendRequest: (method: string) => + method === 'files.searchPaths' + ? Promise.resolve({ ok: false, error: { code: 'method_not_found' } }) + : new Promise((resolve) => pending.push(resolve)) +}) +const target = { workspaceId: 'A' } +const stale = operations.searchFiles(target, 'app') +await flush() +operations.resetFileSearchCache('A') +operations.resetFileSearchCache('B') +operations.resetFileSearchCache('A') +const fresh = operations.searchFiles(target, 'app') +await flush() +assert.equal(pending.length, 2) +pending[0]({ ok: true, result: { files: [{ relativePath: 'stale/app.ts' }] } }) +await stale +const third = await operations.searchFiles(target, 'app') +results.push({ + id: 'b1', + killed: Array.isArray(third) && third.includes('stale/app.ts'), + observed: JSON.stringify(third) +}) +pending[1]({ ok: true, result: { files: [{ relativePath: 'fresh/app.ts' }] } }) +await fresh +let accepted = false +try { + await project + .nativeHostTaskProjectMutationOperations({ + sendRequest: async () => ({ ok: true, result: null }) + }) + .merge({ number: 42 }, 'repo-1', 'squash') + accepted = true +} catch { + /* The main oracle requires rejection. */ +} +results.push({ + id: 'b2', + killed: accepted, + observed: accepted ? 'null result accepted' : 'null result rejected' +}) +let refuseIssue!: (value: unknown) => void, rejectComments!: (error: Error) => void +const issue = new Promise((resolve) => { + refuseIssue = resolve +}) +const comments = new Promise((_resolve, reject) => { + rejectComments = reject +}) +let error: unknown +const loading = detail + .nativeHostTaskDetailOperations({ + sendRequest: (method: string) => (method === 'linear.getIssue' ? issue : comments) + }) + .loadLinear({ issueId: 'issue-1', workspaceId: 'workspace-1' }) + .catch((failure: unknown) => { + error = failure + }) +refuseIssue({ ok: false, error: { message: 'issue refused' } }) +await flush() +rejectComments(new Error('comments transport failed')) +await loading +results.push({ + id: 'b3', + killed: error instanceof Error && error.message === 'issue refused', + observed: error instanceof Error ? error.message : String(error) +}) +for (const result of results) { + assert.equal(result.killed, true, `${result.id}: reference no longer exhibits the known defect`) +} +emit('reference-regressions', { + revision, + sources, + supportingFunctions: ['rankSuggestions', 'buildGithubPrParams', 'githubPrRepoSlugParam'], + supportingSource: + 'current main; ranking and sender parameter construction only, never reply acceptance or lifecycle', + results +}) +console.log('reference regressions: b1,b2,b3 killed against pinned source') diff --git a/mobile/scripts/rpc-support-floor.mts b/mobile/scripts/rpc-support-floor.mts index 809a584e784..090ad1b6015 100644 --- a/mobile/scripts/rpc-support-floor.mts +++ b/mobile/scripts/rpc-support-floor.mts @@ -11,7 +11,9 @@ function constants(source: string): Record { /export const ((?:RUNTIME|DESKTOP|MOBILE)_PROTOCOL_VERSION|MIN_COMPATIBLE_(?:RUNTIME_CLIENT|RUNTIME_SERVER|MOBILE|DESKTOP)_VERSION)\s*=\s*(\w+)/g )) { const value = /^\d+$/.test(match[2]) ? Number(match[2]) : result[match[2]] - if (value !== undefined) result[match[1]] = value + if (value !== undefined) { + result[match[1]] = value + } } return result } @@ -33,8 +35,11 @@ for (const value of [ desktop.MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, mobile.MOBILE_PROTOCOL_VERSION, mobile.MIN_COMPATIBLE_DESKTOP_VERSION -]) - if (!Number.isInteger(value)) throw new Error('Missing protocol gate constant') +]) { + if (!Number.isInteger(value)) { + throw new Error('Missing protocol gate constant') + } +} const tags = ( await git( 'tag', @@ -72,7 +77,9 @@ const namespaces = [ const floors = [] for (const namespace of namespaces) { const candidates = tags.filter((tag) => namespace.pattern.test(tag)) - if (!candidates.length) throw new Error(`No tags for ${namespace.name}`) + if (!candidates.length) { + throw new Error(`No tags for ${namespace.name}`) + } const inspected = [] let floor: unknown, atMinimum3: unknown for (const tag of candidates) { @@ -86,12 +93,16 @@ for (const namespace of namespaces) { } const values = constants(source) const version = values[namespace.protocol] ?? values.DESKTOP_PROTOCOL_VERSION - if (!Number.isInteger(version)) throw new Error(`Missing protocol at ${tag}:${namespace.file}`) + if (!Number.isInteger(version)) { + throw new Error(`Missing protocol at ${tag}:${namespace.file}`) + } const peerMinimum = namespace.file === desktopPath ? (values.MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION ?? values.MIN_COMPATIBLE_MOBILE_VERSION) : values.MIN_COMPATIBLE_DESKTOP_VERSION - if (!Number.isInteger(peerMinimum)) throw new Error(`Missing peer minimum at ${tag}`) + if (!Number.isInteger(peerMinimum)) { + throw new Error(`Missing peer minimum at ${tag}`) + } const clientProtocol = namespace.file === desktopPath ? mobile.MOBILE_PROTOCOL_VERSION @@ -105,11 +116,19 @@ for (const namespace of namespaces) { values[namespace.protocol] === undefined ? 'DESKTOP_PROTOCOL_VERSION' : namespace.protocol } inspected.push(evidence) - if (!floor && version >= namespace.minimum && clientProtocol >= peerMinimum) floor = evidence - if (!atMinimum3 && version >= 3 && clientProtocol >= peerMinimum) atMinimum3 = evidence - if (floor && atMinimum3) break + if (!floor && version >= namespace.minimum && clientProtocol >= peerMinimum) { + floor = evidence + } + if (!atMinimum3 && version >= 3 && clientProtocol >= peerMinimum) { + atMinimum3 = evidence + } + if (floor && atMinimum3) { + break + } + } + if (!floor || !atMinimum3) { + throw new Error(`No compatible floor in ${namespace.name}`) } - if (!floor || !atMinimum3) throw new Error(`No compatible floor in ${namespace.name}`) floors.push({ namespace: namespace.name, consideredTags: candidates, diff --git a/mobile/src/tasks/use-mobile-tasks-item-detail-loading.test.ts b/mobile/src/tasks/use-mobile-tasks-item-detail-loading.test.ts index 3f54a40fa30..b7eda224dce 100644 --- a/mobile/src/tasks/use-mobile-tasks-item-detail-loading.test.ts +++ b/mobile/src/tasks/use-mobile-tasks-item-detail-loading.test.ts @@ -31,11 +31,15 @@ describe('Linear detail settlement', () => { setItems: vi.fn() } as unknown as Parameters[0]) refuseIssue({ ok: false, error: { message: 'issue refused' } }) - for (let i = 0; i < 8; i++) await Promise.resolve() + for (let i = 0; i < 8; i++) { + await Promise.resolve() + } expect(setDetailError).toHaveBeenLastCalledWith('') expect(setDetailLoading).toHaveBeenLastCalledWith(true) rejectComments(new Error('comments transport failed')) - for (let i = 0; i < 8; i++) await Promise.resolve() + for (let i = 0; i < 8; i++) { + await Promise.resolve() + } expect(setDetailError).toHaveBeenLastCalledWith('comments transport failed') expect(setDetailLoading).toHaveBeenLastCalledWith(false) expect(setDetailPayload).toHaveBeenCalledTimes(1) diff --git a/mobile/src/transport/rpc-access-resolution.test.ts b/mobile/src/transport/rpc-access-resolution.test.ts new file mode 100644 index 00000000000..053b22233fb --- /dev/null +++ b/mobile/src/transport/rpc-access-resolution.test.ts @@ -0,0 +1,65 @@ +import ts from 'typescript' +import { describe, expect, it } from 'vitest' +import { createRpcAccessResolver } from '../../scripts/rpc-access-resolution.mts' + +function fixture(source: string) { + const file = 'rpc-fixture.ts' + const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true) + const host = ts.createCompilerHost({ noLib: true, types: [] }) + host.getSourceFile = (name) => (name === file ? sourceFile : undefined) + const program = ts.createProgram([file], { noLib: true, types: [] }, host) + return createRpcAccessResolver(program, [file]) +} + +describe('RPC access symbol resolution', () => { + it('follows aliases, destructuring, bracket constants and bound senders', () => { + const resolver = fixture(` + declare const client: { sendRequest(method: string): void; subscribe(method: string): void } + const direct = client.sendRequest + const alias = direct + const { sendRequest: destructured } = client + const bound = client.sendRequest.bind(client) + const key = 'sendRequest' + alias('git.status'); destructured('files.list'); bound('settings.get'); client[key]('repo.list') + const { subscribe: stream } = client + stream('terminal.subscribe') + `) + const recognized = resolver.calls.filter((call) => resolver.resolveKind(call.expression)) + expect(recognized.map((call) => resolver.methods(call.arguments[0]))).toEqual([ + ['git.status'], + ['files.list'], + ['settings.get'], + ['repo.list'], + ['terminal.subscribe'] + ]) + }) + it('fails closed when one caller supplies an unresolved method', () => { + const resolver = fixture(` + declare const client: { sendRequest(method: string): void } + declare const unknownMethod: string + function forward(method: string) { client.sendRequest(method) } + forward('git.status'); forward(unknownMethod) + `) + const raw = resolver.calls.find((call) => resolver.resolveKind(call.expression) === 'request')! + expect(resolver.methods(raw.arguments[0])).toEqual([]) + }) + it('derives the complete literal family from forwarder callers', () => { + const resolver = fixture(` + declare const client: { sendRequest(method: string): void } + function forward(method: string) { client.sendRequest(method) } + forward('git.status'); forward('git.fetch') + `) + const raw = resolver.calls.find((call) => resolver.resolveKind(call.expression) === 'request')! + expect(resolver.methods(raw.arguments[0])).toEqual(['git.fetch', 'git.status']) + }) + it('does not hide an unresolved caller behind an object binding', () => { + const resolver = fixture(` + declare const client: { sendRequest(method: string): void } + declare const unknownMethod: string + function forward(args: { method: string }) { const { method } = args; client.sendRequest(method) } + forward({ method: 'git.status' }); forward({ method: unknownMethod }) + `) + const raw = resolver.calls.find((call) => resolver.resolveKind(call.expression) === 'request')! + expect(resolver.methods(raw.arguments[0])).toEqual([]) + }) +})