refactor(mobile): let the task RPC modules take any unary sender

The hosted page has no socket client, so the modules the native app uses
now take a sender that returns only what a unary caller reads. That is the
same shape the existing Pick<RpcClient, 'sendRequest'> sites meant.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-07 14:53:42 -04:00
parent b68f2246e9
commit 744db6d4a6
16 changed files with 114 additions and 78 deletions
+15 -15
View File
@@ -1,5 +1,5 @@
import type { GitHubPRMergeMethod } from '../../../src/shared/github/pull-request-types'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcRequestSender } from '../transport/rpc-client'
import { buildGithubPrParams, githubPrRepoSlugParam, type GitHubPrRepoSlug } from './github-pr-rpc'
// Mutation wrappers for the github.* PR surface, split out so github-pr-rpc.ts
@@ -17,7 +17,7 @@ export type GitHubPrMutationOutcome = { ok: true; comment?: unknown } | { ok: fa
type RawResult = { ok: true; result: unknown } | { ok: false; error: string }
async function sendRaw(
client: Pick<RpcClient, 'sendRequest'>,
client: RpcRequestSender,
method: string,
params: Record<string, unknown>
): Promise<RawResult> {
@@ -52,7 +52,7 @@ function extractMutationError(error: unknown, method: string): string {
// `response.ok === false` (timeout/connection) is also a failure. Both collapse
// into one outcome the action hook classifies via classifyPrSidebarFailure.
async function sendGithubPrMutation(
client: Pick<RpcClient, 'sendRequest'>,
client: RpcRequestSender,
method: string,
params: Record<string, unknown>,
options?: { timeoutMs?: number }
@@ -83,7 +83,7 @@ async function sendGithubPrMutation(
}
export async function fetchMergePR(
client: Pick<RpcClient, 'sendRequest'>,
client: RpcRequestSender,
worktreeId: string,
args: { prNumber: number; method?: GitHubPRMergeMethod; prRepo?: GitHubPrRepoSlug | null },
options?: { timeoutMs?: number }
@@ -104,7 +104,7 @@ export async function fetchMergePR(
// which sendGithubPrMutation reads via its "no structured status" success branch
// only when not boolean — so handle the boolean explicitly like resolveReviewThread.
export async function fetchUpdatePRTitle(
client: Pick<RpcClient, 'sendRequest'>,
client: RpcRequestSender,
worktreeId: string,
args: { prNumber: number; title: string; prRepo?: GitHubPrRepoSlug | null }
): Promise<GitHubPrMutationOutcome> {
@@ -126,7 +126,7 @@ export async function fetchUpdatePRTitle(
}
export async function fetchSetPRAutoMerge(
client: Pick<RpcClient, 'sendRequest'>,
client: RpcRequestSender,
worktreeId: string,
args: {
prNumber: number
@@ -147,7 +147,7 @@ export async function fetchSetPRAutoMerge(
}
export async function fetchUpdatePRState(
client: Pick<RpcClient, 'sendRequest'>,
client: RpcRequestSender,
worktreeId: string,
args: { prNumber: number; state: 'open' | 'closed'; prRepo?: GitHubPrRepoSlug | null }
): Promise<GitHubPrMutationOutcome> {
@@ -164,7 +164,7 @@ export async function fetchUpdatePRState(
}
export async function fetchRequestPRReviewers(
client: Pick<RpcClient, 'sendRequest'>,
client: RpcRequestSender,
worktreeId: string,
args: { prNumber: number; reviewers: string[]; prRepo?: GitHubPrRepoSlug | null }
): Promise<GitHubPrMutationOutcome> {
@@ -181,7 +181,7 @@ export async function fetchRequestPRReviewers(
}
export async function fetchRemovePRReviewers(
client: Pick<RpcClient, 'sendRequest'>,
client: RpcRequestSender,
worktreeId: string,
args: { prNumber: number; reviewers: string[]; prRepo?: GitHubPrRepoSlug | null }
): Promise<GitHubPrMutationOutcome> {
@@ -201,7 +201,7 @@ export async function fetchRemovePRReviewers(
// (`{ ok, comment } | { ok:false, error }`), which sendGithubPrMutation reads via
// its `ok in result` branch. We refetch afterward, so the returned comment is unused.
export async function fetchAddPRReviewCommentReply(
client: Pick<RpcClient, 'sendRequest'>,
client: RpcRequestSender,
worktreeId: string,
args: {
prNumber: number
@@ -238,7 +238,7 @@ export async function fetchAddPRReviewCommentReply(
// Add a root conversation comment to a PR or an issue. Host returns GitHubCommentResult.
export async function fetchAddIssueComment(
client: Pick<RpcClient, 'sendRequest'>,
client: RpcRequestSender,
worktreeId: string,
args: {
prNumber: number
@@ -265,7 +265,7 @@ export async function fetchAddIssueComment(
// the matching GraphQL mutation). Unlike the comment mutations, the host returns a
// bare boolean, so a falsy result is a failure rather than the "no status" success.
export async function fetchResolveReviewThread(
client: Pick<RpcClient, 'sendRequest'>,
client: RpcRequestSender,
worktreeId: string,
args: { threadId: string; resolve: boolean; prRepo?: GitHubPrRepoSlug | null }
): Promise<GitHubPrMutationOutcome> {
@@ -298,7 +298,7 @@ export async function fetchResolveReviewThread(
// directly rather than via buildGithubPrParams. Host returns the
// GitHubProjectMutationResult `{ ok }` envelope sendGithubPrMutation reads.
export async function fetchUpdateIssueComment(
client: Pick<RpcClient, 'sendRequest'>,
client: RpcRequestSender,
args: { owner: string; repo: string; host?: string; commentId: number; body: string }
): Promise<GitHubPrMutationOutcome> {
return sendGithubPrMutation(client, 'github.project.updateIssueCommentBySlug', {
@@ -310,7 +310,7 @@ export async function fetchUpdateIssueComment(
// Delete a root conversation (issue) comment. Slug-addressed like the edit wrapper.
export async function fetchDeleteIssueComment(
client: Pick<RpcClient, 'sendRequest'>,
client: RpcRequestSender,
args: { owner: string; repo: string; host?: string; commentId: number }
): Promise<GitHubPrMutationOutcome> {
return sendGithubPrMutation(client, 'github.project.deleteIssueCommentBySlug', {
@@ -320,7 +320,7 @@ export async function fetchDeleteIssueComment(
}
export async function fetchRerunPRChecks(
client: Pick<RpcClient, 'sendRequest'>,
client: RpcRequestSender,
worktreeId: string,
args: {
prNumber: number
@@ -5,8 +5,7 @@ import type {
import type { MobileWebTaskLinearIssue } from '../../../src/shared/mobile-web/task-list-contract'
import type { HostTaskDetailOperations } from './host-task-detail-operations'
import { projectGitHubTaskDetail } from './github-task-detail-projection'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import type { RpcRequestSender } from '../transport/rpc-client'
type GitLabRawDetails = Partial<MobileWebTaskGitLabDetailResult> & {
item?: {
@@ -15,7 +14,7 @@ type GitLabRawDetails = Partial<MobileWebTaskGitLabDetailResult> & {
}
}
export function nativeHostTaskDetailOperations(client: RpcClient): HostTaskDetailOperations {
export function nativeHostTaskDetailOperations(client: RpcRequestSender): HostTaskDetailOperations {
return {
async listGitHubLabels(repoId) {
return successfulResult(
@@ -114,5 +113,5 @@ async function successfulResult<T>(request: Promise<unknown>): Promise<T> {
if (!response.ok) {
throw new Error(response.error?.message ?? 'Task provider request failed')
}
return (response as RpcSuccess).result as T
return response.result as T
}
@@ -1,7 +1,9 @@
import type { HostTaskItemFileOperations } from './host-task-item-file-operations'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcRequestSender } from '../transport/rpc-client'
export function nativeHostTaskItemFileOperations(client: RpcClient): HostTaskItemFileOperations {
export function nativeHostTaskItemFileOperations(
client: RpcRequestSender
): HostTaskItemFileOperations {
return {
async refreshChecks(target, headSha) {
const checks = await request(client, 'github.prChecks', {
@@ -70,14 +72,14 @@ function repoPayload(target: { repoId: string }) {
}
async function request<T = unknown>(
client: RpcClient,
client: RpcRequestSender,
method: string,
payload: object,
timeoutMs = 30_000
): Promise<T> {
const response = await client.sendRequest(method, payload, { timeoutMs })
if (!response.ok) {
throw new Error(response.error.message)
throw new Error(response.error?.message ?? 'Task request failed')
}
return response.result as T
}
@@ -3,10 +3,10 @@ import type {
HostTaskItemMutationOperations,
HostTaskItemMutationTarget
} from './host-task-item-mutation-operations'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcRequestSender } from '../transport/rpc-client'
export function nativeHostTaskItemMutationOperations(
client: RpcClient
client: RpcRequestSender
): HostTaskItemMutationOperations {
return {
async setClosed(target, closed) {
@@ -27,7 +27,7 @@ export function nativeHostTaskItemMutationOperations(
}
function setGitHubClosed(
client: RpcClient,
client: RpcRequestSender,
target: Extract<HostTaskItemMutationTarget, { provider: 'github' }>,
closed: boolean
) {
@@ -46,7 +46,7 @@ function setGitHubClosed(
}
function setGitLabClosed(
client: RpcClient,
client: RpcRequestSender,
target: Extract<HostTaskItemMutationTarget, { provider: 'gitlab' }>,
closed: boolean
) {
@@ -67,7 +67,7 @@ function setGitLabClosed(
}
function updateGitHubMetadata(
client: RpcClient,
client: RpcRequestSender,
target: Extract<HostTaskItemMutationTarget, { provider: 'github' }>,
updates: MobileWebTaskItemMetadataUpdates
) {
@@ -89,7 +89,7 @@ function updateGitHubMetadata(
}
function updateGitLabMetadata(
client: RpcClient,
client: RpcRequestSender,
target: Extract<HostTaskItemMutationTarget, { provider: 'gitlab' }>,
updates: MobileWebTaskItemMetadataUpdates
) {
@@ -122,11 +122,11 @@ function updateGitLabMetadata(
}
function assertMutation(
response: Awaited<ReturnType<RpcClient['sendRequest']>>,
response: Awaited<ReturnType<RpcRequestSender['sendRequest']>>,
fallback: string
): void {
if (!response.ok) {
throw new Error(response.error.message)
throw new Error(response.error?.message ?? 'Task request failed')
}
const result = response.result as { ok?: boolean; error?: string }
if (result.ok === false) {
@@ -1,10 +1,10 @@
import type { MobileWebTaskDetailComment } from '../../../src/shared/mobile-web/task-detail-contract'
import type { HostTaskItemMutationTarget } from './host-task-item-mutation-operations'
import type { HostTaskItemReviewOperations } from './host-task-item-review-operations'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcRequestSender } from '../transport/rpc-client'
export function nativeHostTaskItemReviewOperations(
client: RpcClient
client: RpcRequestSender
): HostTaskItemReviewOperations {
return {
async addComment(target, body) {
@@ -89,7 +89,7 @@ export function nativeHostTaskItemReviewOperations(
}
function addGitLabComment(
client: RpcClient,
client: RpcRequestSender,
target: Extract<HostTaskItemMutationTarget, { provider: 'gitlab' }>,
body: string
) {
@@ -117,11 +117,11 @@ function addGitLabComment(
}
function mutationComment(
response: Awaited<ReturnType<RpcClient['sendRequest']>>,
response: Awaited<ReturnType<RpcRequestSender['sendRequest']>>,
fallback: string
): MobileWebTaskDetailComment | undefined {
if (!response.ok) {
throw new Error(response.error.message)
throw new Error(response.error?.message ?? 'Task request failed')
}
const result = response.result as {
ok?: boolean
@@ -135,11 +135,11 @@ function mutationComment(
}
function assertMutation(
response: Awaited<ReturnType<RpcClient['sendRequest']>>,
response: Awaited<ReturnType<RpcRequestSender['sendRequest']>>,
fallback: string
): void {
if (!response.ok) {
throw new Error(response.error.message)
throw new Error(response.error?.message ?? 'Task request failed')
}
const result = response.result as { ok?: boolean; error?: string }
if (result.ok === false) {
@@ -1,7 +1,7 @@
import type { HostTaskLinearOperations } from './host-task-linear-operations'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcRequestSender } from '../transport/rpc-client'
export function nativeHostTaskLinearOperations(client: RpcClient): HostTaskLinearOperations {
export function nativeHostTaskLinearOperations(client: RpcRequestSender): HostTaskLinearOperations {
return {
async connect(apiKey) {
assertMutation(
@@ -74,13 +74,13 @@ export function nativeHostTaskLinearOperations(client: RpcClient): HostTaskLinea
}
async function request<T = unknown>(
client: RpcClient,
client: RpcRequestSender,
method: string,
payload?: object
): Promise<T> {
const response = await client.sendRequest(method, payload, { timeoutMs: 30_000 })
if (!response.ok) {
throw new Error(response.error.message)
throw new Error(response.error?.message ?? 'Task request failed')
}
return response.result as T
}
@@ -1,9 +1,8 @@
import { extractLinearIssueReadItems } from './linear-mobile-issue-read'
import type { HostTaskListOperations } from './host-task-list-operations'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import type { RpcRequestSender } from '../transport/rpc-client'
export function nativeHostTaskListOperations(client: RpcClient): HostTaskListOperations {
export function nativeHostTaskListOperations(client: RpcRequestSender): HostTaskListOperations {
return {
async listGitHub(payload) {
return successfulResult(
@@ -73,5 +72,5 @@ async function successfulResult<T>(request: Promise<unknown>): Promise<T> {
if (!response.ok) {
throw new Error(response.error?.message ?? 'Task provider request failed')
}
return (response as RpcSuccess).result as T
return response.result as T
}
@@ -1,9 +1,9 @@
import { persistSetupHookTrustApproval } from './setup-hook-trust'
import type { HostTaskPreferenceOperations } from './host-task-preference-operations'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcRequestSender } from '../transport/rpc-client'
export function nativeHostTaskPreferenceOperations(
client: RpcClient
client: RpcRequestSender
): HostTaskPreferenceOperations {
return {
async updateResume(taskResumeState) {
@@ -1,9 +1,9 @@
import type { HostTaskProjectFileOperations } from './host-task-project-file-operations'
import type { HostTaskProjectItemTarget } from './host-task-project-mutation-operations'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcRequestSender } from '../transport/rpc-client'
export function nativeHostTaskProjectFileOperations(
client: RpcClient
client: RpcRequestSender
): HostTaskProjectFileOperations {
return {
async refreshChecks(target, repoId, headSha) {
@@ -60,13 +60,13 @@ function repoPayload(target: HostTaskProjectItemTarget, repoId: string) {
}
async function request<T = unknown>(
client: RpcClient,
client: RpcRequestSender,
method: string,
payload: object
): Promise<T> {
const response = await client.sendRequest(method, payload, { timeoutMs: 30_000 })
if (!response.ok) {
throw new Error(response.error.message)
throw new Error(response.error?.message ?? 'Task request failed')
}
return response.result as T
}
@@ -3,7 +3,7 @@ import type {
HostTaskProjectItemTarget,
HostTaskProjectMutationOperations
} from './host-task-project-mutation-operations'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcRequestSender } from '../transport/rpc-client'
import {
fetchAddIssueComment,
fetchAddPRReviewCommentReply,
@@ -17,7 +17,7 @@ import {
const PROJECT_PR_MUTATION_TIMEOUT_MS = 60_000
export function nativeHostTaskProjectMutationOperations(
client: RpcClient
client: RpcRequestSender
): HostTaskProjectMutationOperations {
return {
async updateItem(target, updates) {
@@ -154,7 +154,7 @@ function slugPayload(target: HostTaskProjectItemTarget) {
}
async function projectMutation<T extends object = object>(
client: RpcClient,
client: RpcRequestSender,
method: string,
payload: object
): Promise<T> {
@@ -6,10 +6,10 @@ import type {
import type { MobileWebTaskProjectTable } from '../../../src/shared/mobile-web/task-project-table-contract'
import type { HostTaskProjectReadOperations } from './host-task-project-read-operations'
import { projectGitHubTaskDetail } from './github-task-detail-projection'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcRequestSender } from '../transport/rpc-client'
export function nativeHostTaskProjectReadOperations(
client: RpcClient
client: RpcRequestSender
): HostTaskProjectReadOperations {
return {
async listAccessible(host) {
@@ -1,8 +1,8 @@
import type { HostTaskProviderWriteOperations } from './host-task-provider-write-operations'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcRequestSender } from '../transport/rpc-client'
export function nativeHostTaskProviderWriteOperations(
client: RpcClient
client: RpcRequestSender
): HostTaskProviderWriteOperations {
return {
async createIssue(payload) {
@@ -15,7 +15,7 @@ export function nativeHostTaskProviderWriteOperations(
}
)
if (!response.ok) {
throw new Error(response.error.message)
throw new Error(response.error?.message ?? 'Task request failed')
}
const result = response.result as {
ok?: boolean
@@ -41,7 +41,7 @@ export function nativeHostTaskProviderWriteOperations(
{ timeoutMs: 15_000 }
)
if (!response.ok) {
throw new Error(response.error.message)
throw new Error(response.error?.message ?? 'Task request failed')
}
}
}
@@ -6,10 +6,9 @@ import type {
HostTaskReadOperations,
HostTaskRepository
} from './host-task-read-operations'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import type { RpcRequestReply, RpcRequestSender } from '../transport/rpc-client'
export function nativeHostTaskReadOperations(client: RpcClient): HostTaskReadOperations {
export function nativeHostTaskReadOperations(client: RpcRequestSender): HostTaskReadOperations {
return {
bootstrap: () => bootstrap(client),
listRepositories: () => listRepositories(client),
@@ -18,10 +17,10 @@ export function nativeHostTaskReadOperations(client: RpcClient): HostTaskReadOpe
}
}
async function bootstrap(client: RpcClient): Promise<HostTaskBootstrap> {
async function bootstrap(client: RpcRequestSender): Promise<HostTaskBootstrap> {
const statusResponse = await client.sendRequest('status.get')
requireSuccess(statusResponse)
const status = (statusResponse as RpcSuccess).result as { capabilities?: string[] }
const status = statusResponse.result as { capabilities?: string[] }
if (!status.capabilities?.includes(MOBILE_TASKS_CAPABILITY)) {
return emptyBootstrap(false)
}
@@ -53,17 +52,17 @@ async function bootstrap(client: RpcClient): Promise<HostTaskBootstrap> {
}
}
async function listRepositories(client: RpcClient): Promise<HostTaskRepository[]> {
async function listRepositories(client: RpcRequestSender): Promise<HostTaskRepository[]> {
const response = await client.sendRequest('repo.list')
requireSuccess(response)
const result = (response as RpcSuccess).result as { repos?: HostTaskRepository[] }
const result = response.result as { repos?: HostTaskRepository[] }
return result.repos ?? []
}
async function loadLinearContext(client: RpcClient): Promise<HostTaskLinearContext> {
async function loadLinearContext(client: RpcRequestSender): Promise<HostTaskLinearContext> {
const statusResponse = await client.sendRequest('linear.status')
requireSuccess(statusResponse)
const status = normalizeLinearStatus((statusResponse as RpcSuccess).result)
const status = normalizeLinearStatus(statusResponse.result)
if (!status.connected) {
return { status, teams: [] }
}
@@ -75,12 +74,12 @@ async function loadLinearContext(client: RpcClient): Promise<HostTaskLinearConte
requireSuccess(teamsResponse)
return {
status: { ...status, selectedWorkspaceId: workspaceId },
teams: (teamsResponse as RpcSuccess).result as HostTaskLinearContext['teams']
teams: teamsResponse.result as HostTaskLinearContext['teams']
}
}
async function resolveGitHubRepoSlug(
client: RpcClient,
client: RpcRequestSender,
repoId: string
): Promise<GitHubOwnerRepo | null> {
const response = await client.sendRequest(
@@ -89,7 +88,7 @@ async function resolveGitHubRepoSlug(
{ timeoutMs: 30_000 }
)
requireSuccess(response)
return (response as RpcSuccess).result as GitHubOwnerRepo | null
return response.result as GitHubOwnerRepo | null
}
function emptyBootstrap(supported: boolean): HostTaskBootstrap {
@@ -113,11 +112,13 @@ function normalizeLinearStatus(value: unknown): HostTaskBootstrap['linearStatus'
}
}
function successResult<T>(response: { ok: boolean; result?: unknown }): T | null {
function successResult<T>(response: RpcRequestReply): T | null {
return response.ok ? (response.result as T) : null
}
function requireSuccess(response: { ok: boolean; error?: { message?: string } }): void {
function requireSuccess(
response: RpcRequestReply
): asserts response is { ok: true; result: unknown } {
if (!response.ok) {
throw new Error(response.error?.message ?? 'Task operation failed')
}
+3 -3
View File
@@ -1,5 +1,5 @@
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcRequestSender } from '../transport/rpc-client'
export type SetupHookTrust = {
contentHash: string
@@ -38,7 +38,7 @@ export function trustedOrcaHooksWithSetupApproval(args: {
}
export async function persistSetupHookTrustApproval(args: {
client: RpcClient
client: RpcRequestSender
trust: PersistedTrustedOrcaHooks
repoId: string
contentHash: string
@@ -47,7 +47,7 @@ export async function persistSetupHookTrustApproval(args: {
const next = trustedOrcaHooksWithSetupApproval(args)
const response = await args.client.sendRequest('ui.set', { trustedOrcaHooks: next })
if (!response.ok) {
throw new Error(response.error.message)
throw new Error(response.error?.message ?? 'Hook trust update failed')
}
return next
}
@@ -0,0 +1,19 @@
import type { MobileWebHostRequestClient } from '../../../src/mobile-web/src/mobile-web-host-request-client'
import type { RpcRequestSender } from '../transport/rpc-client'
/** The hosted page reaches the same desktop methods the native app calls, so the task operation
* modules are shared verbatim. A desktop error arrives as a rejected bridge request, never as an
* `ok: false` reply, which is why this sender only ever resolves successes. */
export function webHostTaskRpcSender(host: MobileWebHostRequestClient): RpcRequestSender {
return {
async sendRequest(method, params) {
return { ok: true, result: await host.request({ method, params: hostParams(params) }) }
}
}
}
function hostParams(params: unknown): Record<string, unknown> {
return typeof params === 'object' && params !== null && !Array.isArray(params)
? (params as Record<string, unknown>)
: {}
}
+16
View File
@@ -18,6 +18,22 @@ export type SendRequestOptions = {
beforeSend?: () => void
}
/** A unary reply as its caller reads it. The hosted page answers over the capability bridge, which
* carries no request id or runtime metadata, so those stay on the transport's own `RpcResponse`. */
export type RpcRequestReply =
| { ok: true; result: unknown }
| { ok: false; error?: { code?: string; message?: string } }
/** The only surface a unary caller needs, so the same operation modules serve the native app over a
* socket and the hosted page over the bridge. */
export type RpcRequestSender = {
sendRequest: (
method: string,
params?: unknown,
options?: SendRequestOptions
) => Promise<RpcRequestReply>
}
type StreamingListener = (result: unknown) => void
export type RpcClient = {